diff --git a/.github/workflows/canvas-canary.yml b/.github/workflows/canvas-canary.yml index 3b6cebc..d111809 100644 --- a/.github/workflows/canvas-canary.yml +++ b/.github/workflows/canvas-canary.yml @@ -48,6 +48,12 @@ jobs: - name: Canvas canary run: pnpm test:canvas + # A designer's and an agent's first hour, walked in the same browser: + # overview, canvas, contract, docs, models, and an agent edit through + # the CLI that the viewer then shows. + - name: Journeys + run: pnpm test:journeys + # Uploaded whether the canary passed or failed, so a human can look at # what it actually saw instead of trusting a byte-count heuristic blind. - name: Upload canvas screenshot diff --git a/.prettierignore b/.prettierignore index 174167b..ad418e3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,8 @@ dist node_modules pnpm-lock.yaml packages/viewer/public +examples/*/generated +examples/*/vendor .uidx .uidx-agent diff --git a/CHANGELOG.md b/CHANGELOG.md index b649f46..9363171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,226 @@ User-visible changes are recorded here. Versions follow semantic versioning; pre-1.0 releases may change the design format or public APIs. Release notes must describe migrations when an existing document or integration is affected. +## Unreleased + +- The inspector's Contract, Connect and Code tabs share one frame: the same + section header (title, faint meta, actions, an (i) for what the section is + for), one status line per tab that stays a single line until opened, and + one empty state with a short hint and at most one action. A fault reads the + same on every tab ("Library file not found"), offers the fix where it lives + (Choose library on Connect, Fix in Connect elsewhere) and keeps the + server's own words behind Details. Tab dots mark only real problems, the + read-only lock is one chip in the identity row, and the library chooser + and Write code moved from Contract to Connect and Code. +- **Restyle a placed component from outside (ADR 0018).** An `` may + set its outer box (`fills`, `strokes` and their weights, `dashPattern`, + corner radii, `padding*`, `opacity`, `effects`) and `textFills`, which + colours every text the component draws, nested instances' too. The box lands + on the frame a wrapper-shaped component wraps, or on the instance a + composition holds, and on through any frame that only wraps another — so + the Shoelace example's Button takes it on its `base` part — together with a + stated size, and beneath the component's state rows, so a hover row still + wins. The Design tab shows the component's values dimmed until a use + changes one, with ↺ per row and per section and Reset all overrides; detach + bakes the restyle in; agents may set the same attributes and are refused + the inside, in a `set_style` cell as on the instance. `uidx check` warns on + an attribute of the component's inside written on an instance or onto one + by a ` + + +
+

+ Every component below is a render of a .uidx identity in .uidx/: + behaviour from the headless hwc-* elements, appearance from the generated + stylesheets, and the React column from the generated components. +

+
+
+

HTML target — generated markup and CSS

+
+
+
+

React target — generated components

+
+
+
+
+ + + diff --git a/examples/design-system/package.json b/examples/design-system/package.json new file mode 100644 index 0000000..aafeb07 --- /dev/null +++ b/examples/design-system/package.json @@ -0,0 +1,33 @@ +{ + "name": "@uidx/example-design-system", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "A design system whose components are renders of .uidx identities: HTML/CSS and React generated over @hwc/components.", + "scripts": { + "generate": "node scripts/generate.mjs", + "check": "node scripts/generate.mjs --check", + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run --maxWorkers=2 --testTimeout=20000" + }, + "dependencies": { + "@lit/context": "^1.1.0", + "lit": "^3.3.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^26.5.1", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@uidx/codegen": "workspace:*", + "@uidx/format": "workspace:*", + "@uidx/schema": "workspace:*", + "@uidxkit/uidx": "workspace:*", + "typescript": "^5.7.2", + "vite": "^8.3.0", + "vitest": "^5.0.0" + } +} diff --git a/examples/design-system/scripts/generate.mjs b/examples/design-system/scripts/generate.mjs new file mode 100644 index 0000000..88d5887 --- /dev/null +++ b/examples/design-system/scripts/generate.mjs @@ -0,0 +1,57 @@ +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse, formatDiagnostic } from '@uidx/format' +import { generate } from '@uidx/codegen' + +/** + * Renders `generated/` from `.uidx/` (ADR 0017): HTML/CSS, React and the + * contract JSON, checked against the vendored headless manifest. With + * `--check`, compares instead of writing, so CI fails when the output moved + * without the input moving — the cross-target conformance rule (ADR 0017 §4). + */ +const root = fileURLToPath(new URL('..', import.meta.url)) +const check = process.argv.includes('--check') +const out = resolve(root, 'generated') + +const pages = [] +const tokens = [] +for (const name of (await readdir(resolve(root, '.uidx'))) + .filter((n) => n.endsWith('.uidx')) + .sort()) { + const file = `.uidx/${name}` + const { doc, diagnostics } = parse(await readFile(resolve(root, file), 'utf8')) + for (const d of diagnostics) console.error(formatDiagnostic(d, file)) + if (!doc) process.exit(1) + if (doc.tree.element === 'Tokens') tokens.push(doc) + else pages.push({ file, doc }) +} +const manifest = JSON.parse( + await readFile(resolve(root, 'vendor/hwc/custom-elements.json'), 'utf8'), +) +const result = generate({ pages, tokens, manifest }) +for (const d of result.diagnostics) console.error(formatDiagnostic(d, d.file)) +if (result.diagnostics.some((d) => d.severity === 'error')) process.exit(1) + +if (check) { + let drift = 0 + for (const [path, text] of result.files) { + const existing = await readFile(resolve(out, path), 'utf8').catch(() => null) + if (existing !== text) { + drift++ + console.error(`${path}: out of date`) + } + } + if (drift) { + console.error(`${drift} generated file(s) out of date; run pnpm generate`) + process.exit(1) + } + console.log(`${result.files.size} generated files up to date`) +} else { + await rm(out, { recursive: true, force: true }) + for (const [path, text] of result.files) { + await mkdir(dirname(resolve(out, path)), { recursive: true }) + await writeFile(resolve(out, path), text) + } + console.log(`wrote ${result.files.size} files to generated/`) +} diff --git a/examples/design-system/scripts/sync-hwc.mjs b/examples/design-system/scripts/sync-hwc.mjs new file mode 100644 index 0000000..11adcc0 --- /dev/null +++ b/examples/design-system/scripts/sync-hwc.mjs @@ -0,0 +1,30 @@ +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Vendors the built headless components (`@hwc/components`) into + * `vendor/hwc`, until the package is published. Run from a machine that has + * the headless repository checked out; `HWC_DIR` points at its + * `packages/components`. The copied files are committed, so a checkout of + * this repository needs nothing outside it. + */ +const root = fileURLToPath(new URL('..', import.meta.url)) +const source = resolve( + process.env.HWC_DIR ?? resolve(root, '../../../headless-web-components/packages/components'), +) +const target = resolve(root, 'vendor/hwc') + +await rm(target, { recursive: true, force: true }) +await mkdir(target, { recursive: true }) +await cp(resolve(source, 'dist'), resolve(target, 'dist'), { + recursive: true, + filter: (path) => !/\.(test|map)\.(js|d\.ts)$|\.d\.ts\.map$/.test(path), +}) +await cp(resolve(source, 'custom-elements.json'), resolve(target, 'custom-elements.json')) +const pkg = JSON.parse(await readFile(resolve(source, 'package.json'), 'utf8')) +await writeFile( + resolve(target, 'VENDORED.md'), + `# Vendored ${pkg.name}\n\nCopied from a local checkout of the headless-web-components repository by\n\`scripts/sync-hwc.mjs\`. Remove this folder and depend on the published package\nonce it exists on npm.\n`, +) +console.log(`vendored ${pkg.name} into vendor/hwc`) diff --git a/examples/design-system/src/main.ts b/examples/design-system/src/main.ts new file mode 100644 index 0000000..223e920 --- /dev/null +++ b/examples/design-system/src/main.ts @@ -0,0 +1,35 @@ +// The headless behaviour: registering the custom elements is all a page needs. +import '@hwc/components/checkbox/index.js' +import '@hwc/components/field/index.js' +import '@hwc/components/button/index.js' +import '@hwc/components/radio-button/index.js' + +import checkbox from '../generated/html/checkbox.html?raw' +import checkboxField from '../generated/html/checkbox-field.html?raw' +import button from '../generated/html/button.html?raw' +import contactList from '../generated/html/contact-list.html?raw' +import { mountReactDemo } from './react-demo' + +/** The HTML target: the generated fragments, dropped into a page as-is. */ +const sections: [string, string][] = [ + ['Checkbox', checkbox], + ['CheckboxField', checkboxField], + [ + 'Button', + `${button}\n${button.replace('', '')}\n${button.replace('', '')}`, + ], + ['ContactList', contactList], +] +const html = document.getElementById('html-target')! +for (const [title, fragment] of sections) { + const section = document.createElement('section') + const heading = document.createElement('h2') + heading.textContent = title + const row = document.createElement('div') + row.className = 'row' + row.innerHTML = fragment + section.append(heading, row) + html.append(section) +} + +mountReactDemo(document.getElementById('react-target')!) diff --git a/examples/design-system/src/react-demo.tsx b/examples/design-system/src/react-demo.tsx new file mode 100644 index 0000000..6dbb820 --- /dev/null +++ b/examples/design-system/src/react-demo.tsx @@ -0,0 +1,98 @@ +import { StrictMode, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { Button, Checkbox, CheckboxField, ContactList, Field } from '../generated/react' +import type { Contact } from '../generated/react/models' + +/** + * The React target: the generated components, used the way an application + * would. State lives in React; behaviour in the custom elements underneath. + */ +const people: Contact[] = [ + { id: 'ada', name: 'Ada Lovelace', email: 'ada@example.com' }, + { id: 'grace', name: 'Grace Hopper' }, + { id: 'linus', name: 'Linus Torvalds', email: 'linus@example.com' }, +] + +function Demo() { + const [checked, setChecked] = useState(false) + const [chosen, setChosen] = useState(null) + return ( + <> +
+

Checkbox

+
+ setChecked(next)} /> + + +
+
+
+

CheckboxField

+ + } + /> +
+
+

Button

+
+
+
+
+

ContactList

+ ( + setChosen(person.id)} + /> + )} + empty={No contacts yet} + /> +

Chosen: {chosen ?? 'nobody'}

+
+ + ) +} + +import { ContactOption } from '../generated/react' + +function ContactOptionRow({ + person, + chosen, + onChoose, +}: { + person: Contact + chosen: boolean + onChoose: () => void +}) { + return ( + checked && onChoose()} + /> + ) +} + +export function mountReactDemo(container: HTMLElement): void { + createRoot(container).render( + + + , + ) +} diff --git a/examples/design-system/test/generated.test.ts b/examples/design-system/test/generated.test.ts new file mode 100644 index 0000000..318048a --- /dev/null +++ b/examples/design-system/test/generated.test.ts @@ -0,0 +1,59 @@ +import { readdir, readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from '@uidx/format' +import { modelIndex } from '@uidx/schema/design-system' +import { auditDesignSystem } from '@uidx/schema/design-system-audit' +import { generate } from '@uidx/codegen' + +/** + * The cross-target conformance rule (ADR 0017 §4): the committed output is + * what the identities render to, byte for byte, and every identity passes + * the design-system audit against the vendored headless manifest. + */ +const root = resolve(import.meta.dirname, '..') + +async function load() { + const pages = [] + const tokens = [] + for (const name of (await readdir(resolve(root, '.uidx'))) + .filter((n) => n.endsWith('.uidx')) + .sort()) { + const file = `.uidx/${name}` + const { doc, diagnostics } = parse(await readFile(resolve(root, file), 'utf8')) + expect(diagnostics, file).toEqual([]) + if (doc!.tree.element === 'Tokens') tokens.push(doc!) + else pages.push({ file, doc: doc! }) + } + const manifest = JSON.parse( + await readFile(resolve(root, 'vendor/hwc/custom-elements.json'), 'utf8'), + ) + return { pages, tokens, manifest } +} + +describe('the example design system', () => { + it('passes the design-system audit for every page', async () => { + const { pages } = await load() + // As `uidx check` audits: with every page's models in one index, since a + // list names its model by type and the model is written once (ADR 0015 §2). + const models = modelIndex(pages.map((page) => page.doc)) + for (const { file, doc } of pages) { + expect(auditDesignSystem(doc, models).map((d) => `${file}: ${d.message}`)).toEqual([]) + } + }) + + it('conforms to the headless manifest', async () => { + const { pages, tokens, manifest } = await load() + const result = generate({ pages, tokens, manifest, targets: ['contract'] }) + expect(result.diagnostics.map((d) => `${d.file}: ${d.message}`)).toEqual([]) + }) + + it('has generated/ up to date with .uidx/', async () => { + const { pages, tokens, manifest } = await load() + const result = generate({ pages, tokens, manifest }) + for (const [path, text] of result.files) { + const existing = await readFile(resolve(root, 'generated', path), 'utf8').catch(() => null) + expect(existing, path).toBe(text) + } + }) +}) diff --git a/examples/design-system/tsconfig.json b/examples/design-system/tsconfig.json new file mode 100644 index 0000000..a7480ca --- /dev/null +++ b/examples/design-system/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "customConditions": ["development"], + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "types": ["node", "vite/client"], + "paths": { + "@hwc/components/*": ["./vendor/hwc/dist/*"] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "generated/react/**/*.ts", + "generated/react/**/*.tsx", + "test/**/*.ts" + ] +} diff --git a/examples/design-system/vendor/hwc/VENDORED.md b/examples/design-system/vendor/hwc/VENDORED.md new file mode 100644 index 0000000..40e4dcf --- /dev/null +++ b/examples/design-system/vendor/hwc/VENDORED.md @@ -0,0 +1,5 @@ +# Vendored @hwc/components + +Copied from a local checkout of the headless-web-components repository by +`scripts/sync-hwc.mjs`. Remove this folder and depend on the published package +once it exists on npm. diff --git a/examples/design-system/vendor/hwc/custom-elements.json b/examples/design-system/vendor/hwc/custom-elements.json new file mode 100644 index 0000000..0f45f27 --- /dev/null +++ b/examples/design-system/vendor/hwc/custom-elements.json @@ -0,0 +1,2739 @@ +{ + "schemaVersion": "1.0.0", + "readme": "", + "modules": [ + { + "kind": "javascript-module", + "path": "button/button-label.ts", + "declarations": [ + { + "kind": "class", + "description": "A pure positional seam (root README's \"positional seams\" category) —\nalways present, giving a theme a stable, addressable box for the\nbutton's text content instead of an anonymous projected text node no\nstylesheet can reach. Carries no state of its own and consumes none:\nunlike Checkbox's indicators, nothing about a label's presence or\nappearance depends on hwc-button's state — it's a direct child by\nconstruction (GUIDELINES.md rule 4's carve-out). Disabled dimming\nreaches it for free through ordinary CSS inheritance of `color`, since\nhwc-button's own disabled state-to-token mapping sets `color` on itself\nrather than a property this element would need to independently mirror.\n\nNot self-registering — call ButtonLabel.define() (optionally with a tag\nname of your own choosing) before use. See GUIDELINES.md rule 5.", + "name": "ButtonLabel", + "slots": [ + { + "description": "The button's own text content.", + "name": "" + } + ], + "members": [ + { + "kind": "method", + "name": "define", + "static": true, + "parameters": [ + { + "name": "tagName", + "default": "\"hwc-button-label\"" + } + ] + }, + { + "kind": "field", + "name": "internals", + "privacy": "protected", + "readonly": true, + "inheritedFrom": { + "name": "SlottedElement", + "module": "internal/slotted-element.ts" + } + }, + { + "kind": "method", + "name": "toggleState", + "privacy": "protected", + "parameters": [ + { + "name": "state", + "type": { + "text": "string" + } + }, + { + "name": "force", + "type": { + "text": "boolean" + } + } + ], + "inheritedFrom": { + "name": "SlottedElement", + "module": "internal/slotted-element.ts" + } + } + ], + "superclass": { + "name": "SlottedElement", + "module": "/internal/slotted-element.js" + }, + "tagName": "hwc-button-label", + "customElement": true + } + ], + "exports": [ + { + "kind": "js", + "name": "ButtonLabel", + "declaration": { + "name": "ButtonLabel", + "module": "button/button-label.ts" + } + } + ] + }, + { + "kind": "javascript-module", + "path": "button/button-leading-icon.ts", + "declarations": [ + { + "kind": "class", + "description": "A pure positional seam (root README's \"positional seams\" category) —\npresent only when the consumer projects one, giving a theme a fixed,\naddressable box to size and center regardless of what's projected (an\nSVG, an ``, a glyph). Carries no state and consumes none: it's a\ndirect child by construction (GUIDELINES.md rule 4's carve-out), and\ndisabled dimming reaches a `currentColor`-based icon for free through\nordinary CSS inheritance from hwc-button's own `color`.\n\nNot self-registering — call ButtonLeadingIcon.define() (optionally with\na tag name of your own choosing) before use. See GUIDELINES.md rule 5.", + "name": "ButtonLeadingIcon", + "slots": [ + { + "description": "The consumer's own icon (an SVG, an ``, anything).", + "name": "" + } + ], + "members": [ + { + "kind": "method", + "name": "define", + "static": true, + "parameters": [ + { + "name": "tagName", + "default": "\"hwc-button-leading-icon\"" + } + ] + }, + { + "kind": "field", + "name": "internals", + "privacy": "protected", + "readonly": true, + "inheritedFrom": { + "name": "SlottedElement", + "module": "internal/slotted-element.ts" + } + }, + { + "kind": "method", + "name": "toggleState", + "privacy": "protected", + "parameters": [ + { + "name": "state", + "type": { + "text": "string" + } + }, + { + "name": "force", + "type": { + "text": "boolean" + } + } + ], + "inheritedFrom": { + "name": "SlottedElement", + "module": "internal/slotted-element.ts" + } + } + ], + "superclass": { + "name": "SlottedElement", + "module": "/internal/slotted-element.js" + }, + "tagName": "hwc-button-leading-icon", + "customElement": true + } + ], + "exports": [ + { + "kind": "js", + "name": "ButtonLeadingIcon", + "declaration": { + "name": "ButtonLeadingIcon", + "module": "button/button-leading-icon.ts" + } + } + ] + }, + { + "kind": "javascript-module", + "path": "button/button-trailing-icon.ts", + "declarations": [ + { + "kind": "class", + "description": "A pure positional seam (root README's \"positional seams\" category) —\npresent only when the consumer projects one, giving a theme a fixed,\naddressable box to size and center regardless of what's projected (an\nSVG, an ``, a glyph). Carries no state and consumes none: it's a\ndirect child by construction (GUIDELINES.md rule 4's carve-out), and\ndisabled dimming reaches a `currentColor`-based icon for free through\nordinary CSS inheritance from hwc-button's own `color`.\n\nNot self-registering — call ButtonTrailingIcon.define() (optionally with\na tag name of your own choosing) before use. See GUIDELINES.md rule 5.", + "name": "ButtonTrailingIcon", + "slots": [ + { + "description": "The consumer's own icon (an SVG, an ``, anything).", + "name": "" + } + ], + "members": [ + { + "kind": "method", + "name": "define", + "static": true, + "parameters": [ + { + "name": "tagName", + "default": "\"hwc-button-trailing-icon\"" + } + ] + }, + { + "kind": "field", + "name": "internals", + "privacy": "protected", + "readonly": true, + "inheritedFrom": { + "name": "SlottedElement", + "module": "internal/slotted-element.ts" + } + }, + { + "kind": "method", + "name": "toggleState", + "privacy": "protected", + "parameters": [ + { + "name": "state", + "type": { + "text": "string" + } + }, + { + "name": "force", + "type": { + "text": "boolean" + } + } + ], + "inheritedFrom": { + "name": "SlottedElement", + "module": "internal/slotted-element.ts" + } + } + ], + "superclass": { + "name": "SlottedElement", + "module": "/internal/slotted-element.js" + }, + "tagName": "hwc-button-trailing-icon", + "customElement": true + } + ], + "exports": [ + { + "kind": "js", + "name": "ButtonTrailingIcon", + "declaration": { + "name": "ButtonTrailingIcon", + "module": "button/button-trailing-icon.ts" + } + } + ] + }, + { + "kind": "javascript-module", + "path": "button/button.ts", + "declarations": [ + { + "kind": "class", + "description": "Headless button root — state, behavior, and accessibility only.\nSee button/SPEC.md for the composition contract and GUIDELINES.md for\nwhy there's no native `', context)[0]?.message).toBe( + '