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..658faac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,75 @@ 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 design-system model (ADRs 0012–0017). A `.uidx` file may now carry a + `## Contract` (props, events, slots, form, accessibility; `` only for + states the element produces itself, since a visual boolean prop is a state + and `hover`/`focus`/`active` are the browser's; `` descriptions are + optional, the tree's `part=` bindings being the declaration), + `## Behavior` guidelines, `## Models` and `## Examples` after its visual + contract, and a `` table beside the root. Models are shared across + pages and named by a prop's type (`Contact`, `Contact[]`). Any layer of a + component repeats over a list with `repeat="{items}"` — or `{person.tags}` + inside an outer repeat, which makes a tree — naming its item with `as`; the + model's samples decide how many rows the canvas draws; a repeat on a + `` is the one consumers fill. Declarations only: nothing + in the file computes. The viewer draws a styles table as the variant set it + derives, resolves `{item.field}` bindings to a model's samples and `{prop}` + to the prop's `sample` or default, expands a repeat into sample rows (a + model declared on another page included), and resolves an instance's + `props={{ label: '{label}' }}` in the consuming component's scope. A `` that states no size hugs its placeholder. `uidx check` audits the regions against each + other and the tree; `uidx contract` prints them as JSON. +- `@uidx/codegen` and `uidx codegen`: HTML/CSS and React rendered from the + identities over headless custom elements, with props, events and slots from + the contract — a plain slot as a `ReactNode` prop, a repeating slot as its + list prop plus a render prop named after it, a repeat elsewhere as a `map` + in place — and each contract checked against the headless library's + `custom-elements.json`. +- `examples/design-system`: Checkbox, Field, CheckboxField, Button and a + contact list rendered end to end over `@hwc/components`, with the generated + output committed and checked for drift. +- A state is designed on the canvas: the derived variants a styles table draws + are selectable, and a change to one writes the matching cell of its style + row (the new `style` patch op), while a change to the default combination + edits the base tree. The inspector names the state it is editing. A base + layer chosen in the rail is drawn by the default state, so that is what the + canvas highlights, and the default state chosen on the canvas is the base + layer in the inspector. The toolbar gains a Repeat tool that repeats the + selected layer over the first list its component's contract can place; the + first row of a repeat is the layer itself, the rows after it its echoes. +- A **Models** face beside Tokens and Fonts: every model of the document + with the page that declares it and the components that receive it, edited + in place (description; fields with type, key, optional, sample and words), + a new model declared on a chosen page — a `models` page being the shared + one — and models a contract names but nobody declares offered for + declaring. The new `model` and `field` patch ops write one `` back + canonically and invert. A sample changed there redraws every repeat of it, + and a repeat's row in the Contract tab links to its model. +- The Contract tab edits the contract: each prop, event, slot, state and + part opens into a small form (description, type, default, sample, flags; + a slot's `accepts`), a row adds one, and "Fill from library" + declares what the implemented element exposes and the contract lacks. The + new `contract` patch op writes one declaration in canonical form. +- `uidx.json` gains an optional `codegen` (`out`, `targets`): `uidx codegen` + needs no `--out`, and the Contract tab's Generate button renders the code + targets from the server into that folder, reporting what it wrote. +- The inspector gains a **Contract** tab beside Design. A component chooses + the headless element it implements from the library; its parts are bound to + layers from the component's list or from the layer's own row, and any layer + picks the list it repeats over and its item's name. The tab shows the + file's contract beside the bindings and counts the parts still to bind. + `uidx.json` gains an optional `"headless"` path to the library's + `custom-elements.json`, served to the viewer and used by `uidx codegen` when + `--manifest` is absent. Parts a library exposes as shadow parts (`cssParts`) + are offered beside element parts and marked; the code target styles them + through `::part()`. `headless` may also be an object with a `profile` (how + the library reflects props, its own states and parts) and `bindings` (its + names per component), so one design renders over libraries that spell + things differently; when nothing is named, the Contract tab offers the + libraries the project's dependencies ship and writes the choice. + ## 0.1.6 — 2026-09-25 - The first `uidx dev` sets up the workspace itself when the install script did diff --git a/README.md b/README.md index 36da22d..60660b0 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,44 @@ The separate `.uidx/uidx.json` manifest controls which files belong to the docum { "id": "my-project", "files": ["**/*.uidx"], - "assets": ["assets/**"] + "assets": ["assets/**"], + "headless": "../vendor/hwc/custom-elements.json" } ``` +`headless` is optional. It names the headless library's `custom-elements.json`, +relative to `uidx.json`; with it, the viewer's Contract tab offers the library's +elements and parts as choices, and `uidx codegen` checks contracts against it. +Leave it out and the Contract tab offers the libraries your dependencies ship +(any package whose `package.json` has a `customElements` field) and writes your +choice here. The object form binds the same designs to a library that spells +things differently, without editing a design: + +```json +{ + "headless": { + "manifest": "node_modules/@shoelace-style/shoelace/dist/custom-elements.json", + "profile": { "props": "attribute", "customStates": "state", "parts": "element" }, + "bindings": { + "Checkbox": { + "tag": "sl-checkbox", + "parts": { "checked-indicator": "control" }, + "events": { "change": "sl-change" } + } + } + } +} +``` + +`profile` says how the library reflects props (`attribute`, `data-attribute`, +`class`), its own states (`state`, `data-attribute`, `class`) and parts +(`element`, `data-part`). `bindings` maps a component's identity names to the +library's. Both default to the conventions `@hwc/components` follows. + +`codegen` is optional too: `{ "out": "../generated", "targets": ["html", "react", "contract"] }` +says where `uidx codegen` writes without `--out`, and gives the viewer's +Contract tab a Generate button that renders the same output from the server. + ### CLI and MCP `npm run uidx` starts the local viewer, file synchronization server, and MCP HTTP @@ -119,6 +153,74 @@ npx --no-install uidx fmt --check The CLI also supports reading, creating, editing, and rendering pages. See the [CLI guide](packages/cli/README.md) and [agent architecture](packages/agent/README.md). +## Design system: one file, every render + +A `.uidx` file can be the identity of a component, and the components you +ship — on the canvas, in Figma, as HTML/CSS, as React — are renders of it +(ADRs 0012–0017). Nothing in the file computes; implementation is a renderer's +job. One checkbox, top to bottom: + +```mdx +--- +id: checkbox +--- + +Lets a user toggle one option. The box and its marks are the design system's; +the label comes from a Field. + +## Visual Contract + + + + + + + + + + + +
+

+ 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 ` + + + + + Read only
@@ -1273,85 +1354,111 @@ function onDetach(prop: string, value: JsonValue): void {
-

- {{ - (selection?.length ?? 0) > 1 - ? 'Select a single layer to edit its properties.' - : 'Select a layer to adjust its size, layout, and appearance.' - }} -

-

- Reconnect to edit. You can still inspect properties and export. -

- - - -
-

- This fills the slot {{ fillSlot.name }}. Its layout belongs to the component that declares it; what is inside is this - page’s. -

-
- - -
-
- -
- -
- - px -
-

1rem = {{ rootFontSize }}px

-
- -
- +
+ -
- -
- - -
- - {{ swapRow.component }} - -
+
+ + {{ swapRow.component }} + +
- - + - - + - + -

This node declares no properties.

+

This node declares no properties.

- - - - - - - - - - - - - - - - + - -
- -
+
+ +
- - + - -
- Alignment - -
+ +
+ Alignment + +
-
- Padding +
- -
- Padding - -
-
- Corner radius - -
-
-
-
+
+ Corner radius + +
+ + + - - + - + - -
- -
-
- - - - - - - -
+ + -
+ + + +
+ +
- -
- - - Pixels - - {{ exportPixels ? `${exportPixels.width} × ${exportPixels.height}` : '—' }} +
+ + + Pixels + + {{ exportPixels ? `${exportPixels.width} × ${exportPixels.height}` : '—' }} + - -
+
- - -
-
-
+ + + +
+ @@ -1983,6 +2094,48 @@ h2 { color: var(--warn); font-size: var(--ui-size-sm); } +/* The same toggle the left rail uses for Elements / Tokens / Fonts. */ +.face-toggle { + display: flex; + gap: 2px; + padding: 3px; + background: var(--bg); + border-radius: 8px; +} +.face-toggle button { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + padding: 4px 10px; + border: 0; + border-radius: 5px; + background: none; + color: var(--text-dim); + font: inherit; + font-size: 11px; + font-weight: 500; + cursor: pointer; +} +.face-toggle button[aria-pressed='true'] { + background: var(--raised); + color: var(--text); + box-shadow: var(--shadow-sm); +} +.face-toggle button:hover { + color: var(--text); +} +.face-toggle .badge { + min-width: 14px; + padding: 0 4px; + border-radius: 999px; + background: var(--warn); + color: var(--bg); + font-size: 9px; + font-weight: 600; + line-height: 14px; + text-align: center; +} .note { color: var(--text-faint); font-size: var(--ui-size); diff --git a/packages/viewer/src/WorkspaceNav.vue b/packages/viewer/src/WorkspaceNav.vue index 2e197c6..2d90572 100644 --- a/packages/viewer/src/WorkspaceNav.vue +++ b/packages/viewer/src/WorkspaceNav.vue @@ -2,12 +2,12 @@ defineProps<{ title: string page?: string | null - view: 'home' | 'page' | 'tokens' | 'fonts' + view: 'home' | 'page' | 'tokens' | 'fonts' | 'models' canGoHome: boolean renderable: boolean }>() -const emit = defineEmits<{ home: []; face: [view: 'page' | 'tokens' | 'fonts'] }>() +const emit = defineEmits<{ home: []; face: [view: 'page' | 'tokens' | 'fonts' | 'models'] }>() diff --git a/packages/viewer/src/contract-edits.ts b/packages/viewer/src/contract-edits.ts new file mode 100644 index 0000000..66296f0 --- /dev/null +++ b/packages/viewer/src/contract-edits.ts @@ -0,0 +1,552 @@ +import { + resolve, + toAlias, + type ContractDeclaration, + type ContractKind, + type UidxDocument, + type UidxNode, + type UidxPatch, +} from '@uidx/format' +import { + derivedTarget, + repeatListType, + repeatModel, + repeatOf, + sampleCount, + type ModelIndex, + type RepeatScope, +} from '@uidx/schema' +import { enclosingComponent } from './component-prop-edits' +import type { HeadlessElement, HeadlessLibrary } from './headless' + +/** + * What the Contract tab shows and writes (ADR 0013 §3, ADR 0017 §2). + * + * The tab binds the visual tree to the code render: a `` names the + * headless element it implements, a layer names the part it draws, and any + * layer names the list it repeats over. Three bindings — `implements`, + * `part`, `repeat`/`as` — that the prop table deliberately does not + * know, because they are bindings to the contract rather than scene fields. + * + * Pure, like `instance-prop-edits`: the view is computed from the document + * and the library, and every gesture is a list of patches the shell applies. + */ + +/** The elements a part may be bound to: things that draw, not holes or uses. */ +const BINDABLE: ReadonlySet = new Set(['Frame', 'Text', 'Vector', 'Rectangle', 'Ellipse']) + +export interface PartRow { + name: string + /** Where the name is declared: the library, the file's ``, or both. */ + declaredBy: 'library' | 'contract' | 'both' + /** How the library exposes it; absent for a part only the contract declares. */ + kind?: 'element' | 'shadow' + /** The layer bound to it, or null while unbound. */ + boundTo: { address: string; name: string; element: string } | null +} + +export interface SlotRow { + name: string + /** The `` providing it, with what it repeats over when it does, or null while missing. */ + provided: { address: string; repeat: { list: string } | null } | null +} + +export interface Candidate { + address: string + name: string + element: string + depth: number +} + +export interface ComponentView { + kind: 'component' + component: UidxNode + implementsValue: string | null + /** The library's roots, plus the current value when the library lacks it. */ + rootOptions: { tag: string; known: boolean }[] + element: HeadlessElement | null + parts: PartRow[] + /** Layers a part could be bound to, in tree order, excluding those already bound. */ + candidates: Candidate[] + slots: SlotRow[] + /** Layers bound to a part nothing declares. */ + strayParts: { address: string; name: string; part: string }[] +} + +/** How a layer repeats (ADR 0017 §2): the list it walks, its item's name, the canvas's rows. */ +export interface RepeatBinding { + /** The alias target: `items`, or `person.tags` inside an outer repeat. */ + list: string + as: string + /** Rows the canvas draws: the model's longest sample list, three when it has none. */ + rows: number + /** The model of one item, when the contract can place the list; by type alone when unknown. */ + model: string | null + /** True when the list is placed but its model is declared on no page in the index. */ + unknownModel: boolean +} + +/** The repeat rows every layer inside a component shows: part, slot, or instance alike. */ +export interface RepeatFacet { + repeat: RepeatBinding | null + /** Lists a repeat here may walk: the contract's list props, then list fields of enclosing items. */ + lists: string[] +} + +export interface PartView extends RepeatFacet { + kind: 'part' + node: UidxNode + component: UidxNode + partValue: string | null + /** Every declared part; `takenBy` names the other layer holding it. */ + options: { name: string; takenBy: string | null; kind?: 'element' | 'shadow' }[] + /** True when neither the library nor the contract declares any part. */ + undeclared: boolean +} + +export interface SlotView extends RepeatFacet { + kind: 'slot' + node: UidxNode + component: UidxNode | null + declared: { accepts?: string } | null +} + +export interface InstanceView extends RepeatFacet { + kind: 'instance' + node: UidxNode + component: UidxNode | null +} + +export interface OtherView { + kind: 'page' | 'other' + node: UidxNode | null +} + +/** A node of a derived state: bindings live on the base layer it was copied from. */ +export interface DerivedView { + kind: 'derived' + node: UidxNode + base: UidxNode + component: UidxNode + state: string +} + +export type ContractView = + ComponentView | PartView | SlotView | InstanceView | OtherView | DerivedView + +/** The parts a component may bind, from the library element and the contract. */ +export function declaredParts(component: UidxNode, element: HeadlessElement | null): PartRow[] { + const contract = (component.spec?.contract?.parts ?? []).map((part) => part.name) + const rows = new Map() + for (const part of element?.parts ?? []) + rows.set(part.name, { name: part.name, declaredBy: 'library', kind: part.kind, boundTo: null }) + for (const name of contract) { + const row = rows.get(name) + if (row) row.declaredBy = 'both' + else rows.set(name, { name, declaredBy: 'contract', boundTo: null }) + } + return [...rows.values()] +} + +function implementedElement( + component: UidxNode, + library: HeadlessLibrary | null, +): HeadlessElement | null { + const tag = component.attrs.implements?.value + return typeof tag === 'string' ? (library?.elements.get(tag) ?? null) : null +} + +/** Every layer below a component, depth-first, with its depth for indenting. */ +function descendants(component: UidxNode): Candidate[] { + const out: Candidate[] = [] + const walk = (node: UidxNode, depth: number): void => { + for (const child of node.children) { + out.push({ address: child.address, name: child.name, element: child.element, depth }) + // An instance's insides belong to another component; a part cannot + // reach into them (ADR 0013 §3 binds parts within one tree). + if (child.element !== 'Instance') walk(child, depth + 1) + } + } + walk(component, 0) + return out +} + +function partBindings(component: UidxNode): Map { + const out = new Map() + const walk = (node: UidxNode): void => { + for (const child of node.children) { + const part = child.attrs.part?.value + if (typeof part === 'string' && !out.has(part)) out.set(part, child) + if (child.element !== 'Instance') walk(child) + } + } + walk(component) + return out +} + +function componentView(component: UidxNode, library: HeadlessLibrary | null): ComponentView { + const value = component.attrs.implements?.value + const implementsValue = typeof value === 'string' ? value : null + const element = implementedElement(component, library) + const rootOptions = (library?.roots ?? []).map((root) => ({ tag: root.tag, known: true })) + if (implementsValue !== null && !rootOptions.some((option) => option.tag === implementsValue)) + rootOptions.unshift({ tag: implementsValue, known: false }) + + const bindings = partBindings(component) + const parts = declaredParts(component, element) + for (const row of parts) { + const node = bindings.get(row.name) + if (node) row.boundTo = { address: node.address, name: node.name, element: node.element } + } + const declared = new Set(parts.map((row) => row.name)) + const strayParts = [...bindings] + .filter(([name]) => !declared.has(name)) + .map(([part, node]) => ({ address: node.address, name: node.name, part })) + + const bound = new Set([...bindings.values()].map((node) => node.address)) + const candidates = descendants(component).filter( + (candidate) => BINDABLE.has(candidate.element) && !bound.has(candidate.address), + ) + + const slots: SlotRow[] = (component.spec?.contract?.slots ?? []).map((slot) => ({ + name: slot.name, + provided: null, + })) + for (const name of element?.slots ?? []) { + if (name !== '' && !slots.some((slot) => slot.name === name)) + slots.push({ name, provided: null }) + } + const provide = (node: UidxNode): void => { + for (const child of node.children) { + if (child.element === 'Slot') { + const row = slots.find((slot) => slot.name === child.name) + const repeat = repeatOf(child) + if (row && !row.provided) + row.provided = { + address: child.address, + repeat: repeat ? { list: repeat.list } : null, + } + } + if (child.element !== 'Instance') provide(child) + } + } + provide(component) + + return { + kind: 'component', + component, + implementsValue, + rootOptions, + element, + parts, + candidates, + slots, + strayParts, + } +} + +function partView( + node: UidxNode, + component: UidxNode, + library: HeadlessLibrary | null, + models?: ModelIndex, +): PartView { + const element = implementedElement(component, library) + const value = node.attrs.part?.value + const partValue = typeof value === 'string' ? value : null + const bindings = partBindings(component) + const options = declaredParts(component, element).map((row) => { + const holder = bindings.get(row.name) + return { + name: row.name, + takenBy: holder && holder.address !== node.address ? holder.name : null, + ...(row.kind ? { kind: row.kind } : {}), + } + }) + // A value nothing declares still shows, so the row never lies about the file. + if (partValue !== null && !options.some((option) => option.name === partValue)) + options.unshift({ name: partValue, takenBy: null }) + return { + kind: 'part', + node, + component, + partValue, + options, + undeclared: options.length === 0, + ...repeatFacet(component, node, models), + } +} + +/* ------------------------------------------------------------ repeats */ + +/** The layers from the component down to, excluding, the node — the repeats among them scope it. */ +function ancestorsWithin(component: UidxNode, node: UidxNode): UidxNode[] { + const path: UidxNode[] = [] + const walk = (current: UidxNode): boolean => { + if (current === node) return true + for (const child of current.children) { + path.push(current) + if (walk(child)) return true + path.pop() + } + return false + } + return walk(component) ? path : [] +} + +/** The repeats enclosing a node, outermost first, each with the model its item carries. */ +function enclosingRepeats(component: UidxNode, node: UidxNode, models?: ModelIndex): RepeatScope[] { + const scopes: RepeatScope[] = [] + for (const ancestor of ancestorsWithin(component, node)) { + const repeat = repeatOf(ancestor) + if (repeat) + scopes.push({ as: repeat.as, model: repeatModel(repeat, component.spec, scopes, models) }) + } + return scopes +} + +/** + * The lists a repeat on this layer may walk (ADR 0017 §2): the contract's + * list props as `items`, and the list fields of every enclosing item as + * `person.tags` — what a tree or a grouped list nests on. + */ +export function placeableLists( + component: UidxNode | null, + node: UidxNode, + models?: ModelIndex, +): string[] { + if (!component) return [] + const out: string[] = [] + for (const prop of component.spec?.contract?.props ?? []) + if (prop.type.trim().endsWith('[]')) out.push(prop.name) + for (const scope of enclosingRepeats(component, node, models)) + for (const field of scope.model?.fields ?? []) + if (field.type.trim().endsWith('[]')) out.push(`${scope.as}.${field.name}`) + return out +} + +function repeatFacet(component: UidxNode | null, node: UidxNode, models?: ModelIndex): RepeatFacet { + const lists = placeableLists(component, node, models) + const attrs = repeatOf(node) + if (!attrs || !component) return { repeat: null, lists } + const enclosing = enclosingRepeats(component, node, models) + const model = repeatModel(attrs, component.spec, enclosing, models) + const placed = model ? undefined : repeatListType(attrs, component.spec, enclosing, models) + return { + repeat: { + list: attrs.list, + as: attrs.as, + rows: sampleCount(model), + model: model?.name ?? (placed ? placed.slice(0, -2) : null), + unknownModel: !model && typeof placed === 'string', + }, + lists, + } +} + +/** + * The tab's view of the selection. + * + * A component is the anchor: everything the tab writes is a binding to a + * component's contract, so a layer outside one has nothing to bind and says so. + */ +export function contractView( + doc: UidxDocument | null, + node: UidxNode | null, + library: HeadlessLibrary | null, + models?: ModelIndex, +): ContractView { + if (!doc || !node) return { kind: 'page', node: null } + if (node.element === 'Component') return componentView(node, library) + const from = derivedTarget(doc, node.address) + if (from) { + // The default state draws the base tree (ADR 0016 §4): selected there, a + // layer is the authored one, and the component's root is the component. + if (from.isDefault) return contractView(doc, from.base, library, models) + const state = Object.entries(from.keys) + .map(([axis, value]) => `${axis}=${value}`) + .join(', ') + return { kind: 'derived', node, base: from.base, component: from.component, state } + } + const component = enclosingComponent(doc, node.address) + if (node.element === 'Slot') { + const declared = component?.spec?.contract?.slots.find((slot) => slot.name === node.name) + return { + kind: 'slot', + node, + component, + declared: declared ? (declared.accepts ? { accepts: declared.accepts } : {}) : null, + ...repeatFacet(component, node, models), + } + } + if (node.element === 'Instance') + return { kind: 'instance', node, component, ...repeatFacet(component, node, models) } + if (component && BINDABLE.has(node.element)) return partView(node, component, library, models) + return { kind: 'other', node } +} + +/** Parts declared and unbound, plus stray bindings — what the tab's badge counts. */ +export function contractIssues(view: ContractView): number { + if (view.kind === 'component') + return view.parts.filter((row) => !row.boundTo).length + view.strayParts.length + if (view.kind === 'part' && view.partValue !== null) + return view.options.some((option) => option.name === view.partValue && !option.takenBy) ? 0 : 1 + return 0 +} + +/* ------------------------------------------------------------ writes */ + +function setAttr(node: UidxNode, prop: string, value: string | number | null): UidxPatch[] { + if (value === null || value === '') + return node.attrs[prop] === undefined ? [] : [{ op: 'remove', address: node.address, prop }] + return [ + { op: node.attrs[prop] === undefined ? 'add' : 'set', address: node.address, prop, value }, + ] +} + +/** `implements` on a component; empty clears it. */ +export function setImplements(component: UidxNode, tag: string | null): UidxPatch[] { + return setAttr(component, 'implements', tag) +} + +/** `part` on a layer; empty clears it. */ +export function setPart(node: UidxNode, part: string | null): UidxPatch[] { + return setAttr(node, 'part', part) +} + +/** + * Binds a part to a layer from the component's side, moving it off whichever + * layer held it — a part is bound once (ADR 0013 §3), so choosing a new layer + * is also unbinding the old one, and the two land in one patch. + */ +export function bindPart( + doc: UidxDocument, + component: UidxNode, + part: string, + address: string, +): UidxPatch[] { + const target = resolve(doc.tree, address) + if (!target) return [] + const holder = partBindings(component).get(part) + const out: UidxPatch[] = [] + if (holder && holder.address !== target.address) out.push(...setPart(holder, null)) + out.push(...setPart(target, part)) + return out +} + +/** + * `repeat="{list}"` on a layer (ADR 0017 §2). Clearing it takes `as` with + * it — first, since the parser refuses `as` without a repeat to ride on and + * every patch must leave a valid file. + */ +export function setRepeat(node: UidxNode, list: string | null): UidxPatch[] { + const target = list?.trim().replace(/^\{|\}$/g, '') ?? '' + if (target === '') return ['as', 'repeat'].flatMap((prop) => setAttr(node, prop, null)) + return setAttr(node, 'repeat', toAlias(target)) +} + +/** The item's name for the bindings below a repeat; `item` is the default and is not written. */ +export function setRepeatAs(node: UidxNode, as: string | null): UidxPatch[] { + const name = as?.trim() ?? '' + if (name === '' || name === 'item') return setAttr(node, 'as', null) + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return [] + return setAttr(node, 'as', name) +} + +/* ------------------------------------------------- the contract itself */ + +/** Writes one declaration of the contract (ADR 0013 §2), creating its list and region as needed. */ +export function declare( + kind: ContractKind, + name: string, + declaration: ContractDeclaration, +): UidxPatch[] { + if (!name.trim()) return [] + return [{ op: 'contract', kind, name: name.trim(), declaration }] +} + +/** Removes one declaration; a list left empty goes with it. */ +export function undeclare(kind: ContractKind, name: string): UidxPatch[] { + return [{ op: 'contract', kind, name }] +} + +/** The words a scaffolded declaration carries until someone writes its own. */ +export const PLACEHOLDER = 'Describe ' + +/** True for a description nobody has written yet. */ +export const isPlaceholder = (description: string): boolean => description.startsWith(PLACEHOLDER) + +/** + * The contract's type for a manifest attribute type: `boolean` stays, + * a union of quoted strings becomes an enum in the contract's spelling, and + * anything else — `string`, `number`, or nothing — is text. + */ +export function contractType(manifestType: string | undefined): string { + if (!manifestType) return 'string' + const text = manifestType.trim() + if (text === 'boolean') return 'boolean' + if (text === 'number') return 'number' + const parts = text.split('|').map((part) => part.trim()) + if (parts.length > 1 && parts.every((part) => /^(['"]).*\1$/.test(part))) + return parts.map((part) => `'${part.slice(1, -1)}'`).join(' | ') + return 'string' +} + +/** + * Declarations the library's element implies and the contract lacks (ADR + * 0013 §5): its attributes as props, its events, its named slots, its parts. + * Descriptions come from the manifest where it has them and are otherwise + * placeholders the tab marks until they are written — a contract with words + * missing is a draft, not a lie. + */ +export function scaffoldFromLibrary(component: UidxNode, element: HeadlessElement): UidxPatch[] { + const contract = component.spec?.contract + const has = (kind: ContractKind, name: string): boolean => { + switch (kind) { + case 'prop': + return contract?.props.some((entry) => entry.name === name) ?? false + case 'event': + return contract?.events.some((entry) => entry.name === name) ?? false + case 'slot': + return contract?.slots.some((entry) => entry.name === name) ?? false + case 'part': + return contract?.parts.some((entry) => entry.name === name) ?? false + case 'state': + return contract?.states.some((entry) => entry.name === name) ?? false + } + } + const words = (member: { name: string; description?: string }, what: string): string => + member.description ?? `${PLACEHOLDER}the ${what} "${member.name}".` + const out: UidxPatch[] = [] + for (const attribute of element.members.attributes) { + if (!attribute.name || has('prop', attribute.name)) continue + const type = contractType(attribute.type) + out.push( + ...declare('prop', attribute.name, { + attrs: { + type, + ...(type === 'boolean' ? { default: false, visual: true } : {}), + }, + description: words(attribute, 'prop'), + }), + ) + } + for (const event of element.members.events) { + if (!event.name || has('event', event.name)) continue + out.push(...declare('event', event.name, { attrs: {}, description: words(event, 'event') })) + } + for (const slot of element.members.slots) { + if (!slot.name || has('slot', slot.name)) continue + out.push(...declare('slot', slot.name, { attrs: {}, description: words(slot, 'slot') })) + } + for (const part of element.parts) { + if (has('part', part.name)) continue + out.push( + ...declare('part', part.name, { + attrs: {}, + description: `${PLACEHOLDER}the part "${part.name}".`, + }), + ) + } + return out +} diff --git a/packages/viewer/src/derived-edits.ts b/packages/viewer/src/derived-edits.ts new file mode 100644 index 0000000..69de592 --- /dev/null +++ b/packages/viewer/src/derived-edits.ts @@ -0,0 +1,87 @@ +import type { UidxDocument, UidxPatch } from '@uidx/format' +import { derivedTarget, type DerivedTarget } from '@uidx/schema' + +/** + * Where an edit to a derived variant goes (ADR 0016 §4). + * + * The canvas draws every state of a component from its styles table, and an + * author designs a state the Figma way: select the hover variant, change the + * stroke. The node they changed has no source span, so the patch the panel + * wrote — `set` on `Checkbox#state=hover/root` — cannot land as written. It + * lands as a cell of the `state="hover"` row instead; on the default + * combination it lands on the base tree, since that is what the default + * draws. Structural edits have nowhere to go and are refused with a reason. + * + * Pure, like every edit helper here: patches in, patches or a refusal out. + */ + +/** Properties a state may not change: where a variant sits is the arrangement's. */ +const NOT_A_STYLE: ReadonlySet = new Set(['x', 'y', 'rotation', 'name', 'part']) + +export type Routed = { patches: UidxPatch[] } | { refused: string } + +export function routeDerivedPatches(doc: UidxDocument, patches: readonly UidxPatch[]): Routed { + const out: UidxPatch[] = [] + for (const patch of patches) { + const at = addressOf(patch) + const target = at === null ? null : derivedTarget(doc, at) + if (!target) { + out.push(patch) + continue + } + const where = describe(target) + switch (patch.op) { + case 'set': + case 'add': + case 'remove': { + if (target.isDefault) { + out.push({ ...patch, address: target.base.address }) + break + } + if (NOT_A_STYLE.has(patch.prop) && target.target === 'root') { + return { + refused: `${where} draws where its base draws; move or rename the base instead`, + } + } + if (NOT_A_STYLE.has(patch.prop)) { + return { refused: `"${patch.prop}" is not a look; change it on the base layer` } + } + out.push({ + op: 'style', + keys: { ...target.keys }, + target: target.target, + prop: patch.prop, + ...(patch.op === 'remove' ? {} : { value: patch.value }), + }) + break + } + default: + return { + refused: `${where} is drawn from the base tree; add, remove or move layers on the base`, + } + } + } + return { patches: out } +} + +/** `state=hover of Checkbox`, for a notice or a chip. */ +export function describe(target: DerivedTarget): string { + const at = Object.entries(target.keys) + .map(([axis, value]) => `${axis}=${value}`) + .join(', ') + return `${at} of ${target.component.name}` +} + +function addressOf(patch: UidxPatch): string | null { + switch (patch.op) { + case 'insert-node': + return patch.parent + case 'style': + case 'contract': + case 'model': + case 'field': + return null + default: + return patch.address + } +} diff --git a/packages/viewer/src/headless.ts b/packages/viewer/src/headless.ts new file mode 100644 index 0000000..b834d8b --- /dev/null +++ b/packages/viewer/src/headless.ts @@ -0,0 +1,276 @@ +import { shallowRef } from 'vue' + +/** + * The headless library, as the Contract tab reads it (ADR 0013 §3). + * + * A `custom-elements.json` is the library's own description of itself, and + * the tab wants three things from it: which tags exist, which of them are + * *roots* an author may implement, and which parts and slots each root has. + * The rest — attributes, events — is shown for orientation and never written. + */ +/** + * A part as the library exposes it: an element of its own (`-`, + * which can hold the design's content) or a shadow part (`cssParts`, styled + * through `::part()` and drawn by the library). + */ +export interface HeadlessPart { + name: string + kind: 'element' | 'shadow' +} + +/** What the manifest says about one attribute, event or slot: enough to scaffold a contract from. */ +export interface HeadlessMember { + name: string + /** The manifest's type text, e.g. `boolean` or `"a" | "b"`; attributes only. */ + type?: string + description?: string +} + +export interface HeadlessElement { + tag: string + /** The parts this root offers, from `-` elements and `cssParts`. */ + parts: HeadlessPart[] + /** Slot names; `''` is the default slot. */ + slots: string[] + attributes: string[] + events: string[] + /** The same attributes, events and slots with what the manifest says about them. */ + members: { attributes: HeadlessMember[]; events: HeadlessMember[]; slots: HeadlessMember[] } + description?: string +} + +export interface HeadlessLibrary { + /** Where `uidx.json` said the library is, for the panel to name. */ + path: string + /** Every declared element, by tag, parts included. */ + elements: Map + /** The tags an author may implement: elements that are not a part of another. */ + roots: HeadlessElement[] + /** `uidx.json`'s `headless.bindings`: the library's names per component, when configured. */ + bindings: Record }> +} + +/** A library a dependency ships, offered when the document names none. */ +export interface HeadlessCandidate { + package: string + path: string +} + +/** The slice of a `custom-elements.json` this module reads. */ +interface Manifest { + modules?: { + declarations?: { + tagName?: string | null + description?: string + attributes?: { name?: string; type?: { text?: string }; description?: string }[] + events?: { name?: string; description?: string }[] + slots?: { name?: string; description?: string }[] + cssParts?: { name?: string }[] + }[] + }[] +} + +const names = (entries: { name?: string }[] | undefined): string[] => + (entries ?? []).map((entry) => entry.name ?? '').filter((name, i, all) => all.indexOf(name) === i) + +const members = ( + entries: { name?: string; type?: { text?: string }; description?: string }[] | undefined, +): HeadlessMember[] => + (entries ?? []) + .filter((entry, i, all) => all.findIndex((other) => other.name === entry.name) === i) + .map((entry) => ({ + name: entry.name ?? '', + ...(entry.type?.text ? { type: entry.type.text } : {}), + ...(entry.description ? { description: entry.description.split('\n')[0]!.trim() } : {}), + })) + +/** + * The library's shape, from the manifest as written. + * + * Parts follow the convention the code target relies on (ADR 0017 §3): a + * part is an element named `-`, so `hwc-checkbox-checked-indicator` + * is the `checked-indicator` part of `hwc-checkbox`. A manifest that also lists + * `cssParts` on the root contributes those the same way. The longest matching + * root wins, so `hwc-text-input-leading-icon` belongs to `hwc-text-input`, not + * to a shorter `hwc-text` if one existed. + */ +export function parseHeadless( + path: string, + manifest: unknown, + bindings: HeadlessLibrary['bindings'] = {}, +): HeadlessLibrary { + const declared = new Map() + for (const module of (manifest as Manifest)?.modules ?? []) { + for (const declaration of module.declarations ?? []) { + const tag = declaration.tagName + if (typeof tag !== 'string' || tag === '' || declared.has(tag)) continue + declared.set(tag, { + tag, + parts: names(declaration.cssParts).map((name) => ({ name, kind: 'shadow' as const })), + slots: names(declaration.slots), + attributes: names(declaration.attributes), + events: names(declaration.events), + members: { + attributes: members(declaration.attributes), + events: members(declaration.events), + slots: members(declaration.slots), + }, + ...(declaration.description ? { description: declaration.description } : {}), + }) + } + } + + const tags = [...declared.keys()].sort((a, b) => b.length - a.length) + // A part is a leaf: nothing hangs off it, and it takes no attributes and + // fires no events of its own. That is what tells `hwc-text-input` (a root + // with `hwc-text-input-leading-icon` below it) from `hwc-text-input-leading-icon` + // when `hwc-text` is also a root — the prefix alone cannot. + const isLeaf = (element: HeadlessElement): boolean => + element.attributes.length === 0 && + element.events.length === 0 && + !tags.some((other) => other.startsWith(`${element.tag}-`)) + const partOf = new Map() + for (const [tag, element] of declared) { + if (!isLeaf(element)) continue + const root = tags.find((other) => other !== tag && tag.startsWith(`${other}-`)) + if (root !== undefined) partOf.set(tag, root) + } + for (const [tag, root] of partOf) { + const owner = declared.get(root)! + const part = tag.slice(root.length + 1) + const known = owner.parts.find((entry) => entry.name === part) + // An element wins over a cssPart of the same name: it can hold content. + if (known) known.kind = 'element' + else owner.parts.push({ name: part, kind: 'element' }) + } + + const roots = [...declared.values()] + .filter((element) => !partOf.has(element.tag)) + .sort((a, b) => a.tag.localeCompare(b.tag)) + return { path, elements: declared, roots, bindings } +} + +/** `null` until loaded, and when the document declares no library. */ +export const headlessLibrary = shallowRef(null) +/** Why the library could not be read, or `''`. Shown in the tab, never thrown. */ +export const headlessError = shallowRef('') +/** Libraries the project's dependencies ship, while the document names none. */ +export const headlessCandidates = shallowRef([]) + +function unavailable(response: Response): void { + if (!response.headers.get('content-type')?.includes('application/json')) { + throw new Error('The headless library service is unavailable.') + } +} + +interface HeadlessPayload { + path: string | null + library?: unknown + bindings?: HeadlessLibrary['bindings'] + candidates?: HeadlessCandidate[] + codegen?: { out: string } + error?: string +} + +/** Where `uidx.json` says generated code goes (`codegen.out`), and how the last run went. */ +export interface CodegenState { + out: string | null + running: boolean + notice: string +} +export const codegenState = shallowRef({ out: null, running: false, notice: '' }) + +function adopt(data: HeadlessPayload): void { + headlessLibrary.value = + data.path === null ? null : parseHeadless(data.path, data.library, data.bindings ?? {}) + headlessCandidates.value = data.candidates ?? [] + codegenState.value = { ...codegenState.value, out: data.codegen?.out ?? null } +} + +/** + * Renders the code targets into `codegen.out` on the server (ADR 0017 §3): + * the same generator `uidx codegen` runs, over the same files, so the panel + * and the command line never disagree about what the code looks like. + */ +export async function generateCode(): Promise { + codegenState.value = { ...codegenState.value, running: true, notice: '' } + try { + const response = await fetch('/__uidx/codegen', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + signal: AbortSignal.timeout(60_000), + }) + unavailable(response) + const data = (await response.json()) as { + out?: string + written?: string[] + diagnostics?: { + file: string + line: number + column: number + message: string + severity: string + }[] + error?: string + } + if (!response.ok) throw new Error(data.error ?? 'Could not generate code.') + const errors = (data.diagnostics ?? []).filter((d) => d.severity === 'error') + const notice = errors.length + ? `Not written: ${errors.map((d) => `${d.file}:${d.line} ${d.message}`).join('; ')}` + : `Wrote ${data.written?.length ?? 0} files to ${data.out ?? codegenState.value.out}` + codegenState.value = { ...codegenState.value, running: false, notice } + } catch (error) { + codegenState.value = { + ...codegenState.value, + running: false, + notice: error instanceof Error ? error.message : String(error), + } + } +} + +/** + * Names the library the document uses: written into `uidx.json` by the + * server, so the choice is committed with the project and every tool reads + * the same file. `path` is one of the candidates, or any path relative to + * `uidx.json`. + */ +export async function chooseHeadless(path: string): Promise { + headlessError.value = '' + try { + const response = await fetch('/__uidx/headless', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path }), + signal: AbortSignal.timeout(15_000), + }) + unavailable(response) + const data = (await response.json()) as HeadlessPayload + if (!response.ok) throw new Error(data.error ?? 'Could not choose the headless library.') + adopt(data) + } catch (error) { + headlessError.value = error instanceof Error ? error.message : String(error) + } +} + +/** + * Fetches the document's library from the server (`/__uidx/headless`). + * + * Called on every `document:opened`, which is also every reconnect, so a + * library re-synced while the viewer was open shows up on the next reload of + * the page without a restart. + */ +export async function refreshHeadless(): Promise { + headlessError.value = '' + try { + const response = await fetch('/__uidx/headless', { signal: AbortSignal.timeout(15_000) }) + unavailable(response) + const data = (await response.json()) as HeadlessPayload + if (!response.ok) throw new Error(data.error ?? 'Could not read the headless library.') + adopt(data) + } catch (error) { + headlessLibrary.value = null + headlessCandidates.value = [] + headlessError.value = error instanceof Error ? error.message : String(error) + } +} diff --git a/packages/viewer/src/inspector-controls.css b/packages/viewer/src/inspector-controls.css index 2484db6..6de89b9 100644 --- a/packages/viewer/src/inspector-controls.css +++ b/packages/viewer/src/inspector-controls.css @@ -225,11 +225,11 @@ } /* Component rows share the inspector's gutters and leave room for 32px fields. */ -.properties .editor :is(.component-props, .instance-props, .variants) { +.properties .editor :is(.component-props, .instance-props, .variants, .contract) { margin: 0 calc(-1 * var(--section-pad)); padding: 0 var(--section-pad) 12px; } -.properties .editor :is(.component-props, .instance-props, .variants) > .head { +.properties .editor :is(.component-props, .instance-props, .variants, .contract) > .head { height: 44px; align-items: center; } diff --git a/packages/viewer/src/layer-icons.ts b/packages/viewer/src/layer-icons.ts index 115424c..dad0a05 100644 --- a/packages/viewer/src/layer-icons.ts +++ b/packages/viewer/src/layer-icons.ts @@ -7,6 +7,9 @@ import type { UidxElement } from '@uidx/format' * forbids network calls — the same rule that made the canvas vendor CanvasKit's * wasm. Every path is drawn in a 12x12 box to match `--icon`. */ +/** The toolbar's repeat glyph: rows and a rule (ADR 0017 §2). */ +export const REPEAT_ICON = 'M2 2h6v2H2zM2 5h6v2H2zM2 8h6v2H2zM10 2v8' + export const LAYER_ICONS: Record = { Page: 'M2 1h5l3 3v7H2z', Component: 'M6 1l2.5 2.5L6 6 3.5 3.5zM6 6l2.5 2.5L6 11 3.5 8.5z', @@ -26,6 +29,8 @@ export const LAYER_ICONS: Record = { // it reads as "somewhere content goes" rather than as content. Drawn as an // outline, since a slot paints nothing of its own. Slot: 'M2 2h3M7 2h3M10 2v3M10 7v3M10 10H7M5 10H2M2 10V7M2 5V2', + // One filling drawn many times (ADR 0017): three stacked outlines, the + // shape of a list of clones rather than of any one of them. Tokens: 'M2 3h8M2 6h8M2 9h5', Collection: 'M2 2h8v3H2zM2 7h8v3H2z', Variable: 'M4 2v8M8 2v8M2 5h8', diff --git a/packages/viewer/src/model-edits.ts b/packages/viewer/src/model-edits.ts new file mode 100644 index 0000000..c674717 --- /dev/null +++ b/packages/viewer/src/model-edits.ts @@ -0,0 +1,234 @@ +import type { + ContractDeclaration, + FieldSpec, + JsonValue, + ModelSpec, + UidxDocument, + UidxPatch, +} from '@uidx/format' + +/** + * The Models face (ADR 0015 §1): every model of the document in one place, + * the way the Tokens face gathers every collection. + * + * A model is declared once — on the page of the component that shows it, or + * on a shared models page — and named by a prop's type wherever it is used, + * so the face lists models across pages, says which page holds each and + * which components receive it, and writes back through the `model` and + * `field` ops to whichever page declares it. Pure, like the other edit + * modules: the view is computed from the pages, every gesture is patches + * for one file. + */ + +export interface ModelUse { + component: string + file: string + prop: string + /** True for `Contact[]`: the component repeats over a list of them. */ + list: boolean +} + +export interface ModelCard { + name: string + /** The page whose `## Models` declares it. */ + file: string + description: string + fields: FieldSpec[] + usedBy: ModelUse[] + /** Declared on the page the author has open. */ + onPage: boolean +} + +/** What a prop's type names, stripped of the list marker: `Contact[]` → `Contact`. */ +const typeName = (type: string): { name: string; list: boolean } => { + const text = type.trim() + return text.endsWith('[]') + ? { name: text.slice(0, -2).trim(), list: true } + : { name: text, list: false } +} + +/** Every model of the document, the open page's first, then by page and name. */ +export function modelsViewModel( + pages: ReadonlyMap, + current: string | null, +): ModelCard[] { + const uses = new Map() + for (const [file, doc] of pages) { + for (const node of doc.tree.children) { + if (node.element !== 'Component') continue + for (const prop of node.spec?.contract?.props ?? []) { + const { name, list } = typeName(prop.type) + const found = uses.get(name) ?? [] + found.push({ component: node.name, file, prop: prop.name, list }) + uses.set(name, found) + } + } + } + const cards: ModelCard[] = [] + for (const [file, doc] of pages) { + for (const model of doc.spec?.models ?? []) { + if (cards.some((card) => card.name === model.name)) continue + cards.push({ + name: model.name, + file, + description: model.description, + fields: model.fields, + usedBy: uses.get(model.name) ?? [], + onPage: file === current, + }) + } + } + return cards.sort( + (a, b) => + Number(b.onPage) - Number(a.onPage) || + a.file.localeCompare(b.file) || + a.name.localeCompare(b.name), + ) +} + +/** Models a contract names that no page declares — the face offers to declare them. */ +export function undeclaredModels( + pages: ReadonlyMap, +): { name: string; file: string }[] { + const declared = new Set() + for (const doc of pages.values()) + for (const model of doc.spec?.models ?? []) declared.add(model.name) + const out: { name: string; file: string }[] = [] + for (const [file, doc] of pages) { + for (const node of doc.tree.children) { + if (node.element !== 'Component') continue + for (const prop of node.spec?.contract?.props ?? []) { + const { name } = typeName(prop.type) + // A model is a capitalised name; `string`, `boolean`, `'a' | 'b'` are not. + if (!/^[A-Z][A-Za-z0-9_]*$/.test(name) || declared.has(name)) continue + if (!out.some((entry) => entry.name === name)) out.push({ name, file }) + } + } + } + return out +} + +/** A legal model or field name: a bare identifier. */ +export const isIdentifier = (name: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) + +/** + * Where a new model may go: every page, the open one first, and a page + * called `models` — the shared models page ADR 0015 §1 allows — marked as + * the suggestion when there is one. + */ +export function pagesForModels( + pages: ReadonlyMap, + current: string | null, +): { file: string; label: string; suggested: boolean }[] { + return [...pages] + .filter(([, doc]) => doc.tree.element !== 'Tokens') + .map(([file, doc]) => ({ + file, + label: String(doc.frontmatter.id ?? file), + suggested: String(doc.frontmatter.id) === 'models' || /(^|\/)models\.uidx$/.test(file), + })) + .sort( + (a, b) => + Number(b.file === current) - Number(a.file === current) || + Number(b.suggested) - Number(a.suggested) || + a.label.localeCompare(b.label), + ) +} + +/* ------------------------------------------------------------ writes */ + +export const PLACEHOLDER = 'Describe ' + +export function addModel(name: string): UidxPatch[] { + const trimmed = name.trim() + if (!isIdentifier(trimmed)) return [] + return [ + { + op: 'model', + name: trimmed, + declaration: { description: `${PLACEHOLDER}what one ${trimmed} carries.` }, + }, + ] +} + +export function setModelDescription(name: string, description: string): UidxPatch[] { + return [{ op: 'model', name, declaration: { description: description.trim() } }] +} + +export function removeModel(name: string): UidxPatch[] { + return [{ op: 'model', name }] +} + +export function setField( + model: string, + name: string, + declaration: ContractDeclaration, +): UidxPatch[] { + if (!isIdentifier(name)) return [] + return [{ op: 'field', model, name, declaration }] +} + +export function removeField(model: string, name: string): UidxPatch[] { + return [{ op: 'field', model, name }] +} + +/** A field as the op writes it back, with one attribute changed. */ +export function fieldWith( + field: FieldSpec, + change: Partial<{ + type: string + key: boolean + optional: boolean + sample: JsonValue | undefined + description: string + }>, +): ContractDeclaration { + const next = { + type: field.type, + key: field.key, + optional: field.optional, + sample: field.sample, + description: field.description, + ...change, + } + const attrs: Record = { type: next.type } + if (next.key) attrs.key = true + if (next.optional) attrs.optional = true + if (next.sample !== undefined) attrs.sample = next.sample + return { attrs, description: next.description } +} + +/** `field`, `field-2`, … — the first name the model does not have. */ +export function freshFieldName(model: { fields: readonly { name: string }[] }): string { + const taken = new Set(model.fields.map((field) => field.name)) + if (!taken.has('field')) return 'field' + for (let n = 2; ; n++) if (!taken.has(`field-${n}`)) return `field-${n}` +} + +/** A new field: a string with a placeholder description, to be edited into shape. */ +export function newField(model: ModelSpec): UidxPatch[] { + return setField(model.name, freshFieldName(model), { + attrs: { type: 'string' }, + description: `${PLACEHOLDER}the field.`, + }) +} + +/** + * A sample as typed: JSON where it parses (`["Ada", "Grace"]`, `3`, `null`), + * the text otherwise, nothing for an empty field. + */ +export function parseSample(text: string): JsonValue | undefined { + const trimmed = text.trim() + if (trimmed === '') return undefined + try { + return JSON.parse(trimmed) as JsonValue + } catch { + return trimmed + } +} + +/** A sample as shown: a bare string as is, anything else as JSON. */ +export function printSample(value: JsonValue | undefined): string { + if (value === undefined) return '' + return typeof value === 'string' ? value : JSON.stringify(value) +} diff --git a/packages/viewer/src/page-url.ts b/packages/viewer/src/page-url.ts index b8062ea..a996d2e 100644 --- a/packages/viewer/src/page-url.ts +++ b/packages/viewer/src/page-url.ts @@ -67,6 +67,8 @@ export type View = */ | { kind: 'tokens'; file: string } | { kind: 'fonts'; file: string } + /** The document's models (ADR 0015 §1), gathered from every page like tokens are. */ + | { kind: 'models'; file: string } export const HOME: View = { kind: 'home' } @@ -76,7 +78,8 @@ const VIEW_PARAM = 'view' export function urlWithView(href: string, view: View): string { const url = new URL(view.kind === 'home' ? href : urlWithPage(href, view.file)) if (view.kind === 'home') url.searchParams.delete(PARAM) - if (view.kind === 'tokens' || view.kind === 'fonts') url.searchParams.set(VIEW_PARAM, view.kind) + if (view.kind === 'tokens' || view.kind === 'fonts' || view.kind === 'models') + url.searchParams.set(VIEW_PARAM, view.kind) else url.searchParams.delete(VIEW_PARAM) return url.toString() } @@ -100,7 +103,7 @@ export function viewToOpen(href: string, pages: readonly string[], entry: string // An unknown value falls through to the page view: a link from a future // version should still show the page rather than nothing. const view = new URL(href).searchParams.get(VIEW_PARAM) - return view === 'tokens' || view === 'fonts' + return view === 'tokens' || view === 'fonts' || view === 'models' ? { kind: view, file: wanted } : { kind: 'page', file: wanted } } diff --git a/packages/viewer/src/patch-rebase.ts b/packages/viewer/src/patch-rebase.ts index e74b41a..ec0e31e 100644 --- a/packages/viewer/src/patch-rebase.ts +++ b/packages/viewer/src/patch-rebase.ts @@ -93,6 +93,14 @@ export function rebasePatches( pending.push({ from: patch.address, to: addressOf(patch.newParent, node.name) }) break } + // A style row is named by its keys, not by an address the change could + // have moved; the op lands on whatever the table holds now. + case 'style': + case 'contract': + case 'model': + case 'field': + rebased.push(patch) + break case 'retag': { // `nodeAt` already refuses a node whose element changed underneath — // which is exactly the collision that matters here: two retags of one diff --git a/packages/viewer/src/repeat-edits.ts b/packages/viewer/src/repeat-edits.ts new file mode 100644 index 0000000..fc570bc --- /dev/null +++ b/packages/viewer/src/repeat-edits.ts @@ -0,0 +1,58 @@ +import { resolve, toAlias, type UidxDocument, type UidxNode, type UidxPatch } from '@uidx/format' +import { repeatOf, type ModelIndex } from '@uidx/schema' +import { enclosingComponent } from './component-prop-edits' +import { placeableLists } from './contract-edits' + +/** + * Repeating a layer from the toolbar (ADR 0017 §2). + * + * Any layer inside a component may draw itself once per item of a list, so + * the gesture is legal on one shape of selection: a single layer inside a + * component, not repeating yet, with a list the contract can place — a list + * prop, or a list field of an enclosing item. The tool disables itself + * otherwise, the way the slot tool does, rather than failing on press. + */ +export interface RepeatTarget { + node: UidxNode + component: UidxNode + /** The first list the layer may walk. */ + list: string +} + +/** Elements that are structure rather than layers: a repeat rides on what they hold. */ +const NOT_A_LAYER: ReadonlySet = new Set(['Page', 'Component', 'Variant']) + +export function repeatTargetFor( + doc: UidxDocument, + selection: readonly string[], + models?: ModelIndex, +): RepeatTarget | null { + if (selection.length !== 1) return null + const address = selection[0]! + const node = resolve(doc.tree, address) + if (!node || NOT_A_LAYER.has(node.element) || repeatOf(node)) return null + const component = enclosingComponent(doc, address) + if (!component) return null + const [list] = placeableLists(component, node, models) + return list ? { node, component, list } : null +} + +/** + * The gesture: `repeat="{list}"` lands on the layer, and nothing else moves. + * The rows are the model's samples; the Contract tab edits the list and the + * item's name from the layer. + */ +export function newRepeatFor( + doc: UidxDocument, + selection: readonly string[], + models?: ModelIndex, +): { patches: UidxPatch[]; address: string } | null { + const target = repeatTargetFor(doc, selection, models) + if (!target) return null + return { + patches: [ + { op: 'add', address: target.node.address, prop: 'repeat', value: toAlias(target.list) }, + ], + address: target.node.address, + } +} diff --git a/packages/viewer/src/thumbnails.ts b/packages/viewer/src/thumbnails.ts index f9e85c9..0d0d075 100644 --- a/packages/viewer/src/thumbnails.ts +++ b/packages/viewer/src/thumbnails.ts @@ -1,7 +1,13 @@ import { SkiaRenderer } from '@open-pencil/core' import { getCanvasKit } from '@open-pencil/core/canvaskit' import { renderThumbnail } from '@open-pencil/core/io' -import { toSceneGraph, type SceneResult, type TokenIndex, type TokenResolver } from '@uidx/schema' +import { + toSceneGraph, + type SceneResult, + type TokenIndex, + type TokenResolver, + type ModelIndex, +} from '@uidx/schema' import type { JsonValue, UidxDocument, UidxNode } from '@uidx/format' import { createAssetStore, type AssetFetch, type AssetStore } from './asset-store' @@ -66,6 +72,8 @@ export interface ThumbnailRequest { * nearly empty. */ components: ReadonlyMap | undefined + /** Model name -> declaration across every page, so a repeat's rows draw their samples. */ + models?: ModelIndex /** * The page's revision. * @@ -149,6 +157,7 @@ export function sceneForThumbnail(request: ThumbnailRequest, assets?: SceneAsset resolveComponent: (name) => request.components?.get(name), resolveAsset: (src) => assets?.hashOf(src), tokens: request.tokens, + models: request.models, }) // The graph owns the byte store the renderer reads and a fresh graph starts // empty, so it is refilled here — the same two steps `CanvasPane` takes, and diff --git a/packages/viewer/test/contract-edits.test.ts b/packages/viewer/test/contract-edits.test.ts new file mode 100644 index 0000000..cccb3e1 --- /dev/null +++ b/packages/viewer/test/contract-edits.test.ts @@ -0,0 +1,638 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { applyPatches, parseOrThrow, resolve } from '@uidx/format' +import { defaultVariantAddress, derivedDocument } from '@uidx/schema' +import ContractSection from '../src/ContractSection.vue' +import PropertiesPane from '../src/PropertiesPane.vue' +import { + bindPart, + contractIssues, + contractType, + contractView, + scaffoldFromLibrary, + setImplements, + setPart, + setRepeat, + setRepeatAs, +} from '../src/contract-edits' +import { parseHeadless, type HeadlessLibrary } from '../src/headless' + +/** + * Binding the visual tree to its code render from the inspector (ADR 0013 §3, + * ADR 0017 §2). + * + * The claims: a component picks its element from the library's roots, a part + * is bound from either end and lands as one `part` attribute, a bound part + * moves rather than doubles, a repeat picks a list the contract can place, and + * every write is a patch the shell applies unchanged. + */ +const LIBRARY = parseHeadless('vendor/custom-elements.json', { + modules: [ + { + declarations: [ + { + tagName: 'hwc-checkbox', + attributes: [{ name: 'checked' }], + events: [{ name: 'change' }], + }, + { tagName: 'hwc-checkbox-checked-indicator' }, + { tagName: 'hwc-checkbox-indeterminate-indicator' }, + { tagName: 'hwc-field', slots: [{ name: '' }, { name: 'control' }] }, + { tagName: 'hwc-field-label' }, + { tagName: 'hwc-text-input' }, + { tagName: 'hwc-text-input-leading-icon' }, + { tagName: 'hwc-text', cssParts: [{ name: 'glyph' }] }, + ], + }, + ], +}) + +const page = (id: string, body: string, regions = '') => + `---\nid: ${id}\n---\n\n## Visual Contract\n\n\n${body}\n\n${regions}` + +const CHECKBOX = page( + 'checkbox', + ` + + + + `, + ` + +