From 4893949bcc3497338d4e2f5d6298abf8c6ef4fc6 Mon Sep 17 00:00:00 2001 From: Guy Behar Date: Tue, 29 Sep 2026 23:46:54 +0300 Subject: [PATCH 01/19] =?UTF-8?q?docs:=20ADRs=200012=E2=80=930017=20?= =?UTF-8?q?=E2=80=94=20the=20design-system=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identity, contract, behaviour guidelines, models and examples, variants as renders, collections and code targets. Theming beyond modes is recorded as a future task. Co-Authored-By: Claude Fable 5.1 --- docs/decisions/0012-design-system-model.md | 86 +++++++++++++++++ docs/decisions/0013-component-contract.md | 93 +++++++++++++++++++ docs/decisions/0014-behavior-guidelines.md | 56 +++++++++++ docs/decisions/0015-models-and-examples.md | 76 +++++++++++++++ docs/decisions/0016-variants-as-renders.md | 87 +++++++++++++++++ .../0017-collections-and-code-targets.md | 84 +++++++++++++++++ 6 files changed, 482 insertions(+) create mode 100644 docs/decisions/0012-design-system-model.md create mode 100644 docs/decisions/0013-component-contract.md create mode 100644 docs/decisions/0014-behavior-guidelines.md create mode 100644 docs/decisions/0015-models-and-examples.md create mode 100644 docs/decisions/0016-variants-as-renders.md create mode 100644 docs/decisions/0017-collections-and-code-targets.md diff --git a/docs/decisions/0012-design-system-model.md b/docs/decisions/0012-design-system-model.md new file mode 100644 index 0000000..98f6b75 --- /dev/null +++ b/docs/decisions/0012-design-system-model.md @@ -0,0 +1,86 @@ +# ADR 0012 — The design system model: identity, contract, behaviour; components are renders + +Status: **accepted**, 2026-09-29. Umbrella for ADRs 0013–0017, which each +decide one region of the file. + +## Context + +A design system used to be a library of hand-built components that people +assembled. When agents generate most of the UI, generation is cheap and drift +is the default failure, so the question becomes: where does consistency live +when nobody assembles by hand? + +UIDX already has the pieces of an answer. A `.uidx` file is a language, not a +picture: primitives (`Frame`, `Text`, `Vector`), auto-layout and constraints, +tokens with modes, named components with props and variants, and addresses an +agent can patch. What it lacks is everything a component *is* beyond its look: +what it accepts, what it does, and what data it shows. + +Meanwhile the headless web components in `@hwc/components` hold exactly that +other half — state, behaviour, accessibility, form participation — as custom +elements with no paint, each described by a `SPEC.md` with an anatomy of named +parts. + +## Decision + +### 1. A component is an identity; targets render it + +A component's **identity** is platform-free and theme-free and has three +layers, each in its own region of one `.uidx` file: + +| Layer | Region | Reader | +|---|---|---| +| Intent, anatomy, layout, styles | intent prose + `## Visual Contract` | viewer, canvas, Figma export, code emitters | +| Contract: props, events, states, slots, parts, form, accessibility | `## Contract` (ADR 0013) | code emitters, audit, agents | +| Behaviour guidelines | `## Behavior` (ADR 0014) | developers, agents, tests | +| Data shapes and sample scenes | `## Models`, `## Examples` (ADR 0015) | canvas, Figma, code emitters | + +A **theme** is token values in modes. A **concrete component** is +`render(identity, props, theme, target)`. Appearance variants are points in +that space and are never authored (ADR 0016); only variants that change +anatomy are authored trees. + +The targets are the canvas (today), Figma export, HTML/CSS and React +(ADR 0017). Each target reads the regions it can use and ignores the rest. +The viewer and Figma never read `## Contract`, `## Behavior` or `## Models` +beyond what the canvas needs to draw sample data. + +### 2. Declare, never compute + +The file declares. It never contains an implementation: + +- No TypeScript blocks. The headless element implements behaviour in code and + is proven to conform to the declared contract (ADR 0013 §4). +- No expressions. The only dynamic values are aliases — `{radius#md}`, a + component property `{label}`, or a model field `{item.name}` — and each is a + lookup, not a formula. Derived values arrive as fields (ADR 0015 §2). +- No conditions in the visual contract. Presence and appearance under a state + are rows in the styles table (ADR 0016 §2), not `when` attributes. + +### 3. Behaviour lives in the headless layer + +A component's `implements` attribute names its headless root, e.g. +`hwc-checkbox`. Its parts (`part="checked-indicator"`) are that root's part +elements. Slots are that root's slots. The design system owns how every part +looks and where it sits; the headless element owns what it does. Composition +(`Composes with`) is the headless layer's graph, which the audit reads. + +### 4. What stays out of scope for now + +- **Theming beyond modes** — several brands, density, per-product overrides — + is a future task. Modes (ADR 0011 era, story G8) remain the only theming + axis in this iteration. +- Responsive and adaptive layout rules. +- Evals for generated screens. + +## Consequences + +- One file per component is the single source of truth for what it is. Code, + Figma sets and `SPEC.md` become renders of it. +- The parser gains three optional regions after the visual contract + (ADR 0013 §1). Existing files are unchanged and stay valid. +- `uidx check` gains the rules each ADR names. They are what make the model a + tool rather than a document. +- A new package, `@uidx/codegen`, renders HTML/CSS and React from the file + (ADR 0017), and an example package demonstrates the whole path against + `@hwc/components`. diff --git a/docs/decisions/0013-component-contract.md b/docs/decisions/0013-component-contract.md new file mode 100644 index 0000000..e9a5bf3 --- /dev/null +++ b/docs/decisions/0013-component-contract.md @@ -0,0 +1,93 @@ +# ADR 0013 — The component contract region + +Status: **accepted**, 2026-09-29. Part of ADR 0012. + +## Context + +Code needs a component's public surface — props, events, states, slots, +parts, form participation, accessibility — and needs it to be the same on +every render. Today that surface exists twice: in `@hwc/components` as a +generated `custom-elements.json` plus a prose `SPEC.md`, and implicitly in a +`.uidx` component's `props` attribute. Neither is authoritative, and neither +is visible to the audit. + +## Decision + +### 1. Regions after the visual contract + +A `.uidx` file may carry, after `## Visual Contract`, at most one each of +`## Contract`, `## Behavior`, `## Models` and `## Examples`, in any order. The +visual contract region ends at the next depth-2 heading. Everything the +parser recognised there before is unchanged; the new regions are parsed into +`doc.spec`. Files without them are unchanged. + +### 2. Contract elements + +`## Contract` holds elements that are not scene nodes: + +```mdx + + Whether the option is selected. + The row to show. + +Fires once per user toggle. + +checked-indicator, indeterminate-indicator +Consumer text; styled here, filled there. +
+ + +``` + +- `Prop`: `name`, `type` (a TypeScript-ish type string, or `model`), `default`, + `controllable` (framework adapters add controlled/uncontrolled handling), + `visual` (may drive appearance: an axis in ADR 0016 and a Figma variant + property). Text content is the description and is required. +- `Event`: `name`, `detail`; description required. +- `States`: `structural` states mount or unmount parts; `styling` states only + change appearance. Both are the headless root's states. +- `Parts`: the part names the headless root defines. Every one must be bound + in the visual contract by `part="…"`. +- `Slot`: consumer-filled positions; `repeats`, `model` and `accepts` are + ADR 0017 §1. A slot named here must be a `` in the visual + contract. +- `Form`, `Accessibility`, `Composes`: declarations copied into `SPEC.md` and + read by the audit. + +Boolean attributes (`controllable`, `visual`, `key`, `optional`, +`participates`, `repeats`) may be written bare in these regions; the +shorthand rule of the visual contract does not apply here. + +### 3. Binding the visual contract to the contract + +- `` names the headless root. The + component's own frame is the root's host element. +- `part="…"` on any scene node binds it to a declared part. One node per part. +- `` (ADR 0007) is the position of a declared slot. +- Only `visual` props may appear as styles-table keys (ADR 0016). + +### 4. Declaration in uidx, implementation in code + +The contract says what a component is. The headless element implements it. +A conformance check compares the element's `custom-elements.json` entry for +the `implements` tag with the uidx contract: attributes ⊆ props by name, +events by name, part tags by part name. `uidx codegen --manifest` runs it +and fails on a mismatch. The uidx file is authoritative; the code is proven to +conform. + +### 5. Relation to `props={{ … }}` + +The `props` attribute (story F6) stays for files that have it. When a +`## Contract` is present, its `Prop` elements are the declaration and the +audit reports a `props` attribute beside them as redundant. A `Prop` with a +primitive type and a default resolves as a component property inside the +visual contract exactly as a `props` entry does. + +## Consequences + +- `doc.spec.contract` is available to `uidx contract `, which prints it + as JSON for generators that do not parse MDX. +- Removing or renaming a `Prop`, `Event` or `Slot` is a breaking change for + the component and is called out by the audit's contract diff. +- `SPEC.md` in the headless repository becomes a rendered view of this region + once the two are linked; until then the conformance check keeps them equal. diff --git a/docs/decisions/0014-behavior-guidelines.md b/docs/decisions/0014-behavior-guidelines.md new file mode 100644 index 0000000..bcfa983 --- /dev/null +++ b/docs/decisions/0014-behavior-guidelines.md @@ -0,0 +1,56 @@ +# ADR 0014 — Behaviour guidelines: prose with ids, traced to tests + +Status: **accepted**, 2026-09-29. Part of ADR 0012. + +## Context + +Two behaviours can both be legitimate — a number input that clamps at its +limit and one that wraps — and whoever implements the renderer must not be +the one who decides. The decision has to be in the file, in words a developer +and an agent implement from and a test is written against. + +## Decision + +### 1. A `## Behavior` region of bullets + +```mdx +## Behavior + +- toggle: click or Space flips `checked`; a click while `indeterminate` sets `checked` and clears `indeterminate`. +- change-event: `change` fires once per user toggle, never when `checked` is set from code. +- limit-reached: `overflow="clamp"` (default) stops at `min`/`max`; `overflow="wrap"` continues from the other end. +``` + +Each bullet is one observable behaviour: an id before the colon, then a +sentence in the form "when X, the component does Y", naming the props, states +and events involved in backticks. Prose stays prose; the parser reads only the +id and the text. + +### 2. Decide or expose, never leave open + +When more than one behaviour is legitimate, the rule either decides it or +names the prop that selects it. A bullet describing alternatives without a +selecting prop is an open decision and the audit reports it. + +### 3. Traceability + +Every id is an address, `Checkbox#behavior/toggle`. The headless +implementation's tests carry the same ids; a conformance report lists rules +without a test and tests without a rule. The visual targets never read this +region: where a rule has a visual consequence, that consequence is a state in +the contract and a row in the styles table. + +### 4. Conventions + +- Name a rule by the situation, not the implementation. "Clamps" is + behaviour; "uses a reducer" is not. +- Platform parity is a complete rule when true: "matches native + ``". +- Rationale may follow the sentence; it stops the decision being reopened. + +## Consequences + +- `SPEC.md` sections Accessibility, Form participation and the behaviour parts + of Composition become renders of this region. +- Agents writing pages read the rules to know how a component behaves without + opening its implementation. diff --git a/docs/decisions/0015-models-and-examples.md b/docs/decisions/0015-models-and-examples.md new file mode 100644 index 0000000..abb4cd5 --- /dev/null +++ b/docs/decisions/0015-models-and-examples.md @@ -0,0 +1,76 @@ +# ADR 0015 — Models declare inputs; examples are sample scenes + +Status: **accepted**, 2026-09-29. Part of ADR 0012. + +## Context + +A list renders items, and each item design needs to know what an item +contains. React solves this with a render prop; the design tools have no data +at all. The file needs a written contract for the data a component receives, +and sample values so the canvas and Figma have something to draw. + +## Decision + +### 1. A model is a view model, declared, never computed + +```mdx +## Models + + + One row of the contacts list. Every value is display-ready. + Stable identity, used for keys and selection. + Display name. + Shown when no avatar is supplied. Supplied, not derived. + Omitted when unknown. + +``` + +- A model is the declared type of what a component **receives**. It is not + state, and nothing in it is computed by the component. A derived value is a + field, supplied by the consumer's adapter. +- Types are primitives, string enums, `image`, `date`, arrays, and references + to other models. No methods. +- Every field carries a description; the audit refuses one without. +- `sample` is a value or a list of values. A list gives a repeat varied rows; + a `null` entry shows the absent-optional layout. Required fields must have + a sample. +- Exactly one `key` field per model used by a repeating slot. +- Models live in the file that uses them or on a shared models page, and are + referenced as `{models#Contact}`, the alias syntax tokens already use. + +### 2. Bindings are lookups + +A prop declared with `model="{models#Contact}"` makes `{item.name}` a legal +alias inside the component's visual contract, resolving to the prop's field. +In the canvas it resolves to the model's sample (the n-th sample inside a +repeat); in code to `item.name`; in Figma to a text or image property. Only +`{prop.field}` and `{prop.field.field}` are allowed — no operators, no +transforms. `{item.name | initials}` is a formula and is not permitted. + +An absent optional field leaves its bound part empty; a text part collapses. +That is a rendering rule, not a condition in the file. + +### 3. Examples are sample scenes + +```mdx +## Examples + + + + + + +``` + +An example sets counts, states and sample overrides by index. The canvas and +Figma render each example as a scene. Code ignores examples, with one +exception the emitters may use: an example is exactly a Storybook story or a +visual regression fixture, and may be emitted as one. + +## Consequences + +- The consumer owns one adapter per model, and that adapter is the only place + derivation happens. An agent building a feature reads the model and writes + the mapping; the design system never sees application data. +- Removing or retyping a field is a breaking change for every component bound + to the model; the audit lists them. diff --git a/docs/decisions/0016-variants-as-renders.md b/docs/decisions/0016-variants-as-renders.md new file mode 100644 index 0000000..0ec2792 --- /dev/null +++ b/docs/decisions/0016-variants-as-renders.md @@ -0,0 +1,87 @@ +# ADR 0016 — Variants are renders: axes from the contract, a styles table, authored trees only for structure + +Status: **accepted**, 2026-09-29. Part of ADR 0012. **Amends +[ADR 0005](0005-variants.md)**: the declared-axes-and-full-trees model stays +as the *output* the canvas and Figma see; §1's "one `` per +combination" is no longer how appearance variants are written. + +## Context + +ADR 0005 writes one full tree per combination. For a button with three +emphases, three sizes, an icon-only flag and five states that is ninety trees +differing only in token values — the Figma grid of cells this model exists to +replace. Only combinations that change anatomy are decisions a designer makes; +everything else is a computation over identity, props and theme. + +## Decision + +### 1. Axes come from the contract + +A component's variant space is its `visual` enum props (ADR 0013 §2) plus the +states its headless root declares. A `variants={{ … }}` attribute is no longer +needed; when both are present they must agree. + +### 2. Appearance variants: a styles table + +Written once, after the root of the visual contract, keyed by prop values and +states, referencing tokens only: + +```mdx + + + + +
+

+ 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..aa18b03 --- /dev/null +++ b/examples/design-system/scripts/generate.mjs @@ -0,0 +1,53 @@ +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..3fc0451 --- /dev/null +++ b/examples/design-system/scripts/sync-hwc.mjs @@ -0,0 +1,28 @@ +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..7e5747c --- /dev/null +++ b/examples/design-system/src/main.ts @@ -0,0 +1,32 @@ +// 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..650cf1a --- /dev/null +++ b/examples/design-system/src/react-demo.tsx @@ -0,0 +1,76 @@ +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..26e1af6 --- /dev/null +++ b/examples/design-system/test/generated.test.ts @@ -0,0 +1,51 @@ +import { readdir, readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from '@uidx/format' +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() + for (const { file, doc } of pages) { + expect(auditDesignSystem(doc).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..b015de7 --- /dev/null +++ b/examples/design-system/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "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 +1310,105 @@ 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 +2044,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/contract-edits.ts b/packages/viewer/src/contract-edits.ts new file mode 100644 index 0000000..03f60e4 --- /dev/null +++ b/packages/viewer/src/contract-edits.ts @@ -0,0 +1,348 @@ +import { resolve, type UidxDocument, type UidxNode, type UidxPatch } from '@uidx/format' +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, a + * `` names the slot it multiplies. Three attributes — `implements`, + * `part`, `slot`/`count` — 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' + /** The layer bound to it, or null while unbound. */ + boundTo: { address: string; name: string; element: string } | null +} + +export interface SlotRow { + name: string + repeats: boolean + /** How the tree provides it: a ``, a `` (with its count), or not yet. */ + provided: { kind: 'slot' | 'repeat'; address: string; count?: number } | 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 }[] +} + +export interface PartView { + 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 }[] + /** True when neither the library nor the contract declares any part. */ + undeclared: boolean +} + +export interface RepeatView { + kind: 'repeat' + node: UidxNode + component: UidxNode | null + slotValue: string + count: number + /** The contract's repeating slots — the only legal values. */ + slotOptions: string[] + /** The instance being multiplied, if the tree holds one. */ + child: { name: string; component: string } | null +} + +export interface SlotView { + kind: 'slot' + node: UidxNode + component: UidxNode | null + declared: { repeats: boolean; accepts?: string } | null +} + +export interface OtherView { + kind: 'instance' | 'page' | 'other' + node: UidxNode | null +} + +export type ContractView = ComponentView | PartView | RepeatView | SlotView | OtherView + +/** 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 ?? [] + const rows = new Map() + for (const name of element?.parts ?? []) + rows.set(name, { name, declaredBy: 'library', 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, + repeats: slot.repeats, + provided: null, + })) + for (const name of element?.slots ?? []) { + if (name !== '' && !slots.some((slot) => slot.name === name)) + slots.push({ name, repeats: false, 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) + if (row && !row.provided) row.provided = { kind: 'slot', address: child.address } + } else if (child.element === 'Repeat') { + const name = child.attrs.slot?.value + const count = child.attrs.count?.value + const row = slots.find((slot) => slot.name === name) + if (row && !row.provided) + row.provided = { + kind: 'repeat', + address: child.address, + ...(typeof count === 'number' ? { count } : {}), + } + } + 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): 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, + } + }) + // 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 } +} + +function repeatView(node: UidxNode, component: UidxNode | null): RepeatView { + const slot = node.attrs.slot?.value + const count = node.attrs.count?.value + const instance = node.children.find((child) => child.element === 'Instance') + const named = instance?.attrs.component?.value + return { + kind: 'repeat', + node, + component, + slotValue: typeof slot === 'string' ? slot : '', + count: typeof count === 'number' ? count : 0, + slotOptions: (component?.spec?.contract?.slots ?? []) + .filter((entry) => entry.repeats) + .map((entry) => entry.name), + child: instance + ? { name: instance.name, component: typeof named === 'string' ? named : '' } + : null, + } +} + +/** + * 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, +): ContractView { + if (!doc || !node) return { kind: 'page', node: null } + if (node.element === 'Component') return componentView(node, library) + const component = enclosingComponent(doc, node.address) + if (node.element === 'Repeat') return repeatView(node, component) + if (node.element === 'Slot') { + const declared = component?.spec?.contract?.slots.find((slot) => slot.name === node.name) + return { + kind: 'slot', + node, + component, + declared: declared + ? { repeats: declared.repeats, ...(declared.accepts ? { accepts: declared.accepts } : {}) } + : null, + } + } + if (node.element === 'Instance') return { kind: 'instance', node } + if (component && BINDABLE.has(node.element)) return partView(node, component, library) + 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 +} + +/** + * The slot a `` multiplies. Its name is `repeat()` (ADR 0017 + * §2), so this also moves the node's address: the caller reselects it at + * `nextAddress`. + */ +export function setRepeatSlot( + node: UidxNode, + slot: string, +): { patches: UidxPatch[]; nextAddress: string } { + const cut = Math.max(node.address.lastIndexOf('#'), node.address.lastIndexOf('/')) + const nextAddress = `${node.address.slice(0, cut + 1)}repeat(${slot})` + return { patches: slot ? setAttr(node, 'slot', slot) : [], nextAddress } +} + +/** How many rows a `` draws; a non-negative integer, as the parser demands. */ +export function setRepeatCount(node: UidxNode, count: number): UidxPatch[] { + if (!Number.isInteger(count) || count < 0) return [] + return setAttr(node, 'count', count) +} diff --git a/packages/viewer/src/headless.ts b/packages/viewer/src/headless.ts new file mode 100644 index 0000000..29367db --- /dev/null +++ b/packages/viewer/src/headless.ts @@ -0,0 +1,132 @@ +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. + */ +export interface HeadlessElement { + tag: string + /** The part names this root offers, from `-` elements and `cssParts`. */ + parts: string[] + /** Slot names; `''` is the default slot. */ + slots: string[] + attributes: string[] + events: string[] + 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[] +} + +/** The slice of a `custom-elements.json` this module reads. */ +interface Manifest { + modules?: { + declarations?: { + tagName?: string | null + description?: string + attributes?: { name?: string }[] + events?: { name?: string }[] + slots?: { name?: string }[] + cssParts?: { name?: string }[] + }[] + }[] +} + +const names = (entries: { name?: string }[] | undefined): string[] => + (entries ?? []).map((entry) => entry.name ?? '').filter((name, i, all) => all.indexOf(name) === i) + +/** + * 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): 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), + slots: names(declaration.slots), + attributes: names(declaration.attributes), + events: names(declaration.events), + ...(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) + if (!owner.parts.includes(part)) owner.parts.push(part) + } + + const roots = [...declared.values()] + .filter((element) => !partOf.has(element.tag)) + .sort((a, b) => a.tag.localeCompare(b.tag)) + return { path, elements: declared, roots } +} + +/** `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('') + +/** + * 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) }) + if (!response.headers.get('content-type')?.includes('application/json')) { + throw new Error('The headless library service is unavailable.') + } + const data = (await response.json()) as { + path: string | null + library?: unknown + error?: string + } + if (!response.ok) throw new Error(data.error ?? 'Could not read the headless library.') + headlessLibrary.value = data.path === null ? null : parseHeadless(data.path, data.library) + } catch (error) { + headlessLibrary.value = null + 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/test/contract-edits.test.ts b/packages/viewer/test/contract-edits.test.ts new file mode 100644 index 0000000..db8034b --- /dev/null +++ b/packages/viewer/test/contract-edits.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { applyPatches, parseOrThrow, resolve } from '@uidx/format' +import ContractSection from '../src/ContractSection.vue' +import PropertiesPane from '../src/PropertiesPane.vue' +import { + bindPart, + contractIssues, + contractView, + setImplements, + setPart, + setRepeatCount, + setRepeatSlot, +} from '../src/contract-edits' +import { parseHeadless } 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 declared repeating slot, 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', + ` + + + + `, + ` +## Contract + + + Whether the option is selected. + + + Fires on toggle. + +checked-indicator, indeterminate-indicator +`, +) + +const LIST = page( + 'list', + ` + + + + + `, + ` +## Contract + + + Rows. + + + One per item. + While empty. + + +## Models + + + A row. + Identity. + +`, +) + +const bare = page('bare', ` `) + +describe('the headless library, as parsed', () => { + it('finds roots and their parts from - tags and cssParts, longest root first', () => { + expect(LIBRARY.roots.map((root) => root.tag)).toEqual([ + 'hwc-checkbox', + 'hwc-field', + 'hwc-text', + 'hwc-text-input', + ]) + expect(LIBRARY.elements.get('hwc-checkbox')!.parts).toEqual([ + 'checked-indicator', + 'indeterminate-indicator', + ]) + expect(LIBRARY.elements.get('hwc-text-input')!.parts).toEqual(['leading-icon']) + expect(LIBRARY.elements.get('hwc-text')!.parts).toEqual(['glyph']) + expect(LIBRARY.elements.get('hwc-field')!.slots).toEqual(['', 'control']) + }) +}) + +describe('what the tab shows', () => { + it('for a component: its element, every declared part with its layer, and the contract', () => { + const doc = parseOrThrow(CHECKBOX) + const view = contractView(doc, resolve(doc.tree, 'Checkbox'), LIBRARY) + if (view.kind !== 'component') throw new Error(view.kind) + expect(view.implementsValue).toBe('hwc-checkbox') + expect(view.rootOptions.map((o) => o.tag)).toEqual(LIBRARY.roots.map((r) => r.tag)) + expect(view.parts).toEqual([ + { + name: 'checked-indicator', + declaredBy: 'both', + boundTo: { address: 'Checkbox#check', name: 'check', element: 'Vector' }, + }, + { name: 'indeterminate-indicator', declaredBy: 'both', boundTo: null }, + ]) + // Bound layers leave the candidate list; instances never enter it. + expect(view.candidates.map((c) => c.name)).toEqual(['dash', 'ring']) + expect(contractIssues(view)).toBe(1) + }) + + it('keeps an element the library lacks as a choice, marked', () => { + const doc = parseOrThrow(CHECKBOX.replace('hwc-checkbox"', 'x-box"')) + const view = contractView(doc, resolve(doc.tree, 'Checkbox'), LIBRARY) + if (view.kind !== 'component') throw new Error(view.kind) + expect(view.rootOptions[0]).toEqual({ tag: 'x-box', known: false }) + // The contract still declares the parts, so they remain bindable. + expect(view.parts.map((p) => p.declaredBy)).toEqual(['contract', 'contract']) + }) + + it('for a layer: the parts it may draw, naming who holds the others', () => { + const doc = parseOrThrow(CHECKBOX) + const view = contractView(doc, resolve(doc.tree, 'Checkbox#dash'), LIBRARY) + if (view.kind !== 'part') throw new Error(view.kind) + expect(view.partValue).toBeNull() + expect(view.options).toEqual([ + { name: 'checked-indicator', takenBy: 'check' }, + { name: 'indeterminate-indicator', takenBy: null }, + ]) + }) + + it('for a repeat: the declared repeating slots, and what it multiplies', () => { + const doc = parseOrThrow(LIST) + const view = contractView(doc, resolve(doc.tree, 'List#repeat(option)'), LIBRARY) + if (view.kind !== 'repeat') throw new Error(view.kind) + expect(view).toMatchObject({ + slotValue: 'option', + count: 3, + slotOptions: ['option'], + child: { name: 'row', component: 'Row' }, + }) + const component = contractView(doc, resolve(doc.tree, 'List'), LIBRARY) + if (component.kind !== 'component') throw new Error(component.kind) + expect(component.slots).toEqual([ + { + name: 'option', + repeats: true, + provided: { kind: 'repeat', address: 'List#repeat(option)', count: 3 }, + }, + { name: 'empty', repeats: false, provided: { kind: 'slot', address: 'List#empty' } }, + { name: 'control', repeats: false, provided: null }, + ]) + }) + + it('for a layer outside any component, and for a page: nothing to bind', () => { + const doc = parseOrThrow(bare) + expect(contractView(doc, resolve(doc.tree, 'loose'), LIBRARY).kind).toBe('other') + expect(contractView(doc, null, LIBRARY).kind).toBe('page') + }) +}) + +describe('the writes', () => { + const doc = parseOrThrow(CHECKBOX) + const component = resolve(doc.tree, 'Checkbox')! + + it('sets, changes and clears implements', () => { + expect(setImplements(component, 'hwc-field')).toEqual([ + { op: 'set', address: 'Checkbox', prop: 'implements', value: 'hwc-field' }, + ]) + expect(setImplements(component, null)).toEqual([ + { op: 'remove', address: 'Checkbox', prop: 'implements' }, + ]) + const fresh = resolve(parseOrThrow(bare).tree, 'loose')! + expect(setImplements(fresh, 'x')).toEqual([ + { op: 'add', address: 'loose', prop: 'implements', value: 'x' }, + ]) + }) + + it('binds a part from the component, moving it off the layer that held it', () => { + const patches = bindPart(doc, component, 'checked-indicator', 'Checkbox#dash') + expect(patches).toEqual([ + { op: 'remove', address: 'Checkbox#check', prop: 'part' }, + { op: 'add', address: 'Checkbox#dash', prop: 'part', value: 'checked-indicator' }, + ]) + const after = parseOrThrow(applyPatches(CHECKBOX, patches).source) + expect(resolve(after.tree, 'Checkbox#dash')!.attrs.part?.value).toBe('checked-indicator') + expect(resolve(after.tree, 'Checkbox#check')!.attrs.part).toBeUndefined() + }) + + it('binds a part from the layer, and clears it', () => { + const dash = resolve(doc.tree, 'Checkbox#dash')! + expect(setPart(dash, 'indeterminate-indicator')).toEqual([ + { op: 'add', address: 'Checkbox#dash', prop: 'part', value: 'indeterminate-indicator' }, + ]) + expect(setPart(resolve(doc.tree, 'Checkbox#check')!, null)).toEqual([ + { op: 'remove', address: 'Checkbox#check', prop: 'part' }, + ]) + }) + + it('moves a repeat to another slot and says where it will be, and counts whole rows only', () => { + const list = parseOrThrow(LIST) + const repeat = resolve(list.tree, 'List#repeat(option)')! + expect(setRepeatSlot(repeat, 'items')).toEqual({ + patches: [{ op: 'set', address: 'List#repeat(option)', prop: 'slot', value: 'items' }], + nextAddress: 'List#repeat(items)', + }) + expect(setRepeatCount(repeat, 5)).toEqual([ + { op: 'set', address: 'List#repeat(option)', prop: 'count', value: 5 }, + ]) + expect(setRepeatCount(repeat, -1)).toEqual([]) + expect(setRepeatCount(repeat, 2.5)).toEqual([]) + const after = applyPatches(LIST, setRepeatCount(repeat, 5)).source + expect(after).toContain('') + }) +}) + +describe('the Contract section', () => { + const mountFor = (source: string, address: string | null, writable = true, library = LIBRARY) => { + const doc = parseOrThrow(source) + return mount(ContractSection, { + props: { doc, node: address ? resolve(doc.tree, address) : null, library, writable }, + }) + } + + it('offers the library roots for implements and emits the write', async () => { + const section = mountFor(CHECKBOX, 'Checkbox') + const pick = section.find('[data-field="implements"] select') + expect(pick.findAll('option').map((o) => o.text())).toEqual([ + 'None', + 'hwc-checkbox', + 'hwc-field', + 'hwc-text', + 'hwc-text-input', + ]) + await pick.setValue('hwc-field') + expect(section.emitted('patches')).toEqual([ + [[{ op: 'set', address: 'Checkbox', prop: 'implements', value: 'hwc-field' }]], + ]) + }) + + it('falls back to a text field when the document has no library', async () => { + const section = mountFor(CHECKBOX, 'Checkbox', true, null) + const text = section.find('[data-field="implements"] input') + expect(text.exists()).toBe(true) + expect(section.text()).toContain('No headless library') + await text.setValue('hwc-toggle') + expect(section.emitted('patches')).toEqual([ + [[{ op: 'set', address: 'Checkbox', prop: 'implements', value: 'hwc-toggle' }]], + ]) + }) + + it('lists parts with their layer, binds an unbound one, and selects a bound one', async () => { + const section = mountFor(CHECKBOX, 'Checkbox') + expect(section.text()).toContain('1 of 2 bound') + const bound = section.find('[data-part="checked-indicator"] .layer') + expect(bound.text()).toBe('check') + await bound.trigger('click') + expect(section.emitted('select')).toEqual([['Checkbox#check']]) + + const unbound = section.find('[data-part="indeterminate-indicator"] select') + expect(unbound.findAll('option').map((o) => o.text().trim())).toEqual([ + 'Bind a layer…', + 'dash', + 'ring', + ]) + await unbound.setValue('Checkbox#dash') + expect(section.emitted('patches')).toEqual([ + [[{ op: 'add', address: 'Checkbox#dash', prop: 'part', value: 'indeterminate-indicator' }]], + ]) + + await section.find('[data-part="checked-indicator"] .reset').trigger('click') + expect(section.emitted('patches')![1]).toEqual([ + [{ op: 'remove', address: 'Checkbox#check', prop: 'part' }], + ]) + }) + + it("binds from the layer's side, with taken parts named and disabled", async () => { + const section = mountFor(CHECKBOX, 'Checkbox#dash') + const pick = section.find('[data-field="part"] select') + const options = pick.findAll('option') + expect(options.map((o) => o.text().trim())).toEqual([ + 'Nothing — design only', + 'checked-indicator · bound to check', + 'indeterminate-indicator', + ]) + expect(options[1]!.attributes('disabled')).toBeDefined() + await pick.setValue('indeterminate-indicator') + expect(section.emitted('patches')).toEqual([ + [[{ op: 'add', address: 'Checkbox#dash', prop: 'part', value: 'indeterminate-indicator' }]], + ]) + }) + + it('edits a repeat and reselects it at its new address', async () => { + const section = mountFor(LIST, 'List#repeat(option)') + await section.find('[data-field="count"] input').setValue('4') + expect(section.emitted('patches')).toEqual([ + [[{ op: 'set', address: 'List#repeat(option)', prop: 'count', value: 4 }]], + ]) + const slot = section.find('[data-field="slot"] select') + expect(slot.findAll('option').map((o) => o.text().trim())).toEqual(['option']) + }) + + it('goes read-only with the socket', () => { + const section = mountFor(CHECKBOX, 'Checkbox', false) + expect(section.find('[data-field="implements"] select').attributes('disabled')).toBeDefined() + expect( + section.find('[data-part="indeterminate-indicator"] select').attributes('disabled'), + ).toBeDefined() + }) + + it('explains a layer outside a component and an instance', () => { + expect(mountFor(bare, 'loose').text()).toContain('Only layers inside a component') + expect(mountFor(LIST, 'List#repeat(option)/row').text()).toContain('An instance renders') + }) +}) + +describe('the inspector tabs', () => { + it('switches between Design and Contract, and counts parts to bind on the tab', async () => { + const doc = parseOrThrow(CHECKBOX) + const pane = mount(PropertiesPane, { + props: { doc, selection: ['Checkbox'], writable: true, headless: LIBRARY }, + }) + const tabs = pane.findAll('.face-toggle button') + expect(tabs.map((t) => t.text().replace(/\s+/g, ' '))).toEqual(['Design', 'Contract 1']) + expect(tabs[0]!.attributes('aria-pressed')).toBe('true') + expect(pane.find('.contract').exists()).toBe(false) + await tabs[1]!.trigger('click') + expect(pane.find('.contract').exists()).toBe(true) + expect(pane.find('[data-field="implements"] select').exists()).toBe(true) + await pane.find('[data-part="checked-indicator"] .layer').trigger('click') + expect(pane.emitted('select')).toEqual([['Checkbox#check']]) + }) +}) From 2059fad8c4ba9ccb76b5ab69654da8a7c1eeeeb5 Mon Sep 17 00:00:00 2001 From: Guy Behar Date: Wed, 30 Sep 2026 08:46:15 +0300 Subject: [PATCH 07/19] codegen, viewer: shadow parts (cssParts) beside element parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest's own vocabulary for parts is `cssParts`: parts inside the root's shadow tree, styled from outside through `::part()`. Until now every part had to be a `-` element, which is one library's convention. - The component model records each part's kind. A shadow part is styled as `root::part(name)` in the CSS target, emits no element in HTML or React, and gets no compound sub-component. - Conformance accepts either kind. A shadow part with design content under it warns rather than fails: the library draws the part, so that content is styled but not rendered. - The Contract tab marks shadow parts with a pill on the component's list and in the layer's picker, so an author knows it can be styled, not filled. ADR 0017 §3 records the rule. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +- .../0017-collections-and-code-targets.md | 5 ++ packages/codegen/src/conformance.ts | 27 ++++++- packages/codegen/src/html.ts | 10 ++- packages/codegen/src/model.ts | 33 +++++++- packages/codegen/src/react.ts | 5 +- packages/codegen/test/generate.test.ts | 75 +++++++++++++++++++ packages/viewer/src/ContractSection.vue | 9 ++- packages/viewer/src/contract-edits.ts | 9 ++- packages/viewer/src/headless.ts | 21 +++++- packages/viewer/test/contract-edits.test.ts | 57 +++++++++++--- 11 files changed, 231 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4118513..a79692c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,9 @@ describe migrations when an existing document or integration is affected. 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. + `--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()` and conformance warns when the design draws under one. ## 0.1.6 — 2026-09-25 diff --git a/docs/decisions/0017-collections-and-code-targets.md b/docs/decisions/0017-collections-and-code-targets.md index da8e139..8e68279 100644 --- a/docs/decisions/0017-collections-and-code-targets.md +++ b/docs/decisions/0017-collections-and-code-targets.md @@ -62,6 +62,11 @@ same bytes. => ReactNode`, generic over the model's generated type, with `ItemComponent` accepted as sugar for a component whose `item` prop is that model; the key comes from the model's `key` field; + - a part the library exposes as a shadow part (`cssParts` in its + manifest, rather than a `-` element) is styled through + `::part(name)` and never filled: the library draws it, so the design's + content under that node is not rendered by the code target, and + conformance warns when there is any. The inspector marks such parts. - parts with their own element are emitted as compound sub-components (`Field.Label`) so a consumer may compose below the pattern level. Emitted as readable source. diff --git a/packages/codegen/src/conformance.ts b/packages/codegen/src/conformance.ts index f021f00..c6f741b 100644 --- a/packages/codegen/src/conformance.ts +++ b/packages/codegen/src/conformance.ts @@ -49,9 +49,32 @@ export function checkConformance(model: ComponentModel, manifest: Manifest): Dia for (const part of model.contract.parts) { if (part === 'root') continue const tag = partTag(model.tag, part, tagSet) - if (!tagSet.has(tag)) { - report(`part "${part}" has no element in the manifest (looked for ${tag})`) + if (tagSet.has(tag)) continue + if ((root.cssParts ?? []).some((entry) => entry.name === part)) { + // A shadow part can be styled but not filled. What the design drew + // under it is lost to the code target, which is worth a word — but a + // word, not a failure: the identity is still right, and the library + // draws the part itself. + const bound = model.parts.find((entry) => entry.name === part) + const holds = + bound && + (bound.node.children.length > 0 || + bound.node.attrs.characters !== undefined || + bound.node.attrs.vectorPaths !== undefined) + if (bound && holds) { + out.push( + diagnostic( + model.doc.source, + CODES.CONFORMANCE, + `part "${part}" is a shadow part of ${model.tag}: the library draws it, so what "${bound.node.name}" holds is styled through ::part() but not rendered`, + bound.node.openTagLoc ?? at, + 'warning', + ), + ) + } + continue } + report(`part "${part}" has no element in the manifest (looked for ${tag})`) } return out } diff --git a/packages/codegen/src/html.ts b/packages/codegen/src/html.ts index 8ad52ae..5a0decb 100644 --- a/packages/codegen/src/html.ts +++ b/packages/codegen/src/html.ts @@ -66,7 +66,7 @@ function selectorFor(model: ComponentModel, node: UidxNode, root: string): strin const part = model.partOf.get(node) if (part !== undefined) { const info = model.parts.find((entry) => entry.name === part)! - return `${root} ${info.tag}` + return info.kind === 'shadow' ? `${root}::part(${part})` : `${root} ${info.tag}` } if (node.element === 'Slot') return `${root} [data-slot="${node.name}"]` return `${root} [data-node="${node.name}"]` @@ -126,7 +126,9 @@ export function emitCss(model: ComponentModel): string { part === 'root' ? scoped : info - ? `${scoped} ${info.tag}` + ? info.kind === 'shadow' + ? `${scoped}::part(${part})` + : `${scoped} ${info.tag}` : `${scoped} [data-node="${part}"]` rules.push(cssRule(selector, cssDeclarations(props, target ? kindOf(target) : 'container'))) } @@ -171,6 +173,10 @@ function markup( const pad = ' '.repeat(depth) const part = model.partOf.get(node) const info = part === undefined ? undefined : model.parts.find((entry) => entry.name === part) + // A shadow part is the library's to draw: styled from outside through + // `::part()`, never filled. Nothing to emit here; conformance reports what + // the design put under it. + if (info?.kind === 'shadow') return [] const children = (): string[] => node.children.flatMap((child) => markup(model, child, ctx, samples, depth + 1, index, fills)) diff --git a/packages/codegen/src/model.ts b/packages/codegen/src/model.ts index 60d4565..907f2f8 100644 --- a/packages/codegen/src/model.ts +++ b/packages/codegen/src/model.ts @@ -26,6 +26,8 @@ export interface Manifest { attributes?: { name: string; type?: { text?: string } }[] events?: { name: string }[] slots?: { name: string }[] + /** Shadow parts, styled from outside through `::part()`. */ + cssParts?: { name: string }[] }[] }[] } @@ -66,6 +68,25 @@ export function pascal(name: string): string { * tag that ends with the part name and shares the longest prefix with the * root — `hwc-breadcrumbs` owns `hwc-breadcrumb-item`. */ +export type PartKind = 'element' | 'shadow' + +/** + * Which kind of part the library offers under this name (ADR 0017 §3). + * + * An element wins when one exists, since it can hold the design's content; a + * `cssParts` entry on the root makes it a shadow part. Without a manifest, or + * for a name the manifest lacks, `element` is assumed and conformance says so. + */ +export function partKind(rootTag: string, part: string, manifest?: Manifest): PartKind { + if (!manifest) return 'element' + const tags = manifestTags(manifest) + const tagSet = new Set(tags.keys()) + if (tagSet.has(partTag(rootTag, part, tagSet))) return 'element' + return (tags.get(rootTag)?.cssParts ?? []).some((entry) => entry.name === part) + ? 'shadow' + : 'element' +} + export function partTag(rootTag: string, part: string, tags?: ReadonlySet): string { const conventional = `${rootTag}-${part}` if (!tags || tags.has(conventional)) return conventional @@ -88,6 +109,11 @@ export interface PartInfo { name: string node: UidxNode tag: string + /** + * How the library exposes the part: an element of its own (`-`), + * or a shadow part styled through `::part()` and drawn by the library. + */ + kind: PartKind } export interface SlotInfo { @@ -176,7 +202,12 @@ export function componentModel( const walk = (node: UidxNode): void => { const part = node.attrs.part?.value if (typeof part === 'string') { - parts.push({ name: part, node, tag: tag ? partTag(tag, part, tags) : `x-${part}` }) + parts.push({ + name: part, + node, + tag: tag ? partTag(tag, part, tags) : `x-${part}`, + kind: tag ? partKind(tag, part, manifest) : 'element', + }) partOf.set(node, part) } if (node.element === 'Slot') { diff --git a/packages/codegen/src/react.ts b/packages/codegen/src/react.ts index 6c63816..c859e16 100644 --- a/packages/codegen/src/react.ts +++ b/packages/codegen/src/react.ts @@ -259,7 +259,8 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string { const compound: { name: string; tag: string; part: string }[] = [] for (const part of model.parts) { const prop = boundProp(model, part.node.attrs.characters?.value ?? null) - if (prop && part.node.element === 'Text') + // A shadow part has no element to compose below the root. + if (prop && part.node.element === 'Text' && part.kind === 'element') compound.push({ name: pascal(part.name), tag: part.tag, part: part.name }) } @@ -267,6 +268,8 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string { const pad = ' '.repeat(depth) const part = model.partOf.get(node) const info = part === undefined ? undefined : model.parts.find((entry) => entry.name === part) + // As in the HTML target: a shadow part is drawn by the library. + if (info?.kind === 'shadow') return [] const children = () => node.children.flatMap((child) => render(child, depth + 1)) switch (node.element) { case 'Repeat': { diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts index d003456..c89d9cf 100644 --- a/packages/codegen/test/generate.test.ts +++ b/packages/codegen/test/generate.test.ts @@ -149,6 +149,81 @@ A checkbox with its words. }) }) +describe('a shadow part (cssParts in the manifest)', () => { + const manifest = { + modules: [ + { + declarations: [ + { + tagName: 'sl-switch', + attributes: [{ name: 'checked' }], + events: [{ name: 'change' }], + cssParts: [{ name: 'thumb' }, { name: 'label' }], + }, + ], + }, + ], + } + const page = parseOrThrow(`--- +id: switch +--- + +A switch whose parts live in the library's shadow tree. + +## Visual Contract + + + + + + + + + + diff --git a/packages/viewer/src/PropertiesPane.vue b/packages/viewer/src/PropertiesPane.vue index b9f068a..165d4c5 100644 --- a/packages/viewer/src/PropertiesPane.vue +++ b/packages/viewer/src/PropertiesPane.vue @@ -12,7 +12,7 @@ import { import { LENGTH_FIELD_CONTEXT } from './length-field-context' import ContractSection from './ContractSection.vue' import { contractIssues, contractView } from './contract-edits' -import type { HeadlessLibrary } from './headless' +import type { HeadlessCandidate, HeadlessLibrary } from './headless' import { computed, ref, shallowRef, watch } from 'vue' import { vectorEndpoints } from '@open-pencil/core/vector' @@ -124,6 +124,8 @@ const props = defineProps<{ headless?: HeadlessLibrary | null /** Why the library could not be read, shown in the Contract tab. */ headlessError?: string + /** Libraries the project's dependencies ship, offered while none is named. */ + headlessCandidates?: HeadlessCandidate[] /** Token address -> literal, so a bound row can show what it resolves to. */ tokens?: Map /** @@ -235,6 +237,8 @@ const emit = defineEmits<{ makeComponent: [] /** The Contract tab names layers; choosing one selects it, as the rail would. */ select: [address: string] + /** The Contract tab chose a headless library; the shell has the server write it. */ + chooseLibrary: [path: string] }>() /** @@ -1323,9 +1327,11 @@ function onDetach(prop: string, value: JsonValue): void { :node="active" :library="headless ?? null" :library-error="headlessError" + :candidates="headlessCandidates" :writable="writable !== false" @patches="emit('patches', $event)" @select="emit('select', $event)" + @choose-library="emit('chooseLibrary', $event)" /> diff --git a/packages/viewer/src/headless.ts b/packages/viewer/src/headless.ts index 547f78a..dbb37b9 100644 --- a/packages/viewer/src/headless.ts +++ b/packages/viewer/src/headless.ts @@ -36,6 +36,14 @@ export interface HeadlessLibrary { 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. */ @@ -65,7 +73,11 @@ const names = (entries: { name?: string }[] | undefined): string[] => * 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): HeadlessLibrary { +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 ?? []) { @@ -109,13 +121,59 @@ export function parseHeadless(path: string, manifest: unknown): HeadlessLibrary const roots = [...declared.values()] .filter((element) => !partOf.has(element.tag)) .sort((a, b) => a.tag.localeCompare(b.tag)) - return { path, elements: declared, roots } + 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[] + error?: string +} + +function adopt(data: HeadlessPayload): void { + headlessLibrary.value = + data.path === null ? null : parseHeadless(data.path, data.library, data.bindings ?? {}) + headlessCandidates.value = data.candidates ?? [] +} + +/** + * 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`). @@ -128,18 +186,13 @@ export async function refreshHeadless(): Promise { headlessError.value = '' try { const response = await fetch('/__uidx/headless', { signal: AbortSignal.timeout(15_000) }) - if (!response.headers.get('content-type')?.includes('application/json')) { - throw new Error('The headless library service is unavailable.') - } - const data = (await response.json()) as { - path: string | null - library?: unknown - error?: string - } + unavailable(response) + const data = (await response.json()) as HeadlessPayload if (!response.ok) throw new Error(data.error ?? 'Could not read the headless library.') - headlessLibrary.value = data.path === null ? null : parseHeadless(data.path, data.library) + adopt(data) } catch (error) { headlessLibrary.value = null + headlessCandidates.value = [] headlessError.value = error instanceof Error ? error.message : String(error) } } diff --git a/packages/viewer/test/contract-edits.test.ts b/packages/viewer/test/contract-edits.test.ts index 44e4849..1072fe5 100644 --- a/packages/viewer/test/contract-edits.test.ts +++ b/packages/viewer/test/contract-edits.test.ts @@ -384,6 +384,41 @@ describe('the Contract section', () => { }) }) +describe('choosing a library', () => { + it('offers the dependencies that ship one, or a path, and asks the shell to write it', async () => { + const doc = parseOrThrow(bare) + const section = mount(ContractSection, { + props: { + doc, + node: null, + library: null, + candidates: [{ package: '@acme/kit', path: 'node_modules/@acme/kit/custom-elements.json' }], + writable: true, + }, + }) + const pick = section.find('[data-field="choose-library"] select') + expect(pick.findAll('option').map((o) => o.text().trim())).toEqual(['Choose…', '@acme/kit']) + await pick.setValue('node_modules/@acme/kit/custom-elements.json') + expect(section.emitted('chooseLibrary')).toEqual([ + ['node_modules/@acme/kit/custom-elements.json'], + ]) + await section + .find('[data-field="choose-library"] input') + .setValue('../lib/custom-elements.json') + await section.find('[data-field="choose-library"] button').trigger('click') + expect(section.emitted('chooseLibrary')![1]).toEqual(['../lib/custom-elements.json']) + }) + + it('says which tag uidx.json binds a component to', () => { + const doc = parseOrThrow(CHECKBOX) + const bound = parseHeadless('lib.json', { modules: [] }, { Checkbox: { tag: 'sl-checkbox' } }) + const section = mount(ContractSection, { + props: { doc, node: resolve(doc.tree, 'Checkbox'), library: bound, writable: true }, + }) + expect(section.find('[data-field="bound"]').text()).toContain('sl-checkbox') + }) +}) + describe('the inspector tabs', () => { it('switches between Design and Contract, and counts parts to bind on the tab', async () => { const doc = parseOrThrow(CHECKBOX) From 43ff858e24c57e1fffa96552b6efbf2a13709a0a Mon Sep 17 00:00:00 2001 From: Guy Behar Date: Wed, 30 Sep 2026 13:31:03 +0300 Subject: [PATCH 13/19] viewer: design states on the canvas, a Repeat tool, and a Contract tab that edits the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 — a state is designed the Figma way. Derived variants are addressable, so selecting the hover checkbox on the canvas shows its root frame in the inspector with a "state=hover of Checkbox" chip, and a change to it becomes a cell of the `state="hover"` row through the new `style` patch op (format), rather than a patch to a node with no source. An edit to the default combination lands on the base tree; a position, a name or structure is refused with the reason. Undo inverts the cell. The canvas' own scene-write path resolves against the derived document too. The toolbar gains a Repeat tool that wraps the selected instance in a `` on the first open repeating slot; the patcher leaves a Repeat unnamed, as it does a Variant. Phase 4 — the Contract tab edits the contract. The new `contract` patch op writes one ``, ``, ``, `` or `` in canonical form, creating its list and the `## Contract` region when absent and removing a list its last item leaves; it inverts to the previous declaration. In the tab, each declaration opens into a form (description, type, default, sample, visual and controllable; a slot's repeats, `of` and `accepts`), a row adds one, and "Fill from library" declares what the implemented element exposes and the contract lacks, with the manifest's descriptions or placeholders the tab marks. The viewer's library model carries the manifest's types and descriptions for that. Docs: ADR 0013 §3, ADR 0016 §4, ADR 0017 §2, a design-system walkthrough in the README, the example README, the changelog. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 11 + README.md | 66 ++ docs/decisions/0013-component-contract.md | 8 + docs/decisions/0016-variants-as-renders.md | 14 +- .../0017-collections-and-code-targets.md | 4 +- examples/design-system/README.md | 10 +- packages/format/src/incremental.ts | 17 + packages/format/src/index.ts | 13 +- packages/format/src/inverse.ts | 98 ++- packages/format/src/patch.ts | 261 +++++++- packages/format/src/types.ts | 38 ++ packages/format/test/contract-patch.test.ts | 115 ++++ packages/format/test/style-patch.test.ts | 113 ++++ packages/schema/src/design-system.ts | 57 +- packages/schema/src/index.ts | 2 + packages/schema/src/to-scene.ts | 7 +- packages/schema/test/design-system.test.ts | 33 +- packages/viewer/src/App.vue | 37 +- packages/viewer/src/CanvasPane.vue | 6 +- packages/viewer/src/ContractSection.vue | 574 +++++++++++++++++- packages/viewer/src/EditToolbar.vue | 26 + packages/viewer/src/PropertiesPane.vue | 33 +- packages/viewer/src/contract-edits.ts | 127 +++- packages/viewer/src/derived-edits.ts | 85 +++ packages/viewer/src/headless.ts | 32 +- packages/viewer/src/patch-rebase.ts | 6 + packages/viewer/src/repeat-edits.ts | 92 +++ packages/viewer/test/contract-edits.test.ts | 136 +++++ packages/viewer/test/derived-edits.test.ts | 135 ++++ packages/viewer/test/edit-toolbar.test.ts | 1 + packages/viewer/test/repeat-edits.test.ts | 83 +++ 31 files changed, 2183 insertions(+), 57 deletions(-) create mode 100644 packages/format/test/contract-patch.test.ts create mode 100644 packages/format/test/style-patch.test.ts create mode 100644 packages/viewer/src/derived-edits.ts create mode 100644 packages/viewer/src/repeat-edits.ts create mode 100644 packages/viewer/test/derived-edits.test.ts create mode 100644 packages/viewer/test/repeat-edits.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ad978..630fb53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,17 @@ describe migrations when an existing document or integration is affected. - `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. The + toolbar gains a Repeat tool that wraps the selected instance in a `` + on the first open repeating slot of its component's contract. +- 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 `of` and `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. - 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 a diff --git a/README.md b/README.md index 9c620bf..acf5ae3 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,72 @@ 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 + + + + + + + + + diff --git a/packages/viewer/src/PropertiesPane.vue b/packages/viewer/src/PropertiesPane.vue index 923703e..d85f383 100644 --- a/packages/viewer/src/PropertiesPane.vue +++ b/packages/viewer/src/PropertiesPane.vue @@ -243,6 +243,8 @@ const emit = defineEmits<{ finishVector: [] vectorAction: [action: VectorAction] makeComponent: [] + /** The Contract tab names the model a repeat draws; the shell opens the Models face on it. */ + openModel: [name: string] /** The Contract tab names layers; choosing one selects it, as the rail would. */ select: [address: string] /** The Contract tab chose a headless library; the shell has the server write it. */ @@ -1371,6 +1373,7 @@ function onDetach(prop: string, value: JsonValue): void { :writable="writable !== false" @patches="emit('patches', $event)" @select="emit('select', $event)" + @open-model="emit('openModel', $event)" @choose-library="emit('chooseLibrary', $event)" @generate-code="emit('generateCode')" /> 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/derived-edits.ts b/packages/viewer/src/derived-edits.ts index d61d808..69de592 100644 --- a/packages/viewer/src/derived-edits.ts +++ b/packages/viewer/src/derived-edits.ts @@ -78,6 +78,8 @@ function addressOf(patch: UidxPatch): string | null { return patch.parent case 'style': case 'contract': + case 'model': + case 'field': return null default: return patch.address 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 9be30ab..ec0e31e 100644 --- a/packages/viewer/src/patch-rebase.ts +++ b/packages/viewer/src/patch-rebase.ts @@ -97,6 +97,8 @@ export function rebasePatches( // 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': { diff --git a/packages/viewer/test/model-edits.test.ts b/packages/viewer/test/model-edits.test.ts new file mode 100644 index 0000000..7045363 --- /dev/null +++ b/packages/viewer/test/model-edits.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import { applyPatches, parseOrThrow } from '@uidx/format' +import { + addModel, + fieldWith, + freshFieldName, + modelsViewModel, + newField, + pagesForModels, + parseSample, + printSample, + removeModel, + setField, + setModelDescription, + undeclaredModels, +} from '../src/model-edits' + +/** + * The Models face (ADR 0015 §1): models gathered from every page, each with + * the page that declares it and the components that receive it; writes are + * `model` and `field` ops for the declaring page. + */ +const page = (id: string, body: string, regions = '') => `--- +id: ${id} +--- + +## Visual Contract + + +${body} + +${regions}` + +const ROW = page( + 'contact-option', + ` `, + ` +## Contract + + + The person. + + +## Models + + + One person. + Identity. + Name. + +`, +) +const LIST = page( + 'contact-list', + ` `, + ` +## Contract + + + Rows. + Labels. + +`, +) +const TOKENS = `--- +id: tokens +--- + +## Visual Contract + + + + + + +` +const pages = new Map([ + ['contact-list.uidx', parseOrThrow(LIST)], + ['contact-option.uidx', parseOrThrow(ROW)], + ['tokens.uidx', parseOrThrow(TOKENS)], +]) + +describe('the models view', () => { + it('lists every model with its page and who receives it, the open page first', () => { + const cards = modelsViewModel(pages, 'contact-list.uidx') + expect(cards).toHaveLength(1) + expect(cards[0]).toMatchObject({ + name: 'Contact', + file: 'contact-option.uidx', + description: 'One person.', + onPage: false, + usedBy: [ + { component: 'ContactList', file: 'contact-list.uidx', prop: 'items', list: true }, + { component: 'ContactOption', file: 'contact-option.uidx', prop: 'item', list: false }, + ], + }) + expect(cards[0]!.fields.map((field) => field.name)).toEqual(['id', 'name']) + expect(modelsViewModel(pages, 'contact-option.uidx')[0]!.onPage).toBe(true) + }) + + it('names the models a contract types that no page declares', () => { + expect(undeclaredModels(pages)).toEqual([{ name: 'Tag', file: 'contact-list.uidx' }]) + }) + + it('offers every non-token page for a new model, the open one first and a models page marked', () => { + const withShared = new Map(pages) + withShared.set('models.uidx', parseOrThrow(page('models', ''))) + expect(pagesForModels(withShared, 'contact-list.uidx')).toEqual([ + { file: 'contact-list.uidx', label: 'contact-list', suggested: false }, + { file: 'models.uidx', label: 'models', suggested: true }, + { file: 'contact-option.uidx', label: 'contact-option', suggested: false }, + ]) + }) +}) + +describe('the writes', () => { + it('declare, describe and remove a model, and refuse a name that is not one', () => { + expect(addModel('Tag')).toEqual([ + { op: 'model', name: 'Tag', declaration: { description: 'Describe what one Tag carries.' } }, + ]) + expect(addModel('not a name')).toEqual([]) + expect(setModelDescription('Contact', ' One row. ')).toEqual([ + { op: 'model', name: 'Contact', declaration: { description: 'One row.' } }, + ]) + expect(removeModel('Contact')).toEqual([{ op: 'model', name: 'Contact' }]) + }) + + it('write a field with one thing changed, and a fresh one', () => { + const contact = pages.get('contact-option.uidx')!.spec!.models![0]! + const name = contact.fields[1]! + expect(setField('Contact', 'name', fieldWith(name, { optional: true }))).toEqual([ + { + op: 'field', + model: 'Contact', + name: 'name', + declaration: { + attrs: { type: 'string', optional: true, sample: ['Ada', 'Grace'] }, + description: 'Name.', + }, + }, + ]) + expect(fieldWith(name, { sample: undefined }).attrs).toEqual({ type: 'string' }) + expect(setField('Contact', 'bad name', fieldWith(name, {}))).toEqual([]) + expect(freshFieldName(contact)).toBe('field') + expect(freshFieldName({ fields: [{ name: 'field' }, { name: 'field-2' }] })).toBe('field-3') + const after = applyPatches(ROW, newField(contact)).source + expect(after).toContain('Describe the field.') + }) + + it('reads a sample as JSON where it parses and as text otherwise, and prints it back', () => { + expect(parseSample('["Ada", "Grace"]')).toEqual(['Ada', 'Grace']) + expect(parseSample('3')).toBe(3) + expect(parseSample('null')).toBeNull() + expect(parseSample('Ada')).toBe('Ada') + expect(parseSample(' ')).toBeUndefined() + expect(printSample(['Ada', null])).toBe('["Ada",null]') + expect(printSample('Ada')).toBe('Ada') + expect(printSample(undefined)).toBe('') + }) +}) diff --git a/packages/viewer/test/models-pane.test.ts b/packages/viewer/test/models-pane.test.ts new file mode 100644 index 0000000..18839c4 --- /dev/null +++ b/packages/viewer/test/models-pane.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { parseOrThrow } from '@uidx/format' +import ModelsPane from '../src/ModelsPane.vue' +import { modelsViewModel, pagesForModels, undeclaredModels } from '../src/model-edits' + +/** + * The Models face edits a model in place and declares a new one on a chosen + * page; every gesture is patches for one file, which the shell dispatches. + */ +const ROW = `--- +id: contact-option +--- + +## Visual Contract + + + + + +## Contract + + + The person. + Labels. + + +## Models + + + One person. + Identity. + +` +const pages = new Map([['contact-option.uidx', parseOrThrow(ROW)]]) + +const mountPane = (writable = true) => + mount(ModelsPane, { + props: { + cards: modelsViewModel(pages, 'contact-option.uidx'), + undeclared: undeclaredModels(pages), + pages: pagesForModels(pages, 'contact-option.uidx'), + writable, + }, + }) + +describe('the Models pane', () => { + it('shows each model with its page, its users, and its fields', () => { + const pane = mountPane() + const card = pane.find('[data-model="Contact"]') + expect(card.exists()).toBe(true) + expect(card.find('h2').text()).toBe('Contact') + expect(card.find('.where').text()).toBe('this page') + expect(card.findAll('.uses .chip').map((chip) => chip.text())).toEqual(['ContactOption.item']) + expect(card.find('[data-field="id"] input[aria-label="Sample"]').element).toHaveProperty( + 'value', + '["a","b"]', + ) + // A model a contract names stays until the prop is retyped. + expect(card.find('[aria-label="Remove model Contact"]').attributes('disabled')).toBeDefined() + }) + + it('writes a description, a field change, a new field, and a removal for the declaring page', async () => { + const pane = mountPane() + const card = pane.find('[data-model="Contact"]') + await card.find('input[aria-label="Description"]').setValue('One row.') + await card.find('[data-field="id"] input[aria-label="Sample"]').setValue('["x", "y", "z"]') + await card.find('[data-field="id"] input[aria-label="Optional"]').setValue(true) + await card.find('button.add').trigger('click') + await card.find('[aria-label="Remove field id"]').trigger('click') + const edits = pane.emitted('edit')! + expect(edits.map(([file]) => file)).toEqual(Array(5).fill('contact-option.uidx')) + expect(edits.map(([, patches]) => patches)).toEqual([ + [{ op: 'model', name: 'Contact', declaration: { description: 'One row.' } }], + [ + { + op: 'field', + model: 'Contact', + name: 'id', + declaration: { + attrs: { type: 'string', key: true, sample: ['x', 'y', 'z'] }, + description: 'Identity.', + }, + }, + ], + [ + { + op: 'field', + model: 'Contact', + name: 'id', + declaration: { + attrs: { type: 'string', key: true, optional: true, sample: ['a', 'b'] }, + description: 'Identity.', + }, + }, + ], + [ + { + op: 'field', + model: 'Contact', + name: 'field', + declaration: { attrs: { type: 'string' }, description: 'Describe the field.' }, + }, + ], + [{ op: 'field', model: 'Contact', name: 'id' }], + ]) + }) + + it('declares a new model on the chosen page, and one a contract already names', async () => { + const pane = mountPane() + await pane.find('input[aria-label="Model name"]').setValue('Person') + await pane.find('button.add.root').trigger('click') + await pane.find('.undeclared .chip').trigger('click') + expect(pane.emitted('edit')).toEqual([ + [ + 'contact-option.uidx', + [ + { + op: 'model', + name: 'Person', + declaration: { description: 'Describe what one Person carries.' }, + }, + ], + ], + [ + 'contact-option.uidx', + [ + { + op: 'model', + name: 'Tag', + declaration: { description: 'Describe what one Tag carries.' }, + }, + ], + ], + ]) + }) + + it('jumps to a page or a component from a card, and goes read-only with the socket', async () => { + const pane = mountPane() + await pane.find('[data-model="Contact"] .uses .chip').trigger('click') + expect(pane.emitted('open')).toEqual([['contact-option.uidx', 'ContactOption']]) + const frozen = mountPane(false) + expect(frozen.find('input[aria-label="Description"]').attributes('disabled')).toBeDefined() + expect(frozen.find('button.add.root').attributes('disabled')).toBeDefined() + }) +}) diff --git a/packages/viewer/test/page-url.test.ts b/packages/viewer/test/page-url.test.ts index 8575a4c..7e956ed 100644 --- a/packages/viewer/test/page-url.test.ts +++ b/packages/viewer/test/page-url.test.ts @@ -110,6 +110,14 @@ describe('the open page, in the URL', () => { }) }) + it('round-trips a models view through the URL', () => { + const url = urlWithView(AT, { kind: 'models', file: 'contact-list.uidx' }) + expect(viewToOpen(url, ['contact-list.uidx', 'home.uidx'], 'home.uidx')).toEqual({ + kind: 'models', + file: 'contact-list.uidx', + }) + }) + it('switching views rewrites, never accumulates, the view parameter', () => { const there = urlWithView(AT, { kind: 'tokens', file: 'core-tokens.uidx' }) const back = urlWithView(there, { kind: 'page', file: 'core-tokens.uidx' }) From 768c2d6e9a4a64842523c819f33548be02ab03bf Mon Sep 17 00:00:00 2001 From: Guy Behar Date: Wed, 30 Sep 2026 17:48:45 +0300 Subject: [PATCH 19/19] =?UTF-8?q?Drop=20the=20repeat=20count:=20the=20mode?= =?UTF-8?q?l's=20samples=20decide=20(ADR=200017=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `count` only overrode how many sample rows the canvas drew; code never read it. The repeat API is now `repeat="{list}"` and, when the item needs a name, `as`. The canvas draws one row per sample of the model — its longest sample list, three when it has none — so the rows follow the model and there is nothing to keep in step with it. Parser, scene, HTML target, Contract tab, example, tests and docs follow. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 8 +-- .../0017-collections-and-code-targets.md | 11 +++-- .../design-system/.uidx/contact-list.uidx | 2 +- packages/codegen/src/html.ts | 6 +-- packages/codegen/test/fixtures.ts | 2 +- packages/format/src/diagnostics.ts | 2 +- packages/format/src/parse.ts | 17 ++----- packages/format/test/spec.test.ts | 11 ++--- packages/schema/src/design-system.ts | 15 ++---- packages/schema/src/known-props.ts | 6 +-- packages/schema/src/reconcile.ts | 4 +- packages/schema/src/to-scene.ts | 5 +- packages/schema/test/design-system.test.ts | 4 +- packages/schema/test/reconcile.test.ts | 10 +--- packages/viewer/src/ContractSection.vue | 49 +++---------------- packages/viewer/src/contract-edits.ts | 30 ++++-------- packages/viewer/src/repeat-edits.ts | 4 +- packages/viewer/test/contract-edits.test.ts | 39 ++++++--------- 18 files changed, 68 insertions(+), 157 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dfee56..658faac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,9 @@ describe migrations when an existing document or integration is affected. 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` and - the canvas's rows with `count` (the model's samples decide otherwise); a - repeat on a `` is the one consumers fill. Declarations only: nothing + 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 @@ -61,7 +61,7 @@ describe migrations when an existing document or integration is affected. - 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, its item's name and its count. The tab shows the + 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 diff --git a/docs/decisions/0017-collections-and-code-targets.md b/docs/decisions/0017-collections-and-code-targets.md index 0e0610c..417530f 100644 --- a/docs/decisions/0017-collections-and-code-targets.md +++ b/docs/decisions/0017-collections-and-code-targets.md @@ -49,8 +49,9 @@ the row of a tree). The list is a list prop of the contract (`{items}`) or a list field of an enclosing item (`{person.tags}`), so nesting a repeat inside a repeat is a tree. `as` names the item for the bindings below (`item` unless said), and `{as.field}` bindings resolve against the list's model, whose -`key` field keys the rows. `count` is the canvas's row count; absent, the -model's longest sample list decides. +`key` field keys the rows. The model decides how many rows the canvas draws: +its longest sample list, three when it has none. The tree says only what +repeats — there is no count to keep in step with the samples. A repeat on a `` is the one consumers fill: code renders it as a render prop named after the slot (`renderItem(item, index)`), with the slot's @@ -58,12 +59,12 @@ placeholder as the default content, and `accepts` on the declared slot constrains what a consumer passes. A repeat on any other layer is the component's own, rendered in place. The toolbar's Repeat tool writes `repeat="{…}"` on the selected layer with the first list its contract can -place; the Contract tab edits the list, `as` and `count` from the layer. The -canvas expands a repeat to `count` rows, the n-th resolving `{as.*}` from the +place; the Contract tab edits the list and `as` from the layer. The canvas +expands a repeat to one row per sample, the n-th resolving `{as.*}` from the n-th samples: the first row is the layer itself, selected and edited like any other, and the rows after it (`row-2`, `row-3`) are generated echoes that follow it. Figma export renders instances with an instance-swap property. -Code ignores `count` and uses the contract. +Code uses the contract. ### 3. Code targets diff --git a/examples/design-system/.uidx/contact-list.uidx b/examples/design-system/.uidx/contact-list.uidx index d381ca9..197a03b 100644 --- a/examples/design-system/.uidx/contact-list.uidx +++ b/examples/design-system/.uidx/contact-list.uidx @@ -11,7 +11,7 @@ Contact. - + diff --git a/packages/codegen/src/html.ts b/packages/codegen/src/html.ts index 2af2486..aa8574b 100644 --- a/packages/codegen/src/html.ts +++ b/packages/codegen/src/html.ts @@ -100,7 +100,6 @@ const VOID_ATTRS = new Set([ 'vectorPaths', 'implements', 'slot', - 'count', 'component', 'props', 'overrides', @@ -253,10 +252,7 @@ function markup( // headless list holds its items directly. const repeat = repeatFor(model, node) if (repeat && !repeating.has(node)) { - const count = - typeof node.attrs.count?.value === 'number' - ? node.attrs.count.value - : sampleCount(repeat.model) + const count = sampleCount(repeat.model) const lines: string[] = [] repeating.add(node) try { diff --git a/packages/codegen/test/fixtures.ts b/packages/codegen/test/fixtures.ts index 82ba4aa..278eb4c 100644 --- a/packages/codegen/test/fixtures.ts +++ b/packages/codegen/test/fixtures.ts @@ -140,7 +140,7 @@ id: contact-list - + diff --git a/packages/format/src/diagnostics.ts b/packages/format/src/diagnostics.ts index 67dab16..cdd6c0f 100644 --- a/packages/format/src/diagnostics.ts +++ b/packages/format/src/diagnostics.ts @@ -104,7 +104,7 @@ export const CODES = { BAD_SPEC: 'UIDX143', /** A behaviour bullet with no `id:` prefix. */ BAD_BEHAVIOR_RULE: 'UIDX144', - /** A `repeat` that is not a list alias, or `as`/`count` without one (ADR 0017 §2). */ + /** A `repeat` that is not a list alias, or `as` without one (ADR 0017 §2). */ BAD_REPEAT: 'UIDX145', /** A declared part with no `part="…"` node, or a `part` naming none. */ PART_BINDING: 'UIDX146', diff --git a/packages/format/src/parse.ts b/packages/format/src/parse.ts index 212be7e..89de13a 100644 --- a/packages/format/src/parse.ts +++ b/packages/format/src/parse.ts @@ -487,9 +487,9 @@ export class Lowerer { ) } // ADR 0017 §2: `repeat="{items}"` draws this element once per element - // of a list; `as` names the item for the bindings below, `count` is the - // canvas's number of rows. Checked here for shape only — whether the - // list exists is the audit's question, which needs the contract. + // of a list; `as` names the item for the bindings below. Checked here + // for shape only — whether the list exists is the audit's question, + // which needs the contract. if (!isRoot && element !== 'Variant') this.checkRepeat(node, loc) // ADR 0008 §1: a `` *is* a frame, so it holds what a frame holds // — any number of children, and none. The one-child rule it used to share @@ -538,7 +538,6 @@ export class Lowerer { void loc const repeat = node.attrs.repeat const as = node.attrs.as - const count = node.attrs.count if (repeat !== undefined) { const target = typeof repeat.value === 'string' ? aliasTarget(repeat.value) : null if (target === null || target.includes('#')) { @@ -559,16 +558,6 @@ export class Lowerer { as.loc, ) } - if (count !== undefined) { - if (repeat === undefined) - this.error( - CODES.BAD_REPEAT, - '"count" says how many rows a repeat draws; add repeat="{…}"', - count.loc, - ) - else if (typeof count.value !== 'number' || !Number.isInteger(count.value) || count.value < 0) - this.error(CODES.BAD_REPEAT, '"count" is a non-negative integer', count.loc) - } } private checkMetadata(element: UidxElement, attrs: Record): void { diff --git a/packages/format/test/spec.test.ts b/packages/format/test/spec.test.ts index 8a82514..7946b01 100644 --- a/packages/format/test/spec.test.ts +++ b/packages/format/test/spec.test.ts @@ -227,10 +227,10 @@ describe('repeat (ADR 0017 §2)', () => { const page = (body: string) => `---\nid: r\n---\n\n## Visual Contract\n\n\n \n \n${body}\n \n \n\n` - it('is an attribute of the element that repeats, with its item name and canvas count', () => { + it('is an attribute of the element that repeats, with its item name', () => { const doc = parseOrThrow( page( - ' ', + ' ', ), ) const row = doc.tree.children[0]!.children[0]!.children[0]! @@ -238,10 +238,9 @@ describe('repeat (ADR 0017 §2)', () => { expect(row.address).toBe('List#root/row') expect(row.attrs.repeat?.value).toBe('{items}') expect(row.attrs.as?.value).toBe('contact') - expect(row.attrs.count?.value).toBe(3) }) - it('checks the shape of repeat, as and count', () => { + it('checks the shape of repeat and as', () => { const codes = (body: string) => parse(page(body)).diagnostics.map((d) => d.code) expect(codes(' ')).toEqual([CODES.BAD_REPEAT]) expect(codes(' ')).toEqual([CODES.BAD_REPEAT]) @@ -249,10 +248,6 @@ describe('repeat (ADR 0017 §2)', () => { expect(codes(' ')).toEqual([ CODES.BAD_REPEAT, ]) - expect(codes(' ')).toEqual([ - CODES.BAD_REPEAT, - ]) - expect(codes(' ')).toEqual([CODES.BAD_REPEAT]) expect(codes(' ')).toEqual([]) }) }) diff --git a/packages/schema/src/design-system.ts b/packages/schema/src/design-system.ts index 994838d..3c92007 100644 --- a/packages/schema/src/design-system.ts +++ b/packages/schema/src/design-system.ts @@ -123,9 +123,9 @@ export function modelSamples( } /** - * How many rows the canvas draws for a repeat that says no `count`: the - * longest sample list among the model's fields, else three — enough to read - * as a list, few enough to fit. + * How many rows the canvas draws for a repeat: the longest sample list + * among the model's fields, else three — enough to read as a list, few + * enough to fit. The model decides; the tree says only what repeats. */ export function sampleCount(model: ModelSpec | undefined): number { let longest = 0 @@ -140,8 +140,6 @@ export interface RepeatAttrs { list: string /** The item's name for the bindings below; `item` unless `as` says. */ as: string - /** The canvas's row count, when the file says. */ - count?: number } export function repeatOf(node: UidxNode): RepeatAttrs | null { @@ -149,12 +147,7 @@ export function repeatOf(node: UidxNode): RepeatAttrs | null { const list = typeof value === 'string' ? aliasTarget(value) : null if (list === null || list.includes('#')) return null const as = node.attrs.as?.value - const count = node.attrs.count?.value - return { - list, - as: typeof as === 'string' && as !== '' ? as : 'item', - ...(typeof count === 'number' ? { count } : {}), - } + return { list, as: typeof as === 'string' && as !== '' ? as : 'item' } } /** One enclosing repeat, for resolving `{item.children}` and `{item.name}` below it. */ diff --git a/packages/schema/src/known-props.ts b/packages/schema/src/known-props.ts index eede17f..74b0777 100644 --- a/packages/schema/src/known-props.ts +++ b/packages/schema/src/known-props.ts @@ -29,14 +29,12 @@ export const STRUCTURAL_PROPS: readonly string[] = [ 'modes', 'rootFontSize', // ADR 0013 §3: the headless root a component implements, and the part a - // node binds. ADR 0017 §2: what an element repeats over, what it calls the - // item, and how many the canvas draws. Bindings to the contract, never - // scene fields. + // node binds. ADR 0017 §2: what an element repeats over and what it calls + // the item. Bindings to the contract, never scene fields. 'implements', 'part', 'repeat', 'as', - 'count', ] /** diff --git a/packages/schema/src/reconcile.ts b/packages/schema/src/reconcile.ts index d6cefc1..4935a98 100644 --- a/packages/schema/src/reconcile.ts +++ b/packages/schema/src/reconcile.ts @@ -104,7 +104,7 @@ function defaultsFor(type: NodeType): Readonly> { type IndexedNode = { node: UidxNode; parent: string | null; index: number } /** What a repeat rides on (ADR 0017 §2); a change to one re-expands the rows. */ -const REPEATS = ['repeat', 'as', 'count'] +const REPEATS = ['repeat', 'as'] /** True when a repeat, or a node a repeat draws, was added, removed or changed. */ function repeatChanged(before: Map, after: Map): boolean { @@ -188,7 +188,7 @@ export function diffDocuments( // ADR 0017 §2: a repeat's echoes (`row-2`, `row-3`) are generated at ids // this diff does not enumerate, so what a repeat rides on — the list, the - // item's name, the count — and anything a repeat draws, its layer and the + // item's name — and anything a repeat draws, its layer and the // subtree below, rebuild when they change; the same honesty an instance's // copies get. Beside a repeat, the incremental path still serves. if (repeatChanged(before, after)) return null diff --git a/packages/schema/src/to-scene.ts b/packages/schema/src/to-scene.ts index 6e06f04..467d735 100644 --- a/packages/schema/src/to-scene.ts +++ b/packages/schema/src/to-scene.ts @@ -310,7 +310,7 @@ export function toSceneGraph(doc: UidxDocument, options: SceneOptions = {}): Sce // rows (an instance row binds through its own definition); the audit // is where an unplaceable list is reported. const model = repeatModel(repeat, spec, scope.repeats ?? [], scope.models) - const count = repeat.count ?? sampleCount(model) + const count = sampleCount(model) const row = unrepeated(node) for (let index = 0; index < count; index++) { const first = index === 0 @@ -723,7 +723,6 @@ function unrepeated(node: UidxNode): UidxNode { const attrs = { ...node.attrs } delete attrs.repeat delete attrs.as - delete attrs.count return { ...node, attrs } } @@ -1076,7 +1075,7 @@ function expandInstance( const repeat = repeatOf(source) if (repeat) { const model = repeatModel(repeat, definition.spec, local.repeats ?? [], local.models) - const count = repeat.count ?? sampleCount(model) + const count = sampleCount(model) const row = unrepeated(source) for (let index = 0; index < count; index++) { const bindings = model diff --git a/packages/schema/test/design-system.test.ts b/packages/schema/test/design-system.test.ts index 1697410..e506df8 100644 --- a/packages/schema/test/design-system.test.ts +++ b/packages/schema/test/design-system.test.ts @@ -194,7 +194,7 @@ const ROW_SOURCE = page( const LIST_SOURCE = page( 'contact-list', ` - + @@ -360,7 +360,7 @@ describe('repeat draws an element once per item (ADR 0017 §2)', () => { const index = componentIndex(row, list) const scene = toSceneGraph(list, { resolveComponent: (name) => index.get(name) }) - it('draws count rows of a repeating slot, the n-th filled from the n-th sample, wrapping and blanking', () => { + it('draws one row per sample of a repeating slot, the n-th filled from the n-th, wrapping and blanking', () => { const at = (n: number) => (n === 1 ? 'ContactList#item' : `ContactList#item-${n}`) const text = (n: number, part: string) => scene.graph.getNode(`${at(n)}/row/${part}`)!.text expect(scene.graph.getNode('ContactList#item')!.type).toBe('FRAME') diff --git a/packages/schema/test/reconcile.test.ts b/packages/schema/test/reconcile.test.ts index 4884362..0ebcd16 100644 --- a/packages/schema/test/reconcile.test.ts +++ b/packages/schema/test/reconcile.test.ts @@ -57,14 +57,8 @@ const ROWS = ` describe('diffDocuments', () => { it('rebuilds for a repeat that changes, and for anything a repeat draws (ADR 0017 §2)', () => { const before = REPEATED(ROWS) - // The echoes are generated at ids the diff cannot address, so the row - // count, the item's name, the list, and the rows' own look all rebuild. - expect( - diffDocuments( - before, - REPEATED(ROWS.replace('repeat="{items}"', 'repeat="{items}" count={1}')), - ), - ).toBeNull() + // The echoes are generated at ids the diff cannot address, so the + // item's name, the list, and the rows' own look all rebuild. expect( diffDocuments( before, diff --git a/packages/viewer/src/ContractSection.vue b/packages/viewer/src/ContractSection.vue index 32ae2ed..998668e 100644 --- a/packages/viewer/src/ContractSection.vue +++ b/packages/viewer/src/ContractSection.vue @@ -19,7 +19,6 @@ import { setPart, setRepeat, setRepeatAs, - setRepeatCount, undeclare, } from './contract-edits' import type { ModelIndex } from '@uidx/schema' @@ -138,11 +137,6 @@ function chooseAs(raw: string): void { send(setRepeatAs(repeatable.value.node, raw)) } -function chooseCount(raw: string): void { - if (!repeatable.value) return - send(setRepeatCount(repeatable.value.node, raw.trim() === '' ? null : Number(raw))) -} - function findNode(root: UidxNode, address: string): UidxNode | null { if (root.address === address) return root for (const child of root.children) { @@ -424,11 +418,7 @@ const isState = (prop: { type: string; visual: boolean }): boolean => /> - {{ - slot.provided.repeat - ? `Slot × ${slot.provided.repeat.count ?? 'samples'}` - : 'Slot in tree' - }} + {{ slot.provided.repeat ? 'Slot, one per item' : 'Slot in tree' }} No slot in the tree yet @@ -934,8 +924,11 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>