From 4893949bcc3497338d4e2f5d6298abf8c6ef4fc6 Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Tue, 29 Sep 2026 23:46:54 +0300
Subject: [PATCH 01/74] =?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
+
+
+
+
+
+
+
+
+```
+
+- A row's keys are axis assignments; its other attributes are `part.prop`
+ pairs. `root` is the component's own frame; other names are declared parts.
+- Resolution is by specificity: the base tree, then rows matching one key,
+ then rows matching more. Equal specificity resolves in file order.
+- Values are tokens, or literals where the prop has no token type (`visible`,
+ `height`). No new parts and no new props may be introduced by a row.
+- Presence of a part under a state is `part.visible={true}` in a row, which
+ is what replaces a `when` attribute.
+
+### 3. Structural variants: an authored tree
+
+When an axis value changes the anatomy, a `` tree is written for
+that value only, exactly as ADR 0005 shapes it:
+
+```mdx
+
+
+
+```
+
+The other axes still apply their style rows on top of it. A variant tree may
+omit declared parts only when the contract says that axis value has none.
+
+### 4. Derivation
+
+The canvas and the Figma export expand the axes into full trees at scene
+build time: one component set, one tree per combination, named
+`state=hover, size=sm` as ADR 0005 §3 spells it. Derived trees are synthetic:
+they carry no source span, the bimap does not link them, and a gesture on one
+produces no patch. Code targets never expand; they emit one component whose
+CSS is keyed by attributes and states.
+
+### 5. Audit
+
+- Every axis value has at least one style row or one variant tree.
+- A `` tree that differs from the base only in token values is
+ reported as derivable, with the rows that would replace it.
+- Style rows name existing parts and props only, and only tokens where a
+ token type exists.
+
+## Consequences
+
+- Existing files with full `` trees remain valid; the audit
+ suggests, never rewrites.
+- `arrangeVariants`, `variantFor` and the `.fig` export are unchanged: they
+ see the derived set exactly as they saw an authored one.
+- The state axis is named `state` and its first value `default`, which is
+ also the default combination ADR 0005 §2 requires.
diff --git a/docs/decisions/0017-collections-and-code-targets.md b/docs/decisions/0017-collections-and-code-targets.md
new file mode 100644
index 0000000..da8e139
--- /dev/null
+++ b/docs/decisions/0017-collections-and-code-targets.md
@@ -0,0 +1,84 @@
+# ADR 0017 — Collections, and code as a render target
+
+Status: **accepted**, 2026-09-29. Part of ADR 0012.
+
+## Context
+
+A render prop bundles three things: the place items go, the data each item
+receives, and the component that draws one. The headless layer already
+separates them — a root owns selection and keyboard, items are projected
+children — and the file has to declare them so every target renders the same
+thing.
+
+## Decision
+
+### 1. Repeating slots
+
+A slot may repeat, carry a model, and constrain what fills it:
+
+```mdx
+
+ One filling per element of `items`.
+
+```
+
+- `repeats` means one filling per element of the prop whose type is the
+ model array.
+- `model` is the parameter each filling receives.
+- `accepts` names the headless root an item component must implement; the
+ audit refuses a filling that does not.
+
+### 2. `` in the visual contract
+
+```mdx
+
+
+
+```
+
+A visual-only instruction, legal only on a slot declared `repeats`, whose one
+child is an instance of an accepted component. The canvas expands it to
+`count` clones, the n-th resolving `{item.*}` from the n-th samples. Figma
+export renders instances with an instance-swap property. Code ignores
+`count` and uses the contract.
+
+### 3. Code targets
+
+A new package, `@uidx/codegen`, renders identities to code. It reads the
+parsed document, never MDX, and is a pure function of the file: same input,
+same bytes.
+
+- **HTML/CSS**: one stylesheet per component. Tokens become CSS custom
+ properties; the base tree becomes rules on the root and part elements;
+ style rows become rules keyed by `[prop="value"]` attributes and by states
+ (`[checked]`, `[disabled]`, `:hover`, `:focus-visible`). Plus one markup
+ fragment per component showing its anatomy with sample content.
+- **React**: one component per identity, wrapping the headless root with props
+ typed from the contract and events as `on*` callbacks. Slots are the
+ contract's answer to render props:
+ - a plain slot becomes a `ReactNode` prop of the slot's name (`label`,
+ `description`), and a slot named `default` becomes `children`;
+ - a `repeats` slot becomes `items: T[]` plus `renderItem: (item: T, index)
+ => 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;
+ - 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.
+- **Contract JSON**: `uidx contract ` prints `doc.spec` for generators
+ that do not parse MDX.
+- **Conformance**: with `--manifest custom-elements.json`, each `implements`
+ tag is checked against the manifest (ADR 0013 §4).
+
+### 4. Cross-target conformance
+
+The example package holds fixtures rendered by every target. Golden files are
+committed; a change in output with no change in input fails CI.
+
+## Consequences
+
+- `examples/design-system` is a workspace package that vendors the built
+ `@hwc/components` until it is published, and shows Checkbox, Field,
+ CheckboxField, Button and a radio group with a model, end to end.
+- Figma export of repeats and derived sets is measured, not assumed; ADR 0007
+ §6's deferral stands until F2.
From 6a0782e1aa20146093ab7311e8b420d427b5a9ce Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Tue, 29 Sep 2026 23:56:25 +0300
Subject: [PATCH 02/74] =?UTF-8?q?format:=20spec=20regions,=20styles=20tabl?=
=?UTF-8?q?e=20and=20=20(ADRs=200013=E2=80=930017)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A .uidx file may carry ## Contract, ## Behavior, ## Models and ## Examples
after the visual contract, and a table beside the root. They are
lowered into doc.spec as declarations, never scene nodes; the tree the
viewer draws is unchanged. multiplies one instance for
the design tools. The emitter prints the regions back verbatim so uidx fmt
keeps them.
Co-Authored-By: Claude Fable 5.1
---
docs/decisions/0016-variants-as-renders.md | 20 +-
packages/format/src/diagnostics.ts | 27 +
packages/format/src/emit.ts | 17 +-
packages/format/src/index.ts | 13 +
packages/format/src/parse.ts | 103 +++-
packages/format/src/spec.ts | 574 +++++++++++++++++++++
packages/format/src/types.ts | 151 ++++++
packages/format/test/spec.test.ts | 238 +++++++++
8 files changed, 1130 insertions(+), 13 deletions(-)
create mode 100644 packages/format/src/spec.ts
create mode 100644 packages/format/test/spec.test.ts
diff --git a/docs/decisions/0016-variants-as-renders.md b/docs/decisions/0016-variants-as-renders.md
index 0ec2792..72ddf58 100644
--- a/docs/decisions/0016-variants-as-renders.md
+++ b/docs/decisions/0016-variants-as-renders.md
@@ -28,22 +28,24 @@ states, referencing tokens only:
```mdx
-
-
-
-
-
-
+
+
+
+
+
+
```
-- A row's keys are axis assignments; its other attributes are `part.prop`
- pairs. `root` is the component's own frame; other names are declared parts.
+- A row's keys are axis assignments; its other attributes are `part:prop`
+ pairs (a colon, because JSX allows a namespaced attribute name and not a
+ dotted one). `root` is the component's own frame; other names are declared
+ parts.
- Resolution is by specificity: the base tree, then rows matching one key,
then rows matching more. Equal specificity resolves in file order.
- Values are tokens, or literals where the prop has no token type (`visible`,
`height`). No new parts and no new props may be introduced by a row.
-- Presence of a part under a state is `part.visible={true}` in a row, which
+- Presence of a part under a state is `part:visible={true}` in a row, which
is what replaces a `when` attribute.
### 3. Structural variants: an authored tree
diff --git a/packages/format/src/diagnostics.ts b/packages/format/src/diagnostics.ts
index adbc16a..3814db1 100644
--- a/packages/format/src/diagnostics.ts
+++ b/packages/format/src/diagnostics.ts
@@ -93,6 +93,33 @@ export const CODES = {
PIN_NOT_ALLOWED_HERE: 'UIDX135',
/** `SCALE` is a ratio and needs fractional geometry, which is story H5. */
PIN_SCALE_UNSUPPORTED: 'UIDX136',
+ // design system (ADRs 0012–0017)
+ /** A `## X` heading after the visual contract that is not a known region. */
+ UNKNOWN_REGION: 'UIDX140',
+ /** A region written twice. */
+ DUPLICATE_REGION: 'UIDX141',
+ /** An element a spec region does not know. */
+ UNKNOWN_SPEC_ELEMENT: 'UIDX142',
+ /** A spec element with a missing or mistyped attribute, or no description. */
+ BAD_SPEC: 'UIDX143',
+ /** A behaviour bullet with no `id:` prefix. */
+ BAD_BEHAVIOR_RULE: 'UIDX144',
+ /** A `` without a slot, without a positive count, or not holding one child. */
+ BAD_REPEAT: 'UIDX145',
+ /** A declared part with no `part="…"` node, or a `part` naming none. */
+ PART_BINDING: 'UIDX146',
+ /** A styles-table row naming an unknown part, prop or axis value. */
+ STYLE_ROW: 'UIDX147',
+ /** An axis value with no style row and no variant tree. */
+ AXIS_UNCOVERED: 'UIDX148',
+ /** A slot in the contract with no `` in the tree, or the reverse. */
+ SLOT_BINDING: 'UIDX149',
+ /** A model field without a description or a required sample, or a model with no key. */
+ MODEL_FIELD: 'UIDX150',
+ /** A `{prop.field}` binding that names no model prop or no field of its model. */
+ BINDING: 'UIDX151',
+ /** A contract that disagrees with the headless manifest it implements. */
+ CONFORMANCE: 'UIDX152',
// attribute grammar
BAD_VALUE: 'UIDX200',
SHORTHAND_ATTR: 'UIDX201',
diff --git a/packages/format/src/emit.ts b/packages/format/src/emit.ts
index 19364fa..a54979e 100644
--- a/packages/format/src/emit.ts
+++ b/packages/format/src/emit.ts
@@ -70,8 +70,23 @@ export function emitDocument(doc: UidxDocument): string {
// so `uidx fmt` performs that half of the migration by construction.
const contract = emitTree(toSpec(doc.tree, false))
const intent = doc.intent.raw.replace(/^\n+/, '').replace(/\n+$/, '')
+ // The styles table and the spec regions (ADR 0012) are printed back as
+ // written: this emitter reformats the tree and nothing else, and a region
+ // it does not understand must survive a `uidx fmt` byte for byte.
+ const styles = doc.spec?.styles?.length ? `\n${stylesSource(doc)}\n` : ''
+ const trailing = doc.trailing ? `\n${doc.trailing.raw.replace(/\n+$/, '')}\n` : ''
- return `---\n${frontmatter}\n---\n\n${intent}\n\n## Visual Contract\n\n${contract}\n`
+ return `---\n${frontmatter}\n---\n\n${intent}\n\n## Visual Contract\n\n${contract}\n${styles}${trailing}`
+}
+
+/** The `` element's own source text, found by its first row's span. */
+function stylesSource(doc: UidxDocument): string {
+ const rows = doc.spec?.styles ?? []
+ const first = rows[0]!.loc.start
+ const open = doc.source.lastIndexOf('', rows[rows.length - 1]!.loc.end)
+ if (open === -1 || close === -1) return ''
+ return doc.source.slice(open, close + ' '.length)
}
/** Auto-name per spec §3.4: `-`, bumped until siblings are unique. */
diff --git a/packages/format/src/index.ts b/packages/format/src/index.ts
index 056405d..ab5d4bb 100644
--- a/packages/format/src/index.ts
+++ b/packages/format/src/index.ts
@@ -103,6 +103,19 @@ export {
type UidxNode,
type UidxNodeSpec,
type UidxPatch,
+ type BehaviorRule,
+ type ContractSpec,
+ type DocumentSpec,
+ type EventSpec,
+ type ExampleSet,
+ type ExampleSpec,
+ type FieldSpec,
+ type ModelSpec,
+ type PropSpec,
+ type SlotSpec,
+ type SpecNode,
+ type StyleRow,
} from './types.js'
+export { buildSpec, REGION_NAMES, SpecLowerer, type Region, type RegionName } from './spec.js'
export { SCOPE_FOR_PROP, scopesForProp } from './scope-for-prop.js'
export * from './lengths.js'
diff --git a/packages/format/src/parse.ts b/packages/format/src/parse.ts
index e670d50..f2a7ccf 100644
--- a/packages/format/src/parse.ts
+++ b/packages/format/src/parse.ts
@@ -10,6 +10,7 @@ import { mdxMd } from 'micromark-extension-mdx-md'
import { parse as parseYaml } from 'yaml'
import { CODES, diagnostic, UidxError } from './diagnostics.js'
+import { buildSpec, REGION_NAMES, type MdastLike, type Region } from './spec.js'
import { parseExpression, ValueError } from './values.js'
import { fitsVariableType, isAlias, variableTypeOf } from './alias.js'
import { componentProps, instanceProps } from './component-props.js'
@@ -307,6 +308,24 @@ export class Lowerer {
)
}
name = rootName
+ } else if (element === 'Repeat') {
+ // ADR 0017 §2: a repeat is named by the slot it multiplies, the way a
+ // variant is named by its coordinates — nothing an author could misspell.
+ const slot = attrs.slot?.value
+ const count = attrs.count?.value
+ if (typeof slot !== 'string' || slot === '') {
+ this.error(
+ CODES.BAD_REPEAT,
+ ' needs a "slot" naming a repeating slot of the contract',
+ loc,
+ )
+ return null
+ }
+ if (typeof count !== 'number' || !Number.isInteger(count) || count < 0) {
+ this.error(CODES.BAD_REPEAT, ' needs a non-negative integer "count"', loc)
+ return null
+ }
+ name = `repeat(${slot})`
} else if (element === 'Variant') {
// ADR 0005 §3: a variant has no name of its own. Deriving it here rather
// than reading one means the microformat this ADR exists to delete —
@@ -485,6 +504,15 @@ export class Lowerer {
loc,
)
}
+ // ADR 0017 §2: one filling, multiplied. Two children would leave the
+ // question of which one repeats.
+ if (element === 'Repeat' && node.children.length !== 1) {
+ this.error(
+ CODES.BAD_REPEAT,
+ ` holds exactly one child, the instance it multiplies; found ${node.children.length}`,
+ 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
// with `` was never the same rule: a variant is one state's tree
@@ -1431,8 +1459,62 @@ export function parse(source: string): ParseResult {
// ---- contract region --------------------------------------------------
const lowerer = new Lowerer(source)
- const contractNodes = children.filter((c) => c.position.start.offset >= headingLoc.end)
- const roots = contractNodes.filter((c) => c.type === 'mdxJsxFlowElement')
+ const after = children.filter((c) => c.position.start.offset >= headingLoc.end)
+ // The visual contract ends at the next depth-2 heading (ADR 0013 §1); what
+ // follows are the spec regions, each named by its heading.
+ const firstRegion = after.findIndex((c) => c.type === 'heading' && c.depth === 2)
+ const contractNodes = firstRegion === -1 ? after : after.slice(0, firstRegion)
+ const regions: Region[] = []
+ let region: Region | null = null
+ for (const node of firstRegion === -1 ? [] : after.slice(firstRegion)) {
+ if (node.type === 'heading' && node.depth === 2) {
+ const name = textContent(node).trim()
+ region = null
+ if (!(REGION_NAMES as readonly string[]).includes(name)) {
+ diagnostics.push(
+ diagnostic(
+ source,
+ CODES.UNKNOWN_REGION,
+ `unknown region "## ${name}" after the Visual Contract; known regions: ${REGION_NAMES.join(', ')}`,
+ rangeOf(node),
+ ),
+ )
+ continue
+ }
+ if (regions.some((r) => r.name === name)) {
+ diagnostics.push(
+ diagnostic(
+ source,
+ CODES.DUPLICATE_REGION,
+ `"## ${name}" is written twice`,
+ rangeOf(node),
+ ),
+ )
+ continue
+ }
+ region = { name: name as Region['name'], nodes: [], loc: rangeOf(node) }
+ regions.push(region)
+ continue
+ }
+ region?.nodes.push(node as unknown as MdastLike)
+ }
+ // The styles table (ADR 0016 §2) sits beside the root, in the visual
+ // contract, because it is visual data — but it is not a scene node, so it
+ // is lowered with the spec regions rather than as part of the tree.
+ const styleTables = contractNodes.filter(
+ (c) => c.type === 'mdxJsxFlowElement' && c.name === 'Styles',
+ )
+ if (styleTables.length > 1) {
+ diagnostics.push(
+ diagnostic(
+ source,
+ CODES.DUPLICATE_REGION,
+ 'one table per file; merge the rows',
+ rangeOf(styleTables[1]),
+ ),
+ )
+ }
+ const roots = contractNodes.filter((c) => c.type === 'mdxJsxFlowElement' && c.name !== 'Styles')
for (const node of contractNodes) {
if (node.type === 'mdxJsxFlowElement') continue
@@ -1481,7 +1563,14 @@ export function parse(source: string): ParseResult {
rootEl.name === 'Component'
? lowerer.syntheticPage(rootEl, rootName)
: lowerer.element(rootEl, null, null, rootName)
- const all = [...diagnostics, ...lowerer.diagnostics].sort((a, b) => a.loc.start - b.loc.start)
+ const { spec, diagnostics: specDiagnostics } = buildSpec(
+ source,
+ regions,
+ (styleTables[0] as unknown as MdastLike | undefined) ?? null,
+ )
+ const all = [...diagnostics, ...lowerer.diagnostics, ...specDiagnostics].sort(
+ (a, b) => a.loc.start - b.loc.start,
+ )
if (!treeRoot || all.some((d) => d.severity === 'error')) {
return { doc: null, diagnostics: all }
@@ -1494,6 +1583,14 @@ export function parse(source: string): ParseResult {
source,
sourceHash: fnv1a(source),
}
+ if (spec) {
+ doc.spec = spec
+ for (const child of treeRoot.children) if (child.element === 'Component') child.spec = spec
+ }
+ if (regions.length) {
+ const start = regions[0]!.loc.start
+ doc.trailing = { raw: source.slice(start), loc: { start, end: source.length } }
+ }
assertOffsetInvariant(doc)
return { doc, diagnostics: all }
diff --git a/packages/format/src/spec.ts b/packages/format/src/spec.ts
new file mode 100644
index 0000000..9326b7f
--- /dev/null
+++ b/packages/format/src/spec.ts
@@ -0,0 +1,574 @@
+import { parseExpression, ValueError } from './values.js'
+import { CODES, diagnostic } from './diagnostics.js'
+import type {
+ BehaviorRule,
+ ContractSpec,
+ Diagnostic,
+ DocumentSpec,
+ EventSpec,
+ ExampleSet,
+ ExampleSpec,
+ FieldSpec,
+ JsonValue,
+ ModelSpec,
+ PropSpec,
+ Range,
+ SlotSpec,
+ SpecNode,
+ StyleRow,
+} from './types.js'
+
+/**
+ * The regions after the visual contract (ADRs 0013–0016), lowered.
+ *
+ * These are declarations, not scene nodes: a `` draws nothing and has
+ * no address in the scene graph. So they are lowered generically — name,
+ * evaluated attributes, text, children — and the typed shapes are built from
+ * that, with every shape problem a diagnostic rather than a crash. The
+ * generic tree is kept as well: `uidx contract` prints it, so a generator
+ * sees exactly what was written even for an element this file does not type.
+ */
+
+/** The mdast slice this module reads. Kept loose: mdast is untyped here. */
+export interface MdastLike {
+ type: string
+ name?: string
+ value?: string
+ attributes?: { type: string; name?: string; value?: unknown }[]
+ children?: MdastLike[]
+ position: { start: { offset: number }; end: { offset: number } }
+}
+
+export const REGION_NAMES = ['Contract', 'Behavior', 'Models', 'Examples'] as const
+export type RegionName = (typeof REGION_NAMES)[number]
+
+const CONTRACT_ELEMENTS: Record = {
+ Props: ['Prop'],
+ Events: ['Event'],
+ States: [],
+ Parts: [],
+ Slots: ['Slot'],
+ Form: [],
+ Accessibility: [],
+ Composes: [],
+}
+const MODEL_ELEMENTS: Record = { Model: ['Field'] }
+const EXAMPLE_ELEMENTS: Record = { Example: ['Set'] }
+
+function rangeOf(node: MdastLike): Range {
+ return { start: node.position.start.offset, end: node.position.end.offset }
+}
+
+function textOf(node: MdastLike): string {
+ if (typeof node.value === 'string') return node.value
+ return (node.children ?? []).map(textOf).join('')
+}
+
+/** Lowers one JSX element and its subtree, reporting shape problems. */
+export class SpecLowerer {
+ readonly diagnostics: Diagnostic[] = []
+ constructor(private readonly source: string) {}
+
+ private error(code: string, message: string, loc: Range): void {
+ this.diagnostics.push(diagnostic(this.source, code, message, loc))
+ }
+
+ /**
+ * Attributes with the spec regions' one relaxation over the visual
+ * contract: a bare boolean (`controllable`, `key`, `repeats`) is allowed,
+ * because these are declarations and a flag reads better than `={true}`.
+ */
+ attributes(el: MdastLike): Record {
+ const attrs: Record = {}
+ for (const raw of el.attributes ?? []) {
+ const loc = rangeOf(el)
+ if (raw.type !== 'mdxJsxAttribute' || !raw.name) {
+ this.error(CODES.SPREAD_ATTR, 'spread attributes are not part of the UIDX grammar', loc)
+ continue
+ }
+ if (raw.name in attrs) {
+ this.error(CODES.DUPLICATE_ATTR, `duplicate attribute "${raw.name}"`, loc)
+ continue
+ }
+ if (raw.value === null || raw.value === undefined) {
+ attrs[raw.name] = true
+ continue
+ }
+ if (typeof raw.value === 'string') {
+ attrs[raw.name] = raw.value
+ continue
+ }
+ const inner = (raw.value as { value?: string }).value ?? ''
+ try {
+ attrs[raw.name] = parseExpression(inner)
+ } catch (err) {
+ const message = err instanceof ValueError ? err.message : String(err)
+ this.error(CODES.BAD_VALUE, `attribute "${raw.name}": ${message}`, loc)
+ }
+ }
+ return attrs
+ }
+
+ element(
+ el: MdastLike,
+ allowed: Record,
+ parent: string | null,
+ ): SpecNode | null {
+ const name = el.name ?? ''
+ const loc = rangeOf(el)
+ const legal = parent === null ? Object.keys(allowed) : (allowed[parent] ?? [])
+ if (!legal.includes(name)) {
+ this.error(
+ CODES.UNKNOWN_SPEC_ELEMENT,
+ parent === null
+ ? `unknown element <${name || '?'}> here; allowed: ${legal.join(', ')}`
+ : `<${name || '?'}> is not allowed inside <${parent}>; allowed: ${legal.length ? legal.join(', ') : 'nothing'}`,
+ loc,
+ )
+ return null
+ }
+ const node: SpecNode = { name, attrs: this.attributes(el), text: '', children: [], loc }
+ const text: string[] = []
+ this.walk(el.children ?? [], allowed, name, node.children, text)
+ node.text = text.join(' ').replace(/\s+/g, ' ').trim()
+ return node
+ }
+
+ /**
+ * Collects the JSX elements and the text among `nodes`.
+ *
+ * MDX parses an element that sits on one line with its text —
+ * `Its description. ` — as a paragraph holding a
+ * *text* element, and one on its own lines as a *flow* element. Both are
+ * the same declaration to this module, so paragraphs are looked into and
+ * either kind is lowered; what is left is the description text.
+ */
+ private walk(
+ nodes: MdastLike[],
+ allowed: Record,
+ parent: string | null,
+ into: SpecNode[],
+ text: string[],
+ ): void {
+ for (const child of nodes) {
+ if (child.type === 'mdxJsxFlowElement' || child.type === 'mdxJsxTextElement') {
+ const lowered = this.element(child, allowed, parent)
+ if (lowered) into.push(lowered)
+ } else if (child.type === 'mdxFlowExpression' || child.type === 'mdxTextExpression') {
+ continue
+ } else if (child.type === 'paragraph') {
+ this.walk(child.children ?? [], allowed, parent, into, text)
+ } else {
+ text.push(textOf(child))
+ }
+ }
+ }
+
+ /** Every JSX element among `nodes`, lowered; stray prose is reported. */
+ elements(
+ nodes: MdastLike[],
+ allowed: Record,
+ region: string,
+ ): SpecNode[] {
+ const out: SpecNode[] = []
+ for (const node of nodes) {
+ const text: string[] = []
+ this.walk([node], allowed, null, out, text)
+ if (text.join('').trim() !== '') {
+ this.error(
+ CODES.UNEXPECTED_CONTRACT_CONTENT,
+ `the ${region} region holds elements and MDX comments only`,
+ rangeOf(node),
+ )
+ }
+ }
+ return out
+ }
+
+ /* ---------------------------------------------------------- contract */
+
+ private str(node: SpecNode, attr: string, required = true): string | undefined {
+ const value = node.attrs[attr]
+ if (typeof value === 'string' && value !== '') return value
+ if (value === undefined && !required) return undefined
+ this.error(
+ CODES.BAD_SPEC,
+ value === undefined
+ ? `<${node.name}> needs a "${attr}" attribute`
+ : `<${node.name}> "${attr}" must be a non-empty string`,
+ node.loc,
+ )
+ return undefined
+ }
+
+ private flag(node: SpecNode, attr: string): boolean {
+ const value = node.attrs[attr]
+ if (value === undefined || value === false) return false
+ if (value === true) return true
+ this.error(
+ CODES.BAD_SPEC,
+ `<${node.name}> "${attr}" is a flag: write it bare or ={true}`,
+ node.loc,
+ )
+ return false
+ }
+
+ private described(node: SpecNode): string {
+ if (node.text === '') {
+ this.error(
+ CODES.BAD_SPEC,
+ `<${node.name}${typeof node.attrs.name === 'string' ? ` name="${node.attrs.name}"` : ''}> needs a description as its text content`,
+ node.loc,
+ )
+ }
+ return node.text
+ }
+
+ private names(node: SpecNode): string[] {
+ return node.text
+ .split(',')
+ .map((part) => part.trim())
+ .filter((part) => part !== '')
+ }
+
+ private stringList(node: SpecNode, attr: string): string[] {
+ const value = node.attrs[attr]
+ if (value === undefined) return []
+ if (Array.isArray(value) && value.every((entry) => typeof entry === 'string')) {
+ return value as string[]
+ }
+ this.error(CODES.BAD_SPEC, `<${node.name}> "${attr}" must be an array of strings`, node.loc)
+ return []
+ }
+
+ contract(nodes: MdastLike[], loc: Range): ContractSpec {
+ const spec: ContractSpec = {
+ props: [],
+ events: [],
+ states: { structural: [], styling: [] },
+ parts: [],
+ slots: [],
+ composes: [],
+ loc,
+ }
+ const seen = new Set()
+ for (const node of this.elements(nodes, CONTRACT_ELEMENTS, 'Contract')) {
+ if (seen.has(node.name) && node.name !== 'Composes') {
+ this.error(CODES.BAD_SPEC, `<${node.name}> is declared twice`, node.loc)
+ continue
+ }
+ seen.add(node.name)
+ switch (node.name) {
+ case 'Props':
+ for (const child of node.children) {
+ const name = this.str(child, 'name')
+ if (!name) continue
+ const model = this.str(child, 'model', false)
+ const type = this.str(child, 'type', false)
+ if (!model && !type) {
+ this.error(
+ CODES.BAD_SPEC,
+ ` needs a "type" or a "model"`,
+ child.loc,
+ )
+ }
+ if (spec.props.some((prop) => prop.name === name)) {
+ this.error(CODES.BAD_SPEC, ` is declared twice`, child.loc)
+ continue
+ }
+ const prop: PropSpec = {
+ name,
+ controllable: this.flag(child, 'controllable'),
+ visual: this.flag(child, 'visual'),
+ description: this.described(child),
+ loc: child.loc,
+ }
+ if (type) prop.type = type
+ if (model) prop.model = model
+ if (child.attrs.default !== undefined) prop.default = child.attrs.default
+ spec.props.push(prop)
+ }
+ break
+ case 'Events':
+ for (const child of node.children) {
+ const name = this.str(child, 'name')
+ if (!name) continue
+ const event: EventSpec = { name, description: this.described(child), loc: child.loc }
+ const detail = this.str(child, 'detail', false)
+ if (detail) event.detail = detail
+ spec.events.push(event)
+ }
+ break
+ case 'States':
+ spec.states = {
+ structural: this.stringList(node, 'structural'),
+ styling: this.stringList(node, 'styling'),
+ }
+ break
+ case 'Parts':
+ spec.parts = this.names(node)
+ break
+ case 'Slots':
+ for (const child of node.children) {
+ const name = this.str(child, 'name')
+ if (!name) continue
+ const slot: SlotSpec = {
+ name,
+ repeats: this.flag(child, 'repeats'),
+ description: this.described(child),
+ loc: child.loc,
+ }
+ const model = this.str(child, 'model', false)
+ const accepts = this.str(child, 'accepts', false)
+ if (model) slot.model = model
+ if (accepts) slot.accepts = accepts
+ if (slot.repeats && (!model || !accepts)) {
+ this.error(
+ CODES.BAD_SPEC,
+ ` repeats, so it needs a "model" and an "accepts" (ADR 0017 §1)`,
+ child.loc,
+ )
+ }
+ spec.slots.push(slot)
+ }
+ break
+ case 'Form': {
+ const form: ContractSpec['form'] = { participates: this.flag(node, 'participates') }
+ const submits = this.str(node, 'submits', false)
+ if (submits) form.submits = submits
+ spec.form = form
+ break
+ }
+ case 'Accessibility':
+ spec.accessibility = { ...node.attrs }
+ break
+ case 'Composes': {
+ const value = node.attrs.with
+ if (typeof value === 'string')
+ spec.composes.push(...value.split(',').map((s) => s.trim()))
+ else if (Array.isArray(value))
+ spec.composes.push(...value.filter((v): v is string => typeof v === 'string'))
+ else
+ this.error(
+ CODES.BAD_SPEC,
+ ' needs a "with" attribute naming components',
+ node.loc,
+ )
+ break
+ }
+ }
+ }
+ return spec
+ }
+
+ /* ------------------------------------------------------------ models */
+
+ models(nodes: MdastLike[]): ModelSpec[] {
+ const out: ModelSpec[] = []
+ for (const node of this.elements(nodes, MODEL_ELEMENTS, 'Models')) {
+ const name = this.str(node, 'name')
+ if (!name) continue
+ if (out.some((model) => model.name === name)) {
+ this.error(CODES.BAD_SPEC, ` is declared twice`, node.loc)
+ continue
+ }
+ const model: ModelSpec = { name, description: node.text, fields: [], loc: node.loc }
+ for (const child of node.children) {
+ const fieldName = this.str(child, 'name')
+ const type = this.str(child, 'type')
+ if (!fieldName || !type) continue
+ if (model.fields.some((field) => field.name === fieldName)) {
+ this.error(
+ CODES.BAD_SPEC,
+ ` is declared twice in ${name}`,
+ child.loc,
+ )
+ continue
+ }
+ const field: FieldSpec = {
+ name: fieldName,
+ type,
+ key: this.flag(child, 'key'),
+ optional: this.flag(child, 'optional'),
+ description: this.described(child),
+ loc: child.loc,
+ }
+ if (child.attrs.sample !== undefined) field.sample = child.attrs.sample
+ model.fields.push(field)
+ }
+ out.push(model)
+ }
+ return out
+ }
+
+ /* ---------------------------------------------------------- examples */
+
+ examples(nodes: MdastLike[]): ExampleSpec[] {
+ const out: ExampleSpec[] = []
+ for (const node of this.elements(nodes, EXAMPLE_ELEMENTS, 'Examples')) {
+ const name = this.str(node, 'name')
+ if (!name) continue
+ const example: ExampleSpec = { name, sets: [], loc: node.loc }
+ for (const child of node.children) {
+ const set: ExampleSet = { loc: child.loc }
+ const slot = this.str(child, 'slot', false)
+ const at = this.str(child, 'at', false)
+ const state = this.str(child, 'state', false)
+ if (slot) set.slot = slot
+ if (at) set.at = at
+ if (state) set.state = state
+ if (child.attrs.count !== undefined) {
+ const count = child.attrs.count
+ if (typeof count !== 'number' || !Number.isInteger(count) || count < 0) {
+ this.error(CODES.BAD_SPEC, ' "count" must be a non-negative integer', child.loc)
+ } else set.count = count
+ }
+ if (child.attrs.value !== undefined) set.value = child.attrs.value
+ if (!slot && !at) {
+ this.error(
+ CODES.BAD_SPEC,
+ ' needs a "slot" or an "at" to say what it changes',
+ child.loc,
+ )
+ continue
+ }
+ example.sets.push(set)
+ }
+ out.push(example)
+ }
+ return out
+ }
+
+ /* ------------------------------------------------------------ styles */
+
+ /**
+ * The styles table (ADR 0016 §2): each `
+
+## Contract
+
+
+ Whether the option is selected.
+ Inert and dimmed.
+ Form field name.
+
+
+ Fires once per user toggle.
+
+
+root, checked-indicator
+
+ Consumer text, styled here.
+
+
+
+
+
+## Behavior
+
+- toggle: click or Space flips \`checked\`.
+- change-event: \`change\` fires once per user toggle, never when \`checked\` is set from code.
+
+## Models
+
+
+ One row of the contacts list.
+ Stable identity.
+ Display name.
+ Omitted when unknown.
+
+
+## Examples
+
+
+
+
+
+`
+
+describe('spec regions', () => {
+ it('parses the contract, behaviour, models, examples and styles beside the tree', () => {
+ const { doc, diagnostics } = parse(CHECKBOX)
+ expect(diagnostics).toEqual([])
+ const spec = doc!.spec!
+ expect(spec.contract!.props.map((p) => [p.name, p.type, p.controllable, p.visual])).toEqual([
+ ['checked', 'boolean', true, true],
+ ['disabled', 'boolean', false, true],
+ ['name', 'string', false, false],
+ ])
+ expect(spec.contract!.props[0]!.default).toBe(false)
+ expect(spec.contract!.props[0]!.description).toBe('Whether the option is selected.')
+ expect(spec.contract!.events).toMatchObject([
+ { name: 'change', detail: '{ checked: boolean }' },
+ ])
+ expect(spec.contract!.states).toEqual({
+ structural: ['checked'],
+ styling: ['hover', 'focus', 'disabled'],
+ })
+ expect(spec.contract!.parts).toEqual(['root', 'checked-indicator'])
+ expect(spec.contract!.slots).toMatchObject([{ name: 'label', repeats: false }])
+ expect(spec.contract!.form).toEqual({
+ participates: true,
+ submits: 'value while checked, nothing otherwise',
+ })
+ expect(spec.contract!.accessibility).toEqual({ role: 'checkbox', keyboard: 'Space toggles' })
+ expect(spec.contract!.composes).toEqual(['Field'])
+
+ expect(spec.behavior!.map((rule) => rule.id)).toEqual(['toggle', 'change-event'])
+ expect(spec.behavior![0]!.text).toBe('click or Space flips checked.')
+
+ const contact = spec.models![0]!
+ expect(contact.name).toBe('Contact')
+ expect(contact.description).toBe('One row of the contacts list.')
+ expect(contact.fields.map((f) => [f.name, f.key, f.optional])).toEqual([
+ ['id', true, false],
+ ['name', false, false],
+ ['email', false, true],
+ ])
+ expect(contact.fields[2]!.sample).toEqual(['ada@example.com', null])
+
+ expect(spec.examples).toMatchObject([
+ {
+ name: 'checked-and-disabled',
+ sets: [
+ { at: 'root', state: 'checked' },
+ { slot: 'label', value: 'Remember me' },
+ ],
+ },
+ ])
+
+ expect(spec.styles).toMatchObject([
+ {
+ keys: { state: 'checked' },
+ values: { root: { fills: '{surface#accent}' }, 'checked-indicator': { visible: true } },
+ },
+ { keys: { state: 'disabled' }, values: { root: { opacity: 0.4 } } },
+ ])
+ })
+
+ it('leaves the visual tree exactly as it was: the regions are not nodes', () => {
+ const doc = parseOrThrow(CHECKBOX)
+ const component = doc.tree.children[0]!
+ expect(component.attrs.implements!.value).toBe('hwc-checkbox')
+ expect(component.children.map((c) => c.element)).toEqual(['Frame', 'Slot'])
+ expect(component.children[0]!.children[0]!.attrs.part!.value).toBe('checked-indicator')
+ })
+
+ it('keeps the trailing regions and the styles table through the emitter', () => {
+ const doc = parseOrThrow(CHECKBOX)
+ const printed = emitDocument(doc)
+ expect(printed).toContain('')
+ expect(printed).toContain('## Contract')
+ expect(printed).toContain('## Behavior')
+ expect(printed).toContain('- toggle: click or Space flips `checked`.')
+ expect(printed.trimEnd().endsWith('')).toBe(true)
+ // Printed once, not twice: the styles table is not part of the trailing text.
+ expect(printed.split('')).toHaveLength(2)
+ const again = parse(printed)
+ expect(again.diagnostics).toEqual([])
+ expect(again.doc!.spec).toEqual(doc.spec === undefined ? undefined : expect.any(Object))
+ expect(again.doc!.spec!.behavior!.map((r) => r.id)).toEqual(['toggle', 'change-event'])
+ })
+
+ it('is absent for a file that declares none', () => {
+ const doc = parseOrThrow(
+ '---\nid: plain\n---\n\n## Visual Contract\n\n \n',
+ )
+ expect(doc.spec).toBeUndefined()
+ expect(doc.trailing).toBeUndefined()
+ })
+
+ it('names an unknown region and a duplicate one', () => {
+ const base = '---\nid: r\n---\n\n## Visual Contract\n\n \n'
+ expect(parse(`${base}\n## Notes\n\ntext\n`).diagnostics.map((d) => d.code)).toEqual([
+ CODES.UNKNOWN_REGION,
+ ])
+ expect(
+ parse(`${base}\n## Behavior\n\n- a: one.\n\n## Behavior\n\n- b: two.\n`).diagnostics.map(
+ (d) => d.code,
+ ),
+ ).toEqual([CODES.DUPLICATE_REGION])
+ })
+
+ it('demands a description on props and fields, and an id on a behaviour bullet', () => {
+ const base = '---\nid: r\n---\n\n## Visual Contract\n\n \n'
+ const noDescription = parse(
+ `${base}\n## Contract\n\n \n`,
+ )
+ expect(noDescription.diagnostics.map((d) => d.code)).toEqual([CODES.BAD_SPEC])
+ expect(noDescription.diagnostics[0]!.message).toContain('description')
+
+ const noId = parse(`${base}\n## Behavior\n\n- clicking toggles the box.\n`)
+ expect(noId.diagnostics.map((d) => d.code)).toEqual([CODES.BAD_BEHAVIOR_RULE])
+
+ const noSample = parse(
+ `${base}\n## Models\n\nA. \n`,
+ )
+ // Missing samples are the audit's business (ADR 0015 §1), not the parser's.
+ expect(noSample.diagnostics).toEqual([])
+ })
+
+ it('refuses a repeating slot without a model and an accepted root', () => {
+ const base = '---\nid: r\n---\n\n## Visual Contract\n\n \n'
+ const { diagnostics } = parse(
+ `${base}\n## Contract\n\nRows. \n`,
+ )
+ expect(diagnostics.map((d) => d.code)).toEqual([CODES.BAD_SPEC])
+ expect(diagnostics[0]!.message).toContain('accepts')
+ })
+
+ it('reports an element a region does not know, with what it allows', () => {
+ const base = '---\nid: r\n---\n\n## Visual Contract\n\n \n'
+ const { diagnostics } = parse(`${base}\n## Contract\n\n \n`)
+ expect(diagnostics.map((d) => d.code)).toEqual([CODES.UNKNOWN_SPEC_ELEMENT])
+ expect(diagnostics[0]!.message).toContain('Props')
+ })
+})
+
+describe(' (ADR 0017 §2)', () => {
+ const page = (body: string) =>
+ `---\nid: r\n---\n\n## Visual Contract\n\n\n \n \n \n${body}\n \n \n \n`
+
+ it('is named by its slot and holds the one instance it multiplies', () => {
+ const doc = parseOrThrow(
+ page(' '),
+ )
+ const repeat = doc.tree.children[1]!.children[0]!.children[0]!
+ expect(repeat.element).toBe('Repeat')
+ expect(repeat.name).toBe('repeat(item)')
+ expect(repeat.address).toBe('List#root/repeat(item)')
+ expect(repeat.children).toHaveLength(1)
+ })
+
+ it('needs a slot, a count and exactly one child', () => {
+ const codes = (body: string) => parse(page(body)).diagnostics.map((d) => d.code)
+ expect(
+ codes(' '),
+ ).toEqual([CODES.BAD_REPEAT])
+ expect(
+ codes(
+ ' ',
+ ),
+ ).toEqual([CODES.BAD_REPEAT])
+ expect(codes(' ')).toEqual([CODES.BAD_REPEAT])
+ })
+})
From db876218c9fc227a506ba86612381996159951e7 Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 00:05:06 +0300
Subject: [PATCH 03/74] schema: derived variants, model bindings,
expansion, design-system audit, uidx contract
A component with a table is drawn as the variant set the table
derives, one synthetic tree per combination, unlinked from the file. A
contract prop with a model resolves {item.field} to the model's samples, and
a multiplies its instance at successive sample indexes. The audit
checks parts, slots, rows, axes, models, bindings and repeats against each
other, and uidx check runs it. uidx contract prints the spec as JSON.
Co-Authored-By: Claude Fable 5.1
---
packages/cli/src/cli.ts | 4 +
packages/cli/src/commands/check.ts | 4 +
packages/cli/src/commands/contract.ts | 36 ++
packages/schema/package.json | 10 +
packages/schema/src/defaults.ts | 13 +-
packages/schema/src/design-system-audit.ts | 317 +++++++++++++++++
packages/schema/src/design-system.ts | 382 +++++++++++++++++++++
packages/schema/src/index.ts | 20 ++
packages/schema/src/known-props.ts | 7 +
packages/schema/src/reconcile.ts | 5 +
packages/schema/src/to-scene.ts | 106 +++++-
packages/schema/test/design-system.test.ts | 296 ++++++++++++++++
packages/viewer/src/layer-icons.ts | 4 +
13 files changed, 1191 insertions(+), 13 deletions(-)
create mode 100644 packages/cli/src/commands/contract.ts
create mode 100644 packages/schema/src/design-system-audit.ts
create mode 100644 packages/schema/src/design-system.ts
create mode 100644 packages/schema/test/design-system.test.ts
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 8db3422..007ab3a 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -18,6 +18,7 @@ import { applyFmt, fmt, renderFmt } from './commands/fmt.js'
import { runMigrateTokens } from './commands/migrate-tokens.js'
import { runMcp } from './commands/mcp.js'
import { runInit } from './commands/init.js'
+import { runContract } from './commands/contract.js'
import { projectFiles } from './project.js'
import { version } from '../package.json'
import { BootError, open } from './commands/open.js'
@@ -32,6 +33,7 @@ Usage:
uidx mcp [--root dir] connect MCP tools to this project and its viewer
uidx check parse and validate; exit 1 on any error
uidx fmt rewrite files in canonical style
+ uidx contract print a page's contract, behaviour, models and styles as JSON
uidx migrate tokens
write the type every now declares
@@ -139,6 +141,8 @@ export async function run(argv: string[], io: Io = processIo): Promise {
return runMcp(rest, io)
case 'init':
return runInit(rest, io)
+ case 'contract':
+ return await runContract(rest, io)
case 'dev':
return runOpen(rest, io)
case 'audit':
diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts
index b2a49da..bb763dc 100644
--- a/packages/cli/src/commands/check.ts
+++ b/packages/cli/src/commands/check.ts
@@ -16,6 +16,7 @@ import {
type UidxNode,
} from '@uidx/format'
import { isKnownProp } from '@uidx/schema/known-props'
+import { auditDesignSystem } from '@uidx/schema/design-system-audit'
import {
assetProblem,
documentAssets,
@@ -84,6 +85,9 @@ export async function check(patterns: string[], options: CheckOptions = {}): Pro
if (doc) {
all.push(...lintUnknownProps(doc))
+ // The design-system regions have to agree with the tree and with each
+ // other (ADRs 0013–0017); the parser checked each region's own shape.
+ all.push(...auditDesignSystem(doc))
parsed.push({ file, doc })
}
diff --git a/packages/cli/src/commands/contract.ts b/packages/cli/src/commands/contract.ts
new file mode 100644
index 0000000..afa0b4c
--- /dev/null
+++ b/packages/cli/src/commands/contract.ts
@@ -0,0 +1,36 @@
+import { readFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { formatDiagnostic, parse } from '@uidx/format'
+import { contractJson } from '@uidx/schema/design-system'
+import type { Io } from '../cli.js'
+
+/**
+ * `uidx contract ` — the spec regions as JSON (ADR 0017 §3).
+ *
+ * For generators and agents that do not parse MDX: one object per page with
+ * the contract, behaviour, models, examples and styles as written, source
+ * spans dropped, plus each component's name, headless root, bound parts and
+ * tree slots. A page that fails to parse is reported and the command exits 1.
+ */
+export async function runContract(argv: string[], io: Io): Promise {
+ const files = argv.filter((arg) => !arg.startsWith('--'))
+ if (files.length === 0) {
+ io.err('uidx contract \n')
+ return 1
+ }
+ const cwd = io.cwd ?? process.cwd()
+ const out: Record[] = []
+ let failed = false
+ for (const file of files) {
+ const source = await readFile(resolve(cwd, file), 'utf8')
+ const { doc, diagnostics } = parse(source)
+ for (const d of diagnostics) io.err(`${formatDiagnostic(d, file)}\n`)
+ if (!doc) {
+ failed = true
+ continue
+ }
+ out.push({ file, ...contractJson(doc) })
+ }
+ io.out(`${JSON.stringify(out.length === 1 ? out[0] : out, null, 2)}\n`)
+ return failed ? 1 : 0
+}
diff --git a/packages/schema/package.json b/packages/schema/package.json
index 414541c..1a1796b 100644
--- a/packages/schema/package.json
+++ b/packages/schema/package.json
@@ -14,6 +14,16 @@
"development": "./src/known-props.ts",
"types": "./dist/known-props.d.ts",
"default": "./dist/known-props.js"
+ },
+ "./design-system": {
+ "development": "./src/design-system.ts",
+ "types": "./dist/design-system.d.ts",
+ "default": "./dist/design-system.js"
+ },
+ "./design-system-audit": {
+ "development": "./src/design-system-audit.ts",
+ "types": "./dist/design-system-audit.d.ts",
+ "default": "./dist/design-system-audit.js"
}
},
"files": [
diff --git a/packages/schema/src/defaults.ts b/packages/schema/src/defaults.ts
index ca27399..e41bce1 100644
--- a/packages/schema/src/defaults.ts
+++ b/packages/schema/src/defaults.ts
@@ -26,11 +26,12 @@ import { toSceneGraph } from './to-scene.js'
* `Variant` is deliberately absent, and the type says so rather than the table
* carrying a fixture nobody probes: a `` has no geometry of its own at
* all (ADR 0005 §5, and D4's predicate that follows from it), so there is no
- * unset row for the inspector to show dimmed. Excluding it by type keeps the
- * exhaustiveness that makes this table honest for every element that *does*
- * have one.
+ * unset row for the inspector to show dimmed. `Repeat` is absent for the same
+ * reason (ADR 0017 §2): it is expanded, never constructed. Excluding both by
+ * type keeps the exhaustiveness that makes this table honest for every element
+ * that *does* have one.
*/
-const BARE: Record, string> = {
+const BARE: Record, string> = {
Page: ' ',
Component: ' ',
Frame: ' ',
@@ -76,7 +77,7 @@ const ENGINE_SILENT: Record = {
/** Element -> prop -> resolved value, built once on first use. */
let table: Map> | null = null
-function probe(element: Exclude): Map {
+function probe(element: Exclude): Map {
const values = new Map()
const body = BARE[element]
const wrapped = element === 'Page' || element === 'Component' ? body : `${body} `
@@ -113,7 +114,7 @@ function probe(element: Exclude): Map> {
if (table) return table
table = new Map()
- for (const element of Object.keys(BARE) as Exclude[]) {
+ for (const element of Object.keys(BARE) as Exclude[]) {
table.set(element, probe(element))
}
return table
diff --git a/packages/schema/src/design-system-audit.ts b/packages/schema/src/design-system-audit.ts
new file mode 100644
index 0000000..468a61f
--- /dev/null
+++ b/packages/schema/src/design-system-audit.ts
@@ -0,0 +1,317 @@
+import {
+ aliasTarget,
+ CODES,
+ diagnostic,
+ hasVariants,
+ slots as slotsOf,
+ type Diagnostic,
+ type UidxDocument,
+ type UidxNode,
+} from '@uidx/format'
+import { isKnownProp } from './known-props.js'
+import {
+ axesOf,
+ enumValues,
+ modelByRef,
+ partNode,
+ ROOT_PART,
+ STATE_AXIS,
+ DEFAULT_STATE,
+} from './design-system.js'
+
+/**
+ * The rules that make the design-system regions a contract rather than
+ * documentation (ADRs 0013–0017). Cross-region checks only: the parser has
+ * already refused a malformed region, and these ask whether the regions
+ * agree with each other and with the tree.
+ *
+ * Pure: a document in, diagnostics out. `uidx check` runs it on every page.
+ */
+export function auditDesignSystem(doc: UidxDocument): Diagnostic[] {
+ const out: Diagnostic[] = []
+ const error = (code: string, message: string, loc: { start: number; end: number }) =>
+ out.push(diagnostic(doc.source, code, message, loc))
+ const warn = (code: string, message: string, loc: { start: number; end: number }) =>
+ out.push(diagnostic(doc.source, code, message, loc, 'warning'))
+
+ const spec = doc.spec
+ const components = doc.tree.children.filter((node) => node.element === 'Component')
+
+ /* ------------------------------------------------------------ models */
+ const usedByRepeat = new Set()
+ for (const slot of spec?.contract?.slots ?? []) {
+ const model = modelByRef(spec, slot.model)
+ if (slot.model && !model) {
+ error(
+ CODES.MODEL_FIELD,
+ `slot "${slot.name}" names a model that is not declared: ${slot.model}`,
+ slot.loc,
+ )
+ }
+ if (model) usedByRepeat.add(model.name)
+ }
+ for (const prop of spec?.contract?.props ?? []) {
+ if (prop.model && !modelByRef(spec, prop.model)) {
+ error(
+ CODES.MODEL_FIELD,
+ `prop "${prop.name}" names a model that is not declared: ${prop.model}`,
+ prop.loc,
+ )
+ }
+ }
+ for (const model of spec?.models ?? []) {
+ const keys = model.fields.filter((field) => field.key)
+ if (usedByRepeat.has(model.name) && keys.length !== 1) {
+ error(
+ CODES.MODEL_FIELD,
+ `model "${model.name}" fills a repeating slot, so exactly one field must be its key; found ${keys.length}`,
+ model.loc,
+ )
+ }
+ for (const field of model.fields) {
+ if (!field.optional && field.sample === undefined && !modelByRef(spec, field.type)) {
+ error(
+ CODES.MODEL_FIELD,
+ `field "${model.name}.${field.name}" is required, so it needs a sample the design tools can draw`,
+ field.loc,
+ )
+ }
+ }
+ }
+
+ for (const component of components) {
+ const contract = spec?.contract
+ const rows = spec?.styles ?? []
+
+ /* -------------------------------------------------------- props attr */
+ if (contract && component.attrs.props) {
+ warn(
+ CODES.BAD_SPEC,
+ 'this component declares a ## Contract, so the "props" attribute is redundant; move its entries to ',
+ component.attrs.props.loc,
+ )
+ }
+
+ /* ------------------------------------------------------------- parts */
+ const declaredParts = new Set((contract?.parts ?? []).filter((part) => part !== ROOT_PART))
+ const bound = new Map()
+ const walk = (node: UidxNode): void => {
+ const part = node.attrs.part?.value
+ if (typeof part === 'string') (bound.get(part) ?? bound.set(part, []).get(part)!).push(node)
+ for (const child of node.children) walk(child)
+ }
+ for (const child of component.children) walk(child)
+ if (component.attrs.part) {
+ error(
+ CODES.PART_BINDING,
+ 'the component is the "root" part itself (ADR 0016 §2); remove part= from ',
+ component.attrs.part.loc,
+ )
+ }
+ for (const [part, nodes] of bound) {
+ if (part === ROOT_PART) {
+ error(
+ CODES.PART_BINDING,
+ 'part="root" is the component itself; a child cannot be the root part',
+ nodes[0]!.attrs.part!.loc,
+ )
+ continue
+ }
+ if (contract && !declaredParts.has(part)) {
+ error(
+ CODES.PART_BINDING,
+ `part="${part}" is not declared in `,
+ nodes[0]!.attrs.part!.loc,
+ )
+ }
+ if (nodes.length > 1) {
+ error(
+ CODES.PART_BINDING,
+ `part="${part}" is bound ${nodes.length} times; one node per part`,
+ nodes[1]!.attrs.part!.loc,
+ )
+ }
+ }
+ if (contract) {
+ for (const part of declaredParts) {
+ if (!bound.has(part)) {
+ error(
+ CODES.PART_BINDING,
+ `part "${part}" is declared in but no node binds it with part="${part}"`,
+ contract.loc,
+ )
+ }
+ }
+ }
+
+ /* ------------------------------------------------------------- slots */
+ if (contract) {
+ const inTree = slotsOf(component).declared
+ for (const slot of contract.slots) {
+ if (slot.repeats) continue
+ if (!inTree.has(slot.name)) {
+ error(
+ CODES.SLOT_BINDING,
+ `slot "${slot.name}" is declared in but the tree has no `,
+ slot.loc,
+ )
+ }
+ }
+ for (const [name, node] of inTree) {
+ if (!contract.slots.some((slot) => slot.name === name)) {
+ error(CODES.SLOT_BINDING, ` is not declared in `, node.loc)
+ }
+ }
+ }
+
+ /* ----------------------------------------------------------- repeats */
+ const repeats: UidxNode[] = []
+ const findRepeats = (node: UidxNode): void => {
+ if (node.element === 'Repeat') repeats.push(node)
+ for (const child of node.children) findRepeats(child)
+ }
+ for (const child of component.children) findRepeats(child)
+ for (const repeat of repeats) {
+ const slotName = repeat.attrs.slot!.value as string
+ const slot = contract?.slots.find((entry) => entry.name === slotName)
+ if (!slot) {
+ error(
+ CODES.BAD_REPEAT,
+ ` names no slot of the contract`,
+ repeat.attrs.slot!.loc,
+ )
+ } else if (!slot.repeats) {
+ error(
+ CODES.BAD_REPEAT,
+ ` multiplies a slot the contract does not declare as repeating`,
+ repeat.attrs.slot!.loc,
+ )
+ }
+ const child = repeat.children[0]
+ if (child && child.element !== 'Instance') {
+ error(
+ CODES.BAD_REPEAT,
+ ` holds an of an accepted component; found <${child.element}>`,
+ child.loc,
+ )
+ }
+ if (child?.element === 'Instance' && slot?.accepts) {
+ const name = child.attrs.component?.value
+ const definition = components.find((entry) => entry.name === name)
+ const implemented = definition?.attrs.implements?.value
+ if (definition && implemented !== slot.accepts) {
+ error(
+ CODES.BAD_REPEAT,
+ `slot "${slotName}" accepts ${slot.accepts}, but "${name}" implements ${typeof implemented === 'string' ? implemented : 'nothing'}`,
+ child.loc,
+ )
+ }
+ }
+ }
+
+ /* ---------------------------------------------------------- bindings */
+ const modelProps = new Map(
+ (contract?.props ?? []).filter((prop) => prop.model).map((prop) => [prop.name, prop]),
+ )
+ const checkBindings = (node: UidxNode): void => {
+ for (const attr of Object.values(node.attrs)) {
+ const target = typeof attr.value === 'string' ? aliasTarget(attr.value) : null
+ if (target === null || target.includes('#') || !target.includes('.')) continue
+ const [head, ...path] = target.split('.')
+ const prop = modelProps.get(head!)
+ if (!prop) {
+ error(
+ CODES.BINDING,
+ `"{${target}}" binds a field of "${head}", which is not a prop with a model`,
+ attr.loc,
+ )
+ continue
+ }
+ let model = modelByRef(spec, prop.model)
+ for (const segment of path) {
+ const field = model?.fields.find((entry) => entry.name === segment)
+ if (!field) {
+ error(
+ CODES.BINDING,
+ `"{${target}}": model "${model?.name ?? '?'}" has no field "${segment}"`,
+ attr.loc,
+ )
+ model = undefined
+ break
+ }
+ model = modelByRef(spec, field.type)
+ }
+ }
+ for (const child of node.children) checkBindings(child)
+ }
+ for (const child of component.children) checkBindings(child)
+
+ /* ------------------------------------------------------------ styles */
+ if (rows.length && hasVariants(component)) {
+ error(
+ CODES.STYLE_ROW,
+ 'a component with a table derives its variants; remove the "variants" attribute and its trees, or the table',
+ component.attrs.variants!.loc,
+ )
+ }
+ const axes = axesOf(spec)
+ for (const row of rows) {
+ for (const [axis, value] of Object.entries(row.keys)) {
+ const domain = axes.get(axis)
+ if (!domain) {
+ error(
+ CODES.STYLE_ROW,
+ axis === STATE_AXIS
+ ? `
+
+## Contract
+
+
+ Whether the option is selected.
+ Box size.
+ Inert and dimmed.
+
+
+checked-indicator
+Consumer text.
+`,
+)
+
+const componentIndex = (...docs: ReturnType[]) => {
+ const out = new Map()
+ for (const doc of docs)
+ for (const node of doc.tree.children) if (node.element === 'Component') out.set(node.name, node)
+ return out
+}
+
+describe('a styles table derives the variant set (ADR 0016)', () => {
+ const doc = parseOrThrow(CHECKBOX_SOURCE)
+ const scene = toSceneGraph(doc, { resolveAlias })
+ const set = scene.graph.getNode('Checkbox')!
+
+ it('draws the component as a set with one tree per combination, default first', () => {
+ expect(set.type).toBe('COMPONENT_SET')
+ const names = (set.childIds ?? []).map((id) => scene.graph.getNode(id)!.name)
+ // Two sizes, default first, times four states, default first.
+ expect(names).toHaveLength(8)
+ expect(names[0]).toBe('size=md, state=default')
+ expect(names).toContain('size=sm, state=checked')
+ expect(set.componentPropertyDefinitions?.map((d) => [d.name, d.variantOptions])).toEqual([
+ ['size', ['md', 'sm']],
+ ['state', ['default', 'checked', 'hover', 'disabled']],
+ ])
+ })
+
+ it('applies the matching rows to the root and the bound parts', () => {
+ const root = (name: string) => scene.graph.getNode(`Checkbox#${name}/root`)!
+ expect(root('size=md, state=default').fills![0]!.color).toMatchObject({ r: 1, g: 1, b: 1 })
+ expect(root('size=md, state=checked').fills![0]!.color).toMatchObject({ r: 0, g: 0.5, b: 1 })
+ expect(scene.graph.getNode('Checkbox#size=md, state=default/root/check')!.visible).toBe(false)
+ expect(scene.graph.getNode('Checkbox#size=md, state=checked/root/check')!.visible).toBe(true)
+ expect(root('size=md, state=disabled').opacity).toBe(0.4)
+ })
+
+ it('resolves rows by specificity: the more keys a row matches, the later it applies', () => {
+ const root = (name: string) => scene.graph.getNode(`Checkbox#${name}/root`)!
+ expect(root('size=sm, state=default').width).toBe(16)
+ expect(root('size=sm, state=hover').width).toBe(17)
+ expect(root('size=md, state=hover').width).toBe(20)
+ })
+
+ it('links the component and nothing derived: a derived tree has no source to patch', () => {
+ expect(scene.addresses.sceneIdOf('Checkbox')).toBe('Checkbox')
+ expect(scene.addresses.sceneIdOf('Checkbox#size=md, state=default')).toBeUndefined()
+ expect(scene.addresses.sceneIdOf('Checkbox#size=md, state=default/root/check')).toBeUndefined()
+ expect(scene.warnings).toEqual([])
+ })
+
+ it('lets an instance pick a derived combination the way it picks an authored one', () => {
+ const uses = parseOrThrow(
+ page(
+ 'home',
+ `
+ `,
+ ),
+ )
+ const index = componentIndex(doc)
+ const built = toSceneGraph(uses, { resolveAlias, resolveComponent: (name) => index.get(name) })
+ expect(built.graph.getNode('plain#root')!.fills![0]!.color).toMatchObject({ r: 1, g: 1, b: 1 })
+ expect(built.graph.getNode('on#root')!.fills![0]!.color).toMatchObject({ r: 0, g: 0.5, b: 1 })
+ expect(built.graph.getNode('on#root')!.width).toBe(16)
+ expect(built.warnings).toEqual([])
+ })
+})
+
+const ROW_SOURCE = page(
+ 'contact-item',
+ `
+
+
+
+ `,
+ `
+## Contract
+
+
+ The row to show.
+
+name, email, city
+
+## Models
+
+
+ Where a contact lives.
+ City.
+
+
+ One row.
+ Identity.
+ Display name.
+ Omitted when unknown.
+ Where they live.
+
+`,
+)
+
+const LIST_SOURCE = page(
+ 'contact-list',
+ `
+
+
+
+
+ `,
+ `
+## Contract
+
+
+ Rows to show.
+
+
+ One per row.
+ Shown while there are no rows.
+
+
+## Models
+
+
+ One row.
+ Identity.
+ Display name.
+
+`,
+)
+
+describe('models bind samples into the tree (ADR 0015)', () => {
+ it('resolves {item.field} on a definition to the first sample, one model deep', () => {
+ const scene = toSceneGraph(parseOrThrow(ROW_SOURCE))
+ expect(scene.graph.getNode('ContactItem#name')!.text).toBe('Ada')
+ expect(scene.graph.getNode('ContactItem#email')!.text).toBe('ada@example.com')
+ expect(scene.graph.getNode('ContactItem#city')!.text).toBe('London')
+ expect(scene.warnings).toEqual([])
+ })
+})
+
+describe(' multiplies one instance (ADR 0017 §2)', () => {
+ const row = parseOrThrow(ROW_SOURCE)
+ const list = parseOrThrow(LIST_SOURCE)
+ const index = componentIndex(row, list)
+ const scene = toSceneGraph(list, { resolveComponent: (name) => index.get(name) })
+
+ it('draws count clones, the n-th filled from the n-th sample, wrapping and blanking', () => {
+ const text = (n: number, part: string) =>
+ scene.graph.getNode(`ContactList#row-${n}/${part}`)!.text
+ expect(scene.graph.getNode('ContactList#row-1')!.type).toBe('INSTANCE')
+ expect([text(1, 'name'), text(2, 'name'), text(3, 'name')]).toEqual(['Ada', 'Grace', 'Ada'])
+ expect(text(2, 'email')).toBe('')
+ expect(scene.graph.getNode('ContactList#row-4')).toBeUndefined()
+ expect(scene.warnings).toEqual([])
+ })
+
+ it('links neither the repeat nor its clones', () => {
+ expect(scene.addresses.sceneIdOf('ContactList#repeat(item)')).toBeUndefined()
+ expect(scene.addresses.sceneIdOf('ContactList#row')).toBeUndefined()
+ expect(scene.addresses.sceneIdOf('ContactList#empty')).toBe('ContactList#empty')
+ })
+
+ it('expands inside an instance of the list as well', () => {
+ const home = parseOrThrow(page('home', ` `))
+ const built = toSceneGraph(home, { resolveComponent: (name) => index.get(name) })
+ expect(built.graph.getNode('people#row-1/name')!.text).toBe('Ada')
+ expect(built.graph.getNode('people#row-2/name')!.text).toBe('Grace')
+ expect(built.warnings).toEqual([])
+ })
+})
+
+describe('auditDesignSystem', () => {
+ const codes = (source: string) => auditDesignSystem(parseOrThrow(source)).map((d) => d.code)
+
+ it('passes the fixtures', () => {
+ expect(auditDesignSystem(parseOrThrow(CHECKBOX_SOURCE))).toEqual([])
+ expect(auditDesignSystem(parseOrThrow(ROW_SOURCE))).toEqual([])
+ })
+
+ it('demands every declared part be bound once, by a declared name', () => {
+ expect(codes(CHECKBOX_SOURCE.replace('part="checked-indicator"', ''))).toContain(
+ CODES.PART_BINDING,
+ )
+ expect(codes(CHECKBOX_SOURCE.replace('part="checked-indicator"', 'part="tick"'))).toContain(
+ CODES.PART_BINDING,
+ )
+ })
+
+ it('checks style rows against the axes, the parts and the vocabulary', () => {
+ expect(codes(CHECKBOX_SOURCE.replace('state="hover"', 'state="pressed"'))).toContain(
+ CODES.STYLE_ROW,
+ )
+ expect(codes(CHECKBOX_SOURCE.replace('root:opacity', 'halo:opacity'))).toContain(
+ CODES.STYLE_ROW,
+ )
+ })
+
+ it('warns about an axis value nothing styles', () => {
+ const found = auditDesignSystem(
+ parseOrThrow(CHECKBOX_SOURCE.replace('', '')),
+ )
+ expect(found.map((d) => [d.code, d.severity])).toContainEqual([CODES.AXIS_UNCOVERED, 'warning'])
+ })
+
+ it('ties declared slots to nodes', () => {
+ expect(codes(CHECKBOX_SOURCE.replace(' ', ''))).toContain(
+ CODES.SLOT_BINDING,
+ )
+ expect(
+ codes(CHECKBOX_SOURCE.replace(' ', ' ')),
+ ).toContain(CODES.SLOT_BINDING)
+ })
+
+ it('demands samples, keys and real fields of models', () => {
+ expect(codes(ROW_SOURCE.replace(` sample={['Ada', 'Grace']}`, ''))).toContain(CODES.MODEL_FIELD)
+ expect(codes(LIST_SOURCE.replace(' key sample="c1"', ' sample="c1"'))).toContain(
+ CODES.MODEL_FIELD,
+ )
+ expect(codes(ROW_SOURCE.replace('{item.email}', '{item.phone}'))).toContain(CODES.BINDING)
+ expect(codes(ROW_SOURCE.replace('{item.email}', '{row.email}'))).toContain(CODES.BINDING)
+ })
+
+ it('accepts a repeat only on a repeating slot with an accepted instance', () => {
+ expect(codes(LIST_SOURCE.replace('slot="item" count', 'slot="empty" count'))).toContain(
+ CODES.BAD_REPEAT,
+ )
+ expect(codes(LIST_SOURCE)).toEqual([])
+ })
+})
+
+describe('contractJson', () => {
+ it('prints the spec as plain data with the tree facts a generator needs', () => {
+ const json = contractJson(parseOrThrow(CHECKBOX_SOURCE))
+ expect(json.components).toEqual([
+ {
+ name: 'Checkbox',
+ implements: 'hwc-checkbox',
+ status: 'stable',
+ boundParts: ['checked-indicator'],
+ treeSlots: ['label'],
+ axes: { size: ['md', 'sm'], state: ['default', 'checked', 'hover', 'disabled'] },
+ },
+ ])
+ expect(JSON.stringify(json)).not.toContain('"loc"')
+ expect((json.contract as { props: { name: string }[] }).props.map((p) => p.name)).toEqual([
+ 'checked',
+ 'size',
+ 'disabled',
+ ])
+ })
+})
diff --git a/packages/viewer/src/layer-icons.ts b/packages/viewer/src/layer-icons.ts
index 115424c..0725ff1 100644
--- a/packages/viewer/src/layer-icons.ts
+++ b/packages/viewer/src/layer-icons.ts
@@ -26,6 +26,9 @@ export const LAYER_ICONS: Record = {
// it reads as "somewhere content goes" rather than as content. Drawn as an
// outline, since a slot paints nothing of its own.
Slot: 'M2 2h3M7 2h3M10 2v3M10 7v3M10 10H7M5 10H2M2 10V7M2 5V2',
+ // One filling drawn many times (ADR 0017): three stacked outlines, the
+ // shape of a list of clones rather than of any one of them.
+ Repeat: 'M2 2h6v2H2zM2 5h6v2H2zM2 8h6v2H2zM10 2v8',
Tokens: 'M2 3h8M2 6h8M2 9h5',
Collection: 'M2 2h8v3H2zM2 7h8v3H2z',
Variable: 'M4 2v8M8 2v8M2 5h8',
@@ -41,6 +44,7 @@ export const STROKE_ICONS: ReadonlySet = new Set([
'Instance',
'Slot',
'Variant',
+ 'Repeat',
'Text',
'Rectangle',
'Ellipse',
From 1f82b5afcea6cd118f729721c0123bb5a4d1105a Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 00:24:43 +0300
Subject: [PATCH 04/74] codegen: HTML/CSS and React rendered from identities;
example design system over @hwc/components
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
@uidx/codegen renders each component identity to code (ADR 0017): a
stylesheet keyed by the headless root's attributes and states, a markup
fragment with sample content, a typed React component with props and events
from the contract — a plain slot as a ReactNode prop, a repeating slot as
items plus renderItem generic over the model, text parts as compound
sub-components, compositions as the instance they hold — plus models.ts,
runtime.ts, elements.d.ts and the contract as JSON. With a
custom-elements.json manifest, each contract is checked against its element.
`uidx codegen` drives it; `--check` fails on drift.
examples/design-system carries Checkbox, Field, CheckboxField, Button,
ContactOption and ContactList as .uidx identities over the vendored
@hwc/components build, with generated/ committed and tested for drift, a
Vite page mounting the HTML fragments beside the React components, and the
React output type-checked against React 19.
Co-Authored-By: Claude Fable 5.1
---
.prettierignore | 2 +
CHANGELOG.md | 19 +
docs/decisions/0016-variants-as-renders.md | 3 +-
eslint.config.js | 4 +
examples/design-system/.uidx/button.uidx | 55 +
.../design-system/.uidx/checkbox-field.uidx | 33 +
examples/design-system/.uidx/checkbox.uidx | 62 +
examples/design-system/.uidx/config.json | 1 +
.../design-system/.uidx/contact-list.uidx | 62 +
.../design-system/.uidx/contact-option.uidx | 64 +
examples/design-system/.uidx/field.uidx | 49 +
examples/design-system/.uidx/tokens.uidx | 43 +
examples/design-system/.uidx/uidx.json | 1 +
examples/design-system/README.md | 25 +
.../generated/contract/button.json | 282 ++
.../generated/contract/checkbox-field.json | 65 +
.../generated/contract/checkbox.json | 195 ++
.../generated/contract/contact-list.json | 161 +
.../generated/contract/contact-option.json | 187 ++
.../generated/contract/field.json | 115 +
.../design-system/generated/html/button.css | 129 +
.../design-system/generated/html/button.html | 5 +
.../generated/html/checkbox-field.css | 1 +
.../generated/html/checkbox-field.html | 14 +
.../design-system/generated/html/checkbox.css | 57 +
.../generated/html/checkbox.html | 5 +
.../generated/html/contact-list.css | 21 +
.../generated/html/contact-list.html | 33 +
.../generated/html/contact-option.css | 68 +
.../generated/html/contact-option.html | 10 +
.../design-system/generated/html/field.css | 38 +
.../design-system/generated/html/field.html | 11 +
.../design-system/generated/html/tokens.css | 22 +
.../design-system/generated/react/Button.tsx | 40 +
.../generated/react/Checkbox.tsx | 37 +
.../generated/react/CheckboxField.tsx | 35 +
.../generated/react/ContactList.tsx | 45 +
.../generated/react/ContactOption.tsx | 43 +
.../design-system/generated/react/Field.tsx | 54 +
.../design-system/generated/react/button.css | 129 +
.../generated/react/checkbox-field.css | 1 +
.../generated/react/checkbox.css | 57 +
.../generated/react/contact-list.css | 21 +
.../generated/react/contact-option.css | 68 +
.../generated/react/elements.d.ts | 38 +
.../design-system/generated/react/field.css | 38 +
.../design-system/generated/react/index.ts | 10 +
.../design-system/generated/react/models.ts | 13 +
.../design-system/generated/react/runtime.ts | 21 +
.../design-system/generated/react/tokens.css | 22 +
examples/design-system/index.html | 43 +
examples/design-system/package.json | 33 +
examples/design-system/scripts/generate.mjs | 53 +
examples/design-system/scripts/sync-hwc.mjs | 28 +
examples/design-system/src/main.ts | 32 +
examples/design-system/src/react-demo.tsx | 76 +
examples/design-system/test/generated.test.ts | 51 +
examples/design-system/tsconfig.json | 20 +
examples/design-system/vendor/hwc/VENDORED.md | 5 +
.../vendor/hwc/custom-elements.json | 2739 +++++++++++++++++
examples/design-system/vite.config.ts | 13 +
package.json | 2 +-
packages/cli/package.json | 3 +-
packages/cli/src/cli.ts | 5 +
packages/cli/src/commands/codegen.ts | 110 +
packages/codegen/package.json | 48 +
packages/codegen/src/conformance.ts | 57 +
packages/codegen/src/css.ts | 268 ++
packages/codegen/src/generate.ts | 135 +
packages/codegen/src/html.ts | 264 ++
packages/codegen/src/index.ts | 22 +
packages/codegen/src/model.ts | 234 ++
packages/codegen/src/react.ts | 441 +++
packages/codegen/test/fixtures.ts | 218 ++
packages/codegen/test/generate.test.ts | 213 ++
packages/codegen/tsconfig.build.json | 9 +
packages/codegen/tsconfig.json | 8 +
packages/codegen/vitest.config.ts | 24 +
packages/schema/src/design-system-audit.ts | 10 +-
packages/schema/src/design-system.ts | 21 +-
packages/schema/src/index.ts | 2 +
pnpm-lock.yaml | 156 +-
pnpm-workspace.yaml | 1 +
scripts/pack-release.mjs | 2 +-
84 files changed, 7848 insertions(+), 12 deletions(-)
create mode 100644 examples/design-system/.uidx/button.uidx
create mode 100644 examples/design-system/.uidx/checkbox-field.uidx
create mode 100644 examples/design-system/.uidx/checkbox.uidx
create mode 100644 examples/design-system/.uidx/config.json
create mode 100644 examples/design-system/.uidx/contact-list.uidx
create mode 100644 examples/design-system/.uidx/contact-option.uidx
create mode 100644 examples/design-system/.uidx/field.uidx
create mode 100644 examples/design-system/.uidx/tokens.uidx
create mode 100644 examples/design-system/.uidx/uidx.json
create mode 100644 examples/design-system/README.md
create mode 100644 examples/design-system/generated/contract/button.json
create mode 100644 examples/design-system/generated/contract/checkbox-field.json
create mode 100644 examples/design-system/generated/contract/checkbox.json
create mode 100644 examples/design-system/generated/contract/contact-list.json
create mode 100644 examples/design-system/generated/contract/contact-option.json
create mode 100644 examples/design-system/generated/contract/field.json
create mode 100644 examples/design-system/generated/html/button.css
create mode 100644 examples/design-system/generated/html/button.html
create mode 100644 examples/design-system/generated/html/checkbox-field.css
create mode 100644 examples/design-system/generated/html/checkbox-field.html
create mode 100644 examples/design-system/generated/html/checkbox.css
create mode 100644 examples/design-system/generated/html/checkbox.html
create mode 100644 examples/design-system/generated/html/contact-list.css
create mode 100644 examples/design-system/generated/html/contact-list.html
create mode 100644 examples/design-system/generated/html/contact-option.css
create mode 100644 examples/design-system/generated/html/contact-option.html
create mode 100644 examples/design-system/generated/html/field.css
create mode 100644 examples/design-system/generated/html/field.html
create mode 100644 examples/design-system/generated/html/tokens.css
create mode 100644 examples/design-system/generated/react/Button.tsx
create mode 100644 examples/design-system/generated/react/Checkbox.tsx
create mode 100644 examples/design-system/generated/react/CheckboxField.tsx
create mode 100644 examples/design-system/generated/react/ContactList.tsx
create mode 100644 examples/design-system/generated/react/ContactOption.tsx
create mode 100644 examples/design-system/generated/react/Field.tsx
create mode 100644 examples/design-system/generated/react/button.css
create mode 100644 examples/design-system/generated/react/checkbox-field.css
create mode 100644 examples/design-system/generated/react/checkbox.css
create mode 100644 examples/design-system/generated/react/contact-list.css
create mode 100644 examples/design-system/generated/react/contact-option.css
create mode 100644 examples/design-system/generated/react/elements.d.ts
create mode 100644 examples/design-system/generated/react/field.css
create mode 100644 examples/design-system/generated/react/index.ts
create mode 100644 examples/design-system/generated/react/models.ts
create mode 100644 examples/design-system/generated/react/runtime.ts
create mode 100644 examples/design-system/generated/react/tokens.css
create mode 100644 examples/design-system/index.html
create mode 100644 examples/design-system/package.json
create mode 100644 examples/design-system/scripts/generate.mjs
create mode 100644 examples/design-system/scripts/sync-hwc.mjs
create mode 100644 examples/design-system/src/main.ts
create mode 100644 examples/design-system/src/react-demo.tsx
create mode 100644 examples/design-system/test/generated.test.ts
create mode 100644 examples/design-system/tsconfig.json
create mode 100644 examples/design-system/vendor/hwc/VENDORED.md
create mode 100644 examples/design-system/vendor/hwc/custom-elements.json
create mode 100644 examples/design-system/vite.config.ts
create mode 100644 packages/cli/src/commands/codegen.ts
create mode 100644 packages/codegen/package.json
create mode 100644 packages/codegen/src/conformance.ts
create mode 100644 packages/codegen/src/css.ts
create mode 100644 packages/codegen/src/generate.ts
create mode 100644 packages/codegen/src/html.ts
create mode 100644 packages/codegen/src/index.ts
create mode 100644 packages/codegen/src/model.ts
create mode 100644 packages/codegen/src/react.ts
create mode 100644 packages/codegen/test/fixtures.ts
create mode 100644 packages/codegen/test/generate.test.ts
create mode 100644 packages/codegen/tsconfig.build.json
create mode 100644 packages/codegen/tsconfig.json
create mode 100644 packages/codegen/vitest.config.ts
diff --git a/.prettierignore b/.prettierignore
index 174167b..ad418e3 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,6 +2,8 @@ dist
node_modules
pnpm-lock.yaml
packages/viewer/public
+examples/*/generated
+examples/*/vendor
.uidx
.uidx-agent
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b649f46..01ffb25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,25 @@ User-visible changes are recorded here. Versions follow semantic versioning;
pre-1.0 releases may change the design format or public APIs. Release notes must
describe migrations when an existing document or integration is affected.
+## Unreleased
+
+- The design-system model (ADRs 0012–0017). A `.uidx` file may now carry a
+ `## Contract` (props, events, states, parts, slots, form, accessibility),
+ `## Behavior` guidelines, `## Models` and `## Examples` after its visual
+ contract, and a `` table beside the root. 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 expands
+ `` into sample rows. `uidx check` audits the regions against each
+ other and the tree; `uidx contract` prints them as JSON.
+- `@uidx/codegen` and `uidx codegen`: HTML/CSS and React rendered from the
+ identities over headless custom elements, with props, events and slots from
+ the contract — a plain slot as a `ReactNode` prop, a repeating slot as
+ `items` plus `renderItem` generic over the model — and each contract checked
+ against the headless library's `custom-elements.json`.
+- `examples/design-system`: Checkbox, Field, CheckboxField, Button and a
+ contact list rendered end to end over `@hwc/components`, with the generated
+ output committed and checked for drift.
+
## 0.1.6 — 2026-09-25
- The first `uidx dev` sets up the workspace itself when the install script did
diff --git a/docs/decisions/0016-variants-as-renders.md b/docs/decisions/0016-variants-as-renders.md
index 72ddf58..97afe14 100644
--- a/docs/decisions/0016-variants-as-renders.md
+++ b/docs/decisions/0016-variants-as-renders.md
@@ -40,7 +40,8 @@ states, referencing tokens only:
- A row's keys are axis assignments; its other attributes are `part:prop`
pairs (a colon, because JSX allows a namespaced attribute name and not a
dotted one). `root` is the component's own frame; other names are declared
- parts.
+ parts, or the name of one of the design's own nodes — a frame or text that
+ has no headless element and is styled by name.
- Resolution is by specificity: the base tree, then rows matching one key,
then rows matching more. Equal specificity resolves in file order.
- Values are tokens, or literals where the prop has no token type (`visible`,
diff --git a/eslint.config.js b/eslint.config.js
index 986edee..7cbda42 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -22,6 +22,10 @@ export default tseslint.config(
{
ignores: [
'**/dist/**',
+ // Rendered from .uidx identities and a vendored third-party build:
+ // neither is written here, so neither is linted here.
+ 'examples/*/generated/**',
+ 'examples/*/vendor/**',
'**/.build/**',
'**/node_modules/**',
'packages/viewer/public/**',
diff --git a/examples/design-system/.uidx/button.uidx b/examples/design-system/.uidx/button.uidx
new file mode 100644
index 0000000..3d5a7e3
--- /dev/null
+++ b/examples/design-system/.uidx/button.uidx
@@ -0,0 +1,55 @@
+---
+id: button
+---
+
+The one control every screen has. Emphasis and size are visual props; the
+label is consumer text.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The button's text.
+ Emphasis.
+ Padding and type scale.
+ Inert and dimmed.
+ What activation does to the enclosing form.
+
+
+leading-icon, label
+
+
+
+## Behavior
+
+- activation: click, Enter or Space activates; `type="submit"` calls `requestSubmit` on the enclosing form, `type="reset"` resets it, `type="button"` does nothing to the form.
+- disabled: no activation and no events while `disabled`.
+- icon: the leading icon is hidden unless the design turns it on; it never carries the accessible name, the label does.
diff --git a/examples/design-system/.uidx/checkbox-field.uidx b/examples/design-system/.uidx/checkbox-field.uidx
new file mode 100644
index 0000000..5568769
--- /dev/null
+++ b/examples/design-system/.uidx/checkbox-field.uidx
@@ -0,0 +1,33 @@
+---
+id: checkbox-field
+---
+
+The pattern most forms want: a checkbox with its label and helper text. A
+composition of Field and Checkbox and nothing else — the layout and spacing
+come from the Field identity, the box from the Checkbox identity.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The option's name.
+ Helper text under the label.
+ Validation text, shown while in error.
+ Whether the field is in its error state.
+
+
+
+## Behavior
+
+- delegation: everything the label does, Field does; everything the box does, Checkbox does. This component adds layout only.
diff --git a/examples/design-system/.uidx/checkbox.uidx b/examples/design-system/.uidx/checkbox.uidx
new file mode 100644
index 0000000..ce70d15
--- /dev/null
+++ b/examples/design-system/.uidx/checkbox.uidx
@@ -0,0 +1,62 @@
+---
+id: checkbox
+---
+
+Lets a user toggle one option. Indeterminate summarises mixed children. The
+box and its marks are the design system's; the label comes from a Field.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ Whether the option is selected.
+ Mixed state for a parent summarising children.
+ Inert and dimmed.
+ Form field name.
+ Submitted while checked.
+
+
+ Fires once per user toggle, never when set from code.
+
+
+checked-indicator, indeterminate-indicator
+
+
+
+
+## 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.
+- disabled: no interaction and no events while `disabled`; still focusable for assistive technology, as native.
+- form: submits `value` while checked and nothing otherwise, matching native ` `.
+- label: composes with Field; clicking the label toggles, per Field's rules.
+
+## Examples
+
+
+
+
diff --git a/examples/design-system/.uidx/config.json b/examples/design-system/.uidx/config.json
new file mode 100644
index 0000000..2c3c49a
--- /dev/null
+++ b/examples/design-system/.uidx/config.json
@@ -0,0 +1 @@
+{ "port": 4460 }
diff --git a/examples/design-system/.uidx/contact-list.uidx b/examples/design-system/.uidx/contact-list.uidx
new file mode 100644
index 0000000..654e834
--- /dev/null
+++ b/examples/design-system/.uidx/contact-list.uidx
@@ -0,0 +1,62 @@
+---
+id: contact-list
+---
+
+Pick one person from a list. A radio group whose options are ContactOption
+rows: the list owns selection and keyboard navigation, each row shows one
+Contact.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The people to choose from, in order.
+ Form field name shared by every option.
+ Every option inert.
+
+
+
+ One option per element of `items`.
+ Shown instead of the options while `items` is empty.
+
+
+
+
+## Models
+
+
+ One person as a row shows them. Every value is display-ready; the adapter that builds it does any formatting.
+ Stable identity, used for keys and as the submitted value.
+ Full display name.
+ Omitted when unknown; the row hides the line.
+
+
+## Behavior
+
+- one-choice: exactly one option is chosen at a time; choosing one unchooses the rest.
+- keyboard: arrow keys move the choice through the options; Tab leaves the group.
+- empty: with no items the `empty` slot shows and nothing is chosen.
+
+## Examples
+
+
+
+
+
diff --git a/examples/design-system/.uidx/contact-option.uidx b/examples/design-system/.uidx/contact-option.uidx
new file mode 100644
index 0000000..5ef294e
--- /dev/null
+++ b/examples/design-system/.uidx/contact-option.uidx
@@ -0,0 +1,64 @@
+---
+id: contact-option
+---
+
+One selectable person in a list of contacts: a radio whose row shows the
+person. The row is the design system's; the person is the consumer's, as a
+Contact.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The person this option stands for. Display-ready; nothing is derived here.
+ Whether this option is the chosen one.
+ Inert and dimmed.
+ Submitted when chosen; normally the contact's id.
+ Form field name, shared with the group.
+
+
+ Fires when this option becomes the chosen one.
+
+
+checked-indicator
+
+
+## Models
+
+
+ One person as a row shows them. Every value is display-ready; the adapter that builds it does any formatting.
+ Stable identity, used for keys and as the submitted value.
+ Full display name.
+ Omitted when unknown; the row hides the line.
+
+
+## Behavior
+
+- choose: click, or arrow keys within the group, makes this the chosen option and unchooses the others.
+- form: the group submits the chosen option's `value` under the shared `name`.
diff --git a/examples/design-system/.uidx/field.uidx b/examples/design-system/.uidx/field.uidx
new file mode 100644
index 0000000..a0175dc
--- /dev/null
+++ b/examples/design-system/.uidx/field.uidx
@@ -0,0 +1,49 @@
+---
+id: field
+---
+
+Pairs one control with its label, description and error message. The label
+behaves like a native label: clicking it activates the control, and it names
+the control for assistive technology. The field owns how the text looks and
+where it sits; the consumer supplies the words.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The control's name, shown beside it.
+ Secondary text: hints and consequences.
+ Validation text, shown only while in error.
+ Whether the field is in its error state.
+
+
+label, description, error
+
+ The wrapped control: a checkbox, a radio, a text input.
+
+
+
+
+## Behavior
+
+- activation: clicking the label focuses the control and forwards the click, as a native label does; inert while the control is disabled.
+- naming: the control is named by the label and described by the description, plus the error message while shown.
+- error: `error` mounts the error message and asks the control to expose `aria-invalid`; announcing it is the application's policy.
+- claim: the first field-aware control inside the field is the one it labels; a nested field scopes to its own subtree.
diff --git a/examples/design-system/.uidx/tokens.uidx b/examples/design-system/.uidx/tokens.uidx
new file mode 100644
index 0000000..135afbe
--- /dev/null
+++ b/examples/design-system/.uidx/tokens.uidx
@@ -0,0 +1,43 @@
+---
+id: tokens
+---
+
+The theme: every colour, radius and space the components reference. One mode
+for now; theming beyond modes is a future task (ADR 0012 §4).
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/design-system/.uidx/uidx.json b/examples/design-system/.uidx/uidx.json
new file mode 100644
index 0000000..63263bb
--- /dev/null
+++ b/examples/design-system/.uidx/uidx.json
@@ -0,0 +1 @@
+{ "id": "example-design-system", "files": ["**/*.uidx"], "assets": ["assets/**"] }
diff --git a/examples/design-system/README.md b/examples/design-system/README.md
new file mode 100644
index 0000000..75e6d19
--- /dev/null
+++ b/examples/design-system/README.md
@@ -0,0 +1,25 @@
+# Example design system
+
+A design system whose components are renders of their `.uidx` identities
+(ADRs 0012–0017). Nothing in `generated/` is written by hand.
+
+```
+.uidx/ the identities: tokens, Checkbox, Field, CheckboxField, Button, ContactOption, ContactList
+vendor/hwc/ the headless behaviour, `@hwc/components`, vendored until it is published
+generated/html/ one stylesheet and one markup fragment per component, plus tokens.css
+generated/react/ one typed React component per identity, models.ts, runtime.ts, elements.d.ts
+generated/contract/ the contract of each page as JSON, for agents and other generators
+```
+
+- `pnpm generate` renders `generated/` from `.uidx/`, checking each contract
+ against `vendor/hwc/custom-elements.json` first.
+- `pnpm check` fails when `generated/` is out of date, which is what `pnpm test` asserts too.
+- `pnpm dev` opens a page that mounts the HTML fragments beside the React
+ components, both driven by the same custom elements.
+- `pnpm typecheck` type-checks the generated React code against React 19.
+- `pnpm --filter @uidxkit/uidx dev` from the repository root, pointed at this
+ folder, opens the identities in the uidx viewer, where the styles table is
+ drawn as a variant set and a repeat shows the model's samples.
+
+To refresh the vendored headless build from a local checkout:
+`HWC_DIR=/path/to/headless-web-components/packages/components node scripts/sync-hwc.mjs`.
diff --git a/examples/design-system/generated/contract/button.json b/examples/design-system/generated/contract/button.json
new file mode 100644
index 0000000..001f7eb
--- /dev/null
+++ b/examples/design-system/generated/contract/button.json
@@ -0,0 +1,282 @@
+{
+ "id": "button",
+ "components": [
+ {
+ "name": "Button",
+ "implements": "hwc-button",
+ "status": "stable",
+ "boundParts": [
+ "leading-icon",
+ "label"
+ ],
+ "treeSlots": [],
+ "axes": {
+ "variant": [
+ "primary",
+ "secondary",
+ "outline",
+ "ghost",
+ "destructive",
+ "link"
+ ],
+ "size": [
+ "medium",
+ "small",
+ "large"
+ ],
+ "state": [
+ "default",
+ "hover",
+ "focus",
+ "disabled"
+ ]
+ }
+ }
+ ],
+ "contract": {
+ "props": [
+ {
+ "name": "label",
+ "controllable": false,
+ "visual": false,
+ "description": "The button's text.",
+ "type": "string"
+ },
+ {
+ "name": "variant",
+ "controllable": false,
+ "visual": true,
+ "description": "Emphasis.",
+ "type": "'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link'",
+ "default": "primary"
+ },
+ {
+ "name": "size",
+ "controllable": false,
+ "visual": true,
+ "description": "Padding and type scale.",
+ "type": "'small' | 'medium' | 'large'",
+ "default": "medium"
+ },
+ {
+ "name": "disabled",
+ "controllable": false,
+ "visual": true,
+ "description": "Inert and dimmed.",
+ "type": "boolean",
+ "default": false
+ },
+ {
+ "name": "type",
+ "controllable": false,
+ "visual": false,
+ "description": "What activation does to the enclosing form.",
+ "type": "'button' | 'submit' | 'reset'",
+ "default": "button"
+ }
+ ],
+ "events": [],
+ "states": {
+ "structural": [],
+ "styling": [
+ "hover",
+ "focus",
+ "disabled"
+ ]
+ },
+ "parts": [
+ "leading-icon",
+ "label"
+ ],
+ "slots": [],
+ "composes": [],
+ "form": {
+ "participates": false,
+ "submits": "nothing of its own; type=submit calls the form's requestSubmit, type=reset resets it"
+ },
+ "accessibility": {
+ "role": "button",
+ "keyboard": "Enter and Space activate"
+ }
+ },
+ "behavior": [
+ {
+ "id": "activation",
+ "text": "click, Enter or Space activates; type=\"submit\" calls requestSubmit on the enclosing form, type=\"reset\" resets it, type=\"button\" does nothing to the form."
+ },
+ {
+ "id": "disabled",
+ "text": "no activation and no events while disabled."
+ },
+ {
+ "id": "icon",
+ "text": "the leading icon is hidden unless the design turns it on; it never carries the accessible name, the label does."
+ }
+ ],
+ "styles": [
+ {
+ "keys": {
+ "variant": "secondary"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#raised}",
+ "strokes": "{border#default}"
+ },
+ "label": {
+ "fills": "{text#default}"
+ },
+ "leading-icon": {
+ "fills": "{text#default}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "variant": "outline"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#control}",
+ "strokes": "{border#default}"
+ },
+ "label": {
+ "fills": "{text#default}"
+ },
+ "leading-icon": {
+ "fills": "{text#default}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "variant": "ghost"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#control}",
+ "strokes": "{surface#control}"
+ },
+ "label": {
+ "fills": "{text#default}"
+ },
+ "leading-icon": {
+ "fills": "{text#default}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "variant": "destructive"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#danger}",
+ "strokes": "{surface#danger}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "variant": "link"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#control}",
+ "strokes": "{surface#control}"
+ },
+ "label": {
+ "fills": "{surface#accent}"
+ },
+ "leading-icon": {
+ "fills": "{surface#accent}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "size": "small"
+ },
+ "values": {
+ "root": {
+ "paddingLeft": "{space#sm}",
+ "paddingRight": "{space#sm}",
+ "paddingTop": "{space#xs}",
+ "paddingBottom": "{space#xs}"
+ },
+ "label": {
+ "fontSize": 12
+ }
+ }
+ },
+ {
+ "keys": {
+ "size": "large"
+ },
+ "values": {
+ "root": {
+ "paddingLeft": "{space#lg}",
+ "paddingRight": "{space#lg}",
+ "paddingTop": "{space#md}",
+ "paddingBottom": "{space#md}"
+ },
+ "label": {
+ "fontSize": 16
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "hover"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#accentHover}",
+ "strokes": "{surface#accentHover}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "hover",
+ "variant": "secondary"
+ },
+ "values": {
+ "root": {
+ "strokes": "{border#hover}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "hover",
+ "variant": "outline"
+ },
+ "values": {
+ "root": {
+ "strokes": "{border#hover}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "focus"
+ },
+ "values": {
+ "root": {
+ "strokes": "{border#focus}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "disabled"
+ },
+ "values": {
+ "root": {
+ "opacity": "{opacity#disabled}"
+ }
+ }
+ }
+ ]
+}
diff --git a/examples/design-system/generated/contract/checkbox-field.json b/examples/design-system/generated/contract/checkbox-field.json
new file mode 100644
index 0000000..fe6d9a8
--- /dev/null
+++ b/examples/design-system/generated/contract/checkbox-field.json
@@ -0,0 +1,65 @@
+{
+ "id": "checkbox-field",
+ "components": [
+ {
+ "name": "CheckboxField",
+ "implements": null,
+ "status": "stable",
+ "boundParts": [],
+ "treeSlots": [
+ "control"
+ ],
+ "axes": {}
+ }
+ ],
+ "contract": {
+ "props": [
+ {
+ "name": "label",
+ "controllable": false,
+ "visual": false,
+ "description": "The option's name.",
+ "type": "string"
+ },
+ {
+ "name": "description",
+ "controllable": false,
+ "visual": false,
+ "description": "Helper text under the label.",
+ "type": "string"
+ },
+ {
+ "name": "errorMessage",
+ "controllable": false,
+ "visual": false,
+ "description": "Validation text, shown while in error.",
+ "type": "string"
+ },
+ {
+ "name": "error",
+ "controllable": false,
+ "visual": false,
+ "description": "Whether the field is in its error state.",
+ "type": "boolean",
+ "default": false
+ }
+ ],
+ "events": [],
+ "states": {
+ "structural": [],
+ "styling": []
+ },
+ "parts": [],
+ "slots": [],
+ "composes": [
+ "Field",
+ "Checkbox"
+ ]
+ },
+ "behavior": [
+ {
+ "id": "delegation",
+ "text": "everything the label does, Field does; everything the box does, Checkbox does. This component adds layout only."
+ }
+ ]
+}
diff --git a/examples/design-system/generated/contract/checkbox.json b/examples/design-system/generated/contract/checkbox.json
new file mode 100644
index 0000000..934df3d
--- /dev/null
+++ b/examples/design-system/generated/contract/checkbox.json
@@ -0,0 +1,195 @@
+{
+ "id": "checkbox",
+ "components": [
+ {
+ "name": "Checkbox",
+ "implements": "hwc-checkbox",
+ "status": "stable",
+ "boundParts": [
+ "checked-indicator",
+ "indeterminate-indicator"
+ ],
+ "treeSlots": [],
+ "axes": {
+ "state": [
+ "default",
+ "checked",
+ "indeterminate",
+ "hover",
+ "focus",
+ "disabled"
+ ]
+ }
+ }
+ ],
+ "contract": {
+ "props": [
+ {
+ "name": "checked",
+ "controllable": true,
+ "visual": true,
+ "description": "Whether the option is selected.",
+ "type": "boolean",
+ "default": false
+ },
+ {
+ "name": "indeterminate",
+ "controllable": true,
+ "visual": true,
+ "description": "Mixed state for a parent summarising children.",
+ "type": "boolean",
+ "default": false
+ },
+ {
+ "name": "disabled",
+ "controllable": false,
+ "visual": true,
+ "description": "Inert and dimmed.",
+ "type": "boolean",
+ "default": false
+ },
+ {
+ "name": "name",
+ "controllable": false,
+ "visual": false,
+ "description": "Form field name.",
+ "type": "string"
+ },
+ {
+ "name": "value",
+ "controllable": false,
+ "visual": false,
+ "description": "Submitted while checked.",
+ "type": "string",
+ "default": "on"
+ }
+ ],
+ "events": [
+ {
+ "name": "change",
+ "description": "Fires once per user toggle, never when set from code.",
+ "detail": "{ checked: boolean }"
+ }
+ ],
+ "states": {
+ "structural": [
+ "checked",
+ "indeterminate"
+ ],
+ "styling": [
+ "hover",
+ "focus",
+ "disabled"
+ ]
+ },
+ "parts": [
+ "checked-indicator",
+ "indeterminate-indicator"
+ ],
+ "slots": [],
+ "composes": [
+ "Field"
+ ],
+ "form": {
+ "participates": true,
+ "submits": "value while checked, nothing otherwise"
+ },
+ "accessibility": {
+ "role": "checkbox",
+ "keyboard": "Space toggles"
+ }
+ },
+ "behavior": [
+ {
+ "id": "toggle",
+ "text": "click or Space flips checked; a click while indeterminate sets checked and clears indeterminate."
+ },
+ {
+ "id": "change-event",
+ "text": "change fires once per user toggle, never when checked is set from code."
+ },
+ {
+ "id": "disabled",
+ "text": "no interaction and no events while disabled; still focusable for assistive technology, as native."
+ },
+ {
+ "id": "form",
+ "text": "submits value while checked and nothing otherwise, matching native ."
+ },
+ {
+ "id": "label",
+ "text": "composes with Field; clicking the label toggles, per Field's rules."
+ }
+ ],
+ "examples": [
+ {
+ "name": "checked",
+ "sets": [
+ {
+ "at": "root",
+ "state": "checked"
+ }
+ ]
+ }
+ ],
+ "styles": [
+ {
+ "keys": {
+ "state": "checked"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#accent}",
+ "strokes": "{surface#accent}"
+ },
+ "checked-indicator": {
+ "visible": true
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "indeterminate"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#accent}",
+ "strokes": "{surface#accent}"
+ },
+ "indeterminate-indicator": {
+ "visible": true
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "hover"
+ },
+ "values": {
+ "root": {
+ "strokes": "{border#hover}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "focus"
+ },
+ "values": {
+ "root": {
+ "strokes": "{border#focus}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "disabled"
+ },
+ "values": {
+ "root": {
+ "opacity": "{opacity#disabled}"
+ }
+ }
+ }
+ ]
+}
diff --git a/examples/design-system/generated/contract/contact-list.json b/examples/design-system/generated/contract/contact-list.json
new file mode 100644
index 0000000..99754d3
--- /dev/null
+++ b/examples/design-system/generated/contract/contact-list.json
@@ -0,0 +1,161 @@
+{
+ "id": "contact-list",
+ "components": [
+ {
+ "name": "ContactList",
+ "implements": "hwc-radio-group",
+ "status": "stable",
+ "boundParts": [],
+ "treeSlots": [
+ "empty"
+ ],
+ "axes": {
+ "state": [
+ "default",
+ "disabled"
+ ]
+ }
+ }
+ ],
+ "contract": {
+ "props": [
+ {
+ "name": "items",
+ "controllable": false,
+ "visual": false,
+ "description": "The people to choose from, in order.",
+ "type": "Contact[]"
+ },
+ {
+ "name": "name",
+ "controllable": false,
+ "visual": false,
+ "description": "Form field name shared by every option.",
+ "type": "string"
+ },
+ {
+ "name": "disabled",
+ "controllable": false,
+ "visual": true,
+ "description": "Every option inert.",
+ "type": "boolean",
+ "default": false
+ }
+ ],
+ "events": [],
+ "states": {
+ "structural": [],
+ "styling": [
+ "disabled"
+ ]
+ },
+ "parts": [],
+ "slots": [
+ {
+ "name": "option",
+ "repeats": true,
+ "description": "One option per element of items .",
+ "model": "{models#Contact}",
+ "accepts": "hwc-radio"
+ },
+ {
+ "name": "empty",
+ "repeats": false,
+ "description": "Shown instead of the options while items is empty."
+ }
+ ],
+ "composes": [
+ "ContactOption",
+ "Field"
+ ],
+ "accessibility": {
+ "role": "radiogroup",
+ "keyboard": "Arrow keys move the choice"
+ }
+ },
+ "models": [
+ {
+ "name": "Contact",
+ "description": "One person as a row shows them. Every value is display-ready; the adapter that builds it does any formatting.",
+ "fields": [
+ {
+ "name": "id",
+ "type": "string",
+ "key": true,
+ "optional": false,
+ "description": "Stable identity, used for keys and as the submitted value.",
+ "sample": [
+ "ada",
+ "grace",
+ "linus"
+ ]
+ },
+ {
+ "name": "name",
+ "type": "string",
+ "key": false,
+ "optional": false,
+ "description": "Full display name.",
+ "sample": [
+ "Ada Lovelace",
+ "Grace Hopper",
+ "Linus Torvalds"
+ ]
+ },
+ {
+ "name": "email",
+ "type": "string",
+ "key": false,
+ "optional": true,
+ "description": "Omitted when unknown; the row hides the line.",
+ "sample": [
+ "ada@example.com",
+ null,
+ "linus@example.com"
+ ]
+ }
+ ]
+ }
+ ],
+ "behavior": [
+ {
+ "id": "one-choice",
+ "text": "exactly one option is chosen at a time; choosing one unchooses the rest."
+ },
+ {
+ "id": "keyboard",
+ "text": "arrow keys move the choice through the options; Tab leaves the group."
+ },
+ {
+ "id": "empty",
+ "text": "with no items the empty slot shows and nothing is chosen."
+ }
+ ],
+ "examples": [
+ {
+ "name": "second-chosen",
+ "sets": [
+ {
+ "slot": "option",
+ "count": 3
+ },
+ {
+ "at": "option[1]",
+ "state": "checked"
+ }
+ ]
+ }
+ ],
+ "styles": [
+ {
+ "keys": {
+ "state": "disabled"
+ },
+ "values": {
+ "root": {
+ "opacity": "{opacity#disabled}"
+ }
+ }
+ }
+ ]
+}
diff --git a/examples/design-system/generated/contract/contact-option.json b/examples/design-system/generated/contract/contact-option.json
new file mode 100644
index 0000000..e6d2c49
--- /dev/null
+++ b/examples/design-system/generated/contract/contact-option.json
@@ -0,0 +1,187 @@
+{
+ "id": "contact-option",
+ "components": [
+ {
+ "name": "ContactOption",
+ "implements": "hwc-radio",
+ "status": "stable",
+ "boundParts": [
+ "checked-indicator"
+ ],
+ "treeSlots": [],
+ "axes": {
+ "state": [
+ "default",
+ "checked",
+ "hover",
+ "focus",
+ "disabled"
+ ]
+ }
+ }
+ ],
+ "contract": {
+ "props": [
+ {
+ "name": "item",
+ "controllable": false,
+ "visual": false,
+ "description": "The person this option stands for. Display-ready; nothing is derived here.",
+ "model": "{models#Contact}"
+ },
+ {
+ "name": "checked",
+ "controllable": true,
+ "visual": true,
+ "description": "Whether this option is the chosen one.",
+ "type": "boolean",
+ "default": false
+ },
+ {
+ "name": "disabled",
+ "controllable": false,
+ "visual": true,
+ "description": "Inert and dimmed.",
+ "type": "boolean",
+ "default": false
+ },
+ {
+ "name": "value",
+ "controllable": false,
+ "visual": false,
+ "description": "Submitted when chosen; normally the contact's id.",
+ "type": "string"
+ },
+ {
+ "name": "name",
+ "controllable": false,
+ "visual": false,
+ "description": "Form field name, shared with the group.",
+ "type": "string"
+ }
+ ],
+ "events": [
+ {
+ "name": "change",
+ "description": "Fires when this option becomes the chosen one.",
+ "detail": "{ checked: boolean }"
+ }
+ ],
+ "states": {
+ "structural": [
+ "checked"
+ ],
+ "styling": [
+ "hover",
+ "focus",
+ "disabled"
+ ]
+ },
+ "parts": [
+ "checked-indicator"
+ ],
+ "slots": [],
+ "composes": [
+ "ContactList"
+ ]
+ },
+ "models": [
+ {
+ "name": "Contact",
+ "description": "One person as a row shows them. Every value is display-ready; the adapter that builds it does any formatting.",
+ "fields": [
+ {
+ "name": "id",
+ "type": "string",
+ "key": true,
+ "optional": false,
+ "description": "Stable identity, used for keys and as the submitted value.",
+ "sample": [
+ "ada",
+ "grace",
+ "linus"
+ ]
+ },
+ {
+ "name": "name",
+ "type": "string",
+ "key": false,
+ "optional": false,
+ "description": "Full display name.",
+ "sample": [
+ "Ada Lovelace",
+ "Grace Hopper",
+ "Linus Torvalds"
+ ]
+ },
+ {
+ "name": "email",
+ "type": "string",
+ "key": false,
+ "optional": true,
+ "description": "Omitted when unknown; the row hides the line.",
+ "sample": [
+ "ada@example.com",
+ null,
+ "linus@example.com"
+ ]
+ }
+ ]
+ }
+ ],
+ "behavior": [
+ {
+ "id": "choose",
+ "text": "click, or arrow keys within the group, makes this the chosen option and unchooses the others."
+ },
+ {
+ "id": "form",
+ "text": "the group submits the chosen option's value under the shared name."
+ }
+ ],
+ "styles": [
+ {
+ "keys": {
+ "state": "checked"
+ },
+ "values": {
+ "ring": {
+ "strokes": "{surface#accent}"
+ },
+ "checked-indicator": {
+ "visible": true
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "hover"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#raised}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "focus"
+ },
+ "values": {
+ "ring": {
+ "strokes": "{border#focus}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "disabled"
+ },
+ "values": {
+ "root": {
+ "opacity": "{opacity#disabled}"
+ }
+ }
+ }
+ ]
+}
diff --git a/examples/design-system/generated/contract/field.json b/examples/design-system/generated/contract/field.json
new file mode 100644
index 0000000..bd1a251
--- /dev/null
+++ b/examples/design-system/generated/contract/field.json
@@ -0,0 +1,115 @@
+{
+ "id": "field",
+ "components": [
+ {
+ "name": "Field",
+ "implements": "hwc-field",
+ "status": "stable",
+ "boundParts": [
+ "label",
+ "description",
+ "error"
+ ],
+ "treeSlots": [
+ "control"
+ ],
+ "axes": {
+ "state": [
+ "default",
+ "error"
+ ]
+ }
+ }
+ ],
+ "contract": {
+ "props": [
+ {
+ "name": "label",
+ "controllable": false,
+ "visual": false,
+ "description": "The control's name, shown beside it.",
+ "type": "string"
+ },
+ {
+ "name": "description",
+ "controllable": false,
+ "visual": false,
+ "description": "Secondary text: hints and consequences.",
+ "type": "string"
+ },
+ {
+ "name": "errorMessage",
+ "controllable": false,
+ "visual": false,
+ "description": "Validation text, shown only while in error.",
+ "type": "string"
+ },
+ {
+ "name": "error",
+ "controllable": false,
+ "visual": true,
+ "description": "Whether the field is in its error state.",
+ "type": "boolean",
+ "default": false
+ }
+ ],
+ "events": [],
+ "states": {
+ "structural": [
+ "error"
+ ],
+ "styling": []
+ },
+ "parts": [
+ "label",
+ "description",
+ "error"
+ ],
+ "slots": [
+ {
+ "name": "control",
+ "repeats": false,
+ "description": "The wrapped control: a checkbox, a radio, a text input."
+ }
+ ],
+ "composes": [
+ "Checkbox",
+ "RadioGroup",
+ "TextInput"
+ ],
+ "accessibility": {
+ "labels": "aria-labelledby → label",
+ "describes": "aria-describedby → description, plus error while shown"
+ }
+ },
+ "behavior": [
+ {
+ "id": "activation",
+ "text": "clicking the label focuses the control and forwards the click, as a native label does; inert while the control is disabled."
+ },
+ {
+ "id": "naming",
+ "text": "the control is named by the label and described by the description, plus the error message while shown."
+ },
+ {
+ "id": "error",
+ "text": "error mounts the error message and asks the control to expose aria-invalid; announcing it is the application's policy."
+ },
+ {
+ "id": "claim",
+ "text": "the first field-aware control inside the field is the one it labels; a nested field scopes to its own subtree."
+ }
+ ],
+ "styles": [
+ {
+ "keys": {
+ "state": "error"
+ },
+ "values": {
+ "error": {
+ "visible": true
+ }
+ }
+ }
+ ]
+}
diff --git a/examples/design-system/generated/html/button.css b/examples/design-system/generated/html/button.css
new file mode 100644
index 0000000..7636c42
--- /dev/null
+++ b/examples/design-system/generated/html/button.css
@@ -0,0 +1,129 @@
+/* Button — generated by uidx codegen; edit the .uidx file instead. */
+
+hwc-button {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ gap: calc(var(--space-xs) * 1px);
+ padding-left: calc(var(--space-md) * 1px);
+ padding-right: calc(var(--space-md) * 1px);
+ padding-top: calc(var(--space-sm) * 1px);
+ padding-bottom: calc(var(--space-sm) * 1px);
+ border-radius: calc(var(--radius-md) * 1px);
+ background-color: var(--surface-accent);
+ border: 1px solid var(--surface-accent);
+}
+
+hwc-button hwc-button-leading-icon {
+ display: none;
+ width: 14px;
+ height: 14px;
+ color: var(--text-onAccent);
+}
+
+hwc-button hwc-button-label {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-onAccent);
+}
+
+hwc-button[variant="secondary"] {
+ background-color: var(--surface-raised);
+ border: 1px solid var(--border-default);
+}
+
+hwc-button[variant="secondary"] hwc-button-label {
+ color: var(--text-default);
+}
+
+hwc-button[variant="secondary"] hwc-button-leading-icon {
+ color: var(--text-default);
+}
+
+hwc-button[variant="outline"] {
+ background-color: var(--surface-control);
+ border: 1px solid var(--border-default);
+}
+
+hwc-button[variant="outline"] hwc-button-label {
+ color: var(--text-default);
+}
+
+hwc-button[variant="outline"] hwc-button-leading-icon {
+ color: var(--text-default);
+}
+
+hwc-button[variant="ghost"] {
+ background-color: var(--surface-control);
+ border: 1px solid var(--surface-control);
+}
+
+hwc-button[variant="ghost"] hwc-button-label {
+ color: var(--text-default);
+}
+
+hwc-button[variant="ghost"] hwc-button-leading-icon {
+ color: var(--text-default);
+}
+
+hwc-button[variant="destructive"] {
+ background-color: var(--surface-danger);
+ border: 1px solid var(--surface-danger);
+}
+
+hwc-button[variant="link"] {
+ background-color: var(--surface-control);
+ border: 1px solid var(--surface-control);
+}
+
+hwc-button[variant="link"] hwc-button-label {
+ color: var(--surface-accent);
+}
+
+hwc-button[variant="link"] hwc-button-leading-icon {
+ color: var(--surface-accent);
+}
+
+hwc-button[size="small"] {
+ padding-left: calc(var(--space-sm) * 1px);
+ padding-right: calc(var(--space-sm) * 1px);
+ padding-top: calc(var(--space-xs) * 1px);
+ padding-bottom: calc(var(--space-xs) * 1px);
+}
+
+hwc-button[size="small"] hwc-button-label {
+ font-size: 12px;
+}
+
+hwc-button[size="large"] {
+ padding-left: calc(var(--space-lg) * 1px);
+ padding-right: calc(var(--space-lg) * 1px);
+ padding-top: calc(var(--space-md) * 1px);
+ padding-bottom: calc(var(--space-md) * 1px);
+}
+
+hwc-button[size="large"] hwc-button-label {
+ font-size: 16px;
+}
+
+hwc-button:hover {
+ background-color: var(--surface-accentHover);
+ border: 1px solid var(--surface-accentHover);
+}
+
+hwc-button:hover[variant="secondary"] {
+ border: 1px solid var(--border-hover);
+}
+
+hwc-button:hover[variant="outline"] {
+ border: 1px solid var(--border-hover);
+}
+
+hwc-button:focus-visible {
+ border: 1px solid var(--border-focus);
+}
+
+hwc-button[disabled] {
+ opacity: var(--opacity-disabled);
+}
diff --git a/examples/design-system/generated/html/button.html b/examples/design-system/generated/html/button.html
new file mode 100644
index 0000000..6f065ec
--- /dev/null
+++ b/examples/design-system/generated/html/button.html
@@ -0,0 +1,5 @@
+
+
+
+ Label
+
diff --git a/examples/design-system/generated/html/checkbox-field.css b/examples/design-system/generated/html/checkbox-field.css
new file mode 100644
index 0000000..3856bee
--- /dev/null
+++ b/examples/design-system/generated/html/checkbox-field.css
@@ -0,0 +1 @@
+/* CheckboxField — generated by uidx codegen; edit the .uidx file instead. */
diff --git a/examples/design-system/generated/html/checkbox-field.html b/examples/design-system/generated/html/checkbox-field.html
new file mode 100644
index 0000000..5bea78c
--- /dev/null
+++ b/examples/design-system/generated/html/checkbox-field.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+ Label
+ Description
+ Error Message
+
+
diff --git a/examples/design-system/generated/html/checkbox.css b/examples/design-system/generated/html/checkbox.css
new file mode 100644
index 0000000..363e1bf
--- /dev/null
+++ b/examples/design-system/generated/html/checkbox.css
@@ -0,0 +1,57 @@
+/* Checkbox — generated by uidx codegen; edit the .uidx file instead. */
+
+hwc-checkbox {
+ width: 20px;
+ height: 20px;
+ border-radius: calc(var(--radius-sm) * 1px);
+ background-color: var(--surface-control);
+ border: 1px solid var(--border-default);
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+}
+
+hwc-checkbox hwc-checkbox-checked-indicator {
+ display: none;
+ width: 12px;
+ height: 12px;
+ color: var(--text-onAccent);
+}
+
+hwc-checkbox hwc-checkbox-indeterminate-indicator {
+ display: none;
+ width: 12px;
+ height: 12px;
+ color: var(--text-onAccent);
+}
+
+hwc-checkbox[checked] {
+ background-color: var(--surface-accent);
+ border: 1px solid var(--surface-accent);
+}
+
+hwc-checkbox[checked] hwc-checkbox-checked-indicator {
+ display: inline-flex;
+}
+
+hwc-checkbox[indeterminate] {
+ background-color: var(--surface-accent);
+ border: 1px solid var(--surface-accent);
+}
+
+hwc-checkbox[indeterminate] hwc-checkbox-indeterminate-indicator {
+ display: inline-flex;
+}
+
+hwc-checkbox:hover {
+ border: 1px solid var(--border-hover);
+}
+
+hwc-checkbox:focus-visible {
+ border: 1px solid var(--border-focus);
+}
+
+hwc-checkbox[disabled] {
+ opacity: var(--opacity-disabled);
+}
diff --git a/examples/design-system/generated/html/checkbox.html b/examples/design-system/generated/html/checkbox.html
new file mode 100644
index 0000000..8c30aa2
--- /dev/null
+++ b/examples/design-system/generated/html/checkbox.html
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/examples/design-system/generated/html/contact-list.css b/examples/design-system/generated/html/contact-list.css
new file mode 100644
index 0000000..194c221
--- /dev/null
+++ b/examples/design-system/generated/html/contact-list.css
@@ -0,0 +1,21 @@
+/* ContactList — generated by uidx codegen; edit the .uidx file instead. */
+
+hwc-radio-group {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--space-xs) * 1px);
+ width: 280px;
+}
+
+hwc-radio-group [data-slot="empty"] {
+ display: contents;
+}
+
+hwc-radio-group [data-node="nobody"] {
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+hwc-radio-group[disabled] {
+ opacity: var(--opacity-disabled);
+}
diff --git a/examples/design-system/generated/html/contact-list.html b/examples/design-system/generated/html/contact-list.html
new file mode 100644
index 0000000..267ebb2
--- /dev/null
+++ b/examples/design-system/generated/html/contact-list.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+ Ada Lovelace
+ ada@example.com
+
+
+
+
+
+ Grace Hopper
+
+
+
+
+
+
+ Linus Torvalds
+ linus@example.com
+
+
+
+ No contacts yet
+
+
diff --git a/examples/design-system/generated/html/contact-option.css b/examples/design-system/generated/html/contact-option.css
new file mode 100644
index 0000000..5e3c80e
--- /dev/null
+++ b/examples/design-system/generated/html/contact-option.css
@@ -0,0 +1,68 @@
+/* ContactOption — generated by uidx codegen; edit the .uidx file instead. */
+
+hwc-radio {
+ display: flex;
+ flex-direction: row;
+ gap: calc(var(--space-sm) * 1px);
+ align-items: center;
+ padding-left: calc(var(--space-sm) * 1px);
+ padding-right: calc(var(--space-sm) * 1px);
+ padding-top: calc(var(--space-xs) * 1px);
+ padding-bottom: calc(var(--space-xs) * 1px);
+ border-radius: calc(var(--radius-md) * 1px);
+ background-color: var(--surface-control);
+}
+
+hwc-radio [data-node="ring"] {
+ width: 16px;
+ height: 16px;
+ border-radius: calc(var(--radius-full) * 1px);
+ border: 1px solid var(--border-default);
+ background-color: var(--surface-control);
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+}
+
+hwc-radio hwc-radio-checked-indicator {
+ display: none;
+ width: 8px;
+ height: 8px;
+ color: var(--surface-accent);
+}
+
+hwc-radio [data-node="who"] {
+ display: flex;
+ flex-direction: column;
+}
+
+hwc-radio [data-node="name"] {
+ font-size: 14px;
+ color: var(--text-default);
+}
+
+hwc-radio [data-node="email"] {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+hwc-radio[checked] [data-node="ring"] {
+ border: 1px solid var(--surface-accent);
+}
+
+hwc-radio[checked] hwc-radio-checked-indicator {
+ display: inline-flex;
+}
+
+hwc-radio:hover {
+ background-color: var(--surface-raised);
+}
+
+hwc-radio:focus-visible [data-node="ring"] {
+ border: 1px solid var(--border-focus);
+}
+
+hwc-radio[disabled] {
+ opacity: var(--opacity-disabled);
+}
diff --git a/examples/design-system/generated/html/contact-option.html b/examples/design-system/generated/html/contact-option.html
new file mode 100644
index 0000000..9bf6f00
--- /dev/null
+++ b/examples/design-system/generated/html/contact-option.html
@@ -0,0 +1,10 @@
+
+
+
+
+ Ada Lovelace
+ ada@example.com
+
+
diff --git a/examples/design-system/generated/html/field.css b/examples/design-system/generated/html/field.css
new file mode 100644
index 0000000..c72632c
--- /dev/null
+++ b/examples/design-system/generated/html/field.css
@@ -0,0 +1,38 @@
+/* Field — generated by uidx codegen; edit the .uidx file instead. */
+
+hwc-field {
+ display: flex;
+ flex-direction: row;
+ gap: calc(var(--space-sm) * 1px);
+ align-items: flex-start;
+}
+
+hwc-field [data-slot="control"] {
+ display: contents;
+}
+
+hwc-field [data-node="text"] {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--space-xs) * 1px);
+}
+
+hwc-field hwc-field-label {
+ font-size: 14px;
+ color: var(--text-default);
+}
+
+hwc-field hwc-field-description {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+hwc-field hwc-field-error {
+ display: none;
+ font-size: 12px;
+ color: var(--text-danger);
+}
+
+hwc-field[error] hwc-field-error {
+ display: inline-flex;
+}
diff --git a/examples/design-system/generated/html/field.html b/examples/design-system/generated/html/field.html
new file mode 100644
index 0000000..398a4a7
--- /dev/null
+++ b/examples/design-system/generated/html/field.html
@@ -0,0 +1,11 @@
+
+
+
+ control
+
+
+ Label
+ Description
+ Error Message
+
+
diff --git a/examples/design-system/generated/html/tokens.css b/examples/design-system/generated/html/tokens.css
new file mode 100644
index 0000000..5004bda
--- /dev/null
+++ b/examples/design-system/generated/html/tokens.css
@@ -0,0 +1,22 @@
+:root {
+ --border-default: rgb(212 212 216);
+ --border-focus: rgb(13 153 255);
+ --border-hover: rgb(161 161 170);
+ --opacity-disabled: 0.45;
+ --radius-full: 999;
+ --radius-md: 6;
+ --radius-sm: 4;
+ --space-lg: 16;
+ --space-md: 12;
+ --space-sm: 8;
+ --space-xs: 4;
+ --surface-accent: rgb(13 153 255);
+ --surface-accentHover: rgb(5 128 230);
+ --surface-control: rgb(255 255 255);
+ --surface-danger: rgb(220 38 38);
+ --surface-raised: rgb(244 244 246);
+ --text-danger: rgb(220 38 38);
+ --text-default: rgb(24 24 27);
+ --text-muted: rgb(113 113 122);
+ --text-onAccent: rgb(255 255 255);
+}
diff --git a/examples/design-system/generated/react/Button.tsx b/examples/design-system/generated/react/Button.tsx
new file mode 100644
index 0000000..0a5eb13
--- /dev/null
+++ b/examples/design-system/generated/react/Button.tsx
@@ -0,0 +1,40 @@
+// Button — generated by uidx codegen from its .uidx file. Do not edit; change the file.
+
+import { type CSSProperties, type ReactNode, useRef } from 'react'
+import './button.css'
+
+export interface ButtonProps {
+ /** The button's text. */
+ label?: ReactNode
+ /** Emphasis. */
+ variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link'
+ /** Padding and type scale. */
+ size?: 'small' | 'medium' | 'large'
+ /** Inert and dimmed. */
+ disabled?: boolean
+ /** What activation does to the enclosing form. */
+ type?: 'button' | 'submit' | 'reset'
+ /** Extra class names for the root element. */
+ className?: string
+ /** Inline style for the root element. */
+ style?: CSSProperties
+}
+
+function ButtonBase({ label, variant = "primary", size = "medium", disabled, type = "button", className, style }: ButtonProps) {
+ const ref = useRef(null)
+ return (
+
+
+ {label}
+
+ )
+}
+
+function ButtonLabel({ children }: { children?: ReactNode }) {
+ return {children}
+}
+
+/** Button: the pattern, and its parts for composing below it. */
+export const Button = Object.assign(ButtonBase, {
+ Label: ButtonLabel,
+})
diff --git a/examples/design-system/generated/react/Checkbox.tsx b/examples/design-system/generated/react/Checkbox.tsx
new file mode 100644
index 0000000..f31b132
--- /dev/null
+++ b/examples/design-system/generated/react/Checkbox.tsx
@@ -0,0 +1,37 @@
+// Checkbox — generated by uidx codegen from its .uidx file. Do not edit; change the file.
+
+import { type CSSProperties, useRef } from 'react'
+import { useElementEvent } from './runtime'
+import './checkbox.css'
+
+export interface CheckboxProps {
+ /** Whether the option is selected. */
+ checked?: boolean
+ /** Mixed state for a parent summarising children. */
+ indeterminate?: boolean
+ /** Inert and dimmed. */
+ disabled?: boolean
+ /** Form field name. */
+ name?: string
+ /** Submitted while checked. */
+ value?: string
+ /** Fires once per user toggle, never when set from code. */
+ onChange?: (detail: { checked: boolean }) => void
+ /** Extra class names for the root element. */
+ className?: string
+ /** Inline style for the root element. */
+ style?: CSSProperties
+}
+
+function CheckboxBase({ checked, indeterminate, disabled, name, value = "on", onChange, className, style }: CheckboxProps) {
+ const ref = useRef(null)
+ useElementEvent(ref, 'change', onChange)
+ return (
+
+
+
+
+ )
+}
+
+export const Checkbox = CheckboxBase
diff --git a/examples/design-system/generated/react/CheckboxField.tsx b/examples/design-system/generated/react/CheckboxField.tsx
new file mode 100644
index 0000000..1e4f366
--- /dev/null
+++ b/examples/design-system/generated/react/CheckboxField.tsx
@@ -0,0 +1,35 @@
+// CheckboxField — generated by uidx codegen from its .uidx file. Do not edit; change the file.
+
+import { type CSSProperties } from 'react'
+import { Checkbox } from './Checkbox'
+import { Field } from './Field'
+import './checkbox-field.css'
+
+export interface CheckboxFieldProps {
+ /** The option's name. */
+ label?: string
+ /** Helper text under the label. */
+ description?: string
+ /** Validation text, shown while in error. */
+ errorMessage?: string
+ /** Whether the field is in its error state. */
+ error?: boolean
+ /** Extra class names for the root element. */
+ className?: string
+ /** Inline style for the root element. */
+ style?: CSSProperties
+}
+
+function CheckboxFieldBase({ label, description, errorMessage, error, className, style }: CheckboxFieldProps) {
+ return (
+
+
+ >
+ }
+ />
+ )
+}
+
+export const CheckboxField = CheckboxFieldBase
diff --git a/examples/design-system/generated/react/ContactList.tsx b/examples/design-system/generated/react/ContactList.tsx
new file mode 100644
index 0000000..f5d58b3
--- /dev/null
+++ b/examples/design-system/generated/react/ContactList.tsx
@@ -0,0 +1,45 @@
+// ContactList — generated by uidx codegen from its .uidx file. Do not edit; change the file.
+
+import { Fragment, type CSSProperties, type ComponentType, type ReactNode, useRef } from 'react'
+import type { Contact } from './models'
+import { ContactOption } from './ContactOption'
+import './contact-list.css'
+
+export interface ContactListProps {
+ /** Form field name shared by every option. */
+ name?: string
+ /** Every option inert. */
+ disabled?: boolean
+ /** Shown instead of the options while items is empty. */
+ empty?: ReactNode
+ /** One option per element of items . */
+ items: Contact[]
+ /** Draws one row; the default is the component the design repeats. */
+ renderItem?: (item: Contact, index: number) => ReactNode
+ /** A component taking `item`, used when `renderItem` is not given. */
+ ItemComponent?: ComponentType<{ item: Contact }>
+ /** Extra class names for the root element. */
+ className?: string
+ /** Inline style for the root element. */
+ style?: CSSProperties
+}
+
+function ContactListBase({ name, disabled, empty, items, renderItem, ItemComponent, className, style }: ContactListProps) {
+ const ref = useRef(null)
+ return (
+
+ {items.map((item, index) => (
+
+ {renderItem ? renderItem(item, index) : ItemComponent ? : }
+
+ ))}
+
+ {empty !== undefined ? empty : (
+ No contacts yet
+ )}
+
+
+ )
+}
+
+export const ContactList = ContactListBase
diff --git a/examples/design-system/generated/react/ContactOption.tsx b/examples/design-system/generated/react/ContactOption.tsx
new file mode 100644
index 0000000..d881eee
--- /dev/null
+++ b/examples/design-system/generated/react/ContactOption.tsx
@@ -0,0 +1,43 @@
+// ContactOption — generated by uidx codegen from its .uidx file. Do not edit; change the file.
+
+import { type CSSProperties, useRef } from 'react'
+import { useElementEvent } from './runtime'
+import type { Contact } from './models'
+import './contact-option.css'
+
+export interface ContactOptionProps {
+ /** The person this option stands for. Display-ready; nothing is derived here. */
+ item: Contact
+ /** Whether this option is the chosen one. */
+ checked?: boolean
+ /** Inert and dimmed. */
+ disabled?: boolean
+ /** Submitted when chosen; normally the contact's id. */
+ value?: string
+ /** Form field name, shared with the group. */
+ name?: string
+ /** Fires when this option becomes the chosen one. */
+ onChange?: (detail: { checked: boolean }) => void
+ /** Extra class names for the root element. */
+ className?: string
+ /** Inline style for the root element. */
+ style?: CSSProperties
+}
+
+function ContactOptionBase({ item, checked, disabled, value, name, onChange, className, style }: ContactOptionProps) {
+ const ref = useRef(null)
+ useElementEvent(ref, 'change', onChange)
+ return (
+
+
+
+ {item?.name}
+ {item?.email}
+
+
+ )
+}
+
+export const ContactOption = ContactOptionBase
diff --git a/examples/design-system/generated/react/Field.tsx b/examples/design-system/generated/react/Field.tsx
new file mode 100644
index 0000000..7a05d6b
--- /dev/null
+++ b/examples/design-system/generated/react/Field.tsx
@@ -0,0 +1,54 @@
+// Field — generated by uidx codegen from its .uidx file. Do not edit; change the file.
+
+import { type CSSProperties, type ReactNode, useRef } from 'react'
+import './field.css'
+
+export interface FieldProps {
+ /** The control's name, shown beside it. */
+ label?: ReactNode
+ /** Secondary text: hints and consequences. */
+ description?: ReactNode
+ /** Validation text, shown only while in error. */
+ errorMessage?: ReactNode
+ /** Whether the field is in its error state. */
+ error?: boolean
+ /** The wrapped control: a checkbox, a radio, a text input. */
+ control?: ReactNode
+ /** Extra class names for the root element. */
+ className?: string
+ /** Inline style for the root element. */
+ style?: CSSProperties
+}
+
+function FieldBase({ label, description, errorMessage, error, control, className, style }: FieldProps) {
+ const ref = useRef(null)
+ return (
+
+ {control !== undefined ? {control} : null}
+
+ {label}
+ {description}
+ {errorMessage}
+
+
+ )
+}
+
+function FieldLabel({ children }: { children?: ReactNode }) {
+ return {children}
+}
+
+function FieldDescription({ children }: { children?: ReactNode }) {
+ return {children}
+}
+
+function FieldError({ children }: { children?: ReactNode }) {
+ return {children}
+}
+
+/** Field: the pattern, and its parts for composing below it. */
+export const Field = Object.assign(FieldBase, {
+ Label: FieldLabel,
+ Description: FieldDescription,
+ Error: FieldError,
+})
diff --git a/examples/design-system/generated/react/button.css b/examples/design-system/generated/react/button.css
new file mode 100644
index 0000000..7636c42
--- /dev/null
+++ b/examples/design-system/generated/react/button.css
@@ -0,0 +1,129 @@
+/* Button — generated by uidx codegen; edit the .uidx file instead. */
+
+hwc-button {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ gap: calc(var(--space-xs) * 1px);
+ padding-left: calc(var(--space-md) * 1px);
+ padding-right: calc(var(--space-md) * 1px);
+ padding-top: calc(var(--space-sm) * 1px);
+ padding-bottom: calc(var(--space-sm) * 1px);
+ border-radius: calc(var(--radius-md) * 1px);
+ background-color: var(--surface-accent);
+ border: 1px solid var(--surface-accent);
+}
+
+hwc-button hwc-button-leading-icon {
+ display: none;
+ width: 14px;
+ height: 14px;
+ color: var(--text-onAccent);
+}
+
+hwc-button hwc-button-label {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-onAccent);
+}
+
+hwc-button[variant="secondary"] {
+ background-color: var(--surface-raised);
+ border: 1px solid var(--border-default);
+}
+
+hwc-button[variant="secondary"] hwc-button-label {
+ color: var(--text-default);
+}
+
+hwc-button[variant="secondary"] hwc-button-leading-icon {
+ color: var(--text-default);
+}
+
+hwc-button[variant="outline"] {
+ background-color: var(--surface-control);
+ border: 1px solid var(--border-default);
+}
+
+hwc-button[variant="outline"] hwc-button-label {
+ color: var(--text-default);
+}
+
+hwc-button[variant="outline"] hwc-button-leading-icon {
+ color: var(--text-default);
+}
+
+hwc-button[variant="ghost"] {
+ background-color: var(--surface-control);
+ border: 1px solid var(--surface-control);
+}
+
+hwc-button[variant="ghost"] hwc-button-label {
+ color: var(--text-default);
+}
+
+hwc-button[variant="ghost"] hwc-button-leading-icon {
+ color: var(--text-default);
+}
+
+hwc-button[variant="destructive"] {
+ background-color: var(--surface-danger);
+ border: 1px solid var(--surface-danger);
+}
+
+hwc-button[variant="link"] {
+ background-color: var(--surface-control);
+ border: 1px solid var(--surface-control);
+}
+
+hwc-button[variant="link"] hwc-button-label {
+ color: var(--surface-accent);
+}
+
+hwc-button[variant="link"] hwc-button-leading-icon {
+ color: var(--surface-accent);
+}
+
+hwc-button[size="small"] {
+ padding-left: calc(var(--space-sm) * 1px);
+ padding-right: calc(var(--space-sm) * 1px);
+ padding-top: calc(var(--space-xs) * 1px);
+ padding-bottom: calc(var(--space-xs) * 1px);
+}
+
+hwc-button[size="small"] hwc-button-label {
+ font-size: 12px;
+}
+
+hwc-button[size="large"] {
+ padding-left: calc(var(--space-lg) * 1px);
+ padding-right: calc(var(--space-lg) * 1px);
+ padding-top: calc(var(--space-md) * 1px);
+ padding-bottom: calc(var(--space-md) * 1px);
+}
+
+hwc-button[size="large"] hwc-button-label {
+ font-size: 16px;
+}
+
+hwc-button:hover {
+ background-color: var(--surface-accentHover);
+ border: 1px solid var(--surface-accentHover);
+}
+
+hwc-button:hover[variant="secondary"] {
+ border: 1px solid var(--border-hover);
+}
+
+hwc-button:hover[variant="outline"] {
+ border: 1px solid var(--border-hover);
+}
+
+hwc-button:focus-visible {
+ border: 1px solid var(--border-focus);
+}
+
+hwc-button[disabled] {
+ opacity: var(--opacity-disabled);
+}
diff --git a/examples/design-system/generated/react/checkbox-field.css b/examples/design-system/generated/react/checkbox-field.css
new file mode 100644
index 0000000..3856bee
--- /dev/null
+++ b/examples/design-system/generated/react/checkbox-field.css
@@ -0,0 +1 @@
+/* CheckboxField — generated by uidx codegen; edit the .uidx file instead. */
diff --git a/examples/design-system/generated/react/checkbox.css b/examples/design-system/generated/react/checkbox.css
new file mode 100644
index 0000000..363e1bf
--- /dev/null
+++ b/examples/design-system/generated/react/checkbox.css
@@ -0,0 +1,57 @@
+/* Checkbox — generated by uidx codegen; edit the .uidx file instead. */
+
+hwc-checkbox {
+ width: 20px;
+ height: 20px;
+ border-radius: calc(var(--radius-sm) * 1px);
+ background-color: var(--surface-control);
+ border: 1px solid var(--border-default);
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+}
+
+hwc-checkbox hwc-checkbox-checked-indicator {
+ display: none;
+ width: 12px;
+ height: 12px;
+ color: var(--text-onAccent);
+}
+
+hwc-checkbox hwc-checkbox-indeterminate-indicator {
+ display: none;
+ width: 12px;
+ height: 12px;
+ color: var(--text-onAccent);
+}
+
+hwc-checkbox[checked] {
+ background-color: var(--surface-accent);
+ border: 1px solid var(--surface-accent);
+}
+
+hwc-checkbox[checked] hwc-checkbox-checked-indicator {
+ display: inline-flex;
+}
+
+hwc-checkbox[indeterminate] {
+ background-color: var(--surface-accent);
+ border: 1px solid var(--surface-accent);
+}
+
+hwc-checkbox[indeterminate] hwc-checkbox-indeterminate-indicator {
+ display: inline-flex;
+}
+
+hwc-checkbox:hover {
+ border: 1px solid var(--border-hover);
+}
+
+hwc-checkbox:focus-visible {
+ border: 1px solid var(--border-focus);
+}
+
+hwc-checkbox[disabled] {
+ opacity: var(--opacity-disabled);
+}
diff --git a/examples/design-system/generated/react/contact-list.css b/examples/design-system/generated/react/contact-list.css
new file mode 100644
index 0000000..194c221
--- /dev/null
+++ b/examples/design-system/generated/react/contact-list.css
@@ -0,0 +1,21 @@
+/* ContactList — generated by uidx codegen; edit the .uidx file instead. */
+
+hwc-radio-group {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--space-xs) * 1px);
+ width: 280px;
+}
+
+hwc-radio-group [data-slot="empty"] {
+ display: contents;
+}
+
+hwc-radio-group [data-node="nobody"] {
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+hwc-radio-group[disabled] {
+ opacity: var(--opacity-disabled);
+}
diff --git a/examples/design-system/generated/react/contact-option.css b/examples/design-system/generated/react/contact-option.css
new file mode 100644
index 0000000..5e3c80e
--- /dev/null
+++ b/examples/design-system/generated/react/contact-option.css
@@ -0,0 +1,68 @@
+/* ContactOption — generated by uidx codegen; edit the .uidx file instead. */
+
+hwc-radio {
+ display: flex;
+ flex-direction: row;
+ gap: calc(var(--space-sm) * 1px);
+ align-items: center;
+ padding-left: calc(var(--space-sm) * 1px);
+ padding-right: calc(var(--space-sm) * 1px);
+ padding-top: calc(var(--space-xs) * 1px);
+ padding-bottom: calc(var(--space-xs) * 1px);
+ border-radius: calc(var(--radius-md) * 1px);
+ background-color: var(--surface-control);
+}
+
+hwc-radio [data-node="ring"] {
+ width: 16px;
+ height: 16px;
+ border-radius: calc(var(--radius-full) * 1px);
+ border: 1px solid var(--border-default);
+ background-color: var(--surface-control);
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+}
+
+hwc-radio hwc-radio-checked-indicator {
+ display: none;
+ width: 8px;
+ height: 8px;
+ color: var(--surface-accent);
+}
+
+hwc-radio [data-node="who"] {
+ display: flex;
+ flex-direction: column;
+}
+
+hwc-radio [data-node="name"] {
+ font-size: 14px;
+ color: var(--text-default);
+}
+
+hwc-radio [data-node="email"] {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+hwc-radio[checked] [data-node="ring"] {
+ border: 1px solid var(--surface-accent);
+}
+
+hwc-radio[checked] hwc-radio-checked-indicator {
+ display: inline-flex;
+}
+
+hwc-radio:hover {
+ background-color: var(--surface-raised);
+}
+
+hwc-radio:focus-visible [data-node="ring"] {
+ border: 1px solid var(--border-focus);
+}
+
+hwc-radio[disabled] {
+ opacity: var(--opacity-disabled);
+}
diff --git a/examples/design-system/generated/react/elements.d.ts b/examples/design-system/generated/react/elements.d.ts
new file mode 100644
index 0000000..0efaabb
--- /dev/null
+++ b/examples/design-system/generated/react/elements.d.ts
@@ -0,0 +1,38 @@
+// Elements — generated by uidx codegen from its .uidx file. Do not edit; change the file.
+
+import type React from 'react'
+
+declare module 'react' {
+ namespace JSX {
+ interface IntrinsicElements {
+ 'hwc-button': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-button-label': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-button-leading-icon': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-checkbox': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-checkbox-checked-indicator': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-checkbox-indeterminate-indicator': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-field': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-field-description': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-field-error': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-field-label': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-radio': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-radio-checked-indicator': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ 'hwc-radio-group': React.DetailedHTMLProps, HTMLElement> &
+ Record
+ }
+ }
+}
+
+export {}
diff --git a/examples/design-system/generated/react/field.css b/examples/design-system/generated/react/field.css
new file mode 100644
index 0000000..c72632c
--- /dev/null
+++ b/examples/design-system/generated/react/field.css
@@ -0,0 +1,38 @@
+/* Field — generated by uidx codegen; edit the .uidx file instead. */
+
+hwc-field {
+ display: flex;
+ flex-direction: row;
+ gap: calc(var(--space-sm) * 1px);
+ align-items: flex-start;
+}
+
+hwc-field [data-slot="control"] {
+ display: contents;
+}
+
+hwc-field [data-node="text"] {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--space-xs) * 1px);
+}
+
+hwc-field hwc-field-label {
+ font-size: 14px;
+ color: var(--text-default);
+}
+
+hwc-field hwc-field-description {
+ font-size: 12px;
+ color: var(--text-muted);
+}
+
+hwc-field hwc-field-error {
+ display: none;
+ font-size: 12px;
+ color: var(--text-danger);
+}
+
+hwc-field[error] hwc-field-error {
+ display: inline-flex;
+}
diff --git a/examples/design-system/generated/react/index.ts b/examples/design-system/generated/react/index.ts
new file mode 100644
index 0000000..71a1021
--- /dev/null
+++ b/examples/design-system/generated/react/index.ts
@@ -0,0 +1,10 @@
+// Index — generated by uidx codegen from its .uidx file. Do not edit; change the file.
+
+
+export * from './Button'
+export * from './CheckboxField'
+export * from './Checkbox'
+export * from './ContactList'
+export * from './ContactOption'
+export * from './Field'
+export * from './models'
diff --git a/examples/design-system/generated/react/models.ts b/examples/design-system/generated/react/models.ts
new file mode 100644
index 0000000..784385a
--- /dev/null
+++ b/examples/design-system/generated/react/models.ts
@@ -0,0 +1,13 @@
+// Models — generated by uidx codegen from its .uidx file. Do not edit; change the file.
+
+
+/** One person as a row shows them. Every value is display-ready; the adapter that builds it does any formatting. */
+export interface Contact {
+ /** Stable identity, used for keys and as the submitted value. */
+ id: string
+ /** Full display name. */
+ name: string
+ /** Omitted when unknown; the row hides the line. */
+ email?: string
+}
+
diff --git a/examples/design-system/generated/react/runtime.ts b/examples/design-system/generated/react/runtime.ts
new file mode 100644
index 0000000..26ff1f3
--- /dev/null
+++ b/examples/design-system/generated/react/runtime.ts
@@ -0,0 +1,21 @@
+// Runtime — generated by uidx codegen from its .uidx file. Do not edit; change the file.
+
+import { useEffect, type RefObject } from 'react'
+
+/**
+ * Subscribes to a custom element's event and hands the handler its detail.
+ * The element owns the behaviour; React only listens.
+ */
+export function useElementEvent(
+ ref: RefObject,
+ type: string,
+ handler: ((detail: T) => void) | undefined,
+): void {
+ useEffect(() => {
+ const element = ref.current
+ if (!element || !handler) return
+ const listener = (event: Event) => handler((event as CustomEvent).detail)
+ element.addEventListener(type, listener)
+ return () => element.removeEventListener(type, listener)
+ }, [ref, type, handler])
+}
diff --git a/examples/design-system/generated/react/tokens.css b/examples/design-system/generated/react/tokens.css
new file mode 100644
index 0000000..5004bda
--- /dev/null
+++ b/examples/design-system/generated/react/tokens.css
@@ -0,0 +1,22 @@
+:root {
+ --border-default: rgb(212 212 216);
+ --border-focus: rgb(13 153 255);
+ --border-hover: rgb(161 161 170);
+ --opacity-disabled: 0.45;
+ --radius-full: 999;
+ --radius-md: 6;
+ --radius-sm: 4;
+ --space-lg: 16;
+ --space-md: 12;
+ --space-sm: 8;
+ --space-xs: 4;
+ --surface-accent: rgb(13 153 255);
+ --surface-accentHover: rgb(5 128 230);
+ --surface-control: rgb(255 255 255);
+ --surface-danger: rgb(220 38 38);
+ --surface-raised: rgb(244 244 246);
+ --text-danger: rgb(220 38 38);
+ --text-default: rgb(24 24 27);
+ --text-muted: rgb(113 113 122);
+ --text-onAccent: rgb(255 255 255);
+}
diff --git a/examples/design-system/index.html b/examples/design-system/index.html
new file mode 100644
index 0000000..9916c98
--- /dev/null
+++ b/examples/design-system/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+ uidx example design system
+
+
+
+
+
+
+
+
+
+
+
+
+ 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
+
+ }
+ />
+
+
+
+ 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 `` underneath.\n\nA pure form *operator* (GUIDELINES.md rule 8, category 2), not a\nvalue-bearing control: formAssociated exists solely to acquire\ninternals.form — setFormValue() is never called, so hwc-button never\nappears in FormData under any name. On activation, type=\"submit\" calls\ninternals.form?.requestSubmit() (never submit(), so the submit event\nfires and validation runs) and type=\"reset\" calls internals.form?.reset();\ntype=\"button\" (the default — a deliberate divergence from native's\nsubmit-by-default) touches the form not at all. Fieldset disable arrives\nthrough formDisabledCallback, OR'd with the consumer's own disabled\nattribute exactly like Checkbox/Radio.\n\nNot self-registering — call Button.define() (optionally with a tag name\nof your own choosing) before use. See GUIDELINES.md rule 5.",
+ "name": "Button",
+ "members": [
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-button\""
+ }
+ ]
+ },
+ {
+ "kind": "field",
+ "name": "formAssociated",
+ "type": {
+ "text": "boolean"
+ },
+ "static": true,
+ "default": "true"
+ },
+ {
+ "kind": "field",
+ "name": "variant",
+ "type": {
+ "text": "\"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"destructive\" | \"link\""
+ },
+ "default": "\"primary\"",
+ "attribute": "variant",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "size",
+ "type": {
+ "text": "\"small\" | \"medium\" | \"large\""
+ },
+ "default": "\"medium\"",
+ "attribute": "size",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "attribute": "disabled",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "type",
+ "type": {
+ "text": "\"button\" | \"submit\" | \"reset\""
+ },
+ "default": "\"button\"",
+ "attribute": "type",
+ "reflects": true
+ },
+ {
+ "kind": "method",
+ "name": "formDisabledCallback",
+ "parameters": [
+ {
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ }
+ }
+ ]
+ },
+ {
+ "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"
+ }
+ }
+ ],
+ "attributes": [
+ {
+ "name": "variant",
+ "type": {
+ "text": "\"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"destructive\" | \"link\""
+ },
+ "default": "\"primary\"",
+ "fieldName": "variant"
+ },
+ {
+ "name": "size",
+ "type": {
+ "text": "\"small\" | \"medium\" | \"large\""
+ },
+ "default": "\"medium\"",
+ "fieldName": "size"
+ },
+ {
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "fieldName": "disabled"
+ },
+ {
+ "name": "type",
+ "type": {
+ "text": "\"button\" | \"submit\" | \"reset\""
+ },
+ "default": "\"button\"",
+ "fieldName": "type"
+ }
+ ],
+ "superclass": {
+ "name": "SlottedElement",
+ "module": "/internal/slotted-element.js"
+ },
+ "tagName": "hwc-button",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "Button",
+ "declaration": {
+ "name": "Button",
+ "module": "button/button.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "button/index.ts",
+ "declarations": [],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "Button",
+ "declaration": {
+ "name": "Button",
+ "module": "./button.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "ButtonLeadingIcon",
+ "declaration": {
+ "name": "ButtonLeadingIcon",
+ "module": "./button-leading-icon.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "ButtonLabel",
+ "declaration": {
+ "name": "ButtonLabel",
+ "module": "./button-label.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "ButtonTrailingIcon",
+ "declaration": {
+ "name": "ButtonTrailingIcon",
+ "module": "./button-trailing-icon.js"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "checkbox/checkbox-checked-indicator.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "A pure slot — the projected content owns the actual mark's shape; this\nelement only owns filling/centering itself in its parent box and mapping\nits own state to design tokens (GUIDELINES.md rule 3), both of which are\ntag-name-agnostic via :host and so survive a consumer renaming the tag.\n\nConsumes hwc-checkbox's state over context and mirrors it into custom\nstates on its own ElementInternals — never as attributes, so the light\nDOM stays entirely the consumer's (GUIDELINES.md rule 4). Not\nself-registering — call CheckboxCheckedIndicator.define() (optionally\nwith a tag name of your own choosing) before use. See GUIDELINES.md rule 5.",
+ "name": "CheckboxCheckedIndicator",
+ "slots": [
+ {
+ "description": "The consumer's own check mark (an SVG, a glyph, anything).",
+ "name": ""
+ }
+ ],
+ "members": [
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-checkbox-checked-indicator\""
+ }
+ ]
+ },
+ {
+ "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-checkbox-checked-indicator",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "CheckboxCheckedIndicator",
+ "declaration": {
+ "name": "CheckboxCheckedIndicator",
+ "module": "checkbox/checkbox-checked-indicator.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "checkbox/checkbox-context.ts",
+ "declarations": [
+ {
+ "kind": "variable",
+ "name": "checkboxContext",
+ "description": "Broadcasts hwc-checkbox's state to its indicator children so each one can\ndecide its own visibility. Nothing here identifies a descendant by tag\nname — see GUIDELINES.md rules 4 and 5 for why that matters once tag\nnames are consumer-configurable."
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "checkboxContext",
+ "declaration": {
+ "name": "checkboxContext",
+ "module": "checkbox/checkbox-context.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "checkbox/checkbox-indeterminate-indicator.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "A pure slot — the projected content owns the actual mark's shape; this\nelement only owns filling/centering itself in its parent box and mapping\nits own state to design tokens (GUIDELINES.md rule 3), both of which are\ntag-name-agnostic via :host and so survive a consumer renaming the tag.\n\nConsumes hwc-checkbox's state over context and mirrors it into custom\nstates on its own ElementInternals — never as attributes, so the light\nDOM stays entirely the consumer's (GUIDELINES.md rule 4). Not\nself-registering — call CheckboxIndeterminateIndicator.define()\n(optionally with a tag name of your own choosing) before use. See\nGUIDELINES.md rule 5.",
+ "name": "CheckboxIndeterminateIndicator",
+ "slots": [
+ {
+ "description": "The consumer's own indeterminate mark (an SVG, a glyph, anything).",
+ "name": ""
+ }
+ ],
+ "members": [
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-checkbox-indeterminate-indicator\""
+ }
+ ]
+ },
+ {
+ "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-checkbox-indeterminate-indicator",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "CheckboxIndeterminateIndicator",
+ "declaration": {
+ "name": "CheckboxIndeterminateIndicator",
+ "module": "checkbox/checkbox-indeterminate-indicator.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "checkbox/checkbox.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "Headless checkbox root — state, behavior, and accessibility only.\nSee checkbox/SPEC.md for the composition contract and GUIDELINES.md\nfor why there's no native ` ` underneath.\n\nForm-associated (GUIDELINES.md rule 8): submits `value` under `name`\nwhile checked and nothing while unchecked, honors form.reset() and\n``, and restores state on session restore — all\nthrough the same ElementInternals handle the context indicators use.\nOutside a form every one of those is a silent no-op.\n\nNot self-registering — call Checkbox.define() (optionally with a tag\nname of your own choosing) before use. See GUIDELINES.md rule 5.",
+ "name": "Checkbox",
+ "members": [
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-checkbox\""
+ }
+ ]
+ },
+ {
+ "kind": "field",
+ "name": "formAssociated",
+ "type": {
+ "text": "boolean"
+ },
+ "static": true,
+ "default": "true"
+ },
+ {
+ "kind": "field",
+ "name": "checked",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "attribute": "checked",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "indeterminate",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "attribute": "indeterminate",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "attribute": "disabled",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "value",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"on\"",
+ "attribute": "value"
+ },
+ {
+ "kind": "field",
+ "name": "name",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"\"",
+ "attribute": "name",
+ "reflects": true
+ },
+ {
+ "kind": "method",
+ "name": "formResetCallback"
+ },
+ {
+ "kind": "method",
+ "name": "formDisabledCallback",
+ "parameters": [
+ {
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "method",
+ "name": "formStateRestoreCallback",
+ "parameters": [
+ {
+ "name": "state",
+ "type": {
+ "text": "string | File | FormData | null"
+ }
+ },
+ {
+ "name": "_mode",
+ "type": {
+ "text": "\"restore\" | \"autocomplete\""
+ }
+ }
+ ]
+ },
+ {
+ "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"
+ }
+ }
+ ],
+ "events": [
+ {
+ "name": "change",
+ "type": {
+ "text": "Event"
+ },
+ "description": "Dispatched after checked/indeterminate changes from a click or Space keypress."
+ }
+ ],
+ "attributes": [
+ {
+ "name": "checked",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "fieldName": "checked"
+ },
+ {
+ "name": "indeterminate",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "fieldName": "indeterminate"
+ },
+ {
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "fieldName": "disabled"
+ },
+ {
+ "name": "value",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"on\"",
+ "fieldName": "value"
+ },
+ {
+ "name": "name",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"\"",
+ "fieldName": "name"
+ }
+ ],
+ "superclass": {
+ "name": "SlottedElement",
+ "module": "/internal/slotted-element.js"
+ },
+ "tagName": "hwc-checkbox",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "Checkbox",
+ "declaration": {
+ "name": "Checkbox",
+ "module": "checkbox/checkbox.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "checkbox/index.ts",
+ "declarations": [],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "Checkbox",
+ "declaration": {
+ "name": "Checkbox",
+ "module": "./checkbox.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "CheckboxCheckedIndicator",
+ "declaration": {
+ "name": "CheckboxCheckedIndicator",
+ "module": "./checkbox-checked-indicator.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "CheckboxIndeterminateIndicator",
+ "declaration": {
+ "name": "CheckboxIndeterminateIndicator",
+ "module": "./checkbox-indeterminate-indicator.js"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "field/field-context.ts",
+ "declarations": [
+ {
+ "kind": "variable",
+ "name": "fieldContext"
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "fieldContext",
+ "declaration": {
+ "name": "fieldContext",
+ "module": "field/field-context.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "field/field-description.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "The field's description part — a pure slot for secondary text (format\nhints, consequences, helper text). Joins the claimed control's\naria-describedby through the field root, and dims itself while that\ncontrol is disabled, via a custom state consumed over the field context\n(GUIDELINES.md rule 4).\n\nNot self-registering — call FieldDescription.define() (optionally with\na tag name of your own choosing) before use. See GUIDELINES.md rule 5.",
+ "name": "FieldDescription",
+ "slots": [
+ {
+ "description": "The description text for the field's control.",
+ "name": ""
+ }
+ ],
+ "members": [
+ {
+ "kind": "field",
+ "name": "kind",
+ "type": {
+ "text": "FieldPartKind"
+ },
+ "privacy": "protected",
+ "readonly": true,
+ "default": "\"description\"",
+ "inheritedFrom": {
+ "name": "FieldPart",
+ "module": "field/field-part.ts"
+ }
+ },
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-field-description\""
+ }
+ ]
+ },
+ {
+ "kind": "field",
+ "name": "_field",
+ "type": {
+ "text": "FieldContextValue | undefined"
+ },
+ "privacy": "protected",
+ "inheritedFrom": {
+ "name": "FieldPart",
+ "module": "field/field-part.ts"
+ }
+ },
+ {
+ "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": "FieldPart",
+ "module": "/field/field-part.js"
+ },
+ "tagName": "hwc-field-description",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "FieldDescription",
+ "declaration": {
+ "name": "FieldDescription",
+ "module": "field/field-description.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "field/field-error.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "The field's error-message part — state-mounted: renders only while the\nfield root's `error` is set, hidden until context proves otherwise, the\nsame custom-state visibility mechanism as Checkbox's indicators\n(GUIDELINES.md rules 3 and 4). Deliberately no live region — no\nrole=\"alert\" — announcement timing is validation policy, which belongs\nto the application; a consumer who wants immediate announcement puts\nrole=\"alert\" on the content they project (see field/SPEC.md).\n\nNot self-registering — call FieldError.define() (optionally with a tag\nname of your own choosing) before use. See GUIDELINES.md rule 5.",
+ "name": "FieldError",
+ "slots": [
+ {
+ "description": "The validation message shown while the field is in error.",
+ "name": ""
+ }
+ ],
+ "members": [
+ {
+ "kind": "field",
+ "name": "kind",
+ "type": {
+ "text": "FieldPartKind"
+ },
+ "privacy": "protected",
+ "readonly": true,
+ "default": "\"error\"",
+ "inheritedFrom": {
+ "name": "FieldPart",
+ "module": "field/field-part.ts"
+ }
+ },
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-field-error\""
+ }
+ ]
+ },
+ {
+ "kind": "field",
+ "name": "_field",
+ "type": {
+ "text": "FieldContextValue | undefined"
+ },
+ "privacy": "protected",
+ "inheritedFrom": {
+ "name": "FieldPart",
+ "module": "field/field-part.ts"
+ }
+ },
+ {
+ "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": "FieldPart",
+ "module": "/field/field-part.js"
+ },
+ "tagName": "hwc-field-error",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "FieldError",
+ "declaration": {
+ "name": "FieldError",
+ "module": "field/field-error.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "field/field-label.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "The field's label part — a pure slot the consumer projects the label\ntext into. Not focusable, no role: it's text. Owns label activation —\nclicking it focuses and clicks the field's claimed control, native\n`` parity, inert while that control is disabled — and dims itself\nwhile the control is disabled via a custom state consumed over the\nfield context (GUIDELINES.md rule 4). Registration, id self-management,\nand the disabled state come from FieldPart.\n\nNot self-registering — call FieldLabel.define() (optionally with a tag\nname of your own choosing) before use. See GUIDELINES.md rule 5.",
+ "name": "FieldLabel",
+ "slots": [
+ {
+ "description": "The label text naming the field's control.",
+ "name": ""
+ }
+ ],
+ "members": [
+ {
+ "kind": "field",
+ "name": "kind",
+ "type": {
+ "text": "FieldPartKind"
+ },
+ "privacy": "protected",
+ "readonly": true,
+ "default": "\"label\"",
+ "inheritedFrom": {
+ "name": "FieldPart",
+ "module": "field/field-part.ts"
+ }
+ },
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-field-label\""
+ }
+ ]
+ },
+ {
+ "kind": "field",
+ "name": "_field",
+ "type": {
+ "text": "FieldContextValue | undefined"
+ },
+ "privacy": "protected",
+ "inheritedFrom": {
+ "name": "FieldPart",
+ "module": "field/field-part.ts"
+ }
+ },
+ {
+ "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": "FieldPart",
+ "module": "/field/field-part.js"
+ },
+ "tagName": "hwc-field-label",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "FieldLabel",
+ "declaration": {
+ "name": "FieldLabel",
+ "module": "field/field-label.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "field/field-part.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "Shared behavior of the three field text parts (label, description,\nerror) — not itself a custom element. Each part registers itself with\nthe nearest field root over context (never discovered by tag name —\nGUIDELINES.md rule 5), assigns itself an id for the IDREF wiring if the\nconsumer didn't (the same self-managed-host-attribute pattern as a\ncontrol setting its own role and tabindex), and mirrors the context's\nclaimed-control state into custom states on its own ElementInternals\nfor styling (GUIDELINES.md rule 4).",
+ "name": "FieldPart",
+ "members": [
+ {
+ "kind": "field",
+ "name": "kind",
+ "type": {
+ "text": "FieldPartKind"
+ },
+ "privacy": "protected",
+ "readonly": true
+ },
+ {
+ "kind": "field",
+ "name": "_field",
+ "type": {
+ "text": "FieldContextValue | undefined"
+ },
+ "privacy": "protected"
+ },
+ {
+ "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"
+ }
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "FieldPart",
+ "declaration": {
+ "name": "FieldPart",
+ "module": "field/field-part.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "field/field.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "Headless field root — the shared label/description/error wrapper every\nlabeled control composes with. Owns no widget behavior; its job is\nassociation, run entirely over context (field-context.ts): parts\nregister themselves, and the first field-aware control to claim gets\nthe parts wired to it — never discovered by tag name (GUIDELINES.md\nrules 4 and 5). See field/SPEC.md for the full contract.\n\nNot self-registering — call Field.define() (optionally with a tag name\nof your own choosing) before use. See GUIDELINES.md rule 5.",
+ "name": "Field",
+ "slots": [
+ {
+ "description": "The field's parts and its one control, in any order.",
+ "name": ""
+ }
+ ],
+ "members": [
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-field\""
+ }
+ ]
+ },
+ {
+ "kind": "field",
+ "name": "error",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "attribute": "error",
+ "reflects": true
+ },
+ {
+ "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"
+ }
+ }
+ ],
+ "attributes": [
+ {
+ "name": "error",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "fieldName": "error"
+ }
+ ],
+ "superclass": {
+ "name": "SlottedElement",
+ "module": "/internal/slotted-element.js"
+ },
+ "tagName": "hwc-field",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "Field",
+ "declaration": {
+ "name": "Field",
+ "module": "field/field.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "field/index.ts",
+ "declarations": [],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "Field",
+ "declaration": {
+ "name": "Field",
+ "module": "./field.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "FieldLabel",
+ "declaration": {
+ "name": "FieldLabel",
+ "module": "./field-label.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "FieldDescription",
+ "declaration": {
+ "name": "FieldDescription",
+ "module": "./field-description.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "FieldError",
+ "declaration": {
+ "name": "FieldError",
+ "module": "./field-error.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "FieldClaimController",
+ "declaration": {
+ "name": "FieldClaimController",
+ "module": "../internal/field-claim-controller.js"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "internal/define-once.ts",
+ "declarations": [
+ {
+ "kind": "function",
+ "name": "defineOnce",
+ "parameters": [
+ {
+ "name": "tagName",
+ "type": {
+ "text": "string"
+ }
+ },
+ {
+ "name": "ctor",
+ "type": {
+ "text": "CustomElementConstructor"
+ }
+ }
+ ],
+ "description": "Registers `ctor` under `tagName` unless that name is already taken, or\n`ctor` was already registered under a different name — the native\nregistry throws if the same constructor is defined twice under any name,\nso a repeat call (e.g. two entry points importing the same component)\nmust be a safe no-op, not a crash. See GUIDELINES.md rule 5."
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "defineOnce",
+ "declaration": {
+ "name": "defineOnce",
+ "module": "internal/define-once.ts"
+ }
+ },
+ {
+ "kind": "custom-element-definition",
+ "name": "tagName",
+ "declaration": {
+ "name": "ctor",
+ "module": "internal/define-once.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "internal/field-claim-controller.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "The entire control side of the field contract (field/SPEC.md), shared\nby every field-aware control — a component opts in with a single line:\n\n private _fieldClaim = new FieldClaimController(this, () => this._effectiveDisabled);\n\nThe controller claims the nearest field on connect (first claimant\nwins — and it retries whenever a broadcast shows the seat empty, so a\nremoved claimant's seat passes to whoever remains), writes\naria-labelledby/aria-describedby IDREFs on the host from the field's\ndelivered parts, reports the host's effective disabled back so the text\nparts can dim, and releases on disconnect. One place to change when the\ncontract evolves; a component that must not claim (Button, Panel)\nsimply never instantiates it.",
+ "name": "FieldClaimController",
+ "members": [
+ {
+ "kind": "method",
+ "name": "hostDisconnected"
+ },
+ {
+ "kind": "method",
+ "name": "hostUpdated"
+ }
+ ]
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "FieldClaimController",
+ "declaration": {
+ "name": "FieldClaimController",
+ "module": "internal/field-claim-controller.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "internal/slotted-element.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "Base class for every custom element in this package — enforces\nGUIDELINES.md rule 3: a shadow root renders nothing but ``.\nSubclasses add reactive properties and behavior, never markup.\n\nAlso owns the element's one `ElementInternals` handle (`attachInternals()`\nthrows if called twice, so it has to live in exactly one place) and a\n`toggleState()` helper, because `CustomStateSet` ships `add`/`delete` but\nno `toggleAttribute`-style toggle. Custom states are how a component\nexposes *derived* state to its own `:host(:state(...))` CSS — state the\nconsumer sets stays a reflected attribute instead. See GUIDELINES.md\nrule 4.",
+ "name": "SlottedElement",
+ "members": [
+ {
+ "kind": "field",
+ "name": "internals",
+ "privacy": "protected",
+ "readonly": true
+ },
+ {
+ "kind": "method",
+ "name": "toggleState",
+ "privacy": "protected",
+ "parameters": [
+ {
+ "name": "state",
+ "type": {
+ "text": "string"
+ }
+ },
+ {
+ "name": "force",
+ "type": {
+ "text": "boolean"
+ }
+ }
+ ]
+ }
+ ],
+ "superclass": {
+ "name": "LitElement",
+ "package": "lit"
+ },
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "SlottedElement",
+ "declaration": {
+ "name": "SlottedElement",
+ "module": "internal/slotted-element.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "radio-button/index.ts",
+ "declarations": [],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "Radio",
+ "declaration": {
+ "name": "Radio",
+ "module": "./radio.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "RadioCheckedIndicator",
+ "declaration": {
+ "name": "RadioCheckedIndicator",
+ "module": "./radio-checked-indicator.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "RadioGroup",
+ "declaration": {
+ "name": "RadioGroup",
+ "module": "./radio-group.js"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "radio-button/radio-checked-indicator.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "A pure slot — the projected content owns the actual dot's shape; this\nelement only owns filling/centering itself in its parent box and mapping\nits own state to design tokens (GUIDELINES.md rule 3), both of which are\ntag-name-agnostic via :host and so survive a consumer renaming the tag.\n\nConsumes hwc-radio's state over context and mirrors it into custom\nstates on its own ElementInternals — never as attributes, so the light\nDOM stays entirely the consumer's (GUIDELINES.md rule 4). Because the\nvisibility runs entirely on the radio's own state, a lone\n`` — no group, no form, no name — always renders its\ndot correctly. Not self-registering — call\nRadioCheckedIndicator.define() (optionally with a tag name of your own\nchoosing) before use. See GUIDELINES.md rule 5.",
+ "name": "RadioCheckedIndicator",
+ "slots": [
+ {
+ "description": "The consumer's own selection dot (an SVG, a glyph, anything).",
+ "name": ""
+ }
+ ],
+ "members": [
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-radio-checked-indicator\""
+ }
+ ]
+ },
+ {
+ "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-radio-checked-indicator",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "RadioCheckedIndicator",
+ "declaration": {
+ "name": "RadioCheckedIndicator",
+ "module": "radio-button/radio-checked-indicator.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "radio-button/radio-context.ts",
+ "declarations": [
+ {
+ "kind": "variable",
+ "name": "radioContext",
+ "description": "Broadcasts hwc-radio's state to its checked-indicator child so it can\ndecide its own visibility — the same root-to-indicator pattern as\ncheckbox-context.ts. Nothing here identifies a descendant by tag name —\nsee GUIDELINES.md rules 4 and 5 for why that matters once tag names are\nconsumer-configurable."
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "radioContext",
+ "declaration": {
+ "name": "radioContext",
+ "module": "radio-button/radio-context.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "radio-button/radio-group-context.ts",
+ "declarations": [
+ {
+ "kind": "variable",
+ "name": "radioGroupContext",
+ "description": "Provided by hwc-radio-group to descendant radios: the shared (or\nauto-generated) name and a group-wide disabled. Context rather than\ndirect parentage because a radio is commonly wrapped in a hwc-field for\nits label — the field sits between group and radio without knowing\neither exists (GUIDELINES.md rule 4). A radio's own explicit `name`\nattribute always wins over the context-provided one: the consumer's\nattribute is the input API."
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "radioGroupContext",
+ "declaration": {
+ "name": "radioGroupContext",
+ "module": "radio-button/radio-group-context.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "radio-button/radio-group.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "An optional convenience container for radios — not the grouping\nmechanism itself. The registry (radio-registry.ts) owns mutual\nexclusion, roving tabindex, and arrow keys; this element's job is to\nhand descendants a shared name (auto-generated when absent) and a\ngroup-wide disabled over context, plus carry role=\"radiogroup\". Context\nrather than direct parentage because a radio is commonly wrapped in a\nhwc-field for its label (GUIDELINES.md rule 4).\n\nNot self-registering — call RadioGroup.define() (optionally with a tag\nname of your own choosing) before use. See GUIDELINES.md rule 5.",
+ "name": "RadioGroup",
+ "slots": [
+ {
+ "description": "The group's radios, at any depth — commonly each wrapped in a field.",
+ "name": ""
+ }
+ ],
+ "members": [
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-radio-group\""
+ }
+ ]
+ },
+ {
+ "kind": "field",
+ "name": "name",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"\"",
+ "attribute": "name",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "attribute": "disabled",
+ "reflects": true
+ },
+ {
+ "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"
+ }
+ }
+ ],
+ "attributes": [
+ {
+ "name": "name",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"\"",
+ "fieldName": "name"
+ },
+ {
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "fieldName": "disabled"
+ }
+ ],
+ "superclass": {
+ "name": "SlottedElement",
+ "module": "/internal/slotted-element.js"
+ },
+ "tagName": "hwc-radio-group",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "RadioGroup",
+ "declaration": {
+ "name": "RadioGroup",
+ "module": "radio-button/radio-group.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "radio-button/radio-registry.ts",
+ "declarations": [
+ {
+ "kind": "function",
+ "name": "joinSet",
+ "parameters": [
+ {
+ "name": "member",
+ "type": {
+ "text": "RadioMembership"
+ }
+ },
+ {
+ "name": "scope",
+ "type": {
+ "text": "Node"
+ }
+ },
+ {
+ "name": "name",
+ "type": {
+ "text": "string"
+ }
+ }
+ ],
+ "description": "Adds `member` to the set for (scope, name), leaving any previous set\nfirst. An empty name means \"unnamed\" — the member belongs to no set.\nRe-joining the same key is a no-op."
+ },
+ {
+ "kind": "function",
+ "name": "leaveSet",
+ "return": {
+ "type": {
+ "text": "RadioMembership[]"
+ }
+ },
+ "parameters": [
+ {
+ "name": "member",
+ "type": {
+ "text": "RadioMembership"
+ }
+ }
+ ],
+ "description": "Removes `member` from its set, returning the remaining members in DOM\norder so the caller can hand the departed member's tab stop to someone\nelse. No-op (empty array) if the member wasn't in a set."
+ },
+ {
+ "kind": "function",
+ "name": "setMembers",
+ "return": {
+ "type": {
+ "text": "RadioMembership[]"
+ }
+ },
+ "parameters": [
+ {
+ "name": "member",
+ "type": {
+ "text": "RadioMembership"
+ }
+ }
+ ],
+ "description": "The member's whole set in DOM order; empty if it's unnamed (no set)."
+ },
+ {
+ "kind": "function",
+ "name": "applyExclusion",
+ "parameters": [
+ {
+ "name": "member",
+ "type": {
+ "text": "RadioMembership"
+ }
+ }
+ ],
+ "description": "Mutual exclusion: checking `member` unchecks every other member of its set."
+ },
+ {
+ "kind": "function",
+ "name": "syncTabStops",
+ "parameters": [
+ {
+ "name": "members",
+ "type": {
+ "text": "RadioMembership[]"
+ }
+ }
+ ],
+ "description": "Roving tabindex: exactly one member per set is in the tab order — the\nchecked one, or the first non-disabled in DOM order when none is checked\n— so Tab crosses the whole set in one stop."
+ },
+ {
+ "kind": "function",
+ "name": "step",
+ "return": {
+ "type": {
+ "text": "RadioMembership | null"
+ }
+ },
+ "parameters": [
+ {
+ "name": "member",
+ "type": {
+ "text": "RadioMembership"
+ }
+ },
+ {
+ "name": "delta",
+ "type": {
+ "text": "1 | -1"
+ }
+ }
+ ],
+ "description": "The nearest non-disabled member `delta` steps away in DOM order,\nwrapping at the ends; null when the member is alone, unnamed, or every\nother member is disabled."
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "joinSet",
+ "declaration": {
+ "name": "joinSet",
+ "module": "radio-button/radio-registry.ts"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "leaveSet",
+ "declaration": {
+ "name": "leaveSet",
+ "module": "radio-button/radio-registry.ts"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "setMembers",
+ "declaration": {
+ "name": "setMembers",
+ "module": "radio-button/radio-registry.ts"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "applyExclusion",
+ "declaration": {
+ "name": "applyExclusion",
+ "module": "radio-button/radio-registry.ts"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "syncTabStops",
+ "declaration": {
+ "name": "syncTabStops",
+ "module": "radio-button/radio-registry.ts"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "step",
+ "declaration": {
+ "name": "step",
+ "module": "radio-button/radio-registry.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "radio-button/radio.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "Headless radio root — state, behavior, and accessibility only.\nSee radio-button/SPEC.md for the composition contract and GUIDELINES.md\nfor why there's no native ` ` underneath.\n\nGrouping runs through the name registry (radio-registry.ts), scoped by\n(internals.form ?? root node, name) exactly like native radios. A name\narrives from the consumer's attribute, or from a surrounding\nhwc-radio-group over context — the attribute always wins. With no name\nat all the radio is controlled: activation fires `change` without\nmutating state, and the consumer commits `checked` themselves.\n\nForm-associated (GUIDELINES.md rule 8): the checked member of a named\nset submits `value` under the shared name, honors form.reset() and\n``, and restores state on session restore. Outside a\nform every one of those is a silent no-op.\n\nNot self-registering — call Radio.define() (optionally with a tag name\nof your own choosing) before use. See GUIDELINES.md rule 5.",
+ "name": "Radio",
+ "members": [
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-radio\""
+ }
+ ]
+ },
+ {
+ "kind": "field",
+ "name": "formAssociated",
+ "type": {
+ "text": "boolean"
+ },
+ "static": true,
+ "default": "true"
+ },
+ {
+ "kind": "field",
+ "name": "checked",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "attribute": "checked",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "attribute": "disabled",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "value",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"on\"",
+ "attribute": "value"
+ },
+ {
+ "kind": "field",
+ "name": "name",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"\"",
+ "attribute": "name",
+ "reflects": true
+ },
+ {
+ "kind": "method",
+ "name": "formResetCallback"
+ },
+ {
+ "kind": "method",
+ "name": "formDisabledCallback",
+ "parameters": [
+ {
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "method",
+ "name": "formStateRestoreCallback",
+ "parameters": [
+ {
+ "name": "restored",
+ "type": {
+ "text": "string | File | FormData | null"
+ }
+ },
+ {
+ "name": "_mode",
+ "type": {
+ "text": "\"restore\" | \"autocomplete\""
+ }
+ }
+ ]
+ },
+ {
+ "kind": "method",
+ "name": "formAssociatedCallback",
+ "parameters": [
+ {
+ "name": "_form",
+ "type": {
+ "text": "HTMLFormElement | null"
+ }
+ }
+ ]
+ },
+ {
+ "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"
+ }
+ }
+ ],
+ "events": [
+ {
+ "name": "change",
+ "type": {
+ "text": "Event"
+ },
+ "description": "Dispatched on user activation (click, Space, arrow-key movement). Named mode: state already changed — `target.checked` is the new value. Unnamed (controlled) mode: a request — nothing mutated, the consumer commits `checked` themselves. An already-checked radio fires nothing in either mode."
+ }
+ ],
+ "attributes": [
+ {
+ "name": "checked",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "fieldName": "checked"
+ },
+ {
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "fieldName": "disabled"
+ },
+ {
+ "name": "value",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"on\"",
+ "fieldName": "value"
+ },
+ {
+ "name": "name",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"\"",
+ "fieldName": "name"
+ }
+ ],
+ "superclass": {
+ "name": "SlottedElement",
+ "module": "/internal/slotted-element.js"
+ },
+ "tagName": "hwc-radio",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "Radio",
+ "declaration": {
+ "name": "Radio",
+ "module": "radio-button/radio.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "text-input/index.ts",
+ "declarations": [],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "TextInput",
+ "declaration": {
+ "name": "TextInput",
+ "module": "./text-input.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "TextInputLeadingIcon",
+ "declaration": {
+ "name": "TextInputLeadingIcon",
+ "module": "./text-input-leading-icon.js"
+ }
+ },
+ {
+ "kind": "js",
+ "name": "TextInputTrailingIcon",
+ "declaration": {
+ "name": "TextInputTrailingIcon",
+ "module": "./text-input-trailing-icon.js"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "text-input/text-input-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.\nCarries no state and consumes none; disabled dimming reaches a\n`currentColor`-based icon for free through ordinary CSS inheritance\nfrom hwc-text-input's own `color`.\n\nOne wrinkle Button's icons don't have: this box sits *inside* an\neditable host (SPEC.md § Editing model), so it marks itself\n`contenteditable=\"false\"` — a non-editable atomic island the root's\nedit guard recognizes by that attribute, never by tag name.\n\nNot self-registering — call TextInputLeadingIcon.define() (optionally\nwith a tag name of your own choosing) before use. See GUIDELINES.md\nrule 5.",
+ "name": "TextInputLeadingIcon",
+ "slots": [
+ {
+ "description": "The consumer's own icon (an SVG, an ` `, anything).",
+ "name": ""
+ }
+ ],
+ "members": [
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-text-input-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-text-input-leading-icon",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "TextInputLeadingIcon",
+ "declaration": {
+ "name": "TextInputLeadingIcon",
+ "module": "text-input/text-input-leading-icon.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "text-input/text-input-trailing-icon.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "A pure positional seam like the leading icon — see\ntext-input-leading-icon.ts; the same editable-host wrinkle applies, so\nthis box also marks itself `contenteditable=\"false\"`. When what's\nprojected is itself interactive (a clear button, a reveal toggle), that\ninner control needs its own accessible name and must not steal focus\nfrom the input on click (SPEC.md § Composition) — both the consumer's\nresponsibility, since the projected content is theirs.\n\nNot self-registering — call TextInputTrailingIcon.define() (optionally\nwith a tag name of your own choosing) before use. See GUIDELINES.md\nrule 5.",
+ "name": "TextInputTrailingIcon",
+ "slots": [
+ {
+ "description": "The consumer's own icon or trailing control.",
+ "name": ""
+ }
+ ],
+ "members": [
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-text-input-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-text-input-trailing-icon",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "TextInputTrailingIcon",
+ "declaration": {
+ "name": "TextInputTrailingIcon",
+ "module": "text-input/text-input-trailing-icon.ts"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "javascript-module",
+ "path": "text-input/text-input.ts",
+ "declarations": [
+ {
+ "kind": "class",
+ "description": "Headless single-line text input root — state, behavior, and accessibility\nonly. See text-input/SPEC.md for the composition contract and\nGUIDELINES.md rule 2 for why there's no native ` ` underneath: the\nhost itself is the editable surface (contenteditable=\"plaintext-only\"),\nand the shadow root's `` projects the light-DOM text being edited,\nso caret, selection, and IME composition are the platform's own.\n\nThe parts the platform *doesn't* give are rebuilt here (SPEC.md):\n- Value model: the projected text is the value; the `value` attribute is\n the default, tracked under native's dirty-value-flag rules and never\n reflected back.\n- Edit guard: beforeinput operations whose target range touches a\n non-text child (an icon part — a contenteditable=\"false\" island), or\n that would insert newlines, are cancelled and re-performed against the\n text only. Ordinary typing is never intercepted. The guard is dormant\n during IME composition.\n- Form contract (GUIDELINES.md rule 8): live setFormValue on every edit,\n Enter-to-submit (the reimplemented implicit submission), reset to the\n markup default, session restore, fieldset disable.\n- Selection API parity: select(), setSelectionRange(), and\n selectionStart/selectionEnd/selectionDirection, matching\n HTMLInputElement's own — cached rather than read live from the\n platform selection so they persist across blur the same way native's\n does (see the field declarations' own comment).\n\nNot self-registering — call TextInput.define() (optionally with a tag\nname of your own choosing) before use. See GUIDELINES.md rule 5.",
+ "name": "TextInput",
+ "slots": [
+ {
+ "description": "The text being edited, plus optional leading/trailing icon parts.",
+ "name": ""
+ }
+ ],
+ "members": [
+ {
+ "kind": "method",
+ "name": "define",
+ "static": true,
+ "parameters": [
+ {
+ "name": "tagName",
+ "default": "\"hwc-text-input\""
+ }
+ ]
+ },
+ {
+ "kind": "field",
+ "name": "formAssociated",
+ "type": {
+ "text": "boolean"
+ },
+ "static": true,
+ "default": "true"
+ },
+ {
+ "kind": "field",
+ "name": "variant",
+ "type": {
+ "text": "\"default\" | \"filled\" | \"borderless\""
+ },
+ "default": "\"default\"",
+ "attribute": "variant",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "size",
+ "type": {
+ "text": "\"small\" | \"medium\" | \"large\""
+ },
+ "default": "\"medium\"",
+ "attribute": "size",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "attribute": "disabled",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "readOnly",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "attribute": "readonly",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "error",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "attribute": "error",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "name",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"\"",
+ "attribute": "name",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "type",
+ "type": {
+ "text": "| \"text\"\n | \"email\"\n | \"tel\"\n | \"url\"\n | \"search\"\n | \"password\""
+ },
+ "default": "\"text\"",
+ "attribute": "type",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "placeholder",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"\"",
+ "attribute": "placeholder",
+ "reflects": true
+ },
+ {
+ "kind": "field",
+ "name": "value",
+ "type": {
+ "text": "string"
+ },
+ "description": "The current value — the projected light-DOM text itself (direct child\ntext nodes; icon subtrees excluded). Never reflected to the `value`\nattribute, which stays the markup-declared default (SPEC § Value model)."
+ },
+ {
+ "kind": "method",
+ "name": "formResetCallback"
+ },
+ {
+ "kind": "method",
+ "name": "formDisabledCallback",
+ "parameters": [
+ {
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ }
+ }
+ ]
+ },
+ {
+ "kind": "method",
+ "name": "formStateRestoreCallback",
+ "parameters": [
+ {
+ "name": "restored",
+ "type": {
+ "text": "string | File | FormData | null"
+ }
+ },
+ {
+ "name": "_mode",
+ "type": {
+ "text": "\"restore\" | \"autocomplete\""
+ }
+ }
+ ]
+ },
+ {
+ "kind": "method",
+ "name": "select",
+ "description": "Selects all the text — matches native .select(), including its\ndocumented side effect of focusing the element if it wasn't already."
+ },
+ {
+ "kind": "method",
+ "name": "setSelectionRange",
+ "parameters": [
+ {
+ "name": "start",
+ "type": {
+ "text": "number"
+ }
+ },
+ {
+ "name": "end",
+ "type": {
+ "text": "number"
+ }
+ },
+ {
+ "name": "direction",
+ "default": "\"none\"",
+ "type": {
+ "text": "\"forward\" | \"backward\" | \"none\""
+ }
+ }
+ ],
+ "description": "Sets the selection to [start, end), matching HTMLInputElement's own\nalgorithm: end is clamped to the value's length first, then start is\nclamped to at most that (a start past end collapses to end, it is\nnever swapped with it) — direction defaults to \"none\" and any other\nvalue also normalizes to \"none\", same as native. Also focuses the\nelement: unlike a native input, which tracks selection independently\nof focus, this component's selection *is* the document Selection API\n(SPEC § Editing model), so setting it while unfocused would otherwise\nsilently steal whatever selection is active elsewhere on the page."
+ },
+ {
+ "kind": "field",
+ "name": "selectionStart",
+ "type": {
+ "text": "number"
+ },
+ "description": "The offset of the selection's start — persists across blur (tabbing\nback in restores it), same as native, since it's cached rather than\nread fresh from the platform selection every time (see the field's\nown comment for why)."
+ },
+ {
+ "kind": "field",
+ "name": "selectionEnd",
+ "type": {
+ "text": "number"
+ },
+ "description": "The offset of the selection's end."
+ },
+ {
+ "kind": "field",
+ "name": "selectionDirection",
+ "type": {
+ "text": "\"forward\" | \"backward\" | \"none\""
+ },
+ "description": "\"forward\" | \"backward\" | \"none\" — \"none\" for a collapsed caret, always."
+ },
+ {
+ "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"
+ }
+ }
+ ],
+ "events": [
+ {
+ "name": "change",
+ "type": {
+ "text": "Event"
+ },
+ "description": "Dispatched after the value changed, on commit: blur, or Enter before submission."
+ }
+ ],
+ "attributes": [
+ {
+ "name": "value"
+ },
+ {
+ "name": "variant",
+ "type": {
+ "text": "\"default\" | \"filled\" | \"borderless\""
+ },
+ "default": "\"default\"",
+ "fieldName": "variant"
+ },
+ {
+ "name": "size",
+ "type": {
+ "text": "\"small\" | \"medium\" | \"large\""
+ },
+ "default": "\"medium\"",
+ "fieldName": "size"
+ },
+ {
+ "name": "disabled",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "fieldName": "disabled"
+ },
+ {
+ "name": "readonly",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "fieldName": "readOnly"
+ },
+ {
+ "name": "error",
+ "type": {
+ "text": "boolean"
+ },
+ "default": "false",
+ "fieldName": "error"
+ },
+ {
+ "name": "name",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"\"",
+ "fieldName": "name"
+ },
+ {
+ "name": "type",
+ "type": {
+ "text": "| \"text\"\n | \"email\"\n | \"tel\"\n | \"url\"\n | \"search\"\n | \"password\""
+ },
+ "default": "\"text\"",
+ "fieldName": "type"
+ },
+ {
+ "name": "placeholder",
+ "type": {
+ "text": "string"
+ },
+ "default": "\"\"",
+ "fieldName": "placeholder"
+ }
+ ],
+ "superclass": {
+ "name": "SlottedElement",
+ "module": "/internal/slotted-element.js"
+ },
+ "tagName": "hwc-text-input",
+ "customElement": true
+ }
+ ],
+ "exports": [
+ {
+ "kind": "js",
+ "name": "TextInput",
+ "declaration": {
+ "name": "TextInput",
+ "module": "text-input/text-input.ts"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/examples/design-system/vite.config.ts b/examples/design-system/vite.config.ts
new file mode 100644
index 0000000..d33dba9
--- /dev/null
+++ b/examples/design-system/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import { fileURLToPath } from 'node:url'
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ // The headless library, vendored until it is published (see vendor/hwc/VENDORED.md).
+ '@hwc/components': fileURLToPath(new URL('./vendor/hwc/dist', import.meta.url)),
+ },
+ },
+ esbuild: { jsx: 'automatic' },
+ build: { target: 'esnext' },
+})
diff --git a/package.json b/package.json
index 8284701..444d639 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"postinstall": "node scripts/setup-assets.mjs",
"setup:assets": "node scripts/setup-assets.mjs",
"viewer": "pnpm dev",
- "build": "pnpm -r --filter \"@uidx/format\" --filter \"@uidx/schema\" --filter \"@uidx/server\" build",
+ "build": "pnpm -r --filter \"@uidx/format\" --filter \"@uidx/schema\" --filter \"@uidx/server\" --filter \"@uidx/codegen\" build",
"build:cli": "pnpm build && pnpm --filter @uidx/agent build && pnpm --filter @uidx/viewer build && pnpm --filter @uidxkit/uidx build",
"test": "pnpm -r --workspace-concurrency=1 test",
"typecheck": "pnpm -r typecheck",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index fa680ab..8dba35b 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -34,7 +34,8 @@
"tinyglobby": "^0.2.10",
"yaml": "^2.7.0",
"@uidx/agent": "workspace:*",
- "@modelcontextprotocol/sdk": "^1.30.0"
+ "@modelcontextprotocol/sdk": "^1.30.0",
+ "@uidx/codegen": "workspace:*"
},
"devDependencies": {
"@types/node": "^26.5.1",
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 007ab3a..d50db70 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -19,6 +19,7 @@ import { runMigrateTokens } from './commands/migrate-tokens.js'
import { runMcp } from './commands/mcp.js'
import { runInit } from './commands/init.js'
import { runContract } from './commands/contract.js'
+import { runCodegen } from './commands/codegen.js'
import { projectFiles } from './project.js'
import { version } from '../package.json'
import { BootError, open } from './commands/open.js'
@@ -34,6 +35,8 @@ Usage:
uidx check parse and validate; exit 1 on any error
uidx fmt rewrite files in canonical style
uidx contract print a page's contract, behaviour, models and styles as JSON
+ uidx codegen --out
+ render HTML/CSS, React and contract JSON from the pages
uidx migrate tokens
write the type every now declares
@@ -143,6 +146,8 @@ export async function run(argv: string[], io: Io = processIo): Promise {
return runInit(rest, io)
case 'contract':
return await runContract(rest, io)
+ case 'codegen':
+ return await runCodegen(rest, io)
case 'dev':
return runOpen(rest, io)
case 'audit':
diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts
new file mode 100644
index 0000000..3173b1e
--- /dev/null
+++ b/packages/cli/src/commands/codegen.ts
@@ -0,0 +1,110 @@
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { dirname, resolve } from 'node:path'
+import { parseArgs } from 'node:util'
+import { glob } from 'tinyglobby'
+import { formatDiagnostic, parse, type UidxDocument } from '@uidx/format'
+import { generate, type Manifest, type Target } from '@uidx/codegen'
+import type { Io } from '../cli.js'
+
+/**
+ * `uidx codegen --out [--target html,react,contract] [--manifest custom-elements.json]`
+ *
+ * Renders the code targets (ADR 0017 §3) for every page matched. Token pages
+ * among the matches become `tokens.css`. With a manifest, each component's
+ * contract is checked against its headless element first, and a mismatch
+ * fails the run without writing.
+ */
+export async function runCodegen(argv: string[], io: Io): Promise {
+ let parsed
+ try {
+ parsed = parseArgs({
+ args: argv,
+ allowPositionals: true,
+ options: {
+ out: { type: 'string' },
+ target: { type: 'string', default: 'html,react,contract' },
+ manifest: { type: 'string' },
+ check: { type: 'boolean', default: false },
+ },
+ })
+ } catch (err) {
+ io.err(`${(err as Error).message}\n`)
+ return 1
+ }
+ const cwd = io.cwd ?? process.cwd()
+ const out = parsed.values.out
+ if (!out && !parsed.values.check) {
+ io.err(
+ 'uidx codegen --out [--target html,react,contract] [--manifest custom-elements.json] [--check]\n',
+ )
+ return 1
+ }
+ const patterns = parsed.positionals.length ? parsed.positionals : ['**/*.uidx']
+ const files = (
+ await glob(patterns, { cwd, ignore: ['**/node_modules/**', '**/.uidx-agent/**'] })
+ ).sort()
+ if (files.length === 0) {
+ io.err(`no .uidx files matched ${patterns.join(', ')}\n`)
+ return 1
+ }
+ const pages: { file: string; doc: UidxDocument }[] = []
+ const tokens: UidxDocument[] = []
+ let failed = false
+ for (const file of files) {
+ const { doc, diagnostics } = parse(await readFile(resolve(cwd, file), 'utf8'))
+ for (const d of diagnostics)
+ if (d.severity === 'error') io.err(`${formatDiagnostic(d, file)}\n`)
+ if (!doc) {
+ failed = true
+ continue
+ }
+ if (doc.tree.element === 'Tokens') tokens.push(doc)
+ else pages.push({ file, doc })
+ }
+ if (failed) return 1
+
+ let manifest: Manifest | undefined
+ if (parsed.values.manifest) {
+ manifest = JSON.parse(await readFile(resolve(cwd, parsed.values.manifest), 'utf8')) as Manifest
+ }
+ const targets = parsed.values.target!.split(',').map((t) => t.trim()) as Target[]
+ for (const target of targets) {
+ if (!['html', 'react', 'contract'].includes(target)) {
+ io.err(`unknown target "${target}"; choose from html, react, contract\n`)
+ return 1
+ }
+ }
+
+ const result = generate({ pages, tokens, manifest, targets })
+ for (const d of result.diagnostics) io.err(`${formatDiagnostic(d, d.file)}\n`)
+ if (result.diagnostics.some((d) => d.severity === 'error')) return 1
+
+ if (parsed.values.check) {
+ // Compare against what is on disk: a generator whose output moved without
+ // its input moving is the drift ADR 0017 §4 exists to catch.
+ let drifted = 0
+ for (const [path, text] of result.files) {
+ const existing = out
+ ? await readFile(resolve(cwd, out, path), 'utf8').catch(() => null)
+ : null
+ if (existing !== text) {
+ drifted++
+ io.err(`${path}: differs from the generated output\n`)
+ }
+ }
+ if (drifted) {
+ io.err(`${drifted} generated file(s) out of date; run uidx codegen to update them\n`)
+ return 1
+ }
+ io.out(`${result.files.size} generated files up to date\n`)
+ return 0
+ }
+
+ for (const [path, text] of result.files) {
+ const target = resolve(cwd, out!, path)
+ await mkdir(dirname(target), { recursive: true })
+ await writeFile(target, text)
+ }
+ io.out(`wrote ${result.files.size} files to ${out}\n`)
+ return 0
+}
diff --git a/packages/codegen/package.json b/packages/codegen/package.json
new file mode 100644
index 0000000..ca788c3
--- /dev/null
+++ b/packages/codegen/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@uidx/codegen",
+ "version": "0.1.6",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "development": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc -p tsconfig.build.json",
+ "test": "vitest run --maxWorkers=2 --testTimeout=20000",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@uidx/format": "workspace:*",
+ "@uidx/schema": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^26.5.1",
+ "typescript": "^5.7.2",
+ "vitest": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=22.19"
+ },
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/uidxkit/uidx.git",
+ "directory": "packages/codegen"
+ },
+ "homepage": "https://github.com/uidxkit/uidx#readme",
+ "bugs": {
+ "url": "https://github.com/uidxkit/uidx/issues"
+ }
+}
diff --git a/packages/codegen/src/conformance.ts b/packages/codegen/src/conformance.ts
new file mode 100644
index 0000000..f021f00
--- /dev/null
+++ b/packages/codegen/src/conformance.ts
@@ -0,0 +1,57 @@
+import { CODES, diagnostic, type Diagnostic } from '@uidx/format'
+import { manifestTags, partTag, type ComponentModel, type Manifest } from './model.js'
+
+/**
+ * The declared contract against the headless implementation (ADR 0013 §4).
+ *
+ * The uidx file is authoritative; the element is proven to conform: every
+ * attribute the element exposes is a prop the contract declares, every event
+ * it dispatches is declared, and every declared part has an element. Read
+ * from `custom-elements.json`, which the headless build generates from its
+ * source, so drift on either side fails a build rather than a user.
+ */
+export function checkConformance(model: ComponentModel, manifest: Manifest): Diagnostic[] {
+ const out: Diagnostic[] = []
+ if (!model.tag || !model.contract) return out
+ const tags = manifestTags(manifest)
+ const root = tags.get(model.tag)
+ const at = model.node.openTagLoc
+ const report = (message: string) =>
+ out.push(diagnostic(model.doc.source, CODES.CONFORMANCE, message, at))
+ if (!root) {
+ report(`"${model.name}" implements ${model.tag}, which the manifest does not declare`)
+ return out
+ }
+ const props = new Set(model.contract.props.map((prop) => prop.name))
+ for (const attribute of root.attributes ?? []) {
+ if (!props.has(attribute.name)) {
+ report(
+ `${model.tag} exposes the attribute "${attribute.name}", which the contract does not declare as a prop`,
+ )
+ }
+ }
+ const events = new Set(model.contract.events.map((event) => event.name))
+ for (const event of root.events ?? []) {
+ if (!events.has(event.name)) {
+ report(
+ `${model.tag} dispatches "${event.name}", which the contract does not declare as an event`,
+ )
+ }
+ }
+ for (const event of model.contract.events) {
+ if (!(root.events ?? []).some((entry) => entry.name === event.name)) {
+ report(
+ `the contract declares the event "${event.name}", which ${model.tag} does not dispatch`,
+ )
+ }
+ }
+ const tagSet = new Set(tags.keys())
+ 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})`)
+ }
+ }
+ return out
+}
diff --git a/packages/codegen/src/css.ts b/packages/codegen/src/css.ts
new file mode 100644
index 0000000..503d0ca
--- /dev/null
+++ b/packages/codegen/src/css.ts
@@ -0,0 +1,268 @@
+import { aliasTarget, type JsonValue, type UidxDocument } from '@uidx/format'
+import { resolveTokenValues } from '@uidx/schema'
+
+/**
+ * UIDX properties as CSS (ADR 0017 §3).
+ *
+ * The vocabulary is Figma's (ADR 0002), so this is the same translation a
+ * design-to-code plugin makes — with one difference that matters: a token
+ * reference becomes a CSS custom property rather than its value, so the
+ * generated stylesheet stays themeable by swapping `tokens.css`.
+ */
+
+/** `surface#accent` → `--surface-accent`; `type#label/sm` → `--type-label-sm`. */
+export function cssVariable(address: string): string {
+ return `--${address.replace(/[#/]/g, '-').replace(/[^A-Za-z0-9_-]/g, '')}`
+}
+
+const FONT_WEIGHTS: Record = {
+ THIN: 100,
+ EXTRA_LIGHT: 200,
+ LIGHT: 300,
+ REGULAR: 400,
+ NORMAL: 400,
+ MEDIUM: 500,
+ SEMI_BOLD: 600,
+ SEMIBOLD: 600,
+ BOLD: 700,
+ EXTRA_BOLD: 800,
+ BLACK: 900,
+}
+
+const ALIGN: Record = {
+ MIN: 'flex-start',
+ CENTER: 'center',
+ MAX: 'flex-end',
+ SPACE_BETWEEN: 'space-between',
+ BASELINE: 'baseline',
+}
+
+function round(value: number): string {
+ return String(Math.round(value * 1000) / 1000)
+}
+
+/** A Figma colour `{ r, g, b, a }` in 0–1 as a CSS colour. */
+export function cssColor(value: JsonValue): string | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
+ const { r, g, b, a } = value as Record
+ if (typeof r !== 'number' || typeof g !== 'number' || typeof b !== 'number') return null
+ const channel = (n: number) => Math.round(Math.min(1, Math.max(0, n)) * 255)
+ const alpha = typeof a === 'number' ? a : 1
+ return alpha >= 1
+ ? `rgb(${channel(r)} ${channel(g)} ${channel(b)})`
+ : `rgb(${channel(r)} ${channel(g)} ${channel(b)} / ${round(alpha)})`
+}
+
+/**
+ * A length: a number is px, a `"1rem"` string is itself, and a token is its
+ * variable scaled to px — tokens are stored unitless, and this is where the
+ * unit is decided.
+ */
+export function cssLength(value: JsonValue): string | null {
+ if (typeof value === 'number') return `${round(value)}px`
+ if (typeof value !== 'string') return null
+ const target = aliasTarget(value)
+ if (target !== null) return `calc(var(${cssVariable(target)}) * 1px)`
+ return /^-?\d+(\.\d+)?(px|rem|em|%|vw|vh)$/.test(value) ? value : null
+}
+
+/** A unitless number or a token used as one (opacity, flex-grow). */
+function cssNumber(value: JsonValue): string | null {
+ if (typeof value === 'number') return round(value)
+ const target = typeof value === 'string' ? aliasTarget(value) : null
+ return target === null ? null : `var(${cssVariable(target)})`
+}
+
+/**
+ * The colour of a paint list: the first visible solid paint, or the token
+ * the attribute or its entry references.
+ */
+export function cssPaint(value: JsonValue): string | null {
+ if (typeof value === 'string') {
+ const target = aliasTarget(value)
+ return target === null ? null : `var(${cssVariable(target)})`
+ }
+ if (!Array.isArray(value)) return null
+ for (const entry of value) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
+ const paint = entry as Record
+ if (paint.visible === false) continue
+ if (paint.type !== 'SOLID' && paint.type !== undefined) continue
+ const color = paint.color
+ const target = typeof color === 'string' ? aliasTarget(color) : null
+ if (target !== null) return `var(${cssVariable(target)})`
+ const literal = color === undefined ? null : cssColor(color)
+ if (literal) return literal
+ }
+ return null
+}
+
+export type CssKind = 'container' | 'text'
+
+/**
+ * The declarations for one node's attributes. `kind` decides what a fill is:
+ * a frame's fill is its background, a text's fill is its colour.
+ */
+export function cssDeclarations(
+ attrs: Record,
+ kind: CssKind,
+): Record {
+ const out: Record = {}
+ const set = (prop: string, value: string | null) => {
+ if (value !== null) out[prop] = value
+ }
+ for (const [name, value] of Object.entries(attrs)) {
+ switch (name) {
+ case 'fills':
+ set(kind === 'text' ? 'color' : 'background-color', cssPaint(value))
+ break
+ case 'strokes': {
+ const color = cssPaint(value)
+ if (color) {
+ const weight = attrs.strokeWeight === undefined ? '1px' : cssLength(attrs.strokeWeight)
+ set('border', `${weight ?? '1px'} solid ${color}`)
+ }
+ break
+ }
+ case 'cornerRadius':
+ set('border-radius', cssLength(value))
+ break
+ case 'topLeftRadius':
+ set('border-top-left-radius', cssLength(value))
+ break
+ case 'topRightRadius':
+ set('border-top-right-radius', cssLength(value))
+ break
+ case 'bottomRightRadius':
+ set('border-bottom-right-radius', cssLength(value))
+ break
+ case 'bottomLeftRadius':
+ set('border-bottom-left-radius', cssLength(value))
+ break
+ case 'width':
+ case 'height':
+ case 'minWidth':
+ case 'maxWidth':
+ case 'minHeight':
+ case 'maxHeight':
+ set(name.replace(/([A-Z])/g, '-$1').toLowerCase(), cssLength(value))
+ break
+ case 'paddingLeft':
+ case 'paddingRight':
+ case 'paddingTop':
+ case 'paddingBottom':
+ set(name.replace(/([A-Z])/g, '-$1').toLowerCase(), cssLength(value))
+ break
+ case 'itemSpacing':
+ set('gap', cssLength(value))
+ break
+ case 'layoutMode':
+ if (value === 'HORIZONTAL' || value === 'VERTICAL') {
+ out.display = 'flex'
+ out['flex-direction'] = value === 'HORIZONTAL' ? 'row' : 'column'
+ }
+ break
+ case 'primaryAxisAlignItems':
+ if (typeof value === 'string' && ALIGN[value]) out['justify-content'] = ALIGN[value]!
+ break
+ case 'counterAxisAlignItems':
+ if (typeof value === 'string' && ALIGN[value]) out['align-items'] = ALIGN[value]!
+ break
+ case 'layoutGrow':
+ set('flex-grow', cssNumber(value))
+ break
+ case 'layoutAlignSelf':
+ if (value === 'STRETCH') out['align-self'] = 'stretch'
+ break
+ case 'opacity':
+ set('opacity', cssNumber(value))
+ break
+ case 'visible':
+ // A part hidden by the base tree and shown by a state row: the row
+ // needs a display value, and inline-flex suits a part wrapping an
+ // icon or a text alike.
+ if (value === false) out.display = 'none'
+ else if (value === true) out.display = 'inline-flex'
+ break
+ case 'fontSize':
+ set('font-size', cssLength(value))
+ break
+ case 'fontFamily':
+ if (typeof value === 'string')
+ out['font-family'] = value.includes(' ') ? `"${value}"` : value
+ break
+ case 'fontWeight':
+ if (typeof value === 'number') out['font-weight'] = String(value)
+ else if (typeof value === 'string' && FONT_WEIGHTS[value])
+ out['font-weight'] = String(FONT_WEIGHTS[value])
+ else set('font-weight', cssNumber(value))
+ break
+ case 'lineHeight':
+ set('line-height', cssLength(value))
+ break
+ case 'letterSpacing':
+ set('letter-spacing', cssLength(value))
+ break
+ case 'textAlignHorizontal':
+ if (value === 'LEFT' || value === 'CENTER' || value === 'RIGHT' || value === 'JUSTIFIED')
+ out['text-align'] = value === 'JUSTIFIED' ? 'justify' : value.toLowerCase()
+ break
+ case 'rotation':
+ if (typeof value === 'number' && value !== 0) out.transform = `rotate(${round(value)}deg)`
+ break
+ case 'effects':
+ if (Array.isArray(value)) {
+ const shadows: string[] = []
+ for (const entry of value) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
+ const effect = entry as Record
+ if (effect.visible === false) continue
+ if (effect.type !== 'DROP_SHADOW' && effect.type !== 'INNER_SHADOW') continue
+ const offset = (effect.offset ?? {}) as Record
+ const color =
+ effect.color === undefined ? null : cssPaint([{ type: 'SOLID', color: effect.color }])
+ shadows.push(
+ `${effect.type === 'INNER_SHADOW' ? 'inset ' : ''}${cssLength(offset.x ?? 0) ?? '0'} ${cssLength(offset.y ?? 0) ?? '0'} ${cssLength(effect.radius ?? 0) ?? '0'} ${cssLength(effect.spread ?? 0) ?? '0'} ${color ?? 'rgb(0 0 0 / 0.25)'}`,
+ )
+ }
+ if (shadows.length) out['box-shadow'] = shadows.join(', ')
+ }
+ break
+ default:
+ break
+ }
+ }
+ return out
+}
+
+/** One CSS rule, or nothing when there are no declarations. */
+export function cssRule(selector: string, declarations: Record): string {
+ const entries = Object.entries(declarations)
+ if (entries.length === 0) return ''
+ return `${selector} {\n${entries.map(([prop, value]) => ` ${prop}: ${value};`).join('\n')}\n}\n`
+}
+
+/**
+ * `tokens.css`: every token at its default mode as a custom property on
+ * `:root`. Colours become CSS colours; numbers stay unitless, and the rules
+ * that use them add the unit (`cssLength`), so one token serves a radius and
+ * a font size alike.
+ */
+export function tokensCss(docs: readonly UidxDocument[]): string {
+ const values = resolveTokenValues(docs)
+ const lines: string[] = []
+ for (const [address, value] of [...values].sort(([a], [b]) => a.localeCompare(b))) {
+ const color = cssColor(value)
+ const text =
+ color ??
+ (typeof value === 'number'
+ ? round(value)
+ : typeof value === 'string'
+ ? value
+ : typeof value === 'boolean'
+ ? String(value)
+ : null)
+ if (text !== null) lines.push(` ${cssVariable(address)}: ${text};`)
+ }
+ return lines.length ? `:root {\n${lines.join('\n')}\n}\n` : ''
+}
diff --git a/packages/codegen/src/generate.ts b/packages/codegen/src/generate.ts
new file mode 100644
index 0000000..0d3c71e
--- /dev/null
+++ b/packages/codegen/src/generate.ts
@@ -0,0 +1,135 @@
+import type { Diagnostic, UidxDocument } from '@uidx/format'
+import { contractJson } from '@uidx/schema/design-system'
+import { tokensCss } from './css.js'
+import { checkConformance } from './conformance.js'
+import { emitCss, emitHtml } from './html.js'
+import { componentModel, type ComponentModel, type Manifest } from './model.js'
+import { emitElementTypes, emitIndex, emitModels, emitReact, emitRuntime } from './react.js'
+
+export type Target = 'html' | 'react' | 'contract'
+
+export interface GenerateInput {
+ /** Component pages: every `` with an `implements` or a contract is rendered. */
+ pages: readonly { file: string; doc: UidxDocument }[]
+ /** Token pages, for `tokens.css`. */
+ tokens?: readonly UidxDocument[]
+ /** The headless library's `custom-elements.json`, for part tags and conformance. */
+ manifest?: Manifest
+ targets?: readonly Target[]
+}
+
+export interface GenerateOutput {
+ /** Output path → contents. Paths are relative and use `/`. */
+ files: Map
+ diagnostics: (Diagnostic & { file: string })[]
+}
+
+/**
+ * Everything the code targets produce for a document set (ADR 0017 §3).
+ *
+ * A pure function of its input: the same pages and manifest give the same
+ * bytes, which is what lets the example package commit its output as golden
+ * files and fail CI when the output moves without the input moving.
+ */
+export function generate(input: GenerateInput): GenerateOutput {
+ const targets = new Set(input.targets ?? ['html', 'react', 'contract'])
+ const files = new Map()
+ const diagnostics: GenerateOutput['diagnostics'] = []
+
+ const models: ComponentModel[] = []
+ const owner = new Map()
+ for (const { file, doc } of input.pages) {
+ if (doc.tree.element === 'Tokens') continue
+ for (const node of doc.tree.children) {
+ if (node.element !== 'Component') continue
+ const model = componentModel(node, doc, input.manifest)
+ models.push(model)
+ owner.set(model, file)
+ }
+ }
+ const byName = new Map(models.map((model) => [model.name, model]))
+ const rendered = models.filter((model) => model.tag !== undefined || model.contract !== undefined)
+
+ if (input.manifest) {
+ for (const model of rendered) {
+ for (const found of checkConformance(model, input.manifest)) {
+ diagnostics.push({ ...found, file: owner.get(model)! })
+ }
+ }
+ }
+
+ const tokens = input.tokens?.length ? tokensCss(input.tokens) : ''
+
+ if (targets.has('html')) {
+ if (tokens) files.set('html/tokens.css', tokens)
+ for (const model of rendered) {
+ files.set(`html/${model.stem}.css`, emitCss(model))
+ files.set(`html/${model.stem}.html`, emitHtml(model, { components: byName }))
+ }
+ }
+
+ if (targets.has('react')) {
+ if (tokens) files.set('react/tokens.css', tokens)
+ const tags = new Set()
+ const modelSpecs = new Map<
+ string,
+ { spec: ComponentModel['spec']; model: ComponentModel['spec'] }
+ >()
+ for (const model of rendered) {
+ files.set(`react/${model.identifier}.tsx`, emitReact(model, { components: byName }))
+ files.set(`react/${model.stem}.css`, emitCss(model))
+ if (model.tag) tags.add(model.tag)
+ for (const part of model.parts) tags.add(part.tag)
+ for (const spec of model.spec?.models ?? [])
+ modelSpecs.set(spec.name, { spec: model.spec, model: model.spec })
+ }
+ const allModels = [
+ ...new Map(
+ rendered.flatMap((model) =>
+ (model.spec?.models ?? []).map((m) => [m.name, { m, spec: model.spec }]),
+ ),
+ ).values(),
+ ]
+ files.set(
+ 'react/models.ts',
+ emitModels(
+ allModels.map((entry) => entry.m),
+ mergeSpecs(allModels.map((entry) => entry.spec)),
+ ),
+ )
+ files.set('react/runtime.ts', emitRuntime())
+ files.set('react/elements.d.ts', emitElementTypes([...tags]))
+ files.set('react/index.ts', emitIndex(rendered))
+ }
+
+ if (targets.has('contract')) {
+ const seen = new Set()
+ for (const model of rendered) {
+ if (seen.has(model.doc)) continue
+ seen.add(model.doc)
+ files.set(
+ `contract/${model.stem}.json`,
+ `${JSON.stringify(contractJson(model.doc), null, 2)}\n`,
+ )
+ }
+ }
+
+ return { files, diagnostics }
+}
+
+/** One spec whose models are the union of several files' models, for `models.ts`. */
+function mergeSpecs(
+ specs: readonly (ComponentModel['spec'] | undefined)[],
+): ComponentModel['spec'] {
+ const models = new Map['models']>()
+ const merged: NonNullable['models']> = []
+ for (const spec of specs) {
+ for (const model of spec?.models ?? []) {
+ if (!models.has(model.name)) {
+ models.set(model.name, [model])
+ merged.push(model)
+ }
+ }
+ }
+ return { models: merged }
+}
diff --git a/packages/codegen/src/html.ts b/packages/codegen/src/html.ts
new file mode 100644
index 0000000..fd192f0
--- /dev/null
+++ b/packages/codegen/src/html.ts
@@ -0,0 +1,264 @@
+import type { JsonValue, UidxNode } from '@uidx/format'
+import { specBindings, STATE_AXIS, styleTarget } from '@uidx/schema/design-system'
+import { cssDeclarations, cssRule, type CssKind } from './css.js'
+import { boundPath, boundProp, type ComponentModel } from './model.js'
+
+/**
+ * The HTML/CSS target (ADR 0017 §3): one stylesheet per component, keyed by
+ * the headless root's attributes and states, and one markup fragment
+ * showing the anatomy with sample content.
+ */
+
+export interface HtmlContext {
+ /** Every component of the document set, by name — for instances and repeats. */
+ components: Map
+}
+
+/** How a state of the contract is selected in CSS. */
+export function stateSelector(state: string): string {
+ switch (state) {
+ case 'hover':
+ return ':hover'
+ case 'focus':
+ return ':focus-visible'
+ case 'active':
+ case 'pressed':
+ return ':active'
+ default:
+ return `[${state}]`
+ }
+}
+
+const VOID_ATTRS = new Set([
+ 'name',
+ 'part',
+ 'characters',
+ 'vectorPaths',
+ 'implements',
+ 'slot',
+ 'count',
+ 'component',
+ 'props',
+ 'overrides',
+ 'status',
+ 'version',
+ 'modes',
+ 'rootFontSize',
+])
+
+function attrValues(node: UidxNode): Record {
+ const out: Record = {}
+ for (const [name, attr] of Object.entries(node.attrs)) {
+ if (VOID_ATTRS.has(name)) continue
+ out[name] = attr.value
+ }
+ return out
+}
+
+/** A text's fill is its colour; so is a vector's, whose paths use `currentColor`. */
+function kindOf(node: UidxNode): CssKind {
+ return node.element === 'Text' || node.element === 'Vector' ? 'text' : 'container'
+}
+
+/** The selector for a node inside the component, given the root's selector. */
+function selectorFor(model: ComponentModel, node: UidxNode, root: string): string {
+ if (node === model.node) return root
+ const part = model.partOf.get(node)
+ if (part !== undefined) {
+ const info = model.parts.find((entry) => entry.name === part)!
+ return `${root} ${info.tag}`
+ }
+ if (node.element === 'Slot') return `${root} [data-slot="${node.name}"]`
+ return `${root} [data-node="${node.name}"]`
+}
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
+
+/** The stylesheet: the base tree, then the styles table as state and attribute rules. */
+export function emitCss(model: ComponentModel): string {
+ const root = model.tag ?? `.${model.stem}`
+ const rules: string[] = [
+ `/* ${model.name} — generated by uidx codegen; edit the .uidx file instead. */\n`,
+ ]
+ const declared = new Set()
+
+ const walk = (node: UidxNode): void => {
+ if (node.element === 'Repeat') {
+ for (const child of node.children) walk(child)
+ return
+ }
+ if (node.element === 'Instance') return
+ // A composition has no element of its own to style; its instance's
+ // stylesheet is the one that applies.
+ if (node === model.node && model.composes) return
+ const selector = selectorFor(model, node, root)
+ const declarations = cssDeclarations(attrValues(node), kindOf(node))
+ if (node === model.node && !declarations.display && node.attrs.layoutMode === undefined) {
+ // A component is a frame (ADR 0008): its children stack unless it says otherwise.
+ declarations.display = 'inline-flex'
+ declarations['flex-direction'] = 'column'
+ }
+ if (node.element === 'Slot' && !declarations.display) declarations.display = 'contents'
+ if (Object.keys(declarations).length) {
+ rules.push(cssRule(selector, declarations))
+ declared.add(selector)
+ }
+ for (const child of node.children) walk(child)
+ }
+ walk(model.node)
+
+ for (const row of model.spec?.styles ?? []) {
+ let scoped = root
+ for (const [axis, value] of Object.entries(row.keys)) {
+ scoped += axis === STATE_AXIS ? stateSelector(value) : `[${axis}="${value}"]`
+ }
+ for (const [part, props] of Object.entries(row.values)) {
+ const info = part === 'root' ? undefined : model.parts.find((entry) => entry.name === part)
+ // A row targets the root, a part, or one of the design's own nodes by name.
+ const target = part === 'root' ? model.node : (info?.node ?? styleTarget(model.node, part))
+ const selector =
+ part === 'root'
+ ? scoped
+ : info
+ ? `${scoped} ${info.tag}`
+ : `${scoped} [data-node="${part}"]`
+ rules.push(cssRule(selector, cssDeclarations(props, target ? kindOf(target) : 'container')))
+ }
+ }
+ return rules.filter(Boolean).join('\n')
+}
+
+/** Sample text for a node whose `characters` binds a prop or a model field. */
+function sampleText(
+ model: ComponentModel,
+ node: UidxNode,
+ samples: Map,
+): string {
+ const characters = node.attrs.characters?.value
+ if (typeof characters !== 'string') return ''
+ const prop = boundProp(model, characters)
+ if (prop) {
+ const value = samples.get(prop.name) ?? prop.default
+ if (value !== undefined && value !== null) return String(value)
+ // No sample and no default: the prop's name, as a person would write it.
+ return prop.name.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
+ }
+ const path = boundPath(model, characters)
+ if (path) {
+ const value = samples.get(path.join('.'))
+ return value === undefined || value === null ? '' : String(value)
+ }
+ return characters.startsWith('{') ? '' : characters
+}
+
+function markup(
+ model: ComponentModel,
+ node: UidxNode,
+ ctx: HtmlContext,
+ samples: Map,
+ depth: number,
+ /** Which sample an instance inside a repeat shows (ADR 0015 §2). */
+ index = 0,
+ /** What the consuming page put in this component's slots (ADR 0007 §2), already rendered. */
+ fills: ReadonlyMap = new Map(),
+): string[] {
+ const pad = ' '.repeat(depth)
+ const part = model.partOf.get(node)
+ const info = part === undefined ? undefined : model.parts.find((entry) => entry.name === part)
+ const children = (): string[] =>
+ node.children.flatMap((child) => markup(model, child, ctx, samples, depth + 1, index, fills))
+
+ switch (node.element) {
+ case 'Repeat': {
+ const count = typeof node.attrs.count?.value === 'number' ? node.attrs.count.value : 0
+ const child = node.children[0]
+ if (!child) return []
+ const lines: string[] = []
+ for (let n = 0; n < count; n++) {
+ lines.push(...markup(model, child, ctx, specBindings(model.spec, n), depth, n))
+ }
+ return lines
+ }
+ case 'Instance': {
+ const name = node.attrs.component?.value
+ const target = typeof name === 'string' ? ctx.components.get(name) : undefined
+ // The component's own anatomy with its samples at this index — the
+ // markup a page would hold, not a bare tag — and this page's fills in
+ // its slots, rendered here, where their bindings mean this page's.
+ if (!target) return [`${pad}
`]
+ const filled = new Map()
+ for (const fill of node.children) {
+ if (fill.element !== 'Slot') continue
+ filled.set(
+ fill.name,
+ fill.children.flatMap((child) =>
+ markup(model, child, ctx, samples, depth + 2, index, fills),
+ ),
+ )
+ }
+ return markup(
+ target,
+ target.node,
+ ctx,
+ specBindings(target.spec, index),
+ depth,
+ index,
+ filled,
+ )
+ }
+ case 'Slot': {
+ const fill = fills.get(node.name)
+ const inner =
+ fill ?? (node.children.length ? children() : [`${pad} ${escapeHtml(node.name)}`])
+ return [`${pad}`, ...inner, `${pad} `]
+ }
+ case 'Text': {
+ const tag = info?.tag ?? 'span'
+ const open = info ? `<${tag}>` : ``
+ const close = info ? `${tag}>` : ' '
+ return [`${pad}${open}${escapeHtml(sampleText(model, node, samples))}${close}`]
+ }
+ case 'Vector': {
+ const width = typeof node.attrs.width?.value === 'number' ? node.attrs.width.value : 16
+ const height = typeof node.attrs.height?.value === 'number' ? node.attrs.height.value : 16
+ const paths = Array.isArray(node.attrs.vectorPaths?.value)
+ ? (node.attrs.vectorPaths.value as JsonValue[])
+ : []
+ const d = paths
+ .map((entry) =>
+ entry && typeof entry === 'object' && !Array.isArray(entry)
+ ? (entry as Record).data
+ : undefined,
+ )
+ .filter((data): data is string => typeof data === 'string')
+ const svg = `${d.map((data) => ` `).join('')} `
+ if (info) return [`${pad}<${info.tag}>${svg}${info.tag}>`]
+ return [`${pad}${svg} `]
+ }
+ default: {
+ // Frame, Rectangle, Ellipse — and the component itself.
+ if (node === model.node && model.composes)
+ return markup(model, model.composes, ctx, samples, depth, index, fills)
+ if (node === model.node) {
+ const tag = model.tag ?? 'div'
+ const attrs = model.tag ? '' : ` class="${model.stem}"`
+ return [`${pad}<${tag}${attrs}>`, ...children(), `${pad}${tag}>`]
+ }
+ const tag = info?.tag ?? 'div'
+ const open = info ? `<${tag}>` : ``
+ const close = info ? `${tag}>` : '
'
+ return [`${pad}${open}`, ...children(), `${pad}${close}`]
+ }
+ }
+}
+
+/** The anatomy as markup with sample content: what a consumer's page holds. */
+export function emitHtml(model: ComponentModel, ctx: HtmlContext): string {
+ return `\n${markup(model, model.node, ctx, model.samples, 0).join('\n')}\n`
+}
diff --git a/packages/codegen/src/index.ts b/packages/codegen/src/index.ts
new file mode 100644
index 0000000..93ac361
--- /dev/null
+++ b/packages/codegen/src/index.ts
@@ -0,0 +1,22 @@
+export { generate, type GenerateInput, type GenerateOutput, type Target } from './generate.js'
+export {
+ componentModel,
+ kebab,
+ manifestTags,
+ partTag,
+ pascal,
+ type ComponentModel,
+ type Manifest,
+} from './model.js'
+export {
+ cssDeclarations,
+ cssRule,
+ cssVariable,
+ cssColor,
+ cssLength,
+ cssPaint,
+ tokensCss,
+} from './css.js'
+export { emitCss, emitHtml, stateSelector } from './html.js'
+export { emitElementTypes, emitIndex, emitModels, emitReact, emitRuntime, tsType } from './react.js'
+export { checkConformance } from './conformance.js'
diff --git a/packages/codegen/src/model.ts b/packages/codegen/src/model.ts
new file mode 100644
index 0000000..60d4565
--- /dev/null
+++ b/packages/codegen/src/model.ts
@@ -0,0 +1,234 @@
+import {
+ aliasTarget,
+ type ContractSpec,
+ type DocumentSpec,
+ type JsonValue,
+ type PropSpec,
+ type SlotSpec,
+ type UidxDocument,
+ type UidxNode,
+} from '@uidx/format'
+import { axesOf, modelByRef, specBindings } from '@uidx/schema/design-system'
+
+/**
+ * What every code target reads: a component's identity, contract and tree,
+ * with the questions the emitters ask answered once — which node is which
+ * part, which text is a prop, which slot repeats, which tag a part becomes.
+ */
+
+/** The slice of a `custom-elements.json` this package reads. */
+export interface Manifest {
+ modules: {
+ path?: string
+ declarations?: {
+ name?: string
+ tagName?: string | null
+ attributes?: { name: string; type?: { text?: string } }[]
+ events?: { name: string }[]
+ slots?: { name: string }[]
+ }[]
+ }[]
+}
+
+export type ManifestDeclaration = NonNullable[number]
+
+/** Every custom element the manifest declares, by tag. */
+export function manifestTags(manifest: Manifest | undefined): Map {
+ const out = new Map()
+ for (const module of manifest?.modules ?? []) {
+ for (const declaration of module.declarations ?? []) {
+ if (declaration.tagName) out.set(declaration.tagName, declaration)
+ }
+ }
+ return out
+}
+
+/** `ContactItem` → `contact-item`; `Button/Primary` → `button-primary`. */
+export function kebab(name: string): string {
+ return name
+ .replace(/[/\s]+/g, '-')
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
+ .toLowerCase()
+ .replace(/[^a-z0-9-]/g, '')
+}
+
+/** `contact-item` or `Button/Primary` → `ContactItem`, `ButtonPrimary`. */
+export function pascal(name: string): string {
+ return name
+ .split(/[^A-Za-z0-9]+/)
+ .filter(Boolean)
+ .map((part) => part[0]!.toUpperCase() + part.slice(1))
+ .join('')
+}
+
+/**
+ * The element a part is: `-` by convention, or the manifest's
+ * tag that ends with the part name and shares the longest prefix with the
+ * root — `hwc-breadcrumbs` owns `hwc-breadcrumb-item`.
+ */
+export function partTag(rootTag: string, part: string, tags?: ReadonlySet): string {
+ const conventional = `${rootTag}-${part}`
+ if (!tags || tags.has(conventional)) return conventional
+ let best: string | undefined
+ let bestShared = -1
+ for (const tag of tags) {
+ if (!tag.endsWith(`-${part}`)) continue
+ let shared = 0
+ while (shared < tag.length && shared < rootTag.length && tag[shared] === rootTag[shared])
+ shared++
+ if (shared > bestShared) {
+ bestShared = shared
+ best = tag
+ }
+ }
+ return best ?? conventional
+}
+
+export interface PartInfo {
+ name: string
+ node: UidxNode
+ tag: string
+}
+
+export interface SlotInfo {
+ name: string
+ node: UidxNode
+ spec: SlotSpec | undefined
+}
+
+export interface RepeatInfo {
+ node: UidxNode
+ slot: SlotSpec | undefined
+ /** The instance the repeat multiplies, and the component it names. */
+ instance: UidxNode | undefined
+ itemComponent: string | undefined
+}
+
+export interface ComponentModel {
+ /** The `` name as written. */
+ name: string
+ /** A TypeScript identifier for the component. */
+ identifier: string
+ /** A file stem for its outputs. */
+ stem: string
+ /** The headless root it implements, or undefined for a purely visual component. */
+ tag: string | undefined
+ node: UidxNode
+ spec: DocumentSpec | undefined
+ contract: ContractSpec | undefined
+ parts: PartInfo[]
+ slots: SlotInfo[]
+ repeats: RepeatInfo[]
+ axes: Map
+ /** The node bound to a part, by part name. */
+ partOf: Map
+ /** Sample values at index 0: what `{item.name}` and `{label}` resolve to. */
+ samples: Map
+ /** The document the component comes from, for token and asset context. */
+ doc: UidxDocument
+ /**
+ * A composition: no headless root of its own and exactly one child, an
+ * instance of another component. It renders as that instance, with its
+ * props passed through — a pattern, not a new element (ADR 0012 §3).
+ */
+ composes: UidxNode | undefined
+}
+
+/** The prop a bare `{name}` alias names, if the contract declares it. */
+export function boundProp(model: ComponentModel, value: JsonValue): PropSpec | undefined {
+ if (typeof value !== 'string') return undefined
+ const target = aliasTarget(value)
+ if (target === null || target.includes('#') || target.includes('.')) return undefined
+ return model.contract?.props.find((prop) => prop.name === target)
+}
+
+/** `{item.name}` → `['item', 'name']` when `item` is a model prop; else null. */
+export function boundPath(model: ComponentModel, value: JsonValue): string[] | null {
+ if (typeof value !== 'string') return null
+ const target = aliasTarget(value)
+ if (target === null || target.includes('#') || !target.includes('.')) return null
+ const path = target.split('.')
+ const prop = model.contract?.props.find((entry) => entry.name === path[0] && entry.model)
+ return prop ? path : null
+}
+
+/** The declared model a prop or slot refers to. */
+export function modelOf(model: ComponentModel, ref: string | undefined) {
+ return modelByRef(model.spec, ref)
+}
+
+export function componentModel(
+ component: UidxNode,
+ doc: UidxDocument,
+ manifest?: Manifest,
+): ComponentModel {
+ const tags = manifest ? new Set(manifestTags(manifest).keys()) : undefined
+ const spec = component.spec ?? doc.spec
+ const contract = spec?.contract
+ const tag =
+ typeof component.attrs.implements?.value === 'string'
+ ? component.attrs.implements.value
+ : undefined
+ const parts: PartInfo[] = []
+ const slots: SlotInfo[] = []
+ const repeats: RepeatInfo[] = []
+ const partOf = new Map()
+ 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}` })
+ partOf.set(node, part)
+ }
+ if (node.element === 'Slot') {
+ slots.push({
+ name: node.name,
+ node,
+ spec: contract?.slots.find((slot) => slot.name === node.name),
+ })
+ }
+ // An instance's children are the fills it puts in *another* component's
+ // slots (ADR 0007 §2), not this component's parts or slots.
+ if (node.element === 'Instance') return
+ if (node.element === 'Repeat') {
+ const slotName = node.attrs.slot?.value
+ const instance = node.children[0]
+ const itemComponent = instance?.attrs.component?.value
+ repeats.push({
+ node,
+ slot: contract?.slots.find((slot) => slot.name === slotName),
+ instance: instance?.element === 'Instance' ? instance : undefined,
+ itemComponent: typeof itemComponent === 'string' ? itemComponent : undefined,
+ })
+ return
+ }
+ for (const child of node.children) walk(child)
+ }
+ for (const child of component.children) walk(child)
+ return {
+ name: component.name,
+ identifier: pascal(component.name),
+ stem: kebab(component.name),
+ tag,
+ node: component,
+ spec,
+ contract,
+ parts,
+ slots,
+ repeats,
+ axes: axesOf(spec),
+ partOf,
+ samples: specBindings(spec, 0),
+ doc,
+ composes:
+ tag === undefined &&
+ component.children.length === 1 &&
+ component.children[0]!.element === 'Instance'
+ ? component.children[0]
+ : undefined,
+ }
+}
+
+/** The models used by a component, with the ones they reference, by name. */
+export function modelsOf(model: ComponentModel) {
+ return model.spec?.models ?? []
+}
diff --git a/packages/codegen/src/react.ts b/packages/codegen/src/react.ts
new file mode 100644
index 0000000..6c63816
--- /dev/null
+++ b/packages/codegen/src/react.ts
@@ -0,0 +1,441 @@
+import type { FieldSpec, JsonValue, ModelSpec, PropSpec, UidxNode } from '@uidx/format'
+import { modelByRef } from '@uidx/schema/design-system'
+import { boundPath, boundProp, pascal, type ComponentModel } from './model.js'
+
+/**
+ * The React target (ADR 0017 §3): one component per identity, wrapping the
+ * headless root. Props and events come from the contract; slots are the
+ * render-prop answer — a plain slot is a `ReactNode` prop, a repeating slot
+ * is `items` plus `renderItem` generic over the model; parts with consumer
+ * text are compound sub-components. Behaviour and accessibility come from the
+ * custom element at runtime; appearance from the generated stylesheet.
+ */
+
+export interface ReactContext {
+ components: Map
+}
+
+const HEADER = (name: string) =>
+ `// ${name} — generated by uidx codegen from its .uidx file. Do not edit; change the file.\n`
+
+/** A contract type string as TypeScript, with model references resolved. */
+export function tsType(model: ComponentModel, type: string | undefined, modelRef?: string): string {
+ const named = modelByRef(model.spec, modelRef)
+ if (named) return pascal(named.name)
+ if (!type) return 'unknown'
+ const array = type.endsWith('[]')
+ const base = array ? type.slice(0, -2) : type
+ const referenced =
+ modelByRef(model.spec, base) ?? model.spec?.models?.find((m) => m.name === base)
+ const inner = referenced
+ ? pascal(referenced.name)
+ : base === 'image' || base === 'date'
+ ? 'string'
+ : base
+ return array ? `${inner}[]` : inner
+}
+
+function fieldType(spec: ComponentModel['spec'], field: FieldSpec): string {
+ const named = modelByRef(spec, field.type)
+ if (named) return pascal(named.name)
+ const array = field.type.endsWith('[]')
+ const base = array ? field.type.slice(0, -2) : field.type
+ const referenced = spec?.models?.find((m) => m.name === base)
+ const inner = referenced
+ ? pascal(referenced.name)
+ : base === 'image' || base === 'date'
+ ? 'string'
+ : base
+ return array ? `${inner}[]` : inner
+}
+
+/** `models.ts`: one interface per model, fields documented from the file. */
+export function emitModels(models: readonly ModelSpec[], spec: ComponentModel['spec']): string {
+ const lines = [HEADER('Models'), '']
+ for (const model of models) {
+ if (model.description) lines.push(`/** ${model.description} */`)
+ lines.push(`export interface ${pascal(model.name)} {`)
+ for (const field of model.fields) {
+ lines.push(` /** ${field.description} */`)
+ lines.push(` ${field.name}${field.optional ? '?' : ''}: ${fieldType(spec, field)}`)
+ }
+ lines.push('}', '')
+ }
+ return `${lines.join('\n')}\n`
+}
+
+/** `runtime.ts`: the one hook every generated component uses for events. */
+export function emitRuntime(): string {
+ return `${HEADER('Runtime')}
+import { useEffect, type RefObject } from 'react'
+
+/**
+ * Subscribes to a custom element's event and hands the handler its detail.
+ * The element owns the behaviour; React only listens.
+ */
+export function useElementEvent(
+ ref: RefObject,
+ type: string,
+ handler: ((detail: T) => void) | undefined,
+): void {
+ useEffect(() => {
+ const element = ref.current
+ if (!element || !handler) return
+ const listener = (event: Event) => handler((event as CustomEvent).detail)
+ element.addEventListener(type, listener)
+ return () => element.removeEventListener(type, listener)
+ }, [ref, type, handler])
+}
+`
+}
+
+/** `elements.d.ts`: the custom elements as JSX intrinsics, attributes open. */
+export function emitElementTypes(tags: readonly string[]): string {
+ const entries = [...new Set(tags)]
+ .sort()
+ .map(
+ (tag) =>
+ ` '${tag}': React.DetailedHTMLProps, HTMLElement> &\n Record`,
+ )
+ return `${HEADER('Elements')}
+import type React from 'react'
+
+declare module 'react' {
+ namespace JSX {
+ interface IntrinsicElements {
+${entries.join('\n')}
+ }
+ }
+}
+
+export {}
+`
+}
+
+export function emitIndex(models: readonly ComponentModel[]): string {
+ const lines = [HEADER('Index'), '']
+ for (const model of models) lines.push(`export * from './${model.identifier}'`)
+ lines.push(`export * from './models'`)
+ return `${lines.join('\n')}\n`
+}
+
+/* --------------------------------------------------------------- one component */
+
+interface PropLine {
+ name: string
+ type: string
+ optional: boolean
+ doc: string
+}
+
+function jsxText(text: string): string {
+ return text.replace(/[{}<>]/g, (c) => `{'${c}'}`)
+}
+
+function svgFor(node: UidxNode): string {
+ const width = typeof node.attrs.width?.value === 'number' ? node.attrs.width.value : 16
+ const height = typeof node.attrs.height?.value === 'number' ? node.attrs.height.value : 16
+ const paths = Array.isArray(node.attrs.vectorPaths?.value)
+ ? (node.attrs.vectorPaths.value as JsonValue[])
+ : []
+ const d = paths
+ .map((entry) =>
+ entry && typeof entry === 'object' && !Array.isArray(entry)
+ ? (entry as Record).data
+ : undefined,
+ )
+ .filter((data): data is string => typeof data === 'string')
+ return `${d.map((data) => ` `).join('')} `
+}
+
+/** The JSX expression a bound `characters` renders: a prop, a model path, or text. */
+function textExpression(model: ComponentModel, node: UidxNode): string {
+ const characters = node.attrs.characters?.value
+ if (typeof characters !== 'string') return ''
+ const prop = boundProp(model, characters)
+ if (prop) return `{${prop.name}}`
+ const path = boundPath(model, characters)
+ if (path) {
+ const [head, ...rest] = path
+ return `{${head}${rest.map((segment) => `?.${segment}`).join('')}}`
+ }
+ return jsxText(characters)
+}
+
+export function emitReact(model: ComponentModel, ctx: ReactContext): string {
+ const contract = model.contract
+ const props: PropLine[] = []
+ const usesModels = new Set()
+ const imports = new Set()
+ const textBound = new Set()
+
+ // Which props are rendered as text somewhere: those accept a ReactNode.
+ const scanText = (node: UidxNode): void => {
+ const prop = boundProp(model, node.attrs.characters?.value ?? null)
+ if (prop && node.element === 'Text') textBound.add(prop.name)
+ for (const child of node.children) scanText(child)
+ }
+ scanText(model.node)
+
+ const attributeProps: PropSpec[] = []
+ for (const prop of contract?.props ?? []) {
+ if (prop.model) {
+ usesModels.add(pascal(modelByRef(model.spec, prop.model)!.name))
+ props.push({
+ name: prop.name,
+ type: tsType(model, prop.type, prop.model),
+ optional: false,
+ doc: prop.description,
+ })
+ continue
+ }
+ const repeated = contract?.slots.find((slot) => slot.repeats && prop.type?.endsWith('[]'))
+ if (repeated && prop.type?.endsWith('[]')) continue // rendered as `items` below
+ const type = textBound.has(prop.name) ? 'ReactNode' : tsType(model, prop.type)
+ if (type.endsWith('[]') || type === 'ReactNode') {
+ const inner = type.endsWith('[]') ? type.slice(0, -2) : ''
+ if (inner && model.spec?.models?.some((m) => pascal(m.name) === inner)) usesModels.add(inner)
+ }
+ props.push({ name: prop.name, type, optional: true, doc: prop.description })
+ if (!textBound.has(prop.name)) attributeProps.push(prop)
+ }
+ for (const event of contract?.events ?? []) {
+ const handler = `on${pascal(event.name)}`
+ props.push({
+ name: handler,
+ type: `(detail: ${event.detail ?? 'unknown'}) => void`,
+ optional: true,
+ doc: event.description,
+ })
+ }
+ for (const slot of model.slots) {
+ if (slot.spec?.repeats) continue
+ props.push({
+ name: slot.name === 'default' ? 'children' : slot.name,
+ type: 'ReactNode',
+ optional: true,
+ doc: slot.spec?.description ?? `Content of the "${slot.name}" slot.`,
+ })
+ }
+ for (const repeat of model.repeats) {
+ const modelSpec = modelByRef(model.spec, repeat.slot?.model)
+ const item = modelSpec ? pascal(modelSpec.name) : 'unknown'
+ if (modelSpec) usesModels.add(item)
+ props.push({
+ name: 'items',
+ type: `${item}[]`,
+ optional: false,
+ doc: repeat.slot?.description ?? 'The rows to show.',
+ })
+ props.push({
+ name: 'renderItem',
+ type: `(item: ${item}, index: number) => ReactNode`,
+ optional: true,
+ doc: 'Draws one row; the default is the component the design repeats.',
+ })
+ props.push({
+ name: 'ItemComponent',
+ type: `ComponentType<{ item: ${item} }>`,
+ optional: true,
+ doc: 'A component taking `item`, used when `renderItem` is not given.',
+ })
+ if (repeat.itemComponent && ctx.components.has(repeat.itemComponent)) {
+ imports.add(ctx.components.get(repeat.itemComponent)!.identifier)
+ }
+ }
+ props.push({
+ name: 'className',
+ type: 'string',
+ optional: true,
+ doc: 'Extra class names for the root element.',
+ })
+ props.push({
+ name: 'style',
+ type: 'CSSProperties',
+ optional: true,
+ doc: 'Inline style for the root element.',
+ })
+
+ 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')
+ compound.push({ name: pascal(part.name), tag: part.tag, part: part.name })
+ }
+
+ const render = (node: UidxNode, depth: number): string[] => {
+ const pad = ' '.repeat(depth)
+ const part = model.partOf.get(node)
+ const info = part === undefined ? undefined : model.parts.find((entry) => entry.name === part)
+ const children = () => node.children.flatMap((child) => render(child, depth + 1))
+ switch (node.element) {
+ case 'Repeat': {
+ const repeat = model.repeats.find((entry) => entry.node === node)
+ const modelSpec = modelByRef(model.spec, repeat?.slot?.model)
+ const key = modelSpec?.fields.find((field) => field.key)?.name
+ const fallback =
+ repeat?.itemComponent && ctx.components.has(repeat.itemComponent)
+ ? `<${ctx.components.get(repeat.itemComponent)!.identifier} item={item} />`
+ : 'null'
+ return [
+ `${pad}{items.map((item, index) => (`,
+ `${pad} `,
+ `${pad} {renderItem ? renderItem(item, index) : ItemComponent ? : ${fallback}}`,
+ `${pad} `,
+ `${pad}))}`,
+ ]
+ }
+ case 'Instance': {
+ const name = node.attrs.component?.value
+ const target = typeof name === 'string' ? ctx.components.get(name) : undefined
+ if (!target) return [`${pad}
`]
+ imports.add(target.identifier)
+ // A use passes props through (F7): `{label}` hands this component's
+ // own prop down, a literal is a literal. A slot fill (ADR 0007 §2)
+ // becomes the slot's ReactNode prop.
+ const passed: string[] = []
+ const declared = node.attrs.props?.value
+ if (declared && typeof declared === 'object' && !Array.isArray(declared)) {
+ for (const [key, value] of Object.entries(declared as Record)) {
+ const own = boundProp(model, value)
+ if (own) passed.push(`${key}={${own.name}}`)
+ else if (typeof value === 'string' && !value.startsWith('{'))
+ passed.push(`${key}="${value}"`)
+ else if (typeof value !== 'string') passed.push(`${key}={${JSON.stringify(value)}}`)
+ }
+ }
+ const fills = node.children.filter((child) => child.element === 'Slot')
+ if (fills.length === 0)
+ return [`${pad}<${target.identifier}${passed.length ? ` ${passed.join(' ')}` : ''} />`]
+ const lines = [`${pad}<${target.identifier}${passed.length ? ` ${passed.join(' ')}` : ''}`]
+ for (const fill of fills) {
+ const prop = fill.name === 'default' ? 'children' : fill.name
+ const inner = fill.children.flatMap((child) => render(child, depth + 2))
+ lines.push(`${pad} ${prop}={`, `${pad} <>`, ...inner, `${pad} >`, `${pad} }`)
+ }
+ lines.push(`${pad}/>`)
+ return lines
+ }
+ case 'Slot': {
+ const prop = node.name === 'default' ? 'children' : node.name
+ const fallback = node.children.length ? children() : []
+ if (fallback.length) {
+ return [
+ `${pad}`,
+ `${pad} {${prop} !== undefined ? ${prop} : (`,
+ ...fallback.map((line) => ` ${line}`),
+ `${pad} )}`,
+ `${pad} `,
+ ]
+ }
+ return [
+ `${pad}{${prop} !== undefined ? {${prop}} : null}`,
+ ]
+ }
+ case 'Text': {
+ const text = textExpression(model, node)
+ if (info) return [`${pad}<${info.tag}>${text}${info.tag}>`]
+ return [`${pad}${text} `]
+ }
+ case 'Vector': {
+ const svg = svgFor(node)
+ if (info) return [`${pad}<${info.tag}>${svg}${info.tag}>`]
+ return [`${pad}${svg} `]
+ }
+ default: {
+ if (node === model.node) return children()
+ if (info) return [`${pad}<${info.tag}>`, ...children(), `${pad}${info.tag}>`]
+ return [`${pad}`, ...children(), `${pad}
`]
+ }
+ }
+ }
+ const body = render(model.node, 2)
+
+ const rootTag = model.tag ?? 'div'
+ // A composition renders as the instance it holds, className and style
+ // handed to it, so the pattern adds no element of its own.
+ const composed = model.composes ? render(model.composes, 2) : null
+ const attributes = attributeProps
+ .map((prop) => {
+ const isBoolean = prop.type === 'boolean'
+ return `${prop.name}={${isBoolean ? `${prop.name} || undefined` : prop.name}}`
+ })
+ .join(' ')
+ const events = (contract?.events ?? []).map(
+ (event) => ` useElementEvent(ref, '${event.name}', on${pascal(event.name)})`,
+ )
+ const destructure = props
+ .map((prop) => {
+ const fallback = contract?.props.find((entry) => entry.name === prop.name)?.default
+ const isAttribute = attributeProps.some((entry) => entry.name === prop.name)
+ return isAttribute &&
+ fallback !== undefined &&
+ fallback !== null &&
+ typeof fallback !== 'boolean'
+ ? `${prop.name} = ${JSON.stringify(fallback)}`
+ : prop.name
+ })
+ .join(', ')
+
+ const reactImports = [...(model.composes ? [] : ['useRef']), 'type CSSProperties']
+ if (props.some((prop) => prop.type.includes('ReactNode')) || compound.length)
+ reactImports.push('type ReactNode')
+ if (model.repeats.length) reactImports.push('Fragment', 'type ComponentType')
+ const lines: string[] = [
+ HEADER(model.name),
+ `import { ${reactImports.sort().join(', ')} } from 'react'`,
+ ...(contract?.events.length ? [`import { useElementEvent } from './runtime'`] : []),
+ ...(usesModels.size
+ ? [`import type { ${[...usesModels].sort().join(', ')} } from './models'`]
+ : []),
+ ...[...imports].sort().map((name) => `import { ${name} } from './${name}'`),
+ `import './${model.stem}.css'`,
+ '',
+ `export interface ${model.identifier}Props {`,
+ ...props.flatMap((prop) => [
+ ` /** ${prop.doc} */`,
+ ` ${prop.name}${prop.optional ? '?' : ''}: ${prop.type}`,
+ ]),
+ '}',
+ '',
+ `function ${model.identifier}Base({ ${destructure} }: ${model.identifier}Props) {`,
+ ...(composed
+ ? [
+ ` return (`,
+ ...composed.map((line, at) =>
+ at === 0 ? line.replace(/^(\s*<\w+)/, '$1 className={className} style={style}') : line,
+ ),
+ ` )`,
+ ]
+ : [
+ ` const ref = useRef(null)`,
+ ...events,
+ ` return (`,
+ ` <${rootTag} ref={ref} className={className} style={style}${attributes ? ` ${attributes}` : ''}>`,
+ ...body,
+ ` ${rootTag}>`,
+ ` )`,
+ ]),
+ `}`,
+ '',
+ ]
+ for (const sub of compound) {
+ lines.push(
+ `function ${model.identifier}${sub.name}({ children }: { children?: ReactNode }) {`,
+ ` return <${sub.tag}>{children}${sub.tag}>`,
+ `}`,
+ '',
+ )
+ }
+ if (compound.length) {
+ lines.push(
+ `/** ${model.name}: the pattern, and its parts for composing below it. */`,
+ `export const ${model.identifier} = Object.assign(${model.identifier}Base, {`,
+ ...compound.map((sub) => ` ${sub.name}: ${model.identifier}${sub.name},`),
+ `})`,
+ )
+ } else {
+ lines.push(`export const ${model.identifier} = ${model.identifier}Base`)
+ }
+ return `${lines.join('\n')}\n`
+}
diff --git a/packages/codegen/test/fixtures.ts b/packages/codegen/test/fixtures.ts
new file mode 100644
index 0000000..5700aeb
--- /dev/null
+++ b/packages/codegen/test/fixtures.ts
@@ -0,0 +1,218 @@
+import { parseOrThrow } from '@uidx/format'
+
+export const TOKENS = parseOrThrow(`---
+id: tokens
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+`)
+
+export const CHECKBOX = parseOrThrow(`---
+id: checkbox
+---
+
+Lets a user toggle one option.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ Whether the option is selected.
+ Mixed state.
+ Box size.
+ Inert and dimmed.
+ Form field name.
+ Submitted while checked.
+
+
+ Fires once per user toggle.
+
+
+checked-indicator
+
+## Behavior
+
+- toggle: click or Space flips \`checked\`.
+`)
+
+export const FIELD = parseOrThrow(`---
+id: field
+---
+
+Pairs a control with its label, description and error.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The control's name.
+ Helper text.
+ Shows the error state.
+
+
+label, description
+The wrapped control.
+`)
+
+export const CONTACT_ITEM = parseOrThrow(`---
+id: contact-item
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The row to show.
+ Whether this row is the chosen one.
+ Inert.
+ Submitted when chosen.
+ Form field name.
+
+Fires when chosen.
+
+checked-indicator
+
+## Models
+
+
+ One row of the list.
+ Identity.
+ Display name.
+ Omitted when unknown.
+
+`)
+
+export const CONTACT_LIST = parseOrThrow(`---
+id: contact-list
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ Rows to show.
+ Form field name shared by the rows.
+ Inert.
+
+
+ One per row.
+ Shown while empty.
+
+
+## Models
+
+
+ One row of the list.
+ Identity.
+ Display name.
+ Omitted when unknown.
+
+`)
+
+export const MANIFEST = {
+ modules: [
+ {
+ declarations: [
+ {
+ name: 'Checkbox',
+ tagName: 'hwc-checkbox',
+ attributes: [
+ { name: 'checked' },
+ { name: 'indeterminate' },
+ { name: 'disabled' },
+ { name: 'value' },
+ { name: 'name' },
+ ],
+ events: [{ name: 'change' }],
+ },
+ { name: 'CheckboxCheckedIndicator', tagName: 'hwc-checkbox-checked-indicator' },
+ { name: 'Field', tagName: 'hwc-field', attributes: [{ name: 'error' }] },
+ { name: 'FieldLabel', tagName: 'hwc-field-label' },
+ { name: 'FieldDescription', tagName: 'hwc-field-description' },
+ {
+ name: 'Radio',
+ tagName: 'hwc-radio',
+ attributes: [
+ { name: 'checked' },
+ { name: 'disabled' },
+ { name: 'value' },
+ { name: 'name' },
+ ],
+ events: [{ name: 'change' }],
+ },
+ { name: 'RadioCheckedIndicator', tagName: 'hwc-radio-checked-indicator' },
+ {
+ name: 'RadioGroup',
+ tagName: 'hwc-radio-group',
+ attributes: [{ name: 'name' }, { name: 'disabled' }],
+ },
+ ],
+ },
+ ],
+}
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
new file mode 100644
index 0000000..c2e7de3
--- /dev/null
+++ b/packages/codegen/test/generate.test.ts
@@ -0,0 +1,213 @@
+import { describe, expect, it } from 'vitest'
+import { CODES } from '@uidx/format'
+import { generate, tsType, componentModel, partTag } from '../src/index.js'
+import { CHECKBOX, CONTACT_ITEM, CONTACT_LIST, FIELD, MANIFEST, TOKENS } from './fixtures.js'
+
+/**
+ * Code is a render target (ADR 0017 §3). These pin what each target gets
+ * from the identity: the base tree and the styles table as CSS keyed by the
+ * headless root's attributes and states; props, events, slots and repeats as
+ * a typed React component; and the contract checked against the manifest.
+ */
+const PAGES = [
+ { file: 'checkbox.uidx', doc: CHECKBOX },
+ { file: 'field.uidx', doc: FIELD },
+ { file: 'contact-item.uidx', doc: CONTACT_ITEM },
+ { file: 'contact-list.uidx', doc: CONTACT_LIST },
+]
+const output = generate({ pages: PAGES, tokens: [TOKENS], manifest: MANIFEST })
+const file = (path: string) => {
+ const text = output.files.get(path)
+ if (text === undefined) throw new Error(`no ${path}; have ${[...output.files.keys()].join(', ')}`)
+ return text
+}
+
+describe('generate', () => {
+ it('is deterministic: the same input renders the same bytes', () => {
+ const again = generate({ pages: PAGES, tokens: [TOKENS], manifest: MANIFEST })
+ expect([...again.files]).toEqual([...output.files])
+ })
+
+ it('writes every target under its own folder', () => {
+ expect([...output.files.keys()].sort()).toEqual([
+ 'contract/checkbox.json',
+ 'contract/contact-item.json',
+ 'contract/contact-list.json',
+ 'contract/field.json',
+ 'html/checkbox.css',
+ 'html/checkbox.html',
+ 'html/contact-item.css',
+ 'html/contact-item.html',
+ 'html/contact-list.css',
+ 'html/contact-list.html',
+ 'html/field.css',
+ 'html/field.html',
+ 'html/tokens.css',
+ 'react/Checkbox.tsx',
+ 'react/ContactItem.tsx',
+ 'react/ContactList.tsx',
+ 'react/Field.tsx',
+ 'react/checkbox.css',
+ 'react/contact-item.css',
+ 'react/contact-list.css',
+ 'react/elements.d.ts',
+ 'react/field.css',
+ 'react/index.ts',
+ 'react/models.ts',
+ 'react/runtime.ts',
+ 'react/tokens.css',
+ ])
+ })
+
+ it('reports nothing against a manifest that agrees with the contracts', () => {
+ expect(output.diagnostics).toEqual([])
+ })
+})
+
+describe('tokens.css', () => {
+ it('names every token as a custom property, colours as colours, numbers unitless', () => {
+ expect(file('html/tokens.css')).toBe(
+ `:root {\n --radius-sm: 4;\n --space-sm: 8;\n --surface-accent: rgb(0 128 255);\n --surface-control: rgb(255 255 255);\n}\n`,
+ )
+ })
+})
+
+describe('the HTML/CSS target', () => {
+ const css = file('html/checkbox.css')
+
+ it('styles the root and the parts from the base tree, tokens as variables', () => {
+ expect(css).toContain(
+ 'hwc-checkbox {\n width: 20px;\n height: 20px;\n border-radius: calc(var(--radius-sm) * 1px);\n background-color: var(--surface-control);',
+ )
+ expect(css).toContain('hwc-checkbox hwc-checkbox-checked-indicator {\n display: none;')
+ // A vector's fill is its colour: its paths draw with currentColor.
+ expect(css).toContain(' color: rgb(255 255 255);')
+ })
+
+ it('turns style rows into state and attribute rules on the root', () => {
+ expect(css).toContain('hwc-checkbox[checked] {\n background-color: var(--surface-accent);\n}')
+ expect(css).toContain(
+ 'hwc-checkbox[checked] hwc-checkbox-checked-indicator {\n display: inline-flex;\n}',
+ )
+ expect(css).toContain('hwc-checkbox:hover {')
+ expect(css).toContain('hwc-checkbox[size="sm"] {\n width: 16px;\n height: 16px;\n}')
+ expect(css).toContain('hwc-checkbox[disabled] {\n opacity: 0.4;\n}')
+ })
+
+ it('renders the anatomy as markup with the parts as their elements', () => {
+ expect(file('html/checkbox.html')).toContain(
+ '\n Label')
+ expect(file('html/field.html')).toContain('')
+ })
+
+ it("expands a repeat into the item component's markup at successive samples", () => {
+ const html = file('html/contact-list.html')
+ expect(html).toContain('Ada ')
+ expect(html).toContain('Grace ')
+ expect(html).toContain('ada@example.com ')
+ expect(html).toContain(' ')
+ expect(html.split('')).toHaveLength(3)
+ })
+})
+
+describe('the React target', () => {
+ it('types props and events from the contract and wraps the headless root', () => {
+ const tsx = file('react/Checkbox.tsx')
+ expect(tsx).toContain(` /** Whether the option is selected. */\n checked?: boolean`)
+ expect(tsx).toContain(`size?: 'sm' | 'md'`)
+ expect(tsx).toContain(`onChange?: (detail: { checked: boolean }) => void`)
+ expect(tsx).toContain(`useElementEvent(ref, 'change', onChange)`)
+ expect(tsx).toContain(
+ ` {
+ const tsx = file('react/Field.tsx')
+ expect(tsx).toContain(`label?: ReactNode`)
+ expect(tsx).toContain(`control?: ReactNode`)
+ expect(tsx).toContain(`{label} `)
+ expect(tsx).toContain(
+ `{control !== undefined ? {control} : null}`,
+ )
+ expect(tsx).toContain(
+ `export const Field = Object.assign(FieldBase, {\n Label: FieldLabel,\n Description: FieldDescription,\n})`,
+ )
+ })
+
+ it('turns a repeating slot into items plus renderItem, generic over the model', () => {
+ const tsx = file('react/ContactList.tsx')
+ expect(tsx).toContain(`items: Contact[]`)
+ expect(tsx).toContain(`renderItem?: (item: Contact, index: number) => ReactNode`)
+ expect(tsx).toContain(`ItemComponent?: ComponentType<{ item: Contact }>`)
+ expect(tsx).toContain(``)
+ expect(tsx).toContain(` `)
+ expect(tsx).toContain(`import { ContactItem } from './ContactItem'`)
+ })
+
+ it("binds a model prop's fields into the parts", () => {
+ const tsx = file('react/ContactItem.tsx')
+ expect(tsx).toContain(`item: Contact`)
+ expect(tsx).toContain(`{item?.name} `)
+ expect(tsx).toContain(``)
+ })
+
+ it('emits the models as documented interfaces, the runtime hook and the element types', () => {
+ const models = file('react/models.ts')
+ expect(models).toContain(`export interface Contact {`)
+ expect(models).toContain(` /** Omitted when unknown. */\n email?: string`)
+ expect(file('react/runtime.ts')).toContain('export function useElementEvent')
+ expect(file('react/elements.d.ts')).toContain(`'hwc-checkbox':`)
+ expect(file('react/elements.d.ts')).toContain(`'hwc-field-label':`)
+ expect(file('react/index.ts')).toContain(`export * from './Checkbox'`)
+ })
+})
+
+describe('conformance (ADR 0013 §4)', () => {
+ it('reports an element attribute the contract does not declare, and a missing part element', () => {
+ const drifted = {
+ modules: [
+ {
+ declarations: [
+ {
+ name: 'Checkbox',
+ tagName: 'hwc-checkbox',
+ attributes: [{ name: 'checked' }, { name: 'shape' }],
+ events: [{ name: 'change' }],
+ },
+ ],
+ },
+ ],
+ }
+ const result = generate({
+ pages: [{ file: 'checkbox.uidx', doc: CHECKBOX }],
+ manifest: drifted,
+ targets: ['contract'],
+ })
+ const messages = result.diagnostics.map((d) => d.message)
+ expect(result.diagnostics.every((d) => d.code === CODES.CONFORMANCE)).toBe(true)
+ expect(messages).toContainEqual(expect.stringContaining('"shape"'))
+ expect(messages).toContainEqual(expect.stringContaining('hwc-checkbox-checked-indicator'))
+ })
+
+ it("maps a part to the manifest tag that shares the root's prefix", () => {
+ const tags = new Set(['hwc-breadcrumbs', 'hwc-breadcrumb-item', 'hwc-field-label'])
+ expect(partTag('hwc-breadcrumbs', 'item', tags)).toBe('hwc-breadcrumb-item')
+ expect(partTag('hwc-field', 'label', tags)).toBe('hwc-field-label')
+ expect(partTag('hwc-field', 'error', tags)).toBe('hwc-field-error')
+ })
+})
+
+describe('types', () => {
+ it("resolves model references and the format's image and date types", () => {
+ const model = componentModel(CONTACT_ITEM.tree.children[0]!, CONTACT_ITEM)
+ expect(tsType(model, undefined, '{models#Contact}')).toBe('Contact')
+ expect(tsType(model, 'Contact[]')).toBe('Contact[]')
+ expect(tsType(model, 'image')).toBe('string')
+ expect(tsType(model, "'a' | 'b'")).toBe("'a' | 'b'")
+ })
+})
diff --git a/packages/codegen/tsconfig.build.json b/packages/codegen/tsconfig.build.json
new file mode 100644
index 0000000..796337f
--- /dev/null
+++ b/packages/codegen/tsconfig.build.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/codegen/tsconfig.json b/packages/codegen/tsconfig.json
new file mode 100644
index 0000000..61eabd8
--- /dev/null
+++ b/packages/codegen/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts"]
+}
diff --git a/packages/codegen/vitest.config.ts b/packages/codegen/vitest.config.ts
new file mode 100644
index 0000000..ee2f664
--- /dev/null
+++ b/packages/codegen/vitest.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from 'vitest/config'
+
+/**
+ * Watcher-driven tests wait on filesystem events, which behave badly under
+ * concurrency: every `FileSession` starts a chokidar watcher, and running the
+ * test files in parallel puts dozens of them on one machine polling at once.
+ * That starved a single-file watcher badly enough to blow a 15s budget, which
+ * reads as a product bug and is not one.
+ *
+ * So the files run one at a time. Slower, and the alternative was a suite that
+ * failed roughly one run in six for reasons nobody could act on.
+ *
+ * The timeout is generous for the same reason: CI runners are slower than a dev
+ * machine. Helpers stay below this ceiling so a failure names what it was
+ * waiting for rather than being killed mid-wait — the default 5s was exactly
+ * what `server.test.ts`'s own helper used, so vitest always won that race.
+ */
+export default defineConfig({
+ test: {
+ testTimeout: 20_000,
+ hookTimeout: 20_000,
+ fileParallelism: false,
+ },
+})
diff --git a/packages/schema/src/design-system-audit.ts b/packages/schema/src/design-system-audit.ts
index 468a61f..7c28339 100644
--- a/packages/schema/src/design-system-audit.ts
+++ b/packages/schema/src/design-system-audit.ts
@@ -13,8 +13,8 @@ import {
axesOf,
enumValues,
modelByRef,
- partNode,
ROOT_PART,
+ styleTarget,
STATE_AXIS,
DEFAULT_STATE,
} from './design-system.js'
@@ -275,8 +275,12 @@ export function auditDesignSystem(doc: UidxDocument): Diagnostic[] {
}
}
for (const [part, props] of Object.entries(row.values)) {
- if (part !== ROOT_PART && !partNode(component, part)) {
- error(CODES.STYLE_ROW, `
@@ -37,7 +41,7 @@ label is consumer text.
## Contract
- The button's text.
+ The button's text.
Emphasis.
Padding and type scale.
Inert and dimmed.
diff --git a/examples/design-system/.uidx/checkbox-field.uidx b/examples/design-system/.uidx/checkbox-field.uidx
index 5568769..fd0b7dd 100644
--- a/examples/design-system/.uidx/checkbox-field.uidx
+++ b/examples/design-system/.uidx/checkbox-field.uidx
@@ -21,9 +21,9 @@ come from the Field identity, the box from the Checkbox identity.
## Contract
- The option's name.
- Helper text under the label.
- Validation text, shown while in error.
+ The option's name.
+ Helper text under the label.
+ Validation text, shown while in error.
Whether the field is in its error state.
diff --git a/examples/design-system/.uidx/contact-list.uidx b/examples/design-system/.uidx/contact-list.uidx
index 654e834..4a53050 100644
--- a/examples/design-system/.uidx/contact-list.uidx
+++ b/examples/design-system/.uidx/contact-list.uidx
@@ -10,9 +10,9 @@ Contact.
+ layoutMode="VERTICAL" primaryAxisSizingMode="AUTO" itemSpacing="{space#xs}" width={280}>
-
+
diff --git a/examples/design-system/.uidx/contact-option.uidx b/examples/design-system/.uidx/contact-option.uidx
index 5ef294e..581c92b 100644
--- a/examples/design-system/.uidx/contact-option.uidx
+++ b/examples/design-system/.uidx/contact-option.uidx
@@ -10,7 +10,8 @@ Contact.
-
+
diff --git a/examples/design-system/.uidx/field.uidx b/examples/design-system/.uidx/field.uidx
index a0175dc..efefbc3 100644
--- a/examples/design-system/.uidx/field.uidx
+++ b/examples/design-system/.uidx/field.uidx
@@ -11,9 +11,10 @@ where it sits; the consumer supplies the words.
+ layoutMode="HORIZONTAL" primaryAxisSizingMode="AUTO" counterAxisSizingMode="AUTO"
+ itemSpacing="{space#sm}" counterAxisAlignItems="MIN">
-
+
@@ -28,9 +29,9 @@ where it sits; the consumer supplies the words.
## Contract
- The control's name, shown beside it.
- Secondary text: hints and consequences.
- Validation text, shown only while in error.
+ The control's name, shown beside it.
+ Secondary text: hints and consequences.
+ Validation text, shown only while in error.
Whether the field is in its error state.
diff --git a/examples/design-system/generated/contract/button.json b/examples/design-system/generated/contract/button.json
index 001f7eb..4198fe5 100644
--- a/examples/design-system/generated/contract/button.json
+++ b/examples/design-system/generated/contract/button.json
@@ -40,7 +40,8 @@
"controllable": false,
"visual": false,
"description": "The button's text.",
- "type": "string"
+ "type": "string",
+ "sample": "Save changes"
},
{
"name": "variant",
@@ -243,6 +244,7 @@
},
"values": {
"root": {
+ "fills": "{surface#raised}",
"strokes": "{border#hover}"
}
}
@@ -254,10 +256,53 @@
},
"values": {
"root": {
+ "fills": "{surface#control}",
"strokes": "{border#hover}"
}
}
},
+ {
+ "keys": {
+ "state": "hover",
+ "variant": "ghost"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#raised}",
+ "strokes": "{surface#raised}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "hover",
+ "variant": "destructive"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#danger}",
+ "strokes": "{surface#danger}"
+ }
+ }
+ },
+ {
+ "keys": {
+ "state": "hover",
+ "variant": "link"
+ },
+ "values": {
+ "root": {
+ "fills": "{surface#control}",
+ "strokes": "{surface#control}"
+ },
+ "label": {
+ "fills": "{surface#accentHover}"
+ },
+ "leading-icon": {
+ "fills": "{surface#accentHover}"
+ }
+ }
+ },
{
"keys": {
"state": "focus"
diff --git a/examples/design-system/generated/contract/checkbox-field.json b/examples/design-system/generated/contract/checkbox-field.json
index fe6d9a8..6fac4ac 100644
--- a/examples/design-system/generated/contract/checkbox-field.json
+++ b/examples/design-system/generated/contract/checkbox-field.json
@@ -19,21 +19,24 @@
"controllable": false,
"visual": false,
"description": "The option's name.",
- "type": "string"
+ "type": "string",
+ "sample": "Remember me"
},
{
"name": "description",
"controllable": false,
"visual": false,
"description": "Helper text under the label.",
- "type": "string"
+ "type": "string",
+ "sample": "Stay signed in on this device."
},
{
"name": "errorMessage",
"controllable": false,
"visual": false,
"description": "Validation text, shown while in error.",
- "type": "string"
+ "type": "string",
+ "sample": "Choose one option."
},
{
"name": "error",
diff --git a/examples/design-system/generated/contract/field.json b/examples/design-system/generated/contract/field.json
index bd1a251..a34243a 100644
--- a/examples/design-system/generated/contract/field.json
+++ b/examples/design-system/generated/contract/field.json
@@ -28,21 +28,24 @@
"controllable": false,
"visual": false,
"description": "The control's name, shown beside it.",
- "type": "string"
+ "type": "string",
+ "sample": "Email"
},
{
"name": "description",
"controllable": false,
"visual": false,
"description": "Secondary text: hints and consequences.",
- "type": "string"
+ "type": "string",
+ "sample": "We never share it."
},
{
"name": "errorMessage",
"controllable": false,
"visual": false,
"description": "Validation text, shown only while in error.",
- "type": "string"
+ "type": "string",
+ "sample": "Enter a valid address."
},
{
"name": "error",
diff --git a/examples/design-system/generated/html/button.css b/examples/design-system/generated/html/button.css
index 7636c42..c98d98c 100644
--- a/examples/design-system/generated/html/button.css
+++ b/examples/design-system/generated/html/button.css
@@ -113,13 +113,38 @@ hwc-button:hover {
}
hwc-button:hover[variant="secondary"] {
+ background-color: var(--surface-raised);
border: 1px solid var(--border-hover);
}
hwc-button:hover[variant="outline"] {
+ background-color: var(--surface-control);
border: 1px solid var(--border-hover);
}
+hwc-button:hover[variant="ghost"] {
+ background-color: var(--surface-raised);
+ border: 1px solid var(--surface-raised);
+}
+
+hwc-button:hover[variant="destructive"] {
+ background-color: var(--surface-danger);
+ border: 1px solid var(--surface-danger);
+}
+
+hwc-button:hover[variant="link"] {
+ background-color: var(--surface-control);
+ border: 1px solid var(--surface-control);
+}
+
+hwc-button:hover[variant="link"] hwc-button-label {
+ color: var(--surface-accentHover);
+}
+
+hwc-button:hover[variant="link"] hwc-button-leading-icon {
+ color: var(--surface-accentHover);
+}
+
hwc-button:focus-visible {
border: 1px solid var(--border-focus);
}
diff --git a/examples/design-system/generated/html/button.html b/examples/design-system/generated/html/button.html
index 6f065ec..ba344c9 100644
--- a/examples/design-system/generated/html/button.html
+++ b/examples/design-system/generated/html/button.html
@@ -1,5 +1,5 @@
- Label
+ Save changes
diff --git a/examples/design-system/generated/html/checkbox-field.html b/examples/design-system/generated/html/checkbox-field.html
index 5bea78c..d039b1d 100644
--- a/examples/design-system/generated/html/checkbox-field.html
+++ b/examples/design-system/generated/html/checkbox-field.html
@@ -7,8 +7,8 @@
- Label
- Description
- Error Message
+ Remember me
+ Stay signed in on this device.
+ Choose one option.
diff --git a/examples/design-system/generated/html/field.html b/examples/design-system/generated/html/field.html
index 398a4a7..2627edf 100644
--- a/examples/design-system/generated/html/field.html
+++ b/examples/design-system/generated/html/field.html
@@ -4,8 +4,8 @@
control
- Label
- Description
- Error Message
+ Email
+ We never share it.
+ Enter a valid address.
diff --git a/examples/design-system/generated/react/button.css b/examples/design-system/generated/react/button.css
index 7636c42..c98d98c 100644
--- a/examples/design-system/generated/react/button.css
+++ b/examples/design-system/generated/react/button.css
@@ -113,13 +113,38 @@ hwc-button:hover {
}
hwc-button:hover[variant="secondary"] {
+ background-color: var(--surface-raised);
border: 1px solid var(--border-hover);
}
hwc-button:hover[variant="outline"] {
+ background-color: var(--surface-control);
border: 1px solid var(--border-hover);
}
+hwc-button:hover[variant="ghost"] {
+ background-color: var(--surface-raised);
+ border: 1px solid var(--surface-raised);
+}
+
+hwc-button:hover[variant="destructive"] {
+ background-color: var(--surface-danger);
+ border: 1px solid var(--surface-danger);
+}
+
+hwc-button:hover[variant="link"] {
+ background-color: var(--surface-control);
+ border: 1px solid var(--surface-control);
+}
+
+hwc-button:hover[variant="link"] hwc-button-label {
+ color: var(--surface-accentHover);
+}
+
+hwc-button:hover[variant="link"] hwc-button-leading-icon {
+ color: var(--surface-accentHover);
+}
+
hwc-button:focus-visible {
border: 1px solid var(--border-focus);
}
diff --git a/examples/design-system/index.html b/examples/design-system/index.html
index 9916c98..fd931e6 100644
--- a/examples/design-system/index.html
+++ b/examples/design-system/index.html
@@ -12,12 +12,43 @@
diff --git a/examples/design-system/scripts/generate.mjs b/examples/design-system/scripts/generate.mjs
index aa18b03..88d5887 100644
--- a/examples/design-system/scripts/generate.mjs
+++ b/examples/design-system/scripts/generate.mjs
@@ -16,7 +16,9 @@ const out = resolve(root, 'generated')
const pages = []
const tokens = []
-for (const name of (await readdir(resolve(root, '.uidx'))).filter((n) => n.endsWith('.uidx')).sort()) {
+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))
@@ -24,7 +26,9 @@ for (const name of (await readdir(resolve(root, '.uidx'))).filter((n) => n.endsW
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 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)
diff --git a/examples/design-system/scripts/sync-hwc.mjs b/examples/design-system/scripts/sync-hwc.mjs
index 3fc0451..11adcc0 100644
--- a/examples/design-system/scripts/sync-hwc.mjs
+++ b/examples/design-system/scripts/sync-hwc.mjs
@@ -10,7 +10,9 @@ import { fileURLToPath } from 'node:url'
* 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 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 })
diff --git a/examples/design-system/src/main.ts b/examples/design-system/src/main.ts
index 7e5747c..223e920 100644
--- a/examples/design-system/src/main.ts
+++ b/examples/design-system/src/main.ts
@@ -14,7 +14,10 @@ import { mountReactDemo } from './react-demo'
const sections: [string, string][] = [
['Checkbox', checkbox],
['CheckboxField', checkboxField],
- ['Button', `${button}\n${button.replace('', '')}\n${button.replace('', '')}`],
+ [
+ 'Button',
+ `${button}\n${button.replace('', '')}\n${button.replace('', '')}`,
+ ],
['ContactList', contactList],
]
const html = document.getElementById('html-target')!
diff --git a/examples/design-system/src/react-demo.tsx b/examples/design-system/src/react-demo.tsx
index 650cf1a..638476c 100644
--- a/examples/design-system/src/react-demo.tsx
+++ b/examples/design-system/src/react-demo.tsx
@@ -28,7 +28,10 @@ function Demo() {
CheckboxField
-
+
(
- setChosen(person.id)} />
+ setChosen(person.id)}
+ />
)}
empty={No contacts yet }
/>
@@ -63,8 +70,23 @@ function Demo() {
import { ContactOption } from '../generated/react'
-function ContactOptionRow({ person, chosen, onChoose }: { person: Contact; chosen: boolean; onChoose: () => void }) {
- return checked && onChoose()} />
+function ContactOptionRow({
+ person,
+ chosen,
+ onChoose,
+}: {
+ person: Contact
+ chosen: boolean
+ onChoose: () => void
+}) {
+ return (
+ checked && onChoose()}
+ />
+ )
}
export function mountReactDemo(container: HTMLElement): void {
diff --git a/examples/design-system/test/generated.test.ts b/examples/design-system/test/generated.test.ts
index 26e1af6..f37b38a 100644
--- a/examples/design-system/test/generated.test.ts
+++ b/examples/design-system/test/generated.test.ts
@@ -15,14 +15,18 @@ 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()) {
+ 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'))
+ const manifest = JSON.parse(
+ await readFile(resolve(root, 'vendor/hwc/custom-elements.json'), 'utf8'),
+ )
return { pages, tokens, manifest }
}
diff --git a/examples/design-system/tsconfig.json b/examples/design-system/tsconfig.json
index b015de7..49775be 100644
--- a/examples/design-system/tsconfig.json
+++ b/examples/design-system/tsconfig.json
@@ -16,5 +16,11 @@
"@hwc/components/*": ["./vendor/hwc/dist/*"]
}
},
- "include": ["src/**/*.ts", "src/**/*.tsx", "generated/react/**/*.ts", "generated/react/**/*.tsx", "test/**/*.ts"]
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "generated/react/**/*.ts",
+ "generated/react/**/*.tsx",
+ "test/**/*.ts"
+ ]
}
diff --git a/packages/agent/test/compile.test.ts b/packages/agent/test/compile.test.ts
index 9c27d33..ad5365c 100644
--- a/packages/agent/test/compile.test.ts
+++ b/packages/agent/test/compile.test.ts
@@ -64,6 +64,7 @@ describe('editOpsSchema', () => {
'Instance',
'Variant',
'Slot',
+ 'Repeat',
])
})
diff --git a/packages/codegen/src/css.ts b/packages/codegen/src/css.ts
index 503d0ca..ee3d5d5 100644
--- a/packages/codegen/src/css.ts
+++ b/packages/codegen/src/css.ts
@@ -171,6 +171,7 @@ export function cssDeclarations(
case 'layoutGrow':
set('flex-grow', cssNumber(value))
break
+ case 'layoutAlign':
case 'layoutAlignSelf':
if (value === 'STRETCH') out['align-self'] = 'stretch'
break
diff --git a/packages/codegen/src/html.ts b/packages/codegen/src/html.ts
index fd192f0..8ad52ae 100644
--- a/packages/codegen/src/html.ts
+++ b/packages/codegen/src/html.ts
@@ -202,15 +202,19 @@ function markup(
),
)
}
- return markup(
- target,
- target.node,
- ctx,
- specBindings(target.spec, index),
- depth,
- index,
- filled,
- )
+ // What this page passes lies over the target's own samples: a `{label}`
+ // is this page's prop, resolved in *this* page's samples (ADR 0017 §3),
+ // a literal is a literal.
+ const passed = new Map(specBindings(target.spec, index))
+ const declared = node.attrs.props?.value
+ if (declared && typeof declared === 'object' && !Array.isArray(declared)) {
+ for (const [key, value] of Object.entries(declared as Record)) {
+ const own = boundProp(model, value)
+ const resolved = own ? samples.get(own.name) : value
+ if (resolved !== undefined && resolved !== null) passed.set(key, resolved)
+ }
+ }
+ return markup(target, target.node, ctx, passed, depth, index, filled)
}
case 'Slot': {
const fill = fills.get(node.name)
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
index c2e7de3..d003456 100644
--- a/packages/codegen/test/generate.test.ts
+++ b/packages/codegen/test/generate.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { CODES } from '@uidx/format'
+import { CODES, parseOrThrow } from '@uidx/format'
import { generate, tsType, componentModel, partTag } from '../src/index.js'
import { CHECKBOX, CONTACT_ITEM, CONTACT_LIST, FIELD, MANIFEST, TOKENS } from './fixtures.js'
@@ -112,6 +112,43 @@ describe('the HTML/CSS target', () => {
})
})
+describe('a composition in the HTML target', () => {
+ it("renders the instance it holds with this page's props laid over its samples", () => {
+ const page = parseOrThrow(`---
+id: checkbox-field
+---
+
+A checkbox with its words.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The option's name.
+
+
+`)
+ const out = generate({
+ pages: [...PAGES, { file: 'checkbox-field.uidx', doc: page }],
+ tokens: [TOKENS],
+ manifest: MANIFEST,
+ })
+ const html = out.files.get('html/checkbox-field.html')!
+ expect(html).toContain('Remember me ')
+ expect(html).toContain('Helper for this one ')
+ expect(html).toContain('')
+ })
+})
+
describe('the React target', () => {
it('types props and events from the contract and wraps the headless root', () => {
const tsx = file('react/Checkbox.tsx')
diff --git a/packages/format/src/spec.ts b/packages/format/src/spec.ts
index 9326b7f..f2c911d 100644
--- a/packages/format/src/spec.ts
+++ b/packages/format/src/spec.ts
@@ -286,6 +286,7 @@ export class SpecLowerer {
if (type) prop.type = type
if (model) prop.model = model
if (child.attrs.default !== undefined) prop.default = child.attrs.default
+ if (child.attrs.sample !== undefined) prop.sample = child.attrs.sample
spec.props.push(prop)
}
break
diff --git a/packages/format/src/types.ts b/packages/format/src/types.ts
index ffd8187..47647f7 100644
--- a/packages/format/src/types.ts
+++ b/packages/format/src/types.ts
@@ -486,6 +486,12 @@ export interface PropSpec {
/** A `{models#Name}` reference, for a prop that receives a model. */
model?: string
default?: JsonValue
+ /**
+ * A demonstration value for the canvas and generated markup, shown where
+ * the prop is bound (ADR 0015 §1: samples are for demonstration only). Not
+ * the default: a required text prop has no default and still needs words.
+ */
+ sample?: JsonValue
controllable: boolean
visual: boolean
description: string
diff --git a/packages/format/test/spec.test.ts b/packages/format/test/spec.test.ts
index a83820d..8612141 100644
--- a/packages/format/test/spec.test.ts
+++ b/packages/format/test/spec.test.ts
@@ -33,7 +33,7 @@ Lets a user toggle one option.
Whether the option is selected.
Inert and dimmed.
- Form field name.
+ Form field name.
Fires once per user toggle.
@@ -109,6 +109,7 @@ describe('spec regions', () => {
['email', false, true],
])
expect(contact.fields[2]!.sample).toEqual(['ada@example.com', null])
+ expect(spec.contract!.props.find((p) => p.name === 'name')!.sample).toBe('choice')
expect(spec.examples).toMatchObject([
{
diff --git a/packages/schema/src/design-system.ts b/packages/schema/src/design-system.ts
index 72ad0f4..568e514 100644
--- a/packages/schema/src/design-system.ts
+++ b/packages/schema/src/design-system.ts
@@ -94,7 +94,13 @@ export function specBindings(
for (const prop of spec.contract.props) {
const model = modelByRef(spec, prop.model)
if (model) bindModel(prop.name, model, 0)
- else if (prop.default !== undefined) out.set(prop.name, prop.default)
+ else {
+ // The sample is what a demonstration shows; the default is what the
+ // contract promises. A prop with neither stays unbound, and a text
+ // bound to it renders empty on the canvas.
+ const shown = prop.sample ?? prop.default
+ if (shown !== undefined) out.set(prop.name, shown)
+ }
}
return out
}
@@ -314,9 +320,17 @@ export function deriveVariants(component: UidxNode): UidxNode {
const declaration: JsonValue = Object.fromEntries(
[...axes].map(([axis, values]) => [axis, values]),
)
+ // The set keeps only what names and describes the component. Its paint and
+ // layout moved onto every variant's root; left here too, the set would draw
+ // the component's fill behind its own variants (ADR 0005 §5: a set has no
+ // geometry of its own, it is sized by the arrangement).
+ const setAttrs: Record = {}
+ for (const [name, attr] of Object.entries(component.attrs)) {
+ if (!(name in ownAttrs)) setAttrs[name] = attr
+ }
return {
...component,
- attrs: { ...component.attrs, variants: synthAttr('variants', declaration, component) },
+ attrs: { ...setAttrs, variants: synthAttr('variants', declaration, component) },
children: variants,
}
}
diff --git a/packages/schema/src/to-scene.ts b/packages/schema/src/to-scene.ts
index e988c99..38378f4 100644
--- a/packages/schema/src/to-scene.ts
+++ b/packages/schema/src/to-scene.ts
@@ -5,6 +5,7 @@ import {
mapLengthLeaves,
isUnitLength,
UNITLESS_NUMBER_PROPS,
+ type PropSpec,
} from '@uidx/format'
import {
computeAllLayouts,
@@ -719,16 +720,53 @@ function declaredDefaults(component: UidxNode, sampleIndex = 0): Map {
+function instanceValues(
+ instance: UidxNode,
+ definition: UidxNode,
+ resolveAlias?: AliasResolver,
+): Map {
const declared = componentProps(definition).declared
+ const contract = new Map(
+ (definition.spec?.contract?.props ?? []).map((prop) => [prop.name, prop] as const),
+ )
const out = new Map()
- for (const [name, value] of declaredInstanceValues(instance).values) {
+ for (const [name, raw] of declaredInstanceValues(instance).values) {
+ // `props={{ label: '{label}' }}` passes the consuming component's own
+ // property through (ADR 0017 §3), so an alias here is the *consumer's*
+ // — resolved in its scope, before the value enters the definition's. A
+ // token alias resolves the same way. One that resolves to nothing is
+ // left unset, so the instance falls back to the definition's default.
+ const target = aliasTarget(raw)
+ const value = target === null ? raw : resolveAlias?.(target)
+ if (value === undefined) continue
const declaration = declared.get(name)
- if (declaration && matchesType(declaration.type, value)) out.set(name, value)
+ if (declaration) {
+ if (matchesType(declaration.type, value)) out.set(name, value)
+ continue
+ }
+ // A component declared by its contract alone (ADR 0013 §5) has no `props`
+ // attribute to be found in; its `` elements are the declaration.
+ const prop = contract.get(name)
+ if (prop && matchesSpecType(prop, value)) out.set(name, value)
}
return out
}
+/**
+ * Whether a value fits a contract prop's TypeScript-ish type, to the depth a
+ * renderer needs: a boolean where the contract says `boolean`, a number for
+ * `number`, a string for `string` or a string enum. Anything else — a model,
+ * an array, a union nobody spelt — is trusted; `uidx check` is the audit.
+ */
+function matchesSpecType(prop: PropSpec, value: JsonValue): boolean {
+ if (prop.model || prop.type === undefined) return true
+ if (prop.type === 'boolean') return typeof value === 'boolean'
+ if (prop.type === 'number') return typeof value === 'number'
+ if (prop.type === 'string' || /^'[^']*'(\s*\|\s*'[^']*')*$/.test(prop.type))
+ return typeof value === 'string'
+ return true
+}
+
/* ------------------------------------------------- instances (story F3) */
/**
@@ -915,7 +953,7 @@ function expandInstance(
options.resolveAlias,
new Map([
...declaredDefaults(definition, options.sampleIndex),
- ...instanceValues(node, definition),
+ ...instanceValues(node, definition, options.resolveAlias),
]),
),
}
@@ -1114,7 +1152,10 @@ export function generatedChildProps(
...options,
resolveAlias: withProperties(
options.resolveAlias,
- new Map([...declaredDefaults(definition), ...instanceValues(instance, definition)]),
+ new Map([
+ ...declaredDefaults(definition),
+ ...instanceValues(instance, definition, options.resolveAlias),
+ ]),
),
}
const root = variantFor(definition, instance) ?? definition
@@ -1270,7 +1311,11 @@ export function scenePropsFor(
}
return {
- ...(node.element === 'Component' ? componentSizing(node) : {}),
+ // A slot "inherits frame-like properties" (ADR 0007 §1), and the one that
+ // matters when it says nothing about its size is this: its box is its
+ // placeholder content, not the engine's 100x100 default — which would
+ // hold a one-line placeholder in a square and push everything below it.
+ ...(node.element === 'Component' || node.element === 'Slot' ? componentSizing(node) : {}),
...(hasVariants(node) ? variantDefinitions(node) : {}),
...(node.element === 'Text' ? textSizing(node) : {}),
name: node.name,
@@ -1307,6 +1352,31 @@ function variantDefinitions(component: UidxNode): Partial {
/** Attrs whose array entries may carry a `color` alias (spec §4). */
const PAINT_PROPS: ReadonlySet = new Set(['fills', 'strokes', 'effects'])
+/** A Figma colour: `{ r, g, b }` in 0–1, alpha optional. */
+function isColor(value: JsonValue): boolean {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
+ const { r, g, b } = value as Record
+ return typeof r === 'number' && typeof g === 'number' && typeof b === 'number'
+}
+
+/**
+ * Follows a whole-attribute alias on a paint list. `strokes="{border#default}"`
+ * names a COLOR token, and a colour is not a paint list, so it becomes the one
+ * solid paint it means. An alias naming a paint list resolves to that list. An
+ * unresolved alias comes back untouched: `scenePropFor` reports it, and the
+ * caller's array check drops it. Shared by `scenePropFor` and the stroke
+ * composition in `scenePropsFor`, which must agree — the composition is what
+ * folds `strokeWeight` into each stroke, and a stroke it never saw is drawn
+ * with no weight at all.
+ */
+function wholePaintAlias(value: JsonValue, resolveAlias: AliasResolver | undefined): JsonValue {
+ const target = aliasTarget(value)
+ if (target === null) return value
+ const bound = resolveAlias?.(target)
+ if (bound === undefined) return value
+ return isColor(bound) ? [{ type: 'SOLID', color: bound }] : bound
+}
+
/**
* Resolves `color` aliases inside paint/effect entries. An entry whose token
* is missing is dropped — not the whole attribute — because the renderer's
@@ -1395,7 +1465,11 @@ export function scenePropFor(
warnings.push(`${at}: unresolved token "${target}"`)
return null
}
- authored = bound
+ // `fills="{surface#accent}"` names a COLOR token for the whole paint list
+ // (ADR 0016 §2 writes every style row this way). A colour is not a paint
+ // list, so it becomes the one solid paint it means; the per-entry form,
+ // `[{ type: 'SOLID', color: '{token}' }]`, still resolves below.
+ authored = PAINT_PROPS.has(prop) && isColor(bound) ? [{ type: 'SOLID', color: bound }] : bound
}
if (UNITLESS_NUMBER_PROPS.has(prop) && isUnitLength(authored)) {
@@ -1512,7 +1586,7 @@ function overridesFor(
if (node.attrs.strokes) {
const strokes = composeStrokes(
resolvePaintAliases(
- node.attrs.strokes.value,
+ wholePaintAlias(node.attrs.strokes.value, resolveAlias),
resolveAlias,
warnings,
node.address || '',
diff --git a/packages/schema/test/design-system.test.ts b/packages/schema/test/design-system.test.ts
index a0f251c..ee6410b 100644
--- a/packages/schema/test/design-system.test.ts
+++ b/packages/schema/test/design-system.test.ts
@@ -73,6 +73,11 @@ describe('a styles table derives the variant set (ADR 0016)', () => {
])
})
+ it('leaves the set itself unpainted: paint and layout live on each root', () => {
+ expect(set.fills ?? []).toEqual([])
+ expect(set.layoutMode).toBe('NONE')
+ })
+
it('applies the matching rows to the root and the bound parts', () => {
const root = (name: string) => scene.graph.getNode(`Checkbox#${name}/root`)!
expect(root('size=md, state=default').fills![0]!.color).toMatchObject({ r: 1, g: 1, b: 1 })
@@ -181,6 +186,103 @@ describe('models bind samples into the tree (ADR 0015)', () => {
expect(scene.graph.getNode('ContactItem#city')!.text).toBe('London')
expect(scene.warnings).toEqual([])
})
+
+ it('shows a prop by its sample before its default, and a text with neither stays empty', () => {
+ const scene = toSceneGraph(
+ parseOrThrow(
+ page(
+ 'labelled',
+ `
+
+
+
+ `,
+ `
+## Contract
+
+
+ Text.
+ Text.
+ Text.
+
+`,
+ ),
+ ),
+ )
+ expect(scene.graph.getNode('Labelled#a')!.text).toBe('Save changes')
+ expect(scene.graph.getNode('Labelled#b')!.text).toBe('A hint')
+ expect(scene.graph.getNode('Labelled#c')!.text).toBe('')
+ })
+})
+
+describe('a composition passes its own props through (ADR 0017 §3)', () => {
+ const field = parseOrThrow(
+ page(
+ 'field',
+ `
+
+
+ `,
+ `
+## Contract
+
+
+ The control's name.
+
+label
+The control.
+`,
+ ),
+ )
+ const outer = (props: string) =>
+ parseOrThrow(
+ page(
+ 'checkbox-field',
+ `
+
+ `,
+ `
+## Contract
+
+
+ The option's name.
+
+
+`,
+ ),
+ )
+ const build = (props: string) => {
+ const doc = outer(props)
+ const index = componentIndex(doc, field)
+ return toSceneGraph(doc, { resolveComponent: (name) => index.get(name) })
+ }
+
+ it("resolves '{label}' in the consumer's scope, not the definition's", () => {
+ const scene = build(`{ label: '{label}' }`)
+ expect(scene.graph.getNode('CheckboxField#field/label')!.text).toBe('Remember me')
+ expect(scene.warnings).toEqual([])
+ })
+
+ it('accepts a literal for a prop the contract alone declares', () => {
+ expect(build(`{ label: 'Hi' }`).graph.getNode('CheckboxField#field/label')!.text).toBe('Hi')
+ })
+
+ it('keeps the default when the passed value is of the wrong type or unresolved', () => {
+ expect(build(`{ label: true }`).graph.getNode('CheckboxField#field/label')!.text).toBe('Email')
+ expect(build(`{ label: '{nothing}' }`).graph.getNode('CheckboxField#field/label')!.text).toBe(
+ 'Email',
+ )
+ })
+})
+
+describe('a slot that says nothing about its size', () => {
+ it('hugs its placeholder rather than sitting in the engine default box', () => {
+ const scene = toSceneGraph(parseOrThrow(LIST_SOURCE))
+ const empty = scene.graph.getNode('ContactList#empty')
+ expect(empty).toBeDefined()
+ expect(empty!.primaryAxisSizing).toBe('HUG')
+ expect(empty!.counterAxisSizing).toBe('HUG')
+ })
})
describe(' multiplies one instance (ADR 0017 §2)', () => {
@@ -294,3 +396,30 @@ describe('contractJson', () => {
])
})
})
+
+describe('a whole-attribute paint alias', () => {
+ it('resolves a COLOR token on fills and strokes to one solid paint', () => {
+ const doc = parseOrThrow(
+ page(
+ 'paint',
+ ` `,
+ ),
+ )
+ const scene = toSceneGraph(doc, {
+ resolveAlias: (address) =>
+ address === 'color#brand' ? { r: 0, g: 0.5, b: 1, a: 1 } : undefined,
+ })
+ const box = scene.graph.getNode('box')!
+ expect(box.fills).toHaveLength(1)
+ expect(box.fills![0]).toMatchObject({ type: 'SOLID', color: { r: 0, g: 0.5, b: 1, a: 1 } })
+ // Strokes go through the composition that folds `strokeWeight` in; a
+ // stroke that skipped it would keep the colour and lose its weight, and
+ // draw as nothing.
+ expect(box.strokes?.[0]).toMatchObject({
+ color: { r: 0, g: 0.5, b: 1, a: 1 },
+ weight: 2,
+ align: 'INSIDE',
+ })
+ expect(scene.warnings).toEqual([])
+ })
+})
From 10d027243bf9d8b3df1a730cf32c6d04f54eec7a Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 08:23:16 +0300
Subject: [PATCH 06/74] viewer: Contract tab binds components, parts and
repeats to the headless library
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The inspector gains a Contract tab beside Design (ADR 0013 §3 amended).
Figma's precedent: bindings live in the properties panel, declared on the
component and applied from the layer, with choices from a curated list.
- A component chooses the element it implements from the library's roots; a
text field only when the document names no library.
- Each declared part is a row: bound rows name their layer and select it,
unbound rows are a picker over the layers that could draw it. Binding from
a new layer unbinds the old one in the same patch. A layer's own row offers
the same parts, naming who holds the taken ones.
- A picks a declared repeating slot (reselecting itself at its new
address) and its count. Slots show whether the tree provides them.
- The file's contract (props, events) is shown beside the bindings; the tab
badge counts parts still to bind.
uidx.json gains "headless": the library's custom-elements.json, served on
/__uidx/headless and read on every document:opened; uidx codegen uses it
when --manifest is absent. Roots and parts follow the - naming
convention (plus cssParts), with a leaf test so hwc-text-input stays a root
beside hwc-text.
Co-Authored-By: Claude Fable 5.1
---
CHANGELOG.md | 8 +
README.md | 7 +-
docs/decisions/0013-component-contract.md | 10 +
examples/design-system/.uidx/uidx.json | 2 +-
packages/cli/README.md | 5 +-
packages/cli/src/commands/codegen.ts | 13 +-
packages/server/src/document.ts | 13 +
packages/server/src/headless.ts | 73 ++
packages/server/src/server.ts | 2 +
packages/server/test/headless.test.ts | 104 ++
packages/viewer/src/App.vue | 8 +
packages/viewer/src/ContractSection.vue | 656 +++++++++++
packages/viewer/src/PropertiesPane.vue | 1169 ++++++++++---------
packages/viewer/src/contract-edits.ts | 348 ++++++
packages/viewer/src/headless.ts | 132 +++
packages/viewer/src/inspector-controls.css | 4 +-
packages/viewer/test/contract-edits.test.ts | 361 ++++++
17 files changed, 2375 insertions(+), 540 deletions(-)
create mode 100644 packages/server/src/headless.ts
create mode 100644 packages/server/test/headless.test.ts
create mode 100644 packages/viewer/src/ContractSection.vue
create mode 100644 packages/viewer/src/contract-edits.ts
create mode 100644 packages/viewer/src/headless.ts
create mode 100644 packages/viewer/test/contract-edits.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b14281b..4118513 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,14 @@ 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.
+- 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
+ `` picks a declared repeating slot and its count. The tab shows the
+ file's contract beside the bindings and counts the parts still to bind.
+ `uidx.json` gains an optional `"headless"` path to the library's
+ `custom-elements.json`, served to the viewer and used by `uidx codegen` when
+ `--manifest` is absent.
## 0.1.6 — 2026-09-25
diff --git a/README.md b/README.md
index 36da22d..28eb191 100644
--- a/README.md
+++ b/README.md
@@ -100,10 +100,15 @@ The separate `.uidx/uidx.json` manifest controls which files belong to the docum
{
"id": "my-project",
"files": ["**/*.uidx"],
- "assets": ["assets/**"]
+ "assets": ["assets/**"],
+ "headless": "../vendor/hwc/custom-elements.json"
}
```
+`headless` is optional. It names the headless library's `custom-elements.json`,
+relative to `uidx.json`; with it, the viewer's Contract tab offers the library's
+elements and parts as choices, and `uidx codegen` checks contracts against it.
+
### CLI and MCP
`npm run uidx` starts the local viewer, file synchronization server, and MCP HTTP
diff --git a/docs/decisions/0013-component-contract.md b/docs/decisions/0013-component-contract.md
index 40f556b..3ddb527 100644
--- a/docs/decisions/0013-component-contract.md
+++ b/docs/decisions/0013-component-contract.md
@@ -68,6 +68,16 @@ shorthand rule of the visual contract does not apply here.
- `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).
+- The bindings are edited from the inspector's **Contract** tab as well as by
+ hand. `uidx.json` may name the headless library's `custom-elements.json`
+ (`"headless"`); the tab then offers its elements as the choices for
+ `implements`, and each root's parts — the `-` elements, plus any
+ `cssParts` — together with the contract's own `` as the choices for
+ `part`. A part is bound from the component's list or from the layer's row,
+ the way Figma declares a property on the component and applies it from the
+ layer; binding it from a new layer unbinds the old one, since one node per
+ part is the rule. A `` chooses among the contract's repeating slots.
+ The tab never edits the contract's prose; it shows it beside the bindings.
### 4. Declaration in uidx, implementation in code
diff --git a/examples/design-system/.uidx/uidx.json b/examples/design-system/.uidx/uidx.json
index 63263bb..9f9af72 100644
--- a/examples/design-system/.uidx/uidx.json
+++ b/examples/design-system/.uidx/uidx.json
@@ -1 +1 @@
-{ "id": "example-design-system", "files": ["**/*.uidx"], "assets": ["assets/**"] }
+{"id": "example-design-system", "files": ["**/*.uidx"], "assets": ["assets/**"], "headless": "../vendor/hwc/custom-elements.json"}
diff --git a/packages/cli/README.md b/packages/cli/README.md
index d69b142..3cb7399 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -49,7 +49,10 @@ If the port is busy, the server tries the next port; CLI/MCP find the live port.
The default `.uidx/uidx.json` includes `"files": ["**/*.uidx"]` and
`"assets": ["assets/**"]`. Add pages in nested folders; new pages appear
automatically. Images such as `assets/logo.svg` are relative to `.uidx/`.
-Viewer and file edits sync both ways.
+Viewer and file edits sync both ways. An optional `"headless"` names the
+headless library's `custom-elements.json` (relative to `uidx.json`): the
+viewer's Contract tab then offers its elements and parts as choices, and
+`uidx codegen` checks contracts against it without `--manifest`.
## Server and agent tools
diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts
index 3173b1e..6cff9d5 100644
--- a/packages/cli/src/commands/codegen.ts
+++ b/packages/cli/src/commands/codegen.ts
@@ -4,6 +4,7 @@ import { parseArgs } from 'node:util'
import { glob } from 'tinyglobby'
import { formatDiagnostic, parse, type UidxDocument } from '@uidx/format'
import { generate, type Manifest, type Target } from '@uidx/codegen'
+import { findManifest } from '@uidx/server'
import type { Io } from '../cli.js'
/**
@@ -63,9 +64,17 @@ export async function runCodegen(argv: string[], io: Io): Promise {
}
if (failed) return 1
+ // The flag wins; otherwise the document's own `headless` (uidx.json) names
+ // the library, so a checked-in config and the viewer's Contract tab read the
+ // same file this command checks against.
+ let manifestPath = parsed.values.manifest ? resolve(cwd, parsed.values.manifest) : undefined
+ if (!manifestPath) {
+ const found = await findManifest(cwd)
+ if (found?.manifest.headless) manifestPath = resolve(found.dir, found.manifest.headless)
+ }
let manifest: Manifest | undefined
- if (parsed.values.manifest) {
- manifest = JSON.parse(await readFile(resolve(cwd, parsed.values.manifest), 'utf8')) as Manifest
+ if (manifestPath) {
+ manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Manifest
}
const targets = parsed.values.target!.split(',').map((t) => t.trim()) as Target[]
for (const target of targets) {
diff --git a/packages/server/src/document.ts b/packages/server/src/document.ts
index 2ff2c09..f3596ae 100644
--- a/packages/server/src/document.ts
+++ b/packages/server/src/document.ts
@@ -20,6 +20,13 @@ export interface Manifest {
* document that never referenced an image should not have to say so.
*/
assets: string[]
+ /**
+ * The headless library's `custom-elements.json`, relative to `uidx.json`
+ * (ADR 0013 §3). Optional: a document with no code render has none. When
+ * present the viewer offers its elements and parts as choices in the
+ * Contract tab, and `uidx codegen` checks contracts against it by default.
+ */
+ headless?: string
}
export interface FoundManifest {
@@ -108,6 +115,11 @@ export async function readManifest(path: string): Promise {
}
}
+ const headless = record.headless
+ if (headless !== undefined && (typeof headless !== 'string' || headless === '')) {
+ problems.push(`${path}: "headless" must be a path to a custom-elements.json`)
+ }
+
if (problems.length) throw new ManifestError(problems)
return {
id: id as string,
@@ -115,6 +127,7 @@ export async function readManifest(path: string): Promise {
// Absent means the conventional folders, all of them. An empty array is a
// different statement — "this document has no assets" — and is honoured.
assets: assets === undefined ? [...DEFAULT_ASSET_GLOBS] : (assets as string[]),
+ ...(headless === undefined ? {} : { headless: headless as string }),
}
}
diff --git a/packages/server/src/headless.ts b/packages/server/src/headless.ts
new file mode 100644
index 0000000..df6f91e
--- /dev/null
+++ b/packages/server/src/headless.ts
@@ -0,0 +1,73 @@
+import { readFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import type { FoundManifest } from './document.js'
+import type { MiddlewareHost, ViewerPlugin } from './static-viewer.js'
+
+export const HEADLESS_ROUTE = '/__uidx/headless'
+
+/**
+ * What the viewer receives for the headless library (ADR 0013 §3).
+ *
+ * `path` is the manifest's own word for where the library is, relative to
+ * `uidx.json`, so the panel can say which file its choices come from. `null`
+ * means the document declared none — a state, not an error, and the panel
+ * says how to declare one.
+ */
+export interface HeadlessResponse {
+ path: string | null
+ /** The `custom-elements.json` as written; the viewer reads the slice it needs. */
+ library?: unknown
+}
+
+/**
+ * Serves the document's `custom-elements.json` to the viewer.
+ *
+ * Read on every request rather than cached: the library is a build artefact of
+ * another repository, and a designer who re-syncs it should see the new
+ * elements on the next reload without restarting the server. Same holder
+ * indirection as the font and asset routes, for the same reason — the route
+ * registers before the workspace resolves.
+ */
+export function headlessRoutePlugin(manifest: { current: FoundManifest | null }): ViewerPlugin {
+ const handle = async (request: IncomingMessage, response: ServerResponse): Promise => {
+ response.setHeader('cache-control', 'no-store')
+ response.setHeader('content-type', 'application/json')
+ if (request.method !== 'GET') {
+ response.statusCode = 405
+ response.end(JSON.stringify({ error: 'The headless library is read-only.' }))
+ return
+ }
+ const found = manifest.current
+ if (!found) {
+ response.statusCode = 404
+ response.end(JSON.stringify({ error: 'Open a uidx project to read its headless library.' }))
+ return
+ }
+ const declared = found.manifest.headless
+ if (declared === undefined) {
+ response.end(JSON.stringify({ path: null } satisfies HeadlessResponse))
+ return
+ }
+ try {
+ const raw = await readFile(resolve(found.dir, declared), 'utf8')
+ const library: unknown = JSON.parse(raw)
+ response.end(JSON.stringify({ path: declared, library } satisfies HeadlessResponse))
+ } catch (error) {
+ response.statusCode = 400
+ response.end(
+ JSON.stringify({
+ error: `Could not read the headless library at ${declared}: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ }),
+ )
+ }
+ }
+ const configure = (server: MiddlewareHost): void => {
+ server.middlewares.use(HEADLESS_ROUTE, (req, res) => {
+ void handle(req, res)
+ })
+ }
+ return { name: 'uidx:headless', configureServer: configure, configurePreviewServer: configure }
+}
diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts
index 744d1ae..126dbed 100644
--- a/packages/server/src/server.ts
+++ b/packages/server/src/server.ts
@@ -3,6 +3,7 @@ import type { UidxPatch } from '@uidx/format'
import { ASSET_ROUTE, assetReply } from './assets.js'
import { fontRoutePlugin } from './fonts.js'
+import { headlessRoutePlugin } from './headless.js'
import { pagesRoutePlugin } from './pages.js'
import { allowsLocalRequest, localAccessPlugin } from './local-access.js'
import type { FoundManifest } from './document.js'
@@ -528,6 +529,7 @@ async function listenOnFreePort(
const plugins = [
localAccessPlugin(),
fontRoutePlugin(manifest),
+ headlessRoutePlugin(manifest),
assetRoutePlugin(manifest),
selectionRoutePlugin(selection),
patchRoutePlugin(patchRoute),
diff --git a/packages/server/test/headless.test.ts b/packages/server/test/headless.test.ts
new file mode 100644
index 0000000..8c2f562
--- /dev/null
+++ b/packages/server/test/headless.test.ts
@@ -0,0 +1,104 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { readManifest } from '../src/document.js'
+import { createUidxServer, type UidxServer } from '../src/server.js'
+
+/**
+ * The headless library reaches the viewer (ADR 0013 §3).
+ *
+ * `uidx.json` names a `custom-elements.json`; the route serves it as written
+ * so the Contract tab can offer elements and parts from a list rather than a
+ * string an author has to know. A document that names none is a state the
+ * viewer explains, not an error.
+ */
+let dir: string
+let server: UidxServer | undefined
+const source = '---\nid: welcome\n---\n\n## Visual Contract\n\n \n'
+const LIBRARY = {
+ schemaVersion: '1.0.0',
+ modules: [
+ {
+ declarations: [
+ { tagName: 'hwc-button', customElement: true, attributes: [{ name: 'disabled' }] },
+ { tagName: 'hwc-button-label', customElement: true },
+ ],
+ },
+ ],
+}
+
+beforeEach(async () => {
+ dir = await mkdtemp(join(tmpdir(), 'uidx-headless-'))
+ await writeFile(join(dir, 'welcome.uidx'), source)
+ await mkdir(join(dir, 'dist'))
+ await writeFile(join(dir, 'dist/index.html'), 'Viewer ')
+})
+afterEach(async () => {
+ await server?.close()
+ server = undefined
+ await rm(dir, { recursive: true, force: true })
+})
+
+async function start(config: Record) {
+ await writeFile(
+ join(dir, 'uidx.json'),
+ JSON.stringify({ id: 'test', files: ['**/*.uidx'], ...config }),
+ )
+ server = await createUidxServer({
+ file: join(dir, 'welcome.uidx'),
+ root: dir,
+ viewerDist: join(dir, 'dist'),
+ port: 4860,
+ })
+}
+
+describe('uidx.json "headless"', () => {
+ it('is optional, and a path when present', async () => {
+ await writeFile(join(dir, 'uidx.json'), JSON.stringify({ id: 'a', files: ['*.uidx'] }))
+ expect((await readManifest(join(dir, 'uidx.json'))).headless).toBeUndefined()
+ await writeFile(
+ join(dir, 'uidx.json'),
+ JSON.stringify({ id: 'a', files: ['*.uidx'], headless: 'vendor/custom-elements.json' }),
+ )
+ expect((await readManifest(join(dir, 'uidx.json'))).headless).toBe(
+ 'vendor/custom-elements.json',
+ )
+ await writeFile(
+ join(dir, 'uidx.json'),
+ JSON.stringify({ id: 'a', files: ['*.uidx'], headless: 3 }),
+ )
+ await expect(readManifest(join(dir, 'uidx.json'))).rejects.toThrow(/"headless" must be a path/)
+ })
+})
+
+describe('the headless route', () => {
+ it('serves the declared library as written, relative to uidx.json', async () => {
+ await mkdir(join(dir, 'vendor'))
+ await writeFile(join(dir, 'vendor/custom-elements.json'), JSON.stringify(LIBRARY))
+ await start({ headless: 'vendor/custom-elements.json' })
+ const response = await fetch(`${server!.url}/__uidx/headless`)
+ expect(response.status).toBe(200)
+ expect(response.headers.get('cache-control')).toBe('no-store')
+ expect(await response.json()).toEqual({ path: 'vendor/custom-elements.json', library: LIBRARY })
+ })
+
+ it('answers a document that declares none with a null path, and names an unreadable one', async () => {
+ await start({})
+ expect(await (await fetch(`${server!.url}/__uidx/headless`)).json()).toEqual({ path: null })
+ await server!.close()
+ server = undefined
+ await start({ headless: 'missing/custom-elements.json' })
+ const response = await fetch(`${server!.url}/__uidx/headless`)
+ expect(response.status).toBe(400)
+ expect(((await response.json()) as { error: string }).error).toMatch(
+ /missing\/custom-elements\.json/,
+ )
+ })
+
+ it('is read-only', async () => {
+ await start({})
+ const response = await fetch(`${server!.url}/__uidx/headless`, { method: 'POST' })
+ expect(response.status).toBe(405)
+ })
+})
diff --git a/packages/viewer/src/App.vue b/packages/viewer/src/App.vue
index f99abec..0b1e9ea 100644
--- a/packages/viewer/src/App.vue
+++ b/packages/viewer/src/App.vue
@@ -49,6 +49,7 @@ import EditToolbar from './EditToolbar.vue'
import WorkspaceNav from './WorkspaceNav.vue'
import FontsPane from './FontsPane.vue'
import { fontGeneration, fontsInFileKey, openFontsKey } from './font-library'
+import { headlessError, headlessLibrary, refreshHeadless } from './headless'
import WorkspaceStatus from './WorkspaceStatus.vue'
import { canRemove, componentFrom, remapAddress } from './layer-moves'
import { componentRenamePlan, offerableComponents } from './component-rename'
@@ -374,6 +375,10 @@ const socket = createUidxSocket({
// page and no answer at all for the dashboard.
documentId.value = message.id
forgetDeparted(message.pages)
+ // The headless library the Contract tab offers (ADR 0013 §3). On every
+ // announcement rather than once: a reconnect is also when a re-synced
+ // `custom-elements.json` should show its new elements.
+ void refreshHeadless()
// The URL outranks the server's entry page: a reload, a bookmark and a
// Back all arrive here, and every one of them means "the page I was on"
// rather than "the page the command line named". `replaceState` rather
@@ -1715,6 +1720,9 @@ onUnmounted(() => socket.close())
:pin-frame="pinFrame"
:vector-info="vectorInfo"
:can-make-component="componentSource !== null"
+ :headless="headlessLibrary"
+ :headless-error="headlessError"
+ @select="selection = [$event]"
@edit-vector="editVector"
@finish-vector="canvasPane?.finishDrawing()"
@vector-action="canvasPane?.vectorAction($event)"
diff --git a/packages/viewer/src/ContractSection.vue b/packages/viewer/src/ContractSection.vue
new file mode 100644
index 0000000..3e0df73
--- /dev/null
+++ b/packages/viewer/src/ContractSection.vue
@@ -0,0 +1,656 @@
+
+
+
+
+
+
+
+ Select a component to choose the headless element it implements, or a layer inside one to
+ name the part it draws.
+
+
+
+
+
+ Implements
+ {{ view.component.name }}
+
+
+
+ Element
+
+
+ None
+
+ {{ option.tag }}{{ option.known ? '' : ' · not in library' }}
+
+
+
+
+ ↺
+
+
+
+
+
+ Attributes: {{ view.element.attributes.join(', ') }}.
+
+
+ Events: {{ view.element.events.join(', ') }}.
+
+
+
+
+ Parts
+ {{ boundCount }} of {{ view.parts.length }} bound
+
+
+
+ Choose an element above to see the parts it offers.
+
+
+ “{{ view.implementsValue }}” is not in the library, and the contract declares no parts.
+
+ “{{ view.implementsValue }}” has no parts to bind.
+
+
+
+
+ {{ row.name }}
+ ?
+
+
+
+
+
+ {{ row.boundTo.name }}
+
+
+
+ {{ view.candidates.length ? 'Bind a layer…' : 'No layer to bind' }}
+
+
+ {{ indent(c.depth) }}{{ c.name }}
+
+
+
+ ×
+
+
+
+
+ “{{ stray.name }}” is bound to “{{ stray.part }}”, which nothing declares.
+
+ Show it
+
+
+
+
+
+
+
+ {{ slot.name }}
+ repeats
+
+
+
+
+
+
+ {{
+ slot.provided.kind === 'repeat'
+ ? `Repeat × ${slot.provided.count ?? '?'}`
+ : 'Slot in tree'
+ }}
+
+
+
+ {{ slot.repeats ? 'No repeat in the tree yet' : 'No slot in the tree yet' }}
+
+
+
+
+
+
+
+ Contract
+ from the file
+
+
+ {{ prop.name }}
+ {{ typeOf(prop) }}
+
+
+
+ on {{ event.name }}
+ {{ event.detail ?? 'event' }}
+
+
+
+
+
+
+
+ Part
+ of {{ view.component.name }}
+
+
+ {{ view.component.name }} implements no element yet, so there is no part for this layer to
+ draw.
+
+ Select {{ view.component.name }}
+
+
+
+
+ Draws
+
+ Nothing — design only
+
+ {{ option.name }}{{ option.takenBy ? ` · bound to ${option.takenBy}` : '' }}
+
+
+
+ ↺
+
+
+
+
+
+
+
+ Repeat
+ {{ view.child.component || view.child.name }}
+
+
+ Slot
+
+
+ {{ view.slotValue }} · not declared
+
+ {{ name }}
+
+
+
+
+
+ Count
+
+
+
+
+ {{
+ view.component
+ ? `${view.component.name}'s contract declares no repeating slot; add one under ## Contract.`
+ : 'A repeat belongs inside a component.'
+ }}
+
+
+ A repeat multiplies one instance; this one holds none.
+
+
+
+
+
+ Slot
+ of {{ view.component.name }}
+
+
+ “{{ view.node.name }}” is declared by the contract
+ as repeating
+ ; each item is an {{ view.declared.accepts }} . Consumers fill it; what is inside is the placeholder.
+
+
+ The contract does not declare a slot called “{{ view.node.name }}”. Add it under
+ <Slots>, or rename this one.
+
+
+
+
+
+ An instance renders its component's contract. Select the component to change what it
+ implements or how its parts are bound.
+
+
+
+
+
+ Only layers inside a component draw a part. Move this layer into a component, or make one
+ from it.
+
+
+
+
+ {{ libraryError }}
+
+ Library: {{ library.path }} · {{ library.roots.length }} elements
+
+
+ No headless library. Add "headless": "…/custom-elements.json" to uidx.json to
+ choose elements and parts from a list.
+
+
+
+
+
diff --git a/packages/viewer/src/PropertiesPane.vue b/packages/viewer/src/PropertiesPane.vue
index cba0471..b9f068a 100644
--- a/packages/viewer/src/PropertiesPane.vue
+++ b/packages/viewer/src/PropertiesPane.vue
@@ -10,6 +10,9 @@ import {
isUnitLength,
} from '@uidx/format'
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 { computed, ref, shallowRef, watch } from 'vue'
import { vectorEndpoints } from '@open-pencil/core/vector'
@@ -117,6 +120,10 @@ import type { VariableCandidate } from './variable-binding'
const props = defineProps<{
doc: UidxDocument | null
selection?: string[]
+ /** The headless library the Contract tab offers choices from (ADR 0013 §3). */
+ headless?: HeadlessLibrary | null
+ /** Why the library could not be read, shown in the Contract tab. */
+ headlessError?: string
/** Token address -> literal, so a bound row can show what it resolves to. */
tokens?: Map
/**
@@ -226,8 +233,25 @@ const emit = defineEmits<{
finishVector: []
vectorAction: [action: VectorAction]
makeComponent: []
+ /** The Contract tab names layers; choosing one selects it, as the rail would. */
+ select: [address: string]
}>()
+/**
+ * Which face the inspector shows: Design, the scene properties, or Contract,
+ * the bindings to the code render (ADR 0013 §3). Two tabs rather than one
+ * more section, because they answer different questions about the same layer
+ * — what it looks like, and what it is to the headless library — and Figma's
+ * Design / Dev split is the precedent authors already know. Pane-local: the
+ * choice is about how to look, not what is open.
+ */
+const face = ref<'design' | 'contract'>('design')
+
+/** Unbound or stray parts on the selected component, for the tab's badge. */
+const contractIssueCount = computed(() =>
+ contractIssues(contractView(props.doc, active.value, props.headless ?? null)),
+)
+
function onHover(prop: string | null): void {
const address = active.value?.address
if (address) emit('hover', address, prop)
@@ -1255,7 +1279,20 @@ 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.
-
-
-
- Reset slot
-
-
- Delete contents
-
-
-
-
-
-
-
-
+
+
-
-
- Convert to slot
-
-
-
-
-
-
Component
-
{{ swapRow.component }}
-
onLink('component', name)"
- @unlink="onUnlink('component')"
- @create="onCreate('component')"
- @edit="onEditDeclaration"
- />
-
+
+
Component
+
{{ swapRow.component }}
+
onLink('component', name)"
+ @unlink="onUnlink('component')"
+ @create="onCreate('component')"
+ @edit="onEditDeclaration"
+ />
+
-
-
+
-
-
+
-
+
- This node declares no properties.
+ This node declares no properties.
-
-
-
-
- {{ section.label }}
- ›
-
-
-
-
-
-
-
- onLink(link!.prop, name)"
- @unlink="onUnlink(link!.prop)"
- @create="onCreate(link!.prop)"
- @pick-variable="(a) => onPickVariable(link!.prop, a)"
- />
-
-
-
-
-
-
-
-
-
+
-
-
+
-
-
+
-
-
+
+
-
-
-
-
- {{ paired.field.label }}
- {{ paired.pairedWith.label }}
-
-
-
-
+
+ {{ paired.field.label }}
+ {{ paired.pairedWith.label }}
-
-
+
+
-
-
-
- Corner radius
-
-
-
-
-
+
+ Corner radius
+
+
+
+
+
-
-
+
-
+
-
-
-
-
-
-
-
- Export
- ›
-
-
-
-
-
+
+
+
+
+
+ {{ format }}
+
+
-
-
-
-
- Export {{ exportFileName }}
-
-
-
-
+
+ Export {{ exportFileName }}
+
+
+
+
+
@@ -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/74] 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
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ On or off.
+ The words.
+
+
+ Fires on toggle.
+
+
+thumb, label
+`)
+ const out = generate({
+ pages: [{ file: 'switch.uidx', doc: page }],
+ tokens: [TOKENS],
+ manifest,
+ })
+
+ it('styles it through ::part() and emits no element for it', () => {
+ const css = out.files.get('html/switch.css')!
+ expect(css).toContain('sl-switch::part(thumb) {')
+ expect(css).toContain('sl-switch[checked]::part(thumb) {')
+ expect(css).toContain('sl-switch::part(label) {')
+ const html = out.files.get('html/switch.html')!
+ expect(html).not.toContain('sl-switch-thumb')
+ expect(html).not.toContain('sl-switch-label')
+ expect(html).toContain('')
+ const tsx = out.files.get('react/Switch.tsx')!
+ expect(tsx).not.toContain('Switch.Label')
+ expect(tsx).not.toContain('sl-switch-label')
+ })
+
+ it('conforms, with a warning for what the design drew under a part the library draws', () => {
+ expect(out.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
+ const warnings = out.diagnostics.filter((d) => d.severity === 'warning')
+ expect(warnings.map((d) => d.message)).toEqual([
+ 'part "label" is a shadow part of sl-switch: the library draws it, so what "label" holds is styled through ::part() but not rendered',
+ ])
+ })
+})
+
describe('the React target', () => {
it('types props and events from the contract and wraps the headless root', () => {
const tsx = file('react/Checkbox.tsx')
diff --git a/packages/viewer/src/ContractSection.vue b/packages/viewer/src/ContractSection.vue
index 3e0df73..a45b713 100644
--- a/packages/viewer/src/ContractSection.vue
+++ b/packages/viewer/src/ContractSection.vue
@@ -209,6 +209,12 @@ const typeOf = (prop: { type?: string; model?: string }): string => prop.model ?
>
{{ row.name }}
?
+ shadow
prop.model ?
:value="option.name"
:disabled="option.takenBy !== null"
>
- {{ option.name }}{{ option.takenBy ? ` · bound to ${option.takenBy}` : '' }}
+ {{ option.name }}{{ option.kind === 'shadow' ? ' · shadow' : ''
+ }}{{ option.takenBy ? ` · bound to ${option.takenBy}` : '' }}
`, or both. */
declaredBy: 'library' | 'contract' | 'both'
+ /** How the library exposes it; absent for a part only the contract declares. */
+ kind?: 'element' | 'shadow'
/** The layer bound to it, or null while unbound. */
boundTo: { address: string; name: string; element: string } | null
}
@@ -61,7 +63,7 @@ export interface PartView {
component: UidxNode
partValue: string | null
/** Every declared part; `takenBy` names the other layer holding it. */
- options: { name: string; takenBy: string | null }[]
+ options: { name: string; takenBy: string | null; kind?: 'element' | 'shadow' }[]
/** True when neither the library nor the contract declares any part. */
undeclared: boolean
}
@@ -96,8 +98,8 @@ export type ContractView = ComponentView | PartView | RepeatView | SlotView | Ot
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 part of element?.parts ?? [])
+ rows.set(part.name, { name: part.name, declaredBy: 'library', kind: part.kind, boundTo: null })
for (const name of contract) {
const row = rows.get(name)
if (row) row.declaredBy = 'both'
@@ -219,6 +221,7 @@ function partView(node: UidxNode, component: UidxNode, library: HeadlessLibrary
return {
name: row.name,
takenBy: holder && holder.address !== node.address ? holder.name : null,
+ ...(row.kind ? { kind: row.kind } : {}),
}
})
// A value nothing declares still shows, so the row never lies about the file.
diff --git a/packages/viewer/src/headless.ts b/packages/viewer/src/headless.ts
index 29367db..547f78a 100644
--- a/packages/viewer/src/headless.ts
+++ b/packages/viewer/src/headless.ts
@@ -8,10 +8,20 @@ import { shallowRef } from 'vue'
* *roots* an author may implement, and which parts and slots each root has.
* The rest — attributes, events — is shown for orientation and never written.
*/
+/**
+ * A part as the library exposes it: an element of its own (`-`,
+ * which can hold the design's content) or a shadow part (`cssParts`, styled
+ * through `::part()` and drawn by the library).
+ */
+export interface HeadlessPart {
+ name: string
+ kind: 'element' | 'shadow'
+}
+
export interface HeadlessElement {
tag: string
- /** The part names this root offers, from `-` elements and `cssParts`. */
- parts: string[]
+ /** The parts this root offers, from `-` elements and `cssParts`. */
+ parts: HeadlessPart[]
/** Slot names; `''` is the default slot. */
slots: string[]
attributes: string[]
@@ -63,7 +73,7 @@ export function parseHeadless(path: string, manifest: unknown): HeadlessLibrary
if (typeof tag !== 'string' || tag === '' || declared.has(tag)) continue
declared.set(tag, {
tag,
- parts: names(declaration.cssParts),
+ parts: names(declaration.cssParts).map((name) => ({ name, kind: 'shadow' as const })),
slots: names(declaration.slots),
attributes: names(declaration.attributes),
events: names(declaration.events),
@@ -90,7 +100,10 @@ export function parseHeadless(path: string, manifest: unknown): HeadlessLibrary
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 known = owner.parts.find((entry) => entry.name === part)
+ // An element wins over a cssPart of the same name: it can hold content.
+ if (known) known.kind = 'element'
+ else owner.parts.push({ name: part, kind: 'element' })
}
const roots = [...declared.values()]
diff --git a/packages/viewer/test/contract-edits.test.ts b/packages/viewer/test/contract-edits.test.ts
index db8034b..95c55b9 100644
--- a/packages/viewer/test/contract-edits.test.ts
+++ b/packages/viewer/test/contract-edits.test.ts
@@ -12,7 +12,7 @@ import {
setRepeatCount,
setRepeatSlot,
} from '../src/contract-edits'
-import { parseHeadless } from '../src/headless'
+import { parseHeadless, type HeadlessLibrary } from '../src/headless'
/**
* Binding the visual tree to its code render from the inspector (ADR 0013 §3,
@@ -108,11 +108,13 @@ describe('the headless library, as parsed', () => {
'hwc-text-input',
])
expect(LIBRARY.elements.get('hwc-checkbox')!.parts).toEqual([
- 'checked-indicator',
- 'indeterminate-indicator',
+ { name: 'checked-indicator', kind: 'element' },
+ { name: 'indeterminate-indicator', kind: 'element' },
+ ])
+ expect(LIBRARY.elements.get('hwc-text-input')!.parts).toEqual([
+ { name: 'leading-icon', kind: 'element' },
])
- 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-text')!.parts).toEqual([{ name: 'glyph', kind: 'shadow' }])
expect(LIBRARY.elements.get('hwc-field')!.slots).toEqual(['', 'control'])
})
})
@@ -128,9 +130,10 @@ describe('what the tab shows', () => {
{
name: 'checked-indicator',
declaredBy: 'both',
+ kind: 'element',
boundTo: { address: 'Checkbox#check', name: 'check', element: 'Vector' },
},
- { name: 'indeterminate-indicator', declaredBy: 'both', boundTo: null },
+ { name: 'indeterminate-indicator', declaredBy: 'both', kind: 'element', boundTo: null },
])
// Bound layers leave the candidate list; instances never enter it.
expect(view.candidates.map((c) => c.name)).toEqual(['dash', 'ring'])
@@ -152,8 +155,8 @@ describe('what the tab shows', () => {
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 },
+ { name: 'checked-indicator', takenBy: 'check', kind: 'element' },
+ { name: 'indeterminate-indicator', takenBy: null, kind: 'element' },
])
})
@@ -187,6 +190,37 @@ describe('what the tab shows', () => {
})
})
+describe('a shadow part in the tab', () => {
+ it('is offered like any other, and marked so the author knows it cannot be filled', () => {
+ const doc = parseOrThrow(
+ page(
+ 'glyph',
+ `
+
+ `,
+ ),
+ )
+ const view = contractView(doc, resolve(doc.tree, 'Glyph'), LIBRARY)
+ if (view.kind !== 'component') throw new Error(view.kind)
+ expect(view.parts).toEqual([
+ { name: 'glyph', declaredBy: 'library', kind: 'shadow', boundTo: null },
+ ])
+ const section = mount(ContractSection, {
+ props: { doc, node: resolve(doc.tree, 'Glyph'), library: LIBRARY, writable: true },
+ })
+ expect(section.find('[data-part="glyph"] .pill').text()).toBe('shadow')
+ const layer = mount(ContractSection, {
+ props: { doc, node: resolve(doc.tree, 'Glyph#mark'), library: LIBRARY, writable: true },
+ })
+ expect(
+ layer
+ .find('[data-field="part"] select')
+ .findAll('option')
+ .map((o) => o.text().trim()),
+ ).toEqual(['Nothing — design only', 'glyph · shadow'])
+ })
+})
+
describe('the writes', () => {
const doc = parseOrThrow(CHECKBOX)
const component = resolve(doc.tree, 'Checkbox')!
@@ -243,7 +277,12 @@ describe('the writes', () => {
})
describe('the Contract section', () => {
- const mountFor = (source: string, address: string | null, writable = true, library = LIBRARY) => {
+ const mountFor = (
+ source: string,
+ address: string | null,
+ writable = true,
+ library: HeadlessLibrary | null = LIBRARY,
+ ) => {
const doc = parseOrThrow(source)
return mount(ContractSection, {
props: { doc, node: address ? resolve(doc.tree, address) : null, library, writable },
From 749a5754db6b0f1196a25217ff25ab2a40df5432 Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 10:02:01 +0300
Subject: [PATCH 08/74] codegen: a shadow part is not a finding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The conformance warning for design content under a shadow part is gone. The
library drawing the part, and the design drawing its own for the canvas and
Figma, is how a shadow-part library works; a warning nobody can act on is
noise. Conformance accepts a cssParts entry exactly as it accepts an
element. ADR 0017 §3 reworded.
Co-Authored-By: Claude Fable 5.1
---
CHANGELOG.md | 2 +-
.../0017-collections-and-code-targets.md | 7 +++--
packages/codegen/src/conformance.ts | 28 +++----------------
packages/codegen/test/generate.test.ts | 8 ++----
4 files changed, 11 insertions(+), 34 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a79692c..e7c21dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,7 +33,7 @@ describe migrations when an existing document or integration is affected.
`custom-elements.json`, served to the viewer and used by `uidx codegen` when
`--manifest` is absent. Parts a library exposes as shadow parts (`cssParts`)
are offered beside element parts and marked; the code target styles them
- through `::part()` and conformance warns when the design draws under one.
+ through `::part()`.
## 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 8e68279..4f1ce60 100644
--- a/docs/decisions/0017-collections-and-code-targets.md
+++ b/docs/decisions/0017-collections-and-code-targets.md
@@ -64,9 +64,10 @@ same bytes.
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.
+ `::part(name)` and never filled: the library draws it, and the design's
+ own drawing under that node serves the canvas and Figma. That is how such
+ a library works, not a finding, so conformance says nothing about it. The
+ inspector marks such parts so the author knows which kind they bound.
- 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 c6f741b..ee2c1b7 100644
--- a/packages/codegen/src/conformance.ts
+++ b/packages/codegen/src/conformance.ts
@@ -50,30 +50,10 @@ export function checkConformance(model: ComponentModel, manifest: Manifest): Dia
if (part === 'root') continue
const tag = partTag(model.tag, part, tagSet)
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
- }
+ // A shadow part is as declared as an element part. That the library
+ // draws it, and the design's own drawing under it is for the canvas and
+ // Figma, is how such a library works — not a finding.
+ if ((root.cssParts ?? []).some((entry) => entry.name === part)) continue
report(`part "${part}" has no element in the manifest (looked for ${tag})`)
}
return out
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
index c89d9cf..0ec7997 100644
--- a/packages/codegen/test/generate.test.ts
+++ b/packages/codegen/test/generate.test.ts
@@ -215,12 +215,8 @@ A switch whose parts live in the library's shadow tree.
expect(tsx).not.toContain('sl-switch-label')
})
- it('conforms, with a warning for what the design drew under a part the library draws', () => {
- expect(out.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
- const warnings = out.diagnostics.filter((d) => d.severity === 'warning')
- expect(warnings.map((d) => d.message)).toEqual([
- 'part "label" is a shadow part of sl-switch: the library draws it, so what "label" holds is styled through ::part() but not rendered',
- ])
+ it('conforms without comment: the library drawing the part is how it works', () => {
+ expect(out.diagnostics).toEqual([])
})
})
From 419fe7e58f31a6a4277df532fbf8c34ab5b8ac70 Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 12:51:47 +0300
Subject: [PATCH 09/74] contract: booleans are states, models are shared, parts
are described
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The contract API, simplified to what each renderer actually reads:
- `` is gone. A boolean prop marked `visual` is a
state; `hover`, `focus` and `active` are the browser's and need no
declaration; `` remains only for states the element
produces itself, rendered as `:state(name)`. The state axis is default,
the visual booleans in prop order, the interaction states in table order,
then the declared ones.
- `` is optional and written like every other list, ``
with a description. The tree's `part=` bindings are the declaration.
- A model is named by a prop's type — `Contact`, `Contact[]` — and shared
across pages: declared once, on the page of the component that shows it.
`model="{models#…}"` is gone; a repeating slot names the list prop it
iterates, `of="items"`, so the model is written once. The example no
longer declares Contact twice.
- The parser names each old spelling and says what replaced it.
The contract-only helpers (axes, state kinds) move to @uidx/format so the
server's symbol lint can read them: `componentProps` now includes contract
props, a `{label}` passed through an instance is not judged at the use, a
`{item.name}` binding resolves through the model prop, and a derived set
draws every combination. `uidx check` passes on the whole example for the
first time; the audit runs after every page is parsed with a shared model
index. The Contract tab marks visual booleans as states and lists declared
element states.
Co-Authored-By: Claude Fable 5.1
---
CHANGELOG.md | 9 +-
docs/decisions/0013-component-contract.md | 36 ++---
docs/decisions/0015-models-and-examples.md | 10 +-
docs/decisions/0016-variants-as-renders.md | 11 +-
.../0017-collections-and-code-targets.md | 8 +-
examples/design-system/.uidx/button.uidx | 2 -
examples/design-system/.uidx/checkbox.uidx | 2 -
.../design-system/.uidx/contact-list.uidx | 12 +-
.../design-system/.uidx/contact-option.uidx | 4 +-
examples/design-system/.uidx/field.uidx | 2 -
.../generated/contract/button.json | 28 ++--
.../generated/contract/checkbox-field.json | 13 +-
.../generated/contract/checkbox.json | 33 ++---
.../generated/contract/contact-list.json | 63 +--------
.../generated/contract/contact-option.json | 35 ++---
.../generated/contract/field.json | 21 +--
packages/cli/src/commands/check.ts | 15 ++-
packages/cli/src/commands/codegen.ts | 2 +-
packages/codegen/src/conformance.ts | 3 +-
packages/codegen/src/generate.ts | 45 +------
packages/codegen/src/html.ts | 24 ++--
packages/codegen/src/model.ts | 23 +++-
packages/codegen/src/react.ts | 68 +++++-----
packages/codegen/test/fixtures.ts | 19 +--
packages/codegen/test/generate.test.ts | 4 +-
packages/format/src/component-props.ts | 25 ++++
packages/format/src/contract.ts | 101 ++++++++++++++
packages/format/src/index.ts | 12 ++
packages/format/src/spec.ts | 71 +++++++---
packages/format/src/types.ts | 37 +++--
packages/format/test/spec.test.ts | 41 ++++--
packages/schema/src/design-system-audit.ts | 95 ++++++++-----
packages/schema/src/design-system.ts | 127 +++++++++---------
packages/schema/src/index.ts | 6 +
packages/schema/src/to-scene.ts | 1 -
packages/schema/test/design-system.test.ts | 35 +++--
packages/server/src/symbols.ts | 21 ++-
packages/server/test/instance-values.test.ts | 48 +++++++
.../server/test/property-bindings.test.ts | 32 +++++
packages/viewer/src/ContractSection.vue | 43 +++++-
packages/viewer/src/contract-edits.ts | 2 +-
packages/viewer/test/contract-edits.test.ts | 7 +-
42 files changed, 726 insertions(+), 470 deletions(-)
create mode 100644 packages/format/src/contract.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7c21dc..43767db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,9 +7,14 @@ describe migrations when an existing document or integration is affected.
## Unreleased
- The design-system model (ADRs 0012–0017). A `.uidx` file may now carry a
- `## Contract` (props, events, states, parts, slots, form, accessibility),
+ `## Contract` (props, events, slots, form, accessibility; `` only for
+ states the element produces itself, since a visual boolean prop is a state
+ and `hover`/`focus`/`active` are the browser's; `` descriptions are
+ optional, the tree's `part=` bindings being the declaration),
`## Behavior` guidelines, `## Models` and `## Examples` after its visual
- contract, and a `` table beside the root. Declarations only: nothing
+ contract, and a `` table beside the root. Models are shared across
+ pages and named by a prop's type (`Contact`, `Contact[]`); a repeating slot
+ names the list prop it iterates (`of="items"`). 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 `` into sample rows, and
diff --git a/docs/decisions/0013-component-contract.md b/docs/decisions/0013-component-contract.md
index 3ddb527..017aba4 100644
--- a/docs/decisions/0013-component-contract.md
+++ b/docs/decisions/0013-component-contract.md
@@ -28,30 +28,36 @@ parser recognised there before is unchanged; the new regions are parsed into
```mdx
Whether the option is selected.
- The row to show.
+ The row to show.
Fires once per user toggle.
-
-checked-indicator, indeterminate-indicator
+The element failed its own validation.
+The mark drawn while checked.
Consumer text; styled here, filled there.
```
-- `Prop`: `name`, `type` (a TypeScript-ish type string, or `model`), `default`,
- `sample` (a demonstration value for the canvas and generated markup, in the
- sense of ADR 0015 §1 — a required text prop has no default and still needs
- words to draw; it changes nothing in generated code), `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.
+- `Prop`: `name`, `type` (a TypeScript-ish type string; a name that matches a
+ `` on any page means the prop receives that model, `Contact[]` a list
+ of them — ADR 0015 §2), `default`, `sample` (a demonstration value for the
+ canvas and generated markup, in the sense of ADR 0015 §1 — a required text
+ prop has no default and still needs words to draw; it changes nothing in
+ generated code), `controllable` (framework adapters add
+ controlled/uncontrolled handling), `visual` (drawn in the variant set: an
+ enum prop is an axis, a boolean prop is a state — ADR 0016 §1). 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
+- `States`: only the states the element produces itself — `invalid` after
+ validation, `open` on a disclosure that manages itself — as ``
+ with a description. A boolean prop marked `visual` is a state already, and
+ the browser's `hover`, `focus` and `active` need no declaration; most
+ contracts have no `` at all.
+- `Parts`: optional descriptions of the headless root's parts, as ``. The tree's `part="…"` bindings are the declaration; a part described
+ here must be bound.
+- `Slot`: consumer-filled positions; `repeats`, `of` 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
diff --git a/docs/decisions/0015-models-and-examples.md b/docs/decisions/0015-models-and-examples.md
index abb4cd5..fd779d7 100644
--- a/docs/decisions/0015-models-and-examples.md
+++ b/docs/decisions/0015-models-and-examples.md
@@ -35,13 +35,15 @@ and sample values so the canvas and Figma have something to draw.
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.
+- Models are shared across the document: declared once, in the file of the
+ component that displays them or on a shared models page, and named by a
+ prop's `type` — `Contact`, or `Contact[]` for a list. The audit refuses a
+ second declaration of the same name, and a name no page declares.
### 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.
+A prop declared with `type="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
diff --git a/docs/decisions/0016-variants-as-renders.md b/docs/decisions/0016-variants-as-renders.md
index 97afe14..7fae79d 100644
--- a/docs/decisions/0016-variants-as-renders.md
+++ b/docs/decisions/0016-variants-as-renders.md
@@ -17,9 +17,14 @@ everything else is a computation over identity, props and theme.
### 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.
+A component's variant space is its `visual` enum props (ADR 0013 §2), each an
+axis, plus one `state` axis. The `state` axis is `default`, then every
+`visual` boolean prop in declaration order, then the interaction states the
+styles table names (`hover`, `focus`, `active` — the browser's, never
+declared), then the states the element declares itself (``, ADR 0013
+§2). Enum props are axes, booleans are states: that is the whole rule, and it
+is why a boolean is never declared twice. A `variants={{ … }}` attribute is no
+longer needed; when both are present they must agree.
### 2. Appearance variants: a styles table
diff --git a/docs/decisions/0017-collections-and-code-targets.md b/docs/decisions/0017-collections-and-code-targets.md
index 4f1ce60..b161610 100644
--- a/docs/decisions/0017-collections-and-code-targets.md
+++ b/docs/decisions/0017-collections-and-code-targets.md
@@ -18,13 +18,13 @@ A slot may repeat, carry a model, and constrain what fills it:
```mdx
- One filling per element of `items`.
+ One filling per element of `items`.
```
-- `repeats` means one filling per element of the prop whose type is the
- model array.
-- `model` is the parameter each filling receives.
+- `repeats` means one filling per element of a list prop.
+- `of` names that prop; its element type is the model each filling receives,
+ so the model is written once, on the prop.
- `accepts` names the headless root an item component must implement; the
audit refuses a filling that does not.
diff --git a/examples/design-system/.uidx/button.uidx b/examples/design-system/.uidx/button.uidx
index 6598360..c83ef5a 100644
--- a/examples/design-system/.uidx/button.uidx
+++ b/examples/design-system/.uidx/button.uidx
@@ -47,8 +47,6 @@ label is consumer text.
Inert and dimmed.
What activation does to the enclosing form.
-
-leading-icon, label
diff --git a/examples/design-system/.uidx/checkbox.uidx b/examples/design-system/.uidx/checkbox.uidx
index ce70d15..c4b1b80 100644
--- a/examples/design-system/.uidx/checkbox.uidx
+++ b/examples/design-system/.uidx/checkbox.uidx
@@ -41,8 +41,6 @@ box and its marks are the design system's; the label comes from a Field.
Fires once per user toggle, never when set from code.
-
-checked-indicator, indeterminate-indicator
diff --git a/examples/design-system/.uidx/contact-list.uidx b/examples/design-system/.uidx/contact-list.uidx
index 4a53050..ee59bdc 100644
--- a/examples/design-system/.uidx/contact-list.uidx
+++ b/examples/design-system/.uidx/contact-list.uidx
@@ -31,23 +31,13 @@ Contact.
Form field name shared by every option.
Every option inert.
-
- One option per element of `items`.
+ One option per element of `items`.
Shown instead of the options while `items` is empty.
-## Models
-
-
- One person as a row shows them. Every value is display-ready; the adapter that builds it does any formatting.
- Stable identity, used for keys and as the submitted value.
- Full display name.
- Omitted when unknown; the row hides the line.
-
-
## Behavior
- one-choice: exactly one option is chosen at a time; choosing one unchooses the rest.
diff --git a/examples/design-system/.uidx/contact-option.uidx b/examples/design-system/.uidx/contact-option.uidx
index 581c92b..3c3c619 100644
--- a/examples/design-system/.uidx/contact-option.uidx
+++ b/examples/design-system/.uidx/contact-option.uidx
@@ -37,7 +37,7 @@ Contact.
## Contract
- The person this option stands for. Display-ready; nothing is derived here.
+ The person this option stands for. Display-ready; nothing is derived here.
Whether this option is the chosen one.
Inert and dimmed.
Submitted when chosen; normally the contact's id.
@@ -46,8 +46,6 @@ Contact.
Fires when this option becomes the chosen one.
-
-checked-indicator
## Models
diff --git a/examples/design-system/.uidx/field.uidx b/examples/design-system/.uidx/field.uidx
index efefbc3..4c67f3d 100644
--- a/examples/design-system/.uidx/field.uidx
+++ b/examples/design-system/.uidx/field.uidx
@@ -34,8 +34,6 @@ where it sits; the consumer supplies the words.
Validation text, shown only while in error.
Whether the field is in its error state.
-
-label, description, error
The wrapped control: a checkbox, a radio, a text input.
diff --git a/examples/design-system/generated/contract/button.json b/examples/design-system/generated/contract/button.json
index 4198fe5..77c9620 100644
--- a/examples/design-system/generated/contract/button.json
+++ b/examples/design-system/generated/contract/button.json
@@ -26,9 +26,9 @@
],
"state": [
"default",
+ "disabled",
"hover",
- "focus",
- "disabled"
+ "focus"
]
}
}
@@ -37,58 +37,48 @@
"props": [
{
"name": "label",
+ "type": "string",
"controllable": false,
"visual": false,
"description": "The button's text.",
- "type": "string",
"sample": "Save changes"
},
{
"name": "variant",
+ "type": "'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link'",
"controllable": false,
"visual": true,
"description": "Emphasis.",
- "type": "'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link'",
"default": "primary"
},
{
"name": "size",
+ "type": "'small' | 'medium' | 'large'",
"controllable": false,
"visual": true,
"description": "Padding and type scale.",
- "type": "'small' | 'medium' | 'large'",
"default": "medium"
},
{
"name": "disabled",
+ "type": "boolean",
"controllable": false,
"visual": true,
"description": "Inert and dimmed.",
- "type": "boolean",
"default": false
},
{
"name": "type",
+ "type": "'button' | 'submit' | 'reset'",
"controllable": false,
"visual": false,
"description": "What activation does to the enclosing form.",
- "type": "'button' | 'submit' | 'reset'",
"default": "button"
}
],
"events": [],
- "states": {
- "structural": [],
- "styling": [
- "hover",
- "focus",
- "disabled"
- ]
- },
- "parts": [
- "leading-icon",
- "label"
- ],
+ "states": [],
+ "parts": [],
"slots": [],
"composes": [],
"form": {
diff --git a/examples/design-system/generated/contract/checkbox-field.json b/examples/design-system/generated/contract/checkbox-field.json
index 6fac4ac..1c938f6 100644
--- a/examples/design-system/generated/contract/checkbox-field.json
+++ b/examples/design-system/generated/contract/checkbox-field.json
@@ -16,42 +16,39 @@
"props": [
{
"name": "label",
+ "type": "string",
"controllable": false,
"visual": false,
"description": "The option's name.",
- "type": "string",
"sample": "Remember me"
},
{
"name": "description",
+ "type": "string",
"controllable": false,
"visual": false,
"description": "Helper text under the label.",
- "type": "string",
"sample": "Stay signed in on this device."
},
{
"name": "errorMessage",
+ "type": "string",
"controllable": false,
"visual": false,
"description": "Validation text, shown while in error.",
- "type": "string",
"sample": "Choose one option."
},
{
"name": "error",
+ "type": "boolean",
"controllable": false,
"visual": false,
"description": "Whether the field is in its error state.",
- "type": "boolean",
"default": false
}
],
"events": [],
- "states": {
- "structural": [],
- "styling": []
- },
+ "states": [],
"parts": [],
"slots": [],
"composes": [
diff --git a/examples/design-system/generated/contract/checkbox.json b/examples/design-system/generated/contract/checkbox.json
index 934df3d..74c20b0 100644
--- a/examples/design-system/generated/contract/checkbox.json
+++ b/examples/design-system/generated/contract/checkbox.json
@@ -15,9 +15,9 @@
"default",
"checked",
"indeterminate",
+ "disabled",
"hover",
- "focus",
- "disabled"
+ "focus"
]
}
}
@@ -26,41 +26,41 @@
"props": [
{
"name": "checked",
+ "type": "boolean",
"controllable": true,
"visual": true,
"description": "Whether the option is selected.",
- "type": "boolean",
"default": false
},
{
"name": "indeterminate",
+ "type": "boolean",
"controllable": true,
"visual": true,
"description": "Mixed state for a parent summarising children.",
- "type": "boolean",
"default": false
},
{
"name": "disabled",
+ "type": "boolean",
"controllable": false,
"visual": true,
"description": "Inert and dimmed.",
- "type": "boolean",
"default": false
},
{
"name": "name",
+ "type": "string",
"controllable": false,
"visual": false,
- "description": "Form field name.",
- "type": "string"
+ "description": "Form field name."
},
{
"name": "value",
+ "type": "string",
"controllable": false,
"visual": false,
"description": "Submitted while checked.",
- "type": "string",
"default": "on"
}
],
@@ -71,21 +71,8 @@
"detail": "{ checked: boolean }"
}
],
- "states": {
- "structural": [
- "checked",
- "indeterminate"
- ],
- "styling": [
- "hover",
- "focus",
- "disabled"
- ]
- },
- "parts": [
- "checked-indicator",
- "indeterminate-indicator"
- ],
+ "states": [],
+ "parts": [],
"slots": [],
"composes": [
"Field"
diff --git a/examples/design-system/generated/contract/contact-list.json b/examples/design-system/generated/contract/contact-list.json
index 99754d3..b19c5e8 100644
--- a/examples/design-system/generated/contract/contact-list.json
+++ b/examples/design-system/generated/contract/contact-list.json
@@ -21,41 +21,36 @@
"props": [
{
"name": "items",
+ "type": "Contact[]",
"controllable": false,
"visual": false,
- "description": "The people to choose from, in order.",
- "type": "Contact[]"
+ "description": "The people to choose from, in order."
},
{
"name": "name",
+ "type": "string",
"controllable": false,
"visual": false,
- "description": "Form field name shared by every option.",
- "type": "string"
+ "description": "Form field name shared by every option."
},
{
"name": "disabled",
+ "type": "boolean",
"controllable": false,
"visual": true,
"description": "Every option inert.",
- "type": "boolean",
"default": false
}
],
"events": [],
- "states": {
- "structural": [],
- "styling": [
- "disabled"
- ]
- },
+ "states": [],
"parts": [],
"slots": [
{
"name": "option",
"repeats": true,
"description": "One option per element of items .",
- "model": "{models#Contact}",
+ "of": "items",
"accepts": "hwc-radio"
},
{
@@ -73,50 +68,6 @@
"keyboard": "Arrow keys move the choice"
}
},
- "models": [
- {
- "name": "Contact",
- "description": "One person as a row shows them. Every value is display-ready; the adapter that builds it does any formatting.",
- "fields": [
- {
- "name": "id",
- "type": "string",
- "key": true,
- "optional": false,
- "description": "Stable identity, used for keys and as the submitted value.",
- "sample": [
- "ada",
- "grace",
- "linus"
- ]
- },
- {
- "name": "name",
- "type": "string",
- "key": false,
- "optional": false,
- "description": "Full display name.",
- "sample": [
- "Ada Lovelace",
- "Grace Hopper",
- "Linus Torvalds"
- ]
- },
- {
- "name": "email",
- "type": "string",
- "key": false,
- "optional": true,
- "description": "Omitted when unknown; the row hides the line.",
- "sample": [
- "ada@example.com",
- null,
- "linus@example.com"
- ]
- }
- ]
- }
- ],
"behavior": [
{
"id": "one-choice",
diff --git a/examples/design-system/generated/contract/contact-option.json b/examples/design-system/generated/contract/contact-option.json
index e6d2c49..fcc367f 100644
--- a/examples/design-system/generated/contract/contact-option.json
+++ b/examples/design-system/generated/contract/contact-option.json
@@ -13,9 +13,9 @@
"state": [
"default",
"checked",
+ "disabled",
"hover",
- "focus",
- "disabled"
+ "focus"
]
}
}
@@ -24,40 +24,40 @@
"props": [
{
"name": "item",
+ "type": "Contact",
"controllable": false,
"visual": false,
- "description": "The person this option stands for. Display-ready; nothing is derived here.",
- "model": "{models#Contact}"
+ "description": "The person this option stands for. Display-ready; nothing is derived here."
},
{
"name": "checked",
+ "type": "boolean",
"controllable": true,
"visual": true,
"description": "Whether this option is the chosen one.",
- "type": "boolean",
"default": false
},
{
"name": "disabled",
+ "type": "boolean",
"controllable": false,
"visual": true,
"description": "Inert and dimmed.",
- "type": "boolean",
"default": false
},
{
"name": "value",
+ "type": "string",
"controllable": false,
"visual": false,
- "description": "Submitted when chosen; normally the contact's id.",
- "type": "string"
+ "description": "Submitted when chosen; normally the contact's id."
},
{
"name": "name",
+ "type": "string",
"controllable": false,
"visual": false,
- "description": "Form field name, shared with the group.",
- "type": "string"
+ "description": "Form field name, shared with the group."
}
],
"events": [
@@ -67,19 +67,8 @@
"detail": "{ checked: boolean }"
}
],
- "states": {
- "structural": [
- "checked"
- ],
- "styling": [
- "hover",
- "focus",
- "disabled"
- ]
- },
- "parts": [
- "checked-indicator"
- ],
+ "states": [],
+ "parts": [],
"slots": [],
"composes": [
"ContactList"
diff --git a/examples/design-system/generated/contract/field.json b/examples/design-system/generated/contract/field.json
index a34243a..f5269c5 100644
--- a/examples/design-system/generated/contract/field.json
+++ b/examples/design-system/generated/contract/field.json
@@ -25,49 +25,40 @@
"props": [
{
"name": "label",
+ "type": "string",
"controllable": false,
"visual": false,
"description": "The control's name, shown beside it.",
- "type": "string",
"sample": "Email"
},
{
"name": "description",
+ "type": "string",
"controllable": false,
"visual": false,
"description": "Secondary text: hints and consequences.",
- "type": "string",
"sample": "We never share it."
},
{
"name": "errorMessage",
+ "type": "string",
"controllable": false,
"visual": false,
"description": "Validation text, shown only while in error.",
- "type": "string",
"sample": "Enter a valid address."
},
{
"name": "error",
+ "type": "boolean",
"controllable": false,
"visual": true,
"description": "Whether the field is in its error state.",
- "type": "boolean",
"default": false
}
],
"events": [],
- "states": {
- "structural": [
- "error"
- ],
- "styling": []
- },
- "parts": [
- "label",
- "description",
- "error"
- ],
+ "states": [],
+ "parts": [],
"slots": [
{
"name": "control",
diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts
index bb763dc..4f232f5 100644
--- a/packages/cli/src/commands/check.ts
+++ b/packages/cli/src/commands/check.ts
@@ -17,6 +17,7 @@ import {
} from '@uidx/format'
import { isKnownProp } from '@uidx/schema/known-props'
import { auditDesignSystem } from '@uidx/schema/design-system-audit'
+import { modelIndex } from '@uidx/schema/design-system'
import {
assetProblem,
documentAssets,
@@ -85,15 +86,23 @@ export async function check(patterns: string[], options: CheckOptions = {}): Pro
if (doc) {
all.push(...lintUnknownProps(doc))
- // The design-system regions have to agree with the tree and with each
- // other (ADRs 0013–0017); the parser checked each region's own shape.
- all.push(...auditDesignSystem(doc))
parsed.push({ file, doc })
}
reports.push({ file, diagnostics: all })
}
+ // The design-system regions have to agree with the tree and with each
+ // other (ADRs 0013–0017); the parser checked each region's own shape. After
+ // every page is parsed, because a model is shared across pages (ADR 0015
+ // §1) and the audit answers "is Contact declared?" for the whole document.
+ const models = modelIndex(parsed.map((page) => page.doc))
+ for (const { file, doc } of parsed) {
+ reports
+ .find((report) => report.file === file)!
+ .diagnostics.push(...auditDesignSystem(doc, models))
+ }
+
// An image reference is a claim about the filesystem, and CI is where a
// broken one should surface — not the viewer, and not a designer's eye three
// weeks later (ADR 0006 §4). Only checkable here for the same reason
diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts
index 6cff9d5..e7ac5f5 100644
--- a/packages/cli/src/commands/codegen.ts
+++ b/packages/cli/src/commands/codegen.ts
@@ -4,7 +4,7 @@ import { parseArgs } from 'node:util'
import { glob } from 'tinyglobby'
import { formatDiagnostic, parse, type UidxDocument } from '@uidx/format'
import { generate, type Manifest, type Target } from '@uidx/codegen'
-import { findManifest } from '@uidx/server'
+import { findManifest } from '@uidx/server/document'
import type { Io } from '../cli.js'
/**
diff --git a/packages/codegen/src/conformance.ts b/packages/codegen/src/conformance.ts
index ee2c1b7..2232b3d 100644
--- a/packages/codegen/src/conformance.ts
+++ b/packages/codegen/src/conformance.ts
@@ -46,7 +46,8 @@ export function checkConformance(model: ComponentModel, manifest: Manifest): Dia
}
}
const tagSet = new Set(tags.keys())
- for (const part of model.contract.parts) {
+ // The tree's bindings are the parts this component uses (ADR 0013 §3).
+ for (const { name: part } of model.parts) {
if (part === 'root') continue
const tag = partTag(model.tag, part, tagSet)
if (tagSet.has(tag)) continue
diff --git a/packages/codegen/src/generate.ts b/packages/codegen/src/generate.ts
index 0d3c71e..c8d7c7a 100644
--- a/packages/codegen/src/generate.ts
+++ b/packages/codegen/src/generate.ts
@@ -4,6 +4,7 @@ import { tokensCss } from './css.js'
import { checkConformance } from './conformance.js'
import { emitCss, emitHtml } from './html.js'
import { componentModel, type ComponentModel, type Manifest } from './model.js'
+import { modelIndex } from '@uidx/schema/design-system'
import { emitElementTypes, emitIndex, emitModels, emitReact, emitRuntime } from './react.js'
export type Target = 'html' | 'react' | 'contract'
@@ -36,13 +37,15 @@ export function generate(input: GenerateInput): GenerateOutput {
const files = new Map()
const diagnostics: GenerateOutput['diagnostics'] = []
+ // Models are shared across pages (ADR 0015 §1): one index for every component.
+ const index = modelIndex(input.pages.map((page) => page.doc))
const models: ComponentModel[] = []
const owner = new Map()
for (const { file, doc } of input.pages) {
if (doc.tree.element === 'Tokens') continue
for (const node of doc.tree.children) {
if (node.element !== 'Component') continue
- const model = componentModel(node, doc, input.manifest)
+ const model = componentModel(node, doc, input.manifest, index)
models.push(model)
owner.set(model, file)
}
@@ -71,32 +74,13 @@ export function generate(input: GenerateInput): GenerateOutput {
if (targets.has('react')) {
if (tokens) files.set('react/tokens.css', tokens)
const tags = new Set()
- const modelSpecs = new Map<
- string,
- { spec: ComponentModel['spec']; model: ComponentModel['spec'] }
- >()
for (const model of rendered) {
files.set(`react/${model.identifier}.tsx`, emitReact(model, { components: byName }))
files.set(`react/${model.stem}.css`, emitCss(model))
if (model.tag) tags.add(model.tag)
- for (const part of model.parts) tags.add(part.tag)
- for (const spec of model.spec?.models ?? [])
- modelSpecs.set(spec.name, { spec: model.spec, model: model.spec })
+ for (const part of model.parts) if (part.kind === 'element') tags.add(part.tag)
}
- const allModels = [
- ...new Map(
- rendered.flatMap((model) =>
- (model.spec?.models ?? []).map((m) => [m.name, { m, spec: model.spec }]),
- ),
- ).values(),
- ]
- files.set(
- 'react/models.ts',
- emitModels(
- allModels.map((entry) => entry.m),
- mergeSpecs(allModels.map((entry) => entry.spec)),
- ),
- )
+ files.set('react/models.ts', emitModels(index))
files.set('react/runtime.ts', emitRuntime())
files.set('react/elements.d.ts', emitElementTypes([...tags]))
files.set('react/index.ts', emitIndex(rendered))
@@ -116,20 +100,3 @@ export function generate(input: GenerateInput): GenerateOutput {
return { files, diagnostics }
}
-
-/** One spec whose models are the union of several files' models, for `models.ts`. */
-function mergeSpecs(
- specs: readonly (ComponentModel['spec'] | undefined)[],
-): ComponentModel['spec'] {
- const models = new Map['models']>()
- const merged: NonNullable['models']> = []
- for (const spec of specs) {
- for (const model of spec?.models ?? []) {
- if (!models.has(model.name)) {
- models.set(model.name, [model])
- merged.push(model)
- }
- }
- }
- return { models: merged }
-}
diff --git a/packages/codegen/src/html.ts b/packages/codegen/src/html.ts
index 5a0decb..61a9b53 100644
--- a/packages/codegen/src/html.ts
+++ b/packages/codegen/src/html.ts
@@ -1,5 +1,5 @@
-import type { JsonValue, UidxNode } from '@uidx/format'
-import { specBindings, STATE_AXIS, styleTarget } from '@uidx/schema/design-system'
+import type { ContractSpec, JsonValue, UidxNode } from '@uidx/format'
+import { specBindings, STATE_AXIS, stateKind, styleTarget } from '@uidx/schema/design-system'
import { cssDeclarations, cssRule, type CssKind } from './css.js'
import { boundPath, boundProp, type ComponentModel } from './model.js'
@@ -15,15 +15,15 @@ export interface HtmlContext {
}
/** How a state of the contract is selected in CSS. */
-export function stateSelector(state: string): string {
- switch (state) {
- case 'hover':
- return ':hover'
- case 'focus':
- return ':focus-visible'
- case 'active':
- case 'pressed':
- return ':active'
+export function stateSelector(state: string, contract?: ContractSpec): string {
+ switch (stateKind(state, contract)) {
+ case 'interaction':
+ return state === 'hover' ? ':hover' : state === 'focus' ? ':focus-visible' : ':active'
+ // A state the element produces itself, exposed through ElementInternals.
+ case 'declared':
+ return `:state(${state})`
+ // A visual boolean prop, reflected as an attribute — and the fallback
+ // for a name the contract cannot place, which the audit has reported.
default:
return `[${state}]`
}
@@ -116,7 +116,7 @@ export function emitCss(model: ComponentModel): string {
for (const row of model.spec?.styles ?? []) {
let scoped = root
for (const [axis, value] of Object.entries(row.keys)) {
- scoped += axis === STATE_AXIS ? stateSelector(value) : `[${axis}="${value}"]`
+ scoped += axis === STATE_AXIS ? stateSelector(value, model.contract) : `[${axis}="${value}"]`
}
for (const [part, props] of Object.entries(row.values)) {
const info = part === 'root' ? undefined : model.parts.find((entry) => entry.name === part)
diff --git a/packages/codegen/src/model.ts b/packages/codegen/src/model.ts
index 907f2f8..f81bc67 100644
--- a/packages/codegen/src/model.ts
+++ b/packages/codegen/src/model.ts
@@ -8,7 +8,14 @@ import {
type UidxDocument,
type UidxNode,
} from '@uidx/format'
-import { axesOf, modelByRef, specBindings } from '@uidx/schema/design-system'
+import {
+ axesOf,
+ modelByRef,
+ modelIndex,
+ modelOfType,
+ specBindings,
+ type ModelIndex,
+} from '@uidx/schema/design-system'
/**
* What every code target reads: a component's identity, contract and tree,
@@ -158,6 +165,8 @@ export interface ComponentModel {
* props passed through — a pattern, not a new element (ADR 0012 §3).
*/
composes: UidxNode | undefined
+ /** The models a prop's type may name, across the document (ADR 0015 §1). */
+ models: ModelIndex
}
/** The prop a bare `{name}` alias names, if the contract declares it. */
@@ -174,19 +183,24 @@ export function boundPath(model: ComponentModel, value: JsonValue): string[] | n
const target = aliasTarget(value)
if (target === null || target.includes('#') || !target.includes('.')) return null
const path = target.split('.')
- const prop = model.contract?.props.find((entry) => entry.name === path[0] && entry.model)
+ const prop = model.contract?.props.find(
+ (entry) =>
+ entry.name === path[0] && modelOfType(entry.type, model.spec, model.models)?.list === false,
+ )
return prop ? path : null
}
-/** The declared model a prop or slot refers to. */
+/** The declared model a name refers to, on this page or any other. */
export function modelOf(model: ComponentModel, ref: string | undefined) {
- return modelByRef(model.spec, ref)
+ return modelByRef(model.spec, ref, model.models)
}
export function componentModel(
component: UidxNode,
doc: UidxDocument,
manifest?: Manifest,
+ /** Every model the document set declares; this page's alone when absent. */
+ models?: ModelIndex,
): ComponentModel {
const tags = manifest ? new Set(manifestTags(manifest).keys()) : undefined
const spec = component.spec ?? doc.spec
@@ -243,6 +257,7 @@ export function componentModel(
node: component,
spec,
contract,
+ models: models ?? modelIndex([doc]),
parts,
slots,
repeats,
diff --git a/packages/codegen/src/react.ts b/packages/codegen/src/react.ts
index c859e16..bcd87f3 100644
--- a/packages/codegen/src/react.ts
+++ b/packages/codegen/src/react.ts
@@ -1,5 +1,5 @@
import type { FieldSpec, JsonValue, ModelSpec, PropSpec, UidxNode } from '@uidx/format'
-import { modelByRef } from '@uidx/schema/design-system'
+import { modelOfType, slotModel, type ModelIndex } from '@uidx/schema/design-system'
import { boundPath, boundProp, pascal, type ComponentModel } from './model.js'
/**
@@ -18,46 +18,38 @@ export interface ReactContext {
const HEADER = (name: string) =>
`// ${name} — generated by uidx codegen from its .uidx file. Do not edit; change the file.\n`
-/** A contract type string as TypeScript, with model references resolved. */
-export function tsType(model: ComponentModel, type: string | undefined, modelRef?: string): string {
- const named = modelByRef(model.spec, modelRef)
- if (named) return pascal(named.name)
+/** A contract type string as TypeScript, with model names resolved (ADR 0015 §2). */
+export function tsType(model: ComponentModel, type: string | undefined): string {
+ return typeAgainst(type, model.spec, model.models)
+}
+
+function typeAgainst(
+ type: string | undefined,
+ spec: ComponentModel['spec'],
+ models: ModelIndex,
+): string {
if (!type) return 'unknown'
+ const found = modelOfType(type, spec, models)
+ if (found) return found.list ? `${pascal(found.model.name)}[]` : pascal(found.model.name)
const array = type.endsWith('[]')
const base = array ? type.slice(0, -2) : type
- const referenced =
- modelByRef(model.spec, base) ?? model.spec?.models?.find((m) => m.name === base)
- const inner = referenced
- ? pascal(referenced.name)
- : base === 'image' || base === 'date'
- ? 'string'
- : base
+ const inner = base === 'image' || base === 'date' ? 'string' : base
return array ? `${inner}[]` : inner
}
-function fieldType(spec: ComponentModel['spec'], field: FieldSpec): string {
- const named = modelByRef(spec, field.type)
- if (named) return pascal(named.name)
- const array = field.type.endsWith('[]')
- const base = array ? field.type.slice(0, -2) : field.type
- const referenced = spec?.models?.find((m) => m.name === base)
- const inner = referenced
- ? pascal(referenced.name)
- : base === 'image' || base === 'date'
- ? 'string'
- : base
- return array ? `${inner}[]` : inner
+function fieldType(models: ModelIndex, field: FieldSpec): string {
+ return typeAgainst(field.type, undefined, models)
}
/** `models.ts`: one interface per model, fields documented from the file. */
-export function emitModels(models: readonly ModelSpec[], spec: ComponentModel['spec']): string {
+export function emitModels(models: ModelIndex): string {
const lines = [HEADER('Models'), '']
- for (const model of models) {
+ for (const model of models.values()) {
if (model.description) lines.push(`/** ${model.description} */`)
lines.push(`export interface ${pascal(model.name)} {`)
for (const field of model.fields) {
lines.push(` /** ${field.description} */`)
- lines.push(` ${field.name}${field.optional ? '?' : ''}: ${fieldType(spec, field)}`)
+ lines.push(` ${field.name}${field.optional ? '?' : ''}: ${fieldType(models, field)}`)
}
lines.push('}', '')
}
@@ -179,23 +171,23 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
const attributeProps: PropSpec[] = []
for (const prop of contract?.props ?? []) {
- if (prop.model) {
- usesModels.add(pascal(modelByRef(model.spec, prop.model)!.name))
+ const found = modelOfType(prop.type, model.spec, model.models)
+ // A prop that receives a model is the component's input: required, and
+ // its fields are what the parts bind (ADR 0015 §2).
+ if (found && !found.list) {
+ usesModels.add(pascal(found.model.name))
props.push({
name: prop.name,
- type: tsType(model, prop.type, prop.model),
+ type: pascal(found.model.name),
optional: false,
doc: prop.description,
})
continue
}
- const repeated = contract?.slots.find((slot) => slot.repeats && prop.type?.endsWith('[]'))
- if (repeated && prop.type?.endsWith('[]')) continue // rendered as `items` below
+ // The list a repeating slot iterates is rendered as `items` below.
+ if (contract?.slots.some((slot) => slot.repeats && slot.of === prop.name)) continue
const type = textBound.has(prop.name) ? 'ReactNode' : tsType(model, prop.type)
- if (type.endsWith('[]') || type === 'ReactNode') {
- const inner = type.endsWith('[]') ? type.slice(0, -2) : ''
- if (inner && model.spec?.models?.some((m) => pascal(m.name) === inner)) usesModels.add(inner)
- }
+ if (found?.list) usesModels.add(pascal(found.model.name))
props.push({ name: prop.name, type, optional: true, doc: prop.description })
if (!textBound.has(prop.name)) attributeProps.push(prop)
}
@@ -218,7 +210,7 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
})
}
for (const repeat of model.repeats) {
- const modelSpec = modelByRef(model.spec, repeat.slot?.model)
+ const modelSpec = slotModel(repeat.slot, model.spec, model.models)
const item = modelSpec ? pascal(modelSpec.name) : 'unknown'
if (modelSpec) usesModels.add(item)
props.push({
@@ -274,7 +266,7 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
switch (node.element) {
case 'Repeat': {
const repeat = model.repeats.find((entry) => entry.node === node)
- const modelSpec = modelByRef(model.spec, repeat?.slot?.model)
+ const modelSpec = slotModel(repeat?.slot, model.spec, model.models)
const key = modelSpec?.fields.find((field) => field.key)?.name
const fallback =
repeat?.itemComponent && ctx.components.has(repeat.itemComponent)
diff --git a/packages/codegen/test/fixtures.ts b/packages/codegen/test/fixtures.ts
index 5700aeb..4d4ed7f 100644
--- a/packages/codegen/test/fixtures.ts
+++ b/packages/codegen/test/fixtures.ts
@@ -58,8 +58,6 @@ Lets a user toggle one option.
Fires once per user toggle.
-
-checked-indicator
## Behavior
@@ -91,8 +89,6 @@ Pairs a control with its label, description and error.
Helper text.
Shows the error state.
-
-label, description
The wrapped control.
`)
@@ -118,15 +114,13 @@ id: contact-item
## Contract
- The row to show.
+ The row to show.
Whether this row is the chosen one.
Inert.
Submitted when chosen.
Form field name.
Fires when chosen.
-
-checked-indicator
## Models
@@ -161,18 +155,9 @@ id: contact-list
Inert.
- One per row.
+ One per row.
Shown while empty.
-
-## Models
-
-
- One row of the list.
- Identity.
- Display name.
- Omitted when unknown.
-
`)
export const MANIFEST = {
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
index 0ec7997..0b66d6d 100644
--- a/packages/codegen/test/generate.test.ts
+++ b/packages/codegen/test/generate.test.ts
@@ -192,8 +192,6 @@ A switch whose parts live in the library's shadow tree.
Fires on toggle.
-
-thumb, label
`)
const out = generate({
pages: [{ file: 'switch.uidx', doc: page }],
@@ -313,7 +311,7 @@ describe('conformance (ADR 0013 §4)', () => {
describe('types', () => {
it("resolves model references and the format's image and date types", () => {
const model = componentModel(CONTACT_ITEM.tree.children[0]!, CONTACT_ITEM)
- expect(tsType(model, undefined, '{models#Contact}')).toBe('Contact')
+ expect(tsType(model, 'Contact')).toBe('Contact')
expect(tsType(model, 'Contact[]')).toBe('Contact[]')
expect(tsType(model, 'image')).toBe('string')
expect(tsType(model, "'a' | 'b'")).toBe("'a' | 'b'")
diff --git a/packages/format/src/component-props.ts b/packages/format/src/component-props.ts
index 5823154..e247d6f 100644
--- a/packages/format/src/component-props.ts
+++ b/packages/format/src/component-props.ts
@@ -23,6 +23,19 @@ import {
* path maps name → id and the file never sees one.
*/
+/** The panel-editable type a contract prop's type string maps to, or null. */
+function contractPropertyType(type: string): PropertyType | null {
+ if (type === 'boolean') return 'BOOLEAN'
+ if (
+ type === 'string' ||
+ type === 'image' ||
+ type === 'date' ||
+ /^'[^']*'(\s*\|\s*'[^']*')*$/.test(type)
+ )
+ return 'TEXT'
+ return null
+}
+
/** A `{name}` binding with no `#` in it. Reading it is the whole discriminator. */
export function propertyBinding(target: string): string | null {
return target.includes(ENTITY_SEP) ? null : target
@@ -57,6 +70,18 @@ export function componentProps(node: UidxNode): {
} {
const declared = new Map()
const problems: PropertyProblem[] = []
+ // A `## Contract` declares props too (ADR 0013 §5), in the same map, so a
+ // use fills them in and a `{label}` resolves exactly as with `props={{}}`.
+ // Only the shapes the panel can edit: a boolean, or text (a string, an
+ // enum, an image or a date). A model or a list is the code render's.
+ for (const prop of node.spec?.contract?.props ?? []) {
+ const type = contractPropertyType(prop.type)
+ if (!type) continue
+ const shown = prop.sample ?? prop.default
+ const fallback =
+ shown !== undefined && matchesType(type, shown) ? shown : type === 'BOOLEAN' ? false : ''
+ declared.set(prop.name, { type, default: fallback })
+ }
const value = node.attrs.props?.value
if (value === undefined) return { declared, problems }
diff --git a/packages/format/src/contract.ts b/packages/format/src/contract.ts
new file mode 100644
index 0000000..0ab9f98
--- /dev/null
+++ b/packages/format/src/contract.ts
@@ -0,0 +1,101 @@
+import type { ContractSpec, DocumentSpec, StyleRow } from './types.js'
+
+/**
+ * What a contract says about a component's variant space (ADR 0016 §1),
+ * as pure functions of the spec. Here rather than in `@uidx/schema` because
+ * the server's lint and the code target need the same answers without a
+ * scene: which props are axes, which names are states, and where a state
+ * comes from.
+ */
+
+/** The `state` axis every component with states has (ADR 0016 §1). */
+export const STATE_AXIS = 'state'
+export const DEFAULT_STATE = 'default'
+
+/** `'a' | 'b'` → `['a', 'b']`; anything else is not an enum. */
+export function enumValues(type: string | undefined): string[] | null {
+ if (!type) return null
+ const parts = type.split('|').map((part) => part.trim())
+ const values: string[] = []
+ for (const part of parts) {
+ const match = /^'([^']*)'$|^"([^"]*)"$/.exec(part)
+ if (!match) return null
+ values.push(match[1] ?? match[2] ?? '')
+ }
+ return values.length ? values : null
+}
+
+/** The visual enum props of a contract, each with its values, default first. */
+export function visualAxes(contract: ContractSpec | undefined): Map {
+ const axes = new Map()
+ for (const prop of contract?.props ?? []) {
+ if (!prop.visual) continue
+ const values = enumValues(prop.type)
+ if (!values) continue
+ const fallback = typeof prop.default === 'string' ? prop.default : undefined
+ axes.set(
+ prop.name,
+ fallback && values.includes(fallback)
+ ? [fallback, ...values.filter((value) => value !== fallback)]
+ : values,
+ )
+ }
+ return axes
+}
+
+/** The states the browser produces; they need no declaration (ADR 0016 §1). */
+export const INTERACTION_STATES: readonly string[] = ['hover', 'focus', 'active']
+
+/**
+ * Where a state named in a style row comes from (ADR 0016 §1): a visual
+ * boolean prop the consumer sets, an interaction the browser produces, or a
+ * state the element declares itself. Undefined for a name that is none of
+ * them, which the audit reports.
+ */
+export function stateKind(
+ name: string,
+ contract: ContractSpec | undefined,
+): 'prop' | 'interaction' | 'declared' | undefined {
+ if (contract?.props.some((prop) => prop.name === name && prop.visual && prop.type === 'boolean'))
+ return 'prop'
+ if (INTERACTION_STATES.includes(name)) return 'interaction'
+ if (contract?.states.some((state) => state.name === name)) return 'declared'
+ return undefined
+}
+
+/**
+ * The `state` axis, `default` first (ADR 0016 §1): the visual boolean props
+ * in declaration order, then the interaction states in the order the styles
+ * table first mentions them, then the states the element declares. A visual
+ * boolean no row styles is still drawn, so the set shows honestly that it
+ * looks like the default.
+ */
+export function stateAxis(
+ contract: ContractSpec | undefined,
+ styles: readonly StyleRow[] = [],
+): string[] {
+ const states: string[] = [DEFAULT_STATE]
+ const add = (name: string): void => {
+ if (!states.includes(name)) states.push(name)
+ }
+ for (const prop of contract?.props ?? [])
+ if (prop.visual && prop.type === 'boolean') add(prop.name)
+ for (const row of styles) {
+ const named = row.keys[STATE_AXIS]
+ if (named !== undefined && stateKind(named, contract) === 'interaction') add(named)
+ }
+ for (const state of contract?.states ?? []) add(state.name)
+ return states
+}
+
+/**
+ * The variant space of a component: its visual enum props and its states,
+ * in declaration order with the state axis last. Empty when the contract
+ * declares neither, which is also when nothing needs deriving.
+ */
+export function axesOf(spec: DocumentSpec | undefined): Map {
+ const axes = visualAxes(spec?.contract)
+ const states = stateAxis(spec?.contract, spec?.styles ?? [])
+ if (states.length > 1) axes.set(STATE_AXIS, states)
+ return axes
+}
diff --git a/packages/format/src/index.ts b/packages/format/src/index.ts
index ab5d4bb..d93d120 100644
--- a/packages/format/src/index.ts
+++ b/packages/format/src/index.ts
@@ -114,8 +114,20 @@ export {
type PropSpec,
type SlotSpec,
type SpecNode,
+ type StateSpec,
+ type PartSpec,
type StyleRow,
} from './types.js'
export { buildSpec, REGION_NAMES, SpecLowerer, type Region, type RegionName } from './spec.js'
export { SCOPE_FOR_PROP, scopesForProp } from './scope-for-prop.js'
export * from './lengths.js'
+export {
+ axesOf,
+ DEFAULT_STATE,
+ enumValues,
+ INTERACTION_STATES,
+ STATE_AXIS,
+ stateAxis,
+ stateKind,
+ visualAxes,
+} from './contract.js'
diff --git a/packages/format/src/spec.ts b/packages/format/src/spec.ts
index f2c911d..aa33dd6 100644
--- a/packages/format/src/spec.ts
+++ b/packages/format/src/spec.ts
@@ -11,10 +11,12 @@ import type {
FieldSpec,
JsonValue,
ModelSpec,
+ PartSpec,
PropSpec,
Range,
SlotSpec,
SpecNode,
+ StateSpec,
StyleRow,
} from './types.js'
@@ -45,8 +47,8 @@ export type RegionName = (typeof REGION_NAMES)[number]
const CONTRACT_ELEMENTS: Record = {
Props: ['Prop'],
Events: ['Event'],
- States: [],
- Parts: [],
+ States: ['State'],
+ Parts: ['Part'],
Slots: ['Slot'],
Form: [],
Accessibility: [],
@@ -245,7 +247,7 @@ export class SpecLowerer {
const spec: ContractSpec = {
props: [],
events: [],
- states: { structural: [], styling: [] },
+ states: [],
parts: [],
slots: [],
composes: [],
@@ -263,28 +265,27 @@ export class SpecLowerer {
for (const child of node.children) {
const name = this.str(child, 'name')
if (!name) continue
- const model = this.str(child, 'model', false)
- const type = this.str(child, 'type', false)
- if (!model && !type) {
+ if (child.attrs.model !== undefined) {
this.error(
CODES.BAD_SPEC,
- ` needs a "type" or a "model"`,
+ `: a model is named by its type — write type="Contact" (ADR 0015 §2)`,
child.loc,
)
}
+ const type = this.str(child, 'type')
+ if (!type) continue
if (spec.props.some((prop) => prop.name === name)) {
this.error(CODES.BAD_SPEC, ` is declared twice`, child.loc)
continue
}
const prop: PropSpec = {
name,
+ type,
controllable: this.flag(child, 'controllable'),
visual: this.flag(child, 'visual'),
description: this.described(child),
loc: child.loc,
}
- if (type) prop.type = type
- if (model) prop.model = model
if (child.attrs.default !== undefined) prop.default = child.attrs.default
if (child.attrs.sample !== undefined) prop.sample = child.attrs.sample
spec.props.push(prop)
@@ -301,13 +302,42 @@ export class SpecLowerer {
}
break
case 'States':
- spec.states = {
- structural: this.stringList(node, 'structural'),
- styling: this.stringList(node, 'styling'),
+ if (node.attrs.structural !== undefined || node.attrs.styling !== undefined) {
+ this.error(
+ CODES.BAD_SPEC,
+ ': a boolean prop marked visual is a state already; declare here only the states the element produces itself, as ',
+ node.loc,
+ )
+ }
+ for (const child of node.children) {
+ const name = this.str(child, 'name')
+ if (!name) continue
+ if (spec.states.some((state) => state.name === name)) {
+ this.error(CODES.BAD_SPEC, ` is declared twice`, child.loc)
+ continue
+ }
+ const state: StateSpec = { name, description: this.described(child), loc: child.loc }
+ spec.states.push(state)
}
break
case 'Parts':
- spec.parts = this.names(node)
+ if (node.text !== '' && node.children.length === 0) {
+ this.error(
+ CODES.BAD_SPEC,
+ ' lists parts as description children',
+ node.loc,
+ )
+ }
+ for (const child of node.children) {
+ const name = this.str(child, 'name')
+ if (!name) continue
+ if (spec.parts.some((part) => part.name === name)) {
+ this.error(CODES.BAD_SPEC, ` is declared twice`, child.loc)
+ continue
+ }
+ const part: PartSpec = { name, description: child.text, loc: child.loc }
+ spec.parts.push(part)
+ }
break
case 'Slots':
for (const child of node.children) {
@@ -319,14 +349,21 @@ export class SpecLowerer {
description: this.described(child),
loc: child.loc,
}
- const model = this.str(child, 'model', false)
+ const of = this.str(child, 'of', false)
const accepts = this.str(child, 'accepts', false)
- if (model) slot.model = model
+ if (of) slot.of = of
if (accepts) slot.accepts = accepts
- if (slot.repeats && (!model || !accepts)) {
+ if (child.attrs.model !== undefined) {
+ this.error(
+ CODES.BAD_SPEC,
+ `: a repeating slot names the list prop it iterates — write of="items" (ADR 0017 §1)`,
+ child.loc,
+ )
+ }
+ if (slot.repeats && (!of || !accepts)) {
this.error(
CODES.BAD_SPEC,
- ` repeats, so it needs a "model" and an "accepts" (ADR 0017 §1)`,
+ ` repeats, so it needs an "of" naming the list prop and an "accepts" (ADR 0017 §1)`,
child.loc,
)
}
diff --git a/packages/format/src/types.ts b/packages/format/src/types.ts
index 47647f7..1e9ade1 100644
--- a/packages/format/src/types.ts
+++ b/packages/format/src/types.ts
@@ -481,10 +481,12 @@ export interface SpecNode {
/** A `` of the contract (ADR 0013 §2). */
export interface PropSpec {
name: string
- /** A TypeScript-ish type string, or undefined when `model` is set. */
- type?: string
- /** A `{models#Name}` reference, for a prop that receives a model. */
- model?: string
+ /**
+ * A TypeScript-ish type string. A name that matches a `` — on this
+ * page or any other — means the prop receives that model (ADR 0015 §2);
+ * `Contact[]` is a list of them.
+ */
+ type: string
default?: JsonValue
/**
* A demonstration value for the canvas and generated markup, shown where
@@ -505,12 +507,31 @@ export interface EventSpec {
loc: Range
}
+/**
+ * A state the element produces itself (ADR 0013 §2): `invalid` after
+ * validation, `open` on a disclosure that manages itself. Not a prop, since
+ * the consumer cannot set it, and not the browser's `hover` or `focus`,
+ * which need no declaration. Rendered as `:state(name)` in CSS.
+ */
+export interface StateSpec {
+ name: string
+ description: string
+ loc: Range
+}
+
+/** A part of the headless root, described (ADR 0013 §2). Optional: the tree's `part="…"` bindings are the declaration. */
+export interface PartSpec {
+ name: string
+ description: string
+ loc: Range
+}
+
/** A consumer-filled position (ADR 0013 §2, ADR 0017 §1). */
export interface SlotSpec {
name: string
repeats: boolean
- /** The model each filling receives, for a repeating slot. */
- model?: string
+ /** The list prop a repeating slot iterates; its element type is the model each filling receives. */
+ of?: string
/** The headless root a filling must implement, for a repeating slot. */
accepts?: string
description: string
@@ -520,8 +541,8 @@ export interface SlotSpec {
export interface ContractSpec {
props: PropSpec[]
events: EventSpec[]
- states: { structural: string[]; styling: string[] }
- parts: string[]
+ states: StateSpec[]
+ parts: PartSpec[]
slots: SlotSpec[]
form?: { participates: boolean; submits?: string }
accessibility?: Record
diff --git a/packages/format/test/spec.test.ts b/packages/format/test/spec.test.ts
index 8612141..22a3d13 100644
--- a/packages/format/test/spec.test.ts
+++ b/packages/format/test/spec.test.ts
@@ -38,8 +38,12 @@ Lets a user toggle one option.
Fires once per user toggle.
-
-root, checked-indicator
+
+ The element failed its own validation.
+
+
+ The mark shown while checked.
+
Consumer text, styled here.
@@ -84,11 +88,12 @@ describe('spec regions', () => {
expect(spec.contract!.events).toMatchObject([
{ name: 'change', detail: '{ checked: boolean }' },
])
- expect(spec.contract!.states).toEqual({
- structural: ['checked'],
- styling: ['hover', 'focus', 'disabled'],
- })
- expect(spec.contract!.parts).toEqual(['root', 'checked-indicator'])
+ expect(spec.contract!.states.map((state) => [state.name, state.description])).toEqual([
+ ['invalid', 'The element failed its own validation.'],
+ ])
+ expect(spec.contract!.parts.map((part) => [part.name, part.description])).toEqual([
+ ['checked-indicator', 'The mark shown while checked.'],
+ ])
expect(spec.contract!.slots).toMatchObject([{ name: 'label', repeats: false }])
expect(spec.contract!.form).toEqual({
participates: true,
@@ -192,13 +197,31 @@ describe('spec regions', () => {
expect(noSample.diagnostics).toEqual([])
})
- it('refuses a repeating slot without a model and an accepted root', () => {
+ it('refuses a repeating slot without the list prop it iterates and an accepted root', () => {
const base = '---\nid: r\n---\n\n## Visual Contract\n\n \n'
const { diagnostics } = parse(
`${base}\n## Contract\n\nRows. \n`,
)
expect(diagnostics.map((d) => d.code)).toEqual([CODES.BAD_SPEC])
- expect(diagnostics[0]!.message).toContain('accepts')
+ expect(diagnostics[0]!.message).toContain('"of"')
+ })
+
+ it('names the old spellings and says what replaced them', () => {
+ const base = '---\nid: r\n---\n\n## Visual Contract\n\n \n'
+ const messages = (region: string) =>
+ parse(`${base}\n## Contract\n\n${region}\n`).diagnostics.map((d) => d.message)
+ expect(messages(` `)[0]).toContain(
+ 'visual is a state already',
+ )
+ expect(messages('a, b ')[0]).toContain('Row. ')[0],
+ ).toContain('type="Contact"')
+ expect(
+ messages(
+ 'Rows. ',
+ )[0],
+ ).toContain('of="items"')
})
it('reports an element a region does not know, with what it allows', () => {
diff --git a/packages/schema/src/design-system-audit.ts b/packages/schema/src/design-system-audit.ts
index 7c28339..ed78659 100644
--- a/packages/schema/src/design-system-audit.ts
+++ b/packages/schema/src/design-system-audit.ts
@@ -5,6 +5,7 @@ import {
hasVariants,
slots as slotsOf,
type Diagnostic,
+ type ModelSpec,
type UidxDocument,
type UidxNode,
} from '@uidx/format'
@@ -13,10 +14,14 @@ import {
axesOf,
enumValues,
modelByRef,
+ modelOfType,
ROOT_PART,
+ slotModel,
+ stateKind,
styleTarget,
STATE_AXIS,
DEFAULT_STATE,
+ type ModelIndex,
} from './design-system.js'
/**
@@ -27,7 +32,7 @@ import {
*
* Pure: a document in, diagnostics out. `uidx check` runs it on every page.
*/
-export function auditDesignSystem(doc: UidxDocument): Diagnostic[] {
+export function auditDesignSystem(doc: UidxDocument, models?: ModelIndex): Diagnostic[] {
const out: Diagnostic[] = []
const error = (code: string, message: string, loc: { start: number; end: number }) =>
out.push(diagnostic(doc.source, code, message, loc))
@@ -38,38 +43,52 @@ export function auditDesignSystem(doc: UidxDocument): Diagnostic[] {
const components = doc.tree.children.filter((node) => node.element === 'Component')
/* ------------------------------------------------------------ models */
- const usedByRepeat = new Set()
+ // A model is named by a prop's type (ADR 0015 §2) and may live on another
+ // page. Without an index only this page can answer, so a name nobody here
+ // declares is left alone; `uidx check` passes the whole document's.
+ const known = (name: string | undefined) => modelByRef(spec, name, models)
for (const slot of spec?.contract?.slots ?? []) {
- const model = modelByRef(spec, slot.model)
- if (slot.model && !model) {
+ if (!slot.repeats) continue
+ const prop = spec?.contract?.props.find((entry) => entry.name === slot.of)
+ if (!prop) {
error(
CODES.MODEL_FIELD,
- `slot "${slot.name}" names a model that is not declared: ${slot.model}`,
+ `slot "${slot.name}" iterates "${slot.of}", which is not a prop of the contract`,
slot.loc,
)
+ continue
}
- if (model) usedByRepeat.add(model.name)
- }
- for (const prop of spec?.contract?.props ?? []) {
- if (prop.model && !modelByRef(spec, prop.model)) {
+ if (!prop.type.endsWith('[]')) {
error(
CODES.MODEL_FIELD,
- `prop "${prop.name}" names a model that is not declared: ${prop.model}`,
- prop.loc,
+ `slot "${slot.name}" iterates "${slot.of}", whose type ${prop.type} is not a list`,
+ slot.loc,
)
+ continue
}
- }
- for (const model of spec?.models ?? []) {
- const keys = model.fields.filter((field) => field.key)
- if (usedByRepeat.has(model.name) && keys.length !== 1) {
+ const model = slotModel(slot, spec, models)
+ if (model) {
+ // Checked here, where the repeat is, rather than at the model: the
+ // model may live on another page and not know it fills a repeat.
+ const keys = model.fields.filter((field) => field.key)
+ if (keys.length !== 1) {
+ error(
+ CODES.MODEL_FIELD,
+ `slot "${slot.name}" repeats ${model.name}, so exactly one of its fields must be the key; found ${keys.length}`,
+ slot.loc,
+ )
+ }
+ } else if (models) {
error(
CODES.MODEL_FIELD,
- `model "${model.name}" fills a repeating slot, so exactly one field must be its key; found ${keys.length}`,
- model.loc,
+ `slot "${slot.name}" iterates "${slot.of}" of type ${prop.type}, but no page declares a model named ${prop.type.slice(0, -2)}`,
+ slot.loc,
)
}
+ }
+ for (const model of spec?.models ?? []) {
for (const field of model.fields) {
- if (!field.optional && field.sample === undefined && !modelByRef(spec, field.type)) {
+ if (!field.optional && field.sample === undefined && !known(field.type)) {
error(
CODES.MODEL_FIELD,
`field "${model.name}.${field.name}" is required, so it needs a sample the design tools can draw`,
@@ -93,7 +112,9 @@ export function auditDesignSystem(doc: UidxDocument): Diagnostic[] {
}
/* ------------------------------------------------------------- parts */
- const declaredParts = new Set((contract?.parts ?? []).filter((part) => part !== ROOT_PART))
+ const declaredParts = new Set(
+ (contract?.parts ?? []).map((part) => part.name).filter((part) => part !== ROOT_PART),
+ )
const bound = new Map()
const walk = (node: UidxNode): void => {
const part = node.attrs.part?.value
@@ -117,13 +138,6 @@ export function auditDesignSystem(doc: UidxDocument): Diagnostic[] {
)
continue
}
- if (contract && !declaredParts.has(part)) {
- error(
- CODES.PART_BINDING,
- `part="${part}" is not declared in `,
- nodes[0]!.attrs.part!.loc,
- )
- }
if (nodes.length > 1) {
error(
CODES.PART_BINDING,
@@ -137,8 +151,8 @@ export function auditDesignSystem(doc: UidxDocument): Diagnostic[] {
if (!bound.has(part)) {
error(
CODES.PART_BINDING,
- `part "${part}" is declared in but no node binds it with part="${part}"`,
- contract.loc,
+ `part "${part}" is described in but no node binds it with part="${part}"`,
+ contract.parts.find((entry) => entry.name === part)?.loc ?? contract.loc,
)
}
}
@@ -211,7 +225,9 @@ export function auditDesignSystem(doc: UidxDocument): Diagnostic[] {
/* ---------------------------------------------------------- bindings */
const modelProps = new Map(
- (contract?.props ?? []).filter((prop) => prop.model).map((prop) => [prop.name, prop]),
+ (contract?.props ?? [])
+ .filter((prop) => modelOfType(prop.type, spec, models)?.list === false)
+ .map((prop) => [prop.name, prop]),
)
const checkBindings = (node: UidxNode): void => {
for (const attr of Object.values(node.attrs)) {
@@ -227,7 +243,7 @@ export function auditDesignSystem(doc: UidxDocument): Diagnostic[] {
)
continue
}
- let model = modelByRef(spec, prop.model)
+ let model: ModelSpec | undefined = modelOfType(prop.type, spec, models)?.model
for (const segment of path) {
const field = model?.fields.find((entry) => entry.name === segment)
if (!field) {
@@ -239,7 +255,7 @@ export function auditDesignSystem(doc: UidxDocument): Diagnostic[] {
model = undefined
break
}
- model = modelByRef(spec, field.type)
+ model = modelByRef(spec, field.type, models)
}
}
for (const child of node.children) checkBindings(child)
@@ -258,12 +274,21 @@ export function auditDesignSystem(doc: UidxDocument): Diagnostic[] {
for (const row of rows) {
for (const [axis, value] of Object.entries(row.keys)) {
const domain = axes.get(axis)
- if (!domain) {
+ if (axis === STATE_AXIS) {
+ if (stateKind(value, contract) === undefined) {
+ const prop = contract?.props.find((entry) => entry.name === value)
+ error(
+ CODES.STYLE_ROW,
+ prop?.type === 'boolean'
+ ? `
+
+## Contract
+
+
+ The name.
+ Error state.
+ Size.
+
+`,
+ ),
+}
+
+describe('a component declared by its contract (ADR 0013 §5)', () => {
+ it('accepts values for contract props, and an axis the styles table derives', () => {
+ expect(report(uses(`{ label: 'Name', error: true }`, 'Field'), CONTRACTED)).toEqual([])
+ expect(report(uses(`{ size: 'sm', state: 'error' }`, 'Field'), CONTRACTED)).toEqual([])
+ expect(report(uses(`{ nope: 'x' }`, 'Field'), CONTRACTED).map((d) => d.code)).toEqual([
+ WORKSPACE_CODES.UNDECLARED_PROPERTY_VALUE,
+ ])
+ expect(report(uses(`{ error: 'yes' }`, 'Field'), CONTRACTED).map((d) => d.code)).toEqual([
+ WORKSPACE_CODES.PROPERTY_VALUE_MISMATCH,
+ ])
+ })
+
+ it("lets a use pass its own prop through as '{label}' without judging the value", () => {
+ const outer = src(
+ 'outer.uidx',
+ 'outer',
+ `
+
+ `,
+ )
+ expect(report(outer, CONTRACTED)).toEqual([])
+ })
+})
+
describe('an instance filling in its properties', () => {
it('says nothing when every value is declared and typed right', () => {
expect(report(BUTTON, uses(`{ label: 'Save', showIcon: false }`))).toEqual([])
diff --git a/packages/server/test/property-bindings.test.ts b/packages/server/test/property-bindings.test.ts
index bb961b1..99eaac1 100644
--- a/packages/server/test/property-bindings.test.ts
+++ b/packages/server/test/property-bindings.test.ts
@@ -30,6 +30,38 @@ const button = (props: string, body: string) =>
const codes = (pages: Parameters[0]): string[] =>
buildSymbolTable(pages).diagnostics.map((d) => d.code)
+describe('bindings to a contract (ADR 0013 §5, ADR 0015 §2)', () => {
+ it('resolves {label} to a contract prop and {item.name} to a model prop', () => {
+ const doc = parseOrThrow(
+ `${page(
+ 'row',
+ `
+
+
+
+ `,
+ )}
+## Contract
+
+
+ The row.
+ Words.
+
+
+## Models
+
+
+ A person.
+ Name.
+
+`,
+ )
+ const diagnostics = buildSymbolTable([{ file: 'row.uidx', doc }]).diagnostics
+ expect(diagnostics.map((d) => d.code)).toEqual([WORKSPACE_CODES.UNRESOLVED_REFERENCE])
+ expect(diagnostics[0]!.message).toContain('"Row" declares no property "nothing"')
+ })
+})
+
const TOKENS = {
file: 'tokens.uidx',
doc: parseOrThrow(
diff --git a/packages/viewer/src/ContractSection.vue b/packages/viewer/src/ContractSection.vue
index a45b713..9f8ef12 100644
--- a/packages/viewer/src/ContractSection.vue
+++ b/packages/viewer/src/ContractSection.vue
@@ -103,7 +103,9 @@ function findNode(root: UidxNode, address: string): UidxNode | null {
/** `' '`-indented so the picker reads as the layer tree does. */
const indent = (depth: number): string => ' '.repeat(depth)
-const typeOf = (prop: { type?: string; model?: string }): string => prop.model ?? prop.type ?? ''
+/** A visual boolean is drawn as a state of the set (ADR 0016 §1); the list says so. */
+const isState = (prop: { type: string; visual: boolean }): boolean =>
+ prop.visual && prop.type === 'boolean'
@@ -313,7 +315,11 @@ const typeOf = (prop: { type?: string; model?: string }): string => prop.model ?
-
+
Contract
from the file
@@ -324,8 +330,31 @@ const typeOf = (prop: { type?: string; model?: string }): string => prop.model ?
class="row read"
:data-prop="prop.name"
>
- {{ prop.name }}
- {{ typeOf(prop) }}
+
+ {{ prop.name }}
+ state
+
+ {{ prop.type }}
+
+
+
+
+ {{ state.name }}
+ element
+
+ state
prop.model ?
min-height: var(--row-h);
padding: 2px 0;
}
+.name-text {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
.pill {
+ flex: none;
padding: 0 5px;
border-radius: 999px;
background: var(--raised);
diff --git a/packages/viewer/src/contract-edits.ts b/packages/viewer/src/contract-edits.ts
index 2177247..df27e19 100644
--- a/packages/viewer/src/contract-edits.ts
+++ b/packages/viewer/src/contract-edits.ts
@@ -96,7 +96,7 @@ export type ContractView = ComponentView | PartView | RepeatView | SlotView | Ot
/** 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 contract = (component.spec?.contract?.parts ?? []).map((part) => part.name)
const rows = new Map
()
for (const part of element?.parts ?? [])
rows.set(part.name, { name: part.name, declaredBy: 'library', kind: part.kind, boundTo: null })
diff --git a/packages/viewer/test/contract-edits.test.ts b/packages/viewer/test/contract-edits.test.ts
index 95c55b9..44e4849 100644
--- a/packages/viewer/test/contract-edits.test.ts
+++ b/packages/viewer/test/contract-edits.test.ts
@@ -65,7 +65,10 @@ const CHECKBOX = page(
Fires on toggle.
-checked-indicator, indeterminate-indicator
+
+ The mark while checked.
+ The mark while mixed.
+
`,
)
@@ -84,7 +87,7 @@ const LIST = page(
Rows.
- One per item.
+ One per item.
While empty.
From 7eb5c7aacbac7116307b83b7c0b2d8ca4f29d502 Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 12:52:47 +0300
Subject: [PATCH 10/74] lint: drop imports the contract move left unused
Co-Authored-By: Claude Fable 5.1
---
packages/schema/src/design-system.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/packages/schema/src/design-system.ts b/packages/schema/src/design-system.ts
index 4467740..2e4869f 100644
--- a/packages/schema/src/design-system.ts
+++ b/packages/schema/src/design-system.ts
@@ -5,7 +5,6 @@ import {
hasVariants,
METADATA_ATTRS,
variantName,
- type ContractSpec,
type DocumentSpec,
type FieldSpec,
type JsonValue,
@@ -33,7 +32,7 @@ import {
* never links them, and a gesture on one produces no patch (ADR 0016 §4).
*/
-import { axesOf, DEFAULT_STATE, STATE_AXIS } from '@uidx/format'
+import { axesOf } from '@uidx/format'
/** The styles-table name for the component's own frame (ADR 0016 §2). */
export const ROOT_PART = 'root'
From f6bea7653662cb1264a468e232dc438d199aea5c Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 12:53:04 +0300
Subject: [PATCH 11/74] lint: the React emitter no longer imports ModelSpec
Co-Authored-By: Claude Fable 5.1
---
packages/codegen/src/react.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/codegen/src/react.ts b/packages/codegen/src/react.ts
index bcd87f3..c0d71ca 100644
--- a/packages/codegen/src/react.ts
+++ b/packages/codegen/src/react.ts
@@ -1,4 +1,4 @@
-import type { FieldSpec, JsonValue, ModelSpec, PropSpec, UidxNode } from '@uidx/format'
+import type { FieldSpec, JsonValue, PropSpec, UidxNode } from '@uidx/format'
import { modelOfType, slotModel, type ModelIndex } from '@uidx/schema/design-system'
import { boundPath, boundProp, pascal, type ComponentModel } from './model.js'
From c5c28ab6e059b427cb57d0a2d076762117a722c6 Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 13:01:23 +0300
Subject: [PATCH 12/74] headless: library profile, per-library bindings, and
discovery
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The identity never spells a library. This makes that true end to end:
- uidx.json's `headless` may be an object: `manifest`, a `profile` (how the
library reflects props, its own states and parts — attributes, data
attributes, classes, `::part()` or `data-part`) and `bindings` (its tag,
part, event and attribute names per component). The code target and
conformance read both, so the checkbox renders as `sl-checkbox`,
`sl-checkbox[checked]::part(control)` and `sl-change` from the same file
that renders over @hwc/components. Defaults are the example's conventions.
- When nothing is named, the server discovers libraries the project's
dependencies ship (the `customElements` field of package.json) and the
Contract tab offers them, or any path; the choice is written into
uidx.json through the headless route. The tab says which tag a binding
renders a component as.
- `uidx codegen` passes the profile and bindings through from uidx.json.
Co-Authored-By: Claude Fable 5.1
---
CHANGELOG.md | 6 +-
README.md | 25 +++
docs/decisions/0013-component-contract.md | 12 ++
packages/cli/src/commands/codegen.ts | 16 +-
packages/codegen/src/conformance.ts | 28 +++-
packages/codegen/src/generate.ts | 11 +-
packages/codegen/src/html.ts | 93 +++++++++---
packages/codegen/src/index.ts | 4 +
packages/codegen/src/model.ts | 84 +++++++++-
packages/codegen/src/react.ts | 33 +++-
packages/codegen/test/generate.test.ts | 99 ++++++++++++
packages/server/src/document.ts | 63 ++++++--
packages/server/src/headless.ts | 160 +++++++++++++++++---
packages/server/test/headless.test.ts | 111 +++++++++++++-
packages/viewer/src/App.vue | 10 +-
packages/viewer/src/ContractSection.vue | 72 ++++++++-
packages/viewer/src/PropertiesPane.vue | 8 +-
packages/viewer/src/headless.ts | 75 +++++++--
packages/viewer/test/contract-edits.test.ts | 35 +++++
19 files changed, 845 insertions(+), 100 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43767db..c8ad978 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,7 +38,11 @@ describe migrations when an existing document or integration is affected.
`custom-elements.json`, served to the viewer and used by `uidx codegen` when
`--manifest` is absent. Parts a library exposes as shadow parts (`cssParts`)
are offered beside element parts and marked; the code target styles them
- through `::part()`.
+ through `::part()`. `headless` may also be an object with a `profile` (how
+ the library reflects props, its own states and parts) and `bindings` (its
+ names per component), so one design renders over libraries that spell
+ things differently; when nothing is named, the Contract tab offers the
+ libraries the project's dependencies ship and writes the choice.
## 0.1.6 — 2026-09-25
diff --git a/README.md b/README.md
index 28eb191..9c620bf 100644
--- a/README.md
+++ b/README.md
@@ -108,6 +108,31 @@ The separate `.uidx/uidx.json` manifest controls which files belong to the docum
`headless` is optional. It names the headless library's `custom-elements.json`,
relative to `uidx.json`; with it, the viewer's Contract tab offers the library's
elements and parts as choices, and `uidx codegen` checks contracts against it.
+Leave it out and the Contract tab offers the libraries your dependencies ship
+(any package whose `package.json` has a `customElements` field) and writes your
+choice here. The object form binds the same designs to a library that spells
+things differently, without editing a design:
+
+```json
+{
+ "headless": {
+ "manifest": "node_modules/@shoelace-style/shoelace/dist/custom-elements.json",
+ "profile": { "props": "attribute", "customStates": "state", "parts": "element" },
+ "bindings": {
+ "Checkbox": {
+ "tag": "sl-checkbox",
+ "parts": { "checked-indicator": "control" },
+ "events": { "change": "sl-change" }
+ }
+ }
+ }
+}
+```
+
+`profile` says how the library reflects props (`attribute`, `data-attribute`,
+`class`), its own states (`state`, `data-attribute`, `class`) and parts
+(`element`, `data-part`). `bindings` maps a component's identity names to the
+library's. Both default to the conventions `@hwc/components` follows.
### CLI and MCP
diff --git a/docs/decisions/0013-component-contract.md b/docs/decisions/0013-component-contract.md
index 017aba4..88ec6e6 100644
--- a/docs/decisions/0013-component-contract.md
+++ b/docs/decisions/0013-component-contract.md
@@ -84,6 +84,18 @@ shorthand rule of the visual contract does not apply here.
layer; binding it from a new layer unbinds the old one, since one node per
part is the rule. A `` chooses among the contract's repeating slots.
The tab never edits the contract's prose; it shows it beside the bindings.
+ When `uidx.json` names no library, the tab offers those the project's
+ dependencies ship — the Custom Elements Manifest convention of a
+ `customElements` field in `package.json` — and writes the choice.
+- The identity never spells a library. `implements`, `part`, prop and event
+ names are the design system's own words; `uidx.json`'s `headless` may carry
+ a `profile` (how the library reflects props, its own states and parts:
+ attributes, data attributes, classes, `::part()` or `data-part`) and
+ `bindings` (the library's tag, part, event and attribute names per
+ component). The code target and conformance read both, so moving to a
+ library that spells things differently is a new `headless` entry, not an
+ edit to any design. Absent, both default to the conventions the example
+ library follows.
### 4. Declaration in uidx, implementation in code
diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts
index e7ac5f5..17a25ac 100644
--- a/packages/cli/src/commands/codegen.ts
+++ b/packages/cli/src/commands/codegen.ts
@@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { glob } from 'tinyglobby'
import { formatDiagnostic, parse, type UidxDocument } from '@uidx/format'
-import { generate, type Manifest, type Target } from '@uidx/codegen'
+import { generate, type LibraryBindings, type Manifest, type Target } from '@uidx/codegen'
import { findManifest } from '@uidx/server/document'
import type { Io } from '../cli.js'
@@ -68,9 +68,15 @@ export async function runCodegen(argv: string[], io: Io): Promise {
// the library, so a checked-in config and the viewer's Contract tab read the
// same file this command checks against.
let manifestPath = parsed.values.manifest ? resolve(cwd, parsed.values.manifest) : undefined
- if (!manifestPath) {
- const found = await findManifest(cwd)
- if (found?.manifest.headless) manifestPath = resolve(found.dir, found.manifest.headless)
+ let library: LibraryBindings | undefined
+ const found = await findManifest(cwd)
+ const headless = found?.manifest.headless
+ if (headless) {
+ manifestPath ??= resolve(found!.dir, headless.manifest)
+ library = {
+ profile: headless.profile as LibraryBindings['profile'],
+ components: headless.bindings as LibraryBindings['components'],
+ }
}
let manifest: Manifest | undefined
if (manifestPath) {
@@ -84,7 +90,7 @@ export async function runCodegen(argv: string[], io: Io): Promise {
}
}
- const result = generate({ pages, tokens, manifest, targets })
+ const result = generate({ pages, tokens, manifest, library, targets })
for (const d of result.diagnostics) io.err(`${formatDiagnostic(d, d.file)}\n`)
if (result.diagnostics.some((d) => d.severity === 'error')) return 1
diff --git a/packages/codegen/src/conformance.ts b/packages/codegen/src/conformance.ts
index 2232b3d..89c6c83 100644
--- a/packages/codegen/src/conformance.ts
+++ b/packages/codegen/src/conformance.ts
@@ -1,5 +1,12 @@
import { CODES, diagnostic, type Diagnostic } from '@uidx/format'
-import { manifestTags, partTag, type ComponentModel, type Manifest } from './model.js'
+import {
+ attributeName,
+ eventName,
+ manifestTags,
+ partTag,
+ type ComponentModel,
+ type Manifest,
+} from './model.js'
/**
* The declared contract against the headless implementation (ADR 0013 §4).
@@ -22,7 +29,9 @@ export function checkConformance(model: ComponentModel, manifest: Manifest): Dia
report(`"${model.name}" implements ${model.tag}, which the manifest does not declare`)
return out
}
- const props = new Set(model.contract.props.map((prop) => prop.name))
+ // Compared in the library's spelling: a binding may rename an attribute
+ // or an event, and the contract still has to cover what the element does.
+ const props = new Set(model.contract.props.map((prop) => attributeName(model, prop.name)))
for (const attribute of root.attributes ?? []) {
if (!props.has(attribute.name)) {
report(
@@ -30,7 +39,7 @@ export function checkConformance(model: ComponentModel, manifest: Manifest): Dia
)
}
}
- const events = new Set(model.contract.events.map((event) => event.name))
+ const events = new Set(model.contract.events.map((event) => eventName(model, event.name)))
for (const event of root.events ?? []) {
if (!events.has(event.name)) {
report(
@@ -39,7 +48,7 @@ export function checkConformance(model: ComponentModel, manifest: Manifest): Dia
}
}
for (const event of model.contract.events) {
- if (!(root.events ?? []).some((entry) => entry.name === event.name)) {
+ if (!(root.events ?? []).some((entry) => entry.name === eventName(model, event.name))) {
report(
`the contract declares the event "${event.name}", which ${model.tag} does not dispatch`,
)
@@ -47,15 +56,20 @@ export function checkConformance(model: ComponentModel, manifest: Manifest): Dia
}
const tagSet = new Set(tags.keys())
// The tree's bindings are the parts this component uses (ADR 0013 §3).
- for (const { name: part } of model.parts) {
- if (part === 'root') continue
+ // Light-DOM parts marked by `data-part` are the consumer's markup; the
+ // manifest has nothing to say about them.
+ if (model.profile.parts === 'data-part') return out
+ for (const { name, libraryName: part } of model.parts) {
+ if (name === 'root') continue
const tag = partTag(model.tag, part, tagSet)
if (tagSet.has(tag)) continue
// A shadow part is as declared as an element part. That the library
// draws it, and the design's own drawing under it is for the canvas and
// Figma, is how such a library works — not a finding.
if ((root.cssParts ?? []).some((entry) => entry.name === part)) continue
- report(`part "${part}" has no element in the manifest (looked for ${tag})`)
+ report(
+ `part "${name}"${part === name ? '' : ` (bound to "${part}")`} has no element in the manifest (looked for ${tag})`,
+ )
}
return out
}
diff --git a/packages/codegen/src/generate.ts b/packages/codegen/src/generate.ts
index c8d7c7a..922f39e 100644
--- a/packages/codegen/src/generate.ts
+++ b/packages/codegen/src/generate.ts
@@ -3,7 +3,12 @@ import { contractJson } from '@uidx/schema/design-system'
import { tokensCss } from './css.js'
import { checkConformance } from './conformance.js'
import { emitCss, emitHtml } from './html.js'
-import { componentModel, type ComponentModel, type Manifest } from './model.js'
+import {
+ componentModel,
+ type ComponentModel,
+ type LibraryBindings,
+ type Manifest,
+} from './model.js'
import { modelIndex } from '@uidx/schema/design-system'
import { emitElementTypes, emitIndex, emitModels, emitReact, emitRuntime } from './react.js'
@@ -16,6 +21,8 @@ export interface GenerateInput {
tokens?: readonly UidxDocument[]
/** The headless library's `custom-elements.json`, for part tags and conformance. */
manifest?: Manifest
+ /** How the library spells things and its names for this document's (`uidx.json` `headless`). */
+ library?: LibraryBindings
targets?: readonly Target[]
}
@@ -45,7 +52,7 @@ export function generate(input: GenerateInput): GenerateOutput {
if (doc.tree.element === 'Tokens') continue
for (const node of doc.tree.children) {
if (node.element !== 'Component') continue
- const model = componentModel(node, doc, input.manifest, index)
+ const model = componentModel(node, doc, input.manifest, index, input.library)
models.push(model)
owner.set(model, file)
}
diff --git a/packages/codegen/src/html.ts b/packages/codegen/src/html.ts
index 61a9b53..b3120c6 100644
--- a/packages/codegen/src/html.ts
+++ b/packages/codegen/src/html.ts
@@ -1,7 +1,7 @@
import type { ContractSpec, JsonValue, UidxNode } from '@uidx/format'
import { specBindings, STATE_AXIS, stateKind, styleTarget } from '@uidx/schema/design-system'
import { cssDeclarations, cssRule, type CssKind } from './css.js'
-import { boundPath, boundProp, type ComponentModel } from './model.js'
+import { attributeName, boundPath, boundProp, type ComponentModel, type PartInfo } from './model.js'
/**
* The HTML/CSS target (ADR 0017 §3): one stylesheet per component, keyed by
@@ -15,17 +15,67 @@ export interface HtmlContext {
}
/** How a state of the contract is selected in CSS. */
-export function stateSelector(state: string, contract?: ContractSpec): string {
+export function stateSelector(
+ state: string,
+ contract?: ContractSpec,
+ model?: ComponentModel,
+): string {
switch (stateKind(state, contract)) {
case 'interaction':
return state === 'hover' ? ':hover' : state === 'focus' ? ':focus-visible' : ':active'
- // A state the element produces itself, exposed through ElementInternals.
+ // A state the element produces itself: `:state()` through ElementInternals
+ // unless the library spells it otherwise.
case 'declared':
- return `:state(${state})`
- // A visual boolean prop, reflected as an attribute — and the fallback
- // for a name the contract cannot place, which the audit has reported.
+ switch (model?.profile.customStates) {
+ case 'data-attribute':
+ return `[data-${state}]`
+ case 'class':
+ return `.${state}`
+ default:
+ return `:state(${state})`
+ }
+ // A visual boolean prop, reflected the way the library reflects props —
+ // and the fallback for a name the contract cannot place, which the audit
+ // has reported.
+ default:
+ return model ? propSelector(model, state) : `[${state}]`
+ }
+}
+
+/** A prop's selector the way the library reflects it: `[variant="a"]`, `[data-variant="a"]`, `.variant-a`. */
+export function propSelector(model: ComponentModel, prop: string, value?: string): string {
+ const attr = attributeName(model, prop)
+ switch (model.profile.props) {
+ case 'data-attribute':
+ return value === undefined ? `[data-${attr}]` : `[data-${attr}="${value}"]`
+ case 'class':
+ return value === undefined ? `.${attr}` : `.${attr}-${value}`
+ default:
+ return value === undefined ? `[${attr}]` : `[${attr}="${value}"]`
+ }
+}
+
+/** A part's selector below the root, by the kind the library gives it. */
+function partSelector(root: string, info: PartInfo): string {
+ switch (info.kind) {
+ case 'shadow':
+ return `${root}::part(${info.libraryName})`
+ case 'data-part':
+ return `${root} [data-part="${info.libraryName}"]`
+ default:
+ return `${root} ${info.tag}`
+ }
+}
+
+/** The open and close tags a part's node renders as, or null for a shadow part. */
+function partTags(info: PartInfo): [string, string] | null {
+ switch (info.kind) {
+ case 'shadow':
+ return null
+ case 'data-part':
+ return [``, ' ']
default:
- return `[${state}]`
+ return [`<${info.tag}>`, `${info.tag}>`]
}
}
@@ -66,7 +116,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 info.kind === 'shadow' ? `${root}::part(${part})` : `${root} ${info.tag}`
+ return partSelector(root, info)
}
if (node.element === 'Slot') return `${root} [data-slot="${node.name}"]`
return `${root} [data-node="${node.name}"]`
@@ -116,7 +166,10 @@ export function emitCss(model: ComponentModel): string {
for (const row of model.spec?.styles ?? []) {
let scoped = root
for (const [axis, value] of Object.entries(row.keys)) {
- scoped += axis === STATE_AXIS ? stateSelector(value, model.contract) : `[${axis}="${value}"]`
+ scoped +=
+ axis === STATE_AXIS
+ ? stateSelector(value, model.contract, model)
+ : propSelector(model, axis, value)
}
for (const [part, props] of Object.entries(row.values)) {
const info = part === 'root' ? undefined : model.parts.find((entry) => entry.name === part)
@@ -126,9 +179,7 @@ export function emitCss(model: ComponentModel): string {
part === 'root'
? scoped
: info
- ? info.kind === 'shadow'
- ? `${scoped}::part(${part})`
- : `${scoped} ${info.tag}`
+ ? partSelector(scoped, info)
: `${scoped} [data-node="${part}"]`
rules.push(cssRule(selector, cssDeclarations(props, target ? kindOf(target) : 'container')))
}
@@ -229,9 +280,10 @@ function markup(
return [`${pad}`, ...inner, `${pad} `]
}
case 'Text': {
- const tag = info?.tag ?? 'span'
- const open = info ? `<${tag}>` : ``
- const close = info ? `${tag}>` : ' '
+ const [open, close] = (info && partTags(info)) ?? [
+ ``,
+ ' ',
+ ]
return [`${pad}${open}${escapeHtml(sampleText(model, node, samples))}${close}`]
}
case 'Vector': {
@@ -248,8 +300,11 @@ function markup(
)
.filter((data): data is string => typeof data === 'string')
const svg = `${d.map((data) => ` `).join('')} `
- if (info) return [`${pad}<${info.tag}>${svg}${info.tag}>`]
- return [`${pad}${svg} `]
+ const [open, close] = (info && partTags(info)) ?? [
+ ``,
+ ' ',
+ ]
+ return [`${pad}${open}${svg}${close}`]
}
default: {
// Frame, Rectangle, Ellipse — and the component itself.
@@ -260,9 +315,7 @@ function markup(
const attrs = model.tag ? '' : ` class="${model.stem}"`
return [`${pad}<${tag}${attrs}>`, ...children(), `${pad}${tag}>`]
}
- const tag = info?.tag ?? 'div'
- const open = info ? `<${tag}>` : ``
- const close = info ? `${tag}>` : '
'
+ const [open, close] = (info && partTags(info)) ?? [``, '
']
return [`${pad}${open}`, ...children(), `${pad}${close}`]
}
}
diff --git a/packages/codegen/src/index.ts b/packages/codegen/src/index.ts
index 93ac361..930abea 100644
--- a/packages/codegen/src/index.ts
+++ b/packages/codegen/src/index.ts
@@ -2,10 +2,14 @@ export { generate, type GenerateInput, type GenerateOutput, type Target } from '
export {
componentModel,
kebab,
+ libraryProfile,
manifestTags,
partTag,
pascal,
+ type ComponentBinding,
type ComponentModel,
+ type LibraryBindings,
+ type LibraryProfile,
type Manifest,
} from './model.js'
export {
diff --git a/packages/codegen/src/model.ts b/packages/codegen/src/model.ts
index f81bc67..ad2bb21 100644
--- a/packages/codegen/src/model.ts
+++ b/packages/codegen/src/model.ts
@@ -75,7 +75,63 @@ 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'
+export type PartKind = 'element' | 'shadow' | 'data-part'
+
+/**
+ * How a library spells what the identity declares (ADR 0013 §3). The
+ * identity never changes with the library; this does.
+ */
+export interface LibraryProfile {
+ /** A boolean prop: reflected as `[checked]`, `[data-checked]`, or `.checked`. */
+ props: 'attribute' | 'data-attribute' | 'class'
+ /** A state the element produces itself: `:state(x)`, `[data-x]`, or `.x`. */
+ customStates: 'state' | 'data-attribute' | 'class'
+ /** Parts: elements of their own (or `cssParts`), or light-DOM `[data-part="x"]`. */
+ parts: 'element' | 'data-part'
+}
+
+export const DEFAULT_PROFILE: LibraryProfile = {
+ props: 'attribute',
+ customStates: 'state',
+ parts: 'element',
+}
+
+/** The library's names for one component's identity names. Absent entries keep the identity's. */
+export interface ComponentBinding {
+ tag?: string
+ parts?: Record
+ events?: Record
+ attributes?: Record
+}
+
+/** `uidx.json`'s `headless.profile` and `headless.bindings`, as the code target reads them. */
+export interface LibraryBindings {
+ profile?: Partial>
+ components?: Record
+}
+
+/** The profile with every field valid: an unknown or absent value takes the default. */
+export function libraryProfile(library?: LibraryBindings): LibraryProfile {
+ const pick = (key: K, allowed: readonly LibraryProfile[K][]) => {
+ const value = library?.profile?.[key]
+ return (allowed as readonly string[]).includes(value ?? '')
+ ? (value as LibraryProfile[K])
+ : DEFAULT_PROFILE[key]
+ }
+ return {
+ props: pick('props', ['attribute', 'data-attribute', 'class']),
+ customStates: pick('customStates', ['state', 'data-attribute', 'class']),
+ parts: pick('parts', ['element', 'data-part']),
+ }
+}
+
+/** The library's attribute for a prop, and its event for an event. */
+export function attributeName(model: ComponentModel, prop: string): string {
+ return model.binding.attributes?.[prop] ?? prop
+}
+export function eventName(model: ComponentModel, event: string): string {
+ return model.binding.events?.[event] ?? event
+}
/**
* Which kind of part the library offers under this name (ADR 0017 §3).
@@ -121,6 +177,8 @@ export interface PartInfo {
* or a shadow part styled through `::part()` and drawn by the library.
*/
kind: PartKind
+ /** What the library calls it, from the bindings; the identity's name when unbound. */
+ libraryName: string
}
export interface SlotInfo {
@@ -167,6 +225,10 @@ export interface ComponentModel {
composes: UidxNode | undefined
/** The models a prop's type may name, across the document (ADR 0015 §1). */
models: ModelIndex
+ /** How the library spells props, parts and states. */
+ profile: LibraryProfile
+ /** The library's names for this component's, from `uidx.json`; empty when unbound. */
+ binding: ComponentBinding
}
/** The prop a bare `{name}` alias names, if the contract declares it. */
@@ -201,14 +263,19 @@ export function componentModel(
manifest?: Manifest,
/** Every model the document set declares; this page's alone when absent. */
models?: ModelIndex,
+ /** How the library spells things, and its names for this document's (ADR 0013 §3). */
+ library?: LibraryBindings,
): ComponentModel {
const tags = manifest ? new Set(manifestTags(manifest).keys()) : undefined
const spec = component.spec ?? doc.spec
const contract = spec?.contract
- const tag =
+ const profile = libraryProfile(library)
+ const binding = library?.components?.[component.name] ?? {}
+ const implemented =
typeof component.attrs.implements?.value === 'string'
? component.attrs.implements.value
: undefined
+ const tag = implemented === undefined ? undefined : (binding.tag ?? implemented)
const parts: PartInfo[] = []
const slots: SlotInfo[] = []
const repeats: RepeatInfo[] = []
@@ -216,11 +283,18 @@ export function componentModel(
const walk = (node: UidxNode): void => {
const part = node.attrs.part?.value
if (typeof part === 'string') {
+ const libraryName = binding.parts?.[part] ?? part
parts.push({
name: part,
+ libraryName,
node,
- tag: tag ? partTag(tag, part, tags) : `x-${part}`,
- kind: tag ? partKind(tag, part, manifest) : 'element',
+ tag: tag ? partTag(tag, libraryName, tags) : `x-${part}`,
+ kind:
+ profile.parts === 'data-part'
+ ? 'data-part'
+ : tag
+ ? partKind(tag, libraryName, manifest)
+ : 'element',
})
partOf.set(node, part)
}
@@ -258,6 +332,8 @@ export function componentModel(
spec,
contract,
models: models ?? modelIndex([doc]),
+ profile,
+ binding,
parts,
slots,
repeats,
diff --git a/packages/codegen/src/react.ts b/packages/codegen/src/react.ts
index c0d71ca..db2ca02 100644
--- a/packages/codegen/src/react.ts
+++ b/packages/codegen/src/react.ts
@@ -1,6 +1,13 @@
import type { FieldSpec, JsonValue, PropSpec, UidxNode } from '@uidx/format'
import { modelOfType, slotModel, type ModelIndex } from '@uidx/schema/design-system'
-import { boundPath, boundProp, pascal, type ComponentModel } from './model.js'
+import {
+ attributeName,
+ boundPath,
+ boundProp,
+ eventName,
+ pascal,
+ type ComponentModel,
+} from './model.js'
/**
* The React target (ADR 0017 §3): one component per identity, wrapping the
@@ -339,6 +346,8 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
}
default: {
if (node === model.node) return children()
+ if (info?.kind === 'data-part')
+ return [`${pad}`, ...children(), `${pad} `]
if (info) return [`${pad}<${info.tag}>`, ...children(), `${pad}${info.tag}>`]
return [`${pad}`, ...children(), `${pad}
`]
}
@@ -350,14 +359,28 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
// A composition renders as the instance it holds, className and style
// handed to it, so the pattern adds no element of its own.
const composed = model.composes ? render(model.composes, 2) : null
- const attributes = attributeProps
+ // Props reach the element the way the library reflects them (ADR 0013 §3):
+ // as attributes, as data attributes, or as classes joined onto className.
+ const classed = model.profile.props === 'class' ? attributeProps : []
+ const attributes = (model.profile.props === 'class' ? [] : attributeProps)
.map((prop) => {
const isBoolean = prop.type === 'boolean'
- return `${prop.name}={${isBoolean ? `${prop.name} || undefined` : prop.name}}`
+ const name = attributeName(model, prop.name)
+ const attr = model.profile.props === 'data-attribute' ? `data-${name}` : name
+ return `${attr}={${isBoolean ? `${prop.name} || undefined` : prop.name}}`
})
.join(' ')
+ const classNameExpr = classed.length
+ ? `{[className, ${classed
+ .map((prop) =>
+ prop.type === 'boolean'
+ ? `${prop.name} ? '${attributeName(model, prop.name)}' : undefined`
+ : `${prop.name} !== undefined ? \`${attributeName(model, prop.name)}-\${${prop.name}}\` : undefined`,
+ )
+ .join(', ')}].filter(Boolean).join(' ')}`
+ : '{className}'
const events = (contract?.events ?? []).map(
- (event) => ` useElementEvent(ref, '${event.name}', on${pascal(event.name)})`,
+ (event) => ` useElementEvent(ref, '${eventName(model, event.name)}', on${pascal(event.name)})`,
)
const destructure = props
.map((prop) => {
@@ -406,7 +429,7 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
` const ref = useRef(null)`,
...events,
` return (`,
- ` <${rootTag} ref={ref} className={className} style={style}${attributes ? ` ${attributes}` : ''}>`,
+ ` <${rootTag} ref={ref} className=${classNameExpr} style={style}${attributes ? ` ${attributes}` : ''}>`,
...body,
` ${rootTag}>`,
` )`,
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
index 0b66d6d..4c7fdb2 100644
--- a/packages/codegen/test/generate.test.ts
+++ b/packages/codegen/test/generate.test.ts
@@ -218,6 +218,105 @@ A switch whose parts live in the library's shadow tree.
})
})
+describe('a library profile and bindings (ADR 0013 §3)', () => {
+ const shoelace = {
+ modules: [
+ {
+ declarations: [
+ {
+ tagName: 'sl-checkbox',
+ attributes: [{ name: 'checked' }, { name: 'disabled' }, { name: 'indeterminate' }],
+ events: [{ name: 'sl-change' }],
+ cssParts: [{ name: 'control' }],
+ },
+ ],
+ },
+ ],
+ }
+ const bindings = {
+ components: {
+ Checkbox: {
+ tag: 'sl-checkbox',
+ parts: { 'checked-indicator': 'control' },
+ events: { change: 'sl-change' },
+ },
+ },
+ }
+ const out = generate({
+ pages: [{ file: 'checkbox.uidx', doc: CHECKBOX }],
+ tokens: [TOKENS],
+ manifest: shoelace,
+ library: bindings,
+ })
+
+ it("renders the same identity over another library's names, without editing the file", () => {
+ const css = out.files.get('html/checkbox.css')!
+ expect(css).toContain('sl-checkbox {')
+ expect(css).toContain('sl-checkbox[checked]::part(control) {')
+ expect(out.files.get('html/checkbox.html')!).toContain('')
+ const tsx = out.files.get('react/Checkbox.tsx')!
+ expect(tsx).toContain(' {
+ const page = parseOrThrow(`---
+id: toggle
+---
+
+A toggle over a class-driven library.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ Whether it is on.
+ Colour.
+
+Working.
+`)
+ const dataDriven = generate({
+ pages: [{ file: 'toggle.uidx', doc: page }],
+ library: {
+ profile: { props: 'data-attribute', customStates: 'data-attribute', parts: 'data-part' },
+ },
+ })
+ const css = dataDriven.files.get('html/toggle.css')!
+ expect(css).toContain('x-toggle[data-on] {')
+ expect(css).toContain('x-toggle[data-busy] {')
+ expect(css).toContain('x-toggle[data-tone="warm"] {')
+ expect(css).toContain('x-toggle [data-part="knob"] {')
+ expect(dataDriven.files.get('html/toggle.html')!).toContain('')
+ expect(dataDriven.files.get('react/Toggle.tsx')!).toContain('data-on={on || undefined}')
+
+ const classDriven = generate({
+ pages: [{ file: 'toggle.uidx', doc: page }],
+ library: { profile: { props: 'class', customStates: 'class' } },
+ })
+ const classCss = classDriven.files.get('html/toggle.css')!
+ expect(classCss).toContain('x-toggle.on {')
+ expect(classCss).toContain('x-toggle.busy {')
+ expect(classCss).toContain('x-toggle.tone-warm {')
+ expect(classDriven.files.get('react/Toggle.tsx')!).toContain(
+ "className={[className, on ? 'on' : undefined, tone !== undefined ? `tone-${tone}` : undefined].filter(Boolean).join(' ')}",
+ )
+ })
+})
+
describe('the React target', () => {
it('types props and events from the contract and wraps the headless root', () => {
const tsx = file('react/Checkbox.tsx')
diff --git a/packages/server/src/document.ts b/packages/server/src/document.ts
index f3596ae..569efc9 100644
--- a/packages/server/src/document.ts
+++ b/packages/server/src/document.ts
@@ -21,12 +21,27 @@ export interface Manifest {
*/
assets: string[]
/**
- * The headless library's `custom-elements.json`, relative to `uidx.json`
- * (ADR 0013 §3). Optional: a document with no code render has none. When
- * present the viewer offers its elements and parts as choices in the
- * Contract tab, and `uidx codegen` checks contracts against it by default.
+ * The headless library (ADR 0013 §3). Optional: a document with no code
+ * render has none. Written as a path to its `custom-elements.json` or as an
+ * object that also says how the library spells things; normalised to the
+ * object here. When present the viewer offers its elements and parts as
+ * choices in the Contract tab, and `uidx codegen` reads it by default.
*/
- headless?: string
+ headless?: HeadlessConfig
+}
+
+/**
+ * How a design binds to one library without the design changing (ADR 0013
+ * §3). `manifest` is the `custom-elements.json`, relative to `uidx.json`.
+ * `profile` says how the library exposes props, parts and its own states;
+ * `bindings` translates the identity's names to the library's, per
+ * component. Both are optional and default to the conventions the example
+ * library follows; their shapes are validated where they are read.
+ */
+export interface HeadlessConfig {
+ manifest: string
+ profile?: Record
+ bindings?: Record
}
export interface FoundManifest {
@@ -115,9 +130,39 @@ export async function readManifest(path: string): Promise {
}
}
- const headless = record.headless
- if (headless !== undefined && (typeof headless !== 'string' || headless === '')) {
- problems.push(`${path}: "headless" must be a path to a custom-elements.json`)
+ let headless: HeadlessConfig | undefined
+ const declared = record.headless
+ if (typeof declared === 'string' && declared !== '') headless = { manifest: declared }
+ else if (declared !== undefined) {
+ const config =
+ typeof declared === 'object' && declared !== null && !Array.isArray(declared)
+ ? (declared as Record)
+ : undefined
+ const manifest = config?.manifest
+ if (typeof manifest !== 'string' || manifest === '') {
+ problems.push(
+ `${path}: "headless" must be a path to a custom-elements.json, or { "manifest": path, "profile"?, "bindings"? }`,
+ )
+ } else {
+ headless = { manifest }
+ const profile = config!.profile
+ if (profile !== undefined) {
+ if (
+ typeof profile !== 'object' ||
+ profile === null ||
+ Array.isArray(profile) ||
+ Object.values(profile).some((value) => typeof value !== 'string')
+ )
+ problems.push(`${path}: "headless.profile" must be an object of strings`)
+ else headless.profile = profile as Record
+ }
+ const bindings = config!.bindings
+ if (bindings !== undefined) {
+ if (typeof bindings !== 'object' || bindings === null || Array.isArray(bindings))
+ problems.push(`${path}: "headless.bindings" must be an object keyed by component name`)
+ else headless.bindings = bindings as Record
+ }
+ }
}
if (problems.length) throw new ManifestError(problems)
@@ -127,7 +172,7 @@ export async function readManifest(path: string): Promise {
// Absent means the conventional folders, all of them. An empty array is a
// different statement — "this document has no assets" — and is honoured.
assets: assets === undefined ? [...DEFAULT_ASSET_GLOBS] : (assets as string[]),
- ...(headless === undefined ? {} : { headless: headless as string }),
+ ...(headless === undefined ? {} : { headless }),
}
}
diff --git a/packages/server/src/headless.ts b/packages/server/src/headless.ts
index df6f91e..d8911b8 100644
--- a/packages/server/src/headless.ts
+++ b/packages/server/src/headless.ts
@@ -1,30 +1,121 @@
-import { readFile } from 'node:fs/promises'
-import { resolve } from 'node:path'
+import { readFile, writeFile } from 'node:fs/promises'
+import { dirname, relative, resolve, sep } from 'node:path'
import type { IncomingMessage, ServerResponse } from 'node:http'
-import type { FoundManifest } from './document.js'
+import type { FoundManifest, HeadlessConfig } from './document.js'
import type { MiddlewareHost, ViewerPlugin } from './static-viewer.js'
export const HEADLESS_ROUTE = '/__uidx/headless'
+/** A library a dependency ships, found by its `customElements` field. */
+export interface HeadlessCandidate {
+ /** The npm package name. */
+ package: string
+ /** Its manifest, relative to `uidx.json`, ready to write as `headless`. */
+ path: string
+}
+
/**
* What the viewer receives for the headless library (ADR 0013 §3).
*
* `path` is the manifest's own word for where the library is, relative to
* `uidx.json`, so the panel can say which file its choices come from. `null`
- * means the document declared none — a state, not an error, and the panel
- * says how to declare one.
+ * means the document declared none — a state, not an error — and then
+ * `candidates` lists the libraries the project's dependencies ship, so the
+ * panel can offer them instead of a path to type.
*/
export interface HeadlessResponse {
path: string | null
/** The `custom-elements.json` as written; the viewer reads the slice it needs. */
library?: unknown
+ profile?: Record
+ bindings?: Record
+ candidates: HeadlessCandidate[]
+}
+
+/**
+ * The libraries this project depends on, by the Custom Elements Manifest
+ * convention: a package that ships one names it in its `package.json` as
+ * `"customElements": "custom-elements.json"`. The nearest `package.json` at
+ * or above `uidx.json` is the project's; each dependency is looked up under
+ * its `node_modules`, which is where a direct dependency resolves under npm,
+ * pnpm and yarn alike. A dependency that is not installed is skipped.
+ */
+export async function discoverHeadless(dir: string): Promise {
+ let projectDir = dir
+ let project: Record | undefined
+ for (;;) {
+ try {
+ project = JSON.parse(await readFile(resolve(projectDir, 'package.json'), 'utf8')) as Record<
+ string,
+ unknown
+ >
+ break
+ } catch {
+ const parent = dirname(projectDir)
+ if (parent === projectDir) return []
+ projectDir = parent
+ }
+ }
+ const names = new Set()
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
+ const entries = project[field]
+ if (entries && typeof entries === 'object')
+ for (const name of Object.keys(entries)) names.add(name)
+ }
+ const out: HeadlessCandidate[] = []
+ for (const name of [...names].sort()) {
+ const packageDir = resolve(projectDir, 'node_modules', ...name.split('/'))
+ try {
+ const meta = JSON.parse(await readFile(resolve(packageDir, 'package.json'), 'utf8')) as {
+ customElements?: unknown
+ }
+ if (typeof meta.customElements !== 'string' || meta.customElements === '') continue
+ const manifest = resolve(packageDir, meta.customElements)
+ await readFile(manifest, 'utf8')
+ out.push({ package: name, path: relative(dir, manifest).split(sep).join('/') })
+ } catch {
+ continue
+ }
+ }
+ return out
+}
+
+/**
+ * Writes `headless` into `uidx.json`, keeping every other field as it is.
+ *
+ * Re-serialised with two-space indentation rather than patched as text: the
+ * file is small, hand-written JSON, and a formatter pass is what an editor
+ * would do to it too. A configured profile or bindings survive the change
+ * of manifest; a plain string stays a plain string.
+ */
+export async function writeHeadless(found: FoundManifest, manifestPath: string): Promise {
+ const raw = JSON.parse(await readFile(found.path, 'utf8')) as Record
+ const current = raw.headless
+ raw.headless =
+ typeof current === 'object' && current !== null && !Array.isArray(current)
+ ? { ...(current as Record), manifest: manifestPath }
+ : manifestPath
+ await writeFile(found.path, `${JSON.stringify(raw, null, 2)}\n`)
+ found.manifest.headless = { ...(found.manifest.headless ?? {}), manifest: manifestPath }
+}
+
+async function limitedBody(request: IncomingMessage, limit = 4096): Promise {
+ const chunks: Buffer[] = []
+ let size = 0
+ for await (const chunk of request as AsyncIterable) {
+ size += chunk.length
+ if (size > limit) throw new Error('The request is too large.')
+ chunks.push(chunk)
+ }
+ return Buffer.concat(chunks).toString()
}
/**
- * Serves the document's `custom-elements.json` to the viewer.
+ * Serves the document's headless library to the viewer, and lets the viewer
+ * choose one.
*
- * Read on every request rather than cached: the library is a build artefact of
- * another repository, and a designer who re-syncs it should see the new
+ * Read on every request rather than cached: the library is a build artefact
+ * of another repository, and a designer who re-syncs it should see the new
* elements on the next reload without restarting the server. Same holder
* indirection as the font and asset routes, for the same reason — the route
* registers before the workspace resolves.
@@ -33,31 +124,58 @@ export function headlessRoutePlugin(manifest: { current: FoundManifest | null })
const handle = async (request: IncomingMessage, response: ServerResponse): Promise => {
response.setHeader('cache-control', 'no-store')
response.setHeader('content-type', 'application/json')
- if (request.method !== 'GET') {
- response.statusCode = 405
- response.end(JSON.stringify({ error: 'The headless library is read-only.' }))
- return
- }
const found = manifest.current
if (!found) {
response.statusCode = 404
response.end(JSON.stringify({ error: 'Open a uidx project to read its headless library.' }))
return
}
- const declared = found.manifest.headless
- if (declared === undefined) {
- response.end(JSON.stringify({ path: null } satisfies HeadlessResponse))
- return
- }
try {
- const raw = await readFile(resolve(found.dir, declared), 'utf8')
+ if (request.method === 'PUT') {
+ const origin = request.headers.origin
+ if (
+ request.headers['sec-fetch-site'] === 'cross-site' ||
+ (origin && new URL(origin).host !== request.headers.host)
+ ) {
+ response.statusCode = 403
+ response.end(JSON.stringify({ error: 'The library choice must come from this viewer.' }))
+ return
+ }
+ const input = JSON.parse(await limitedBody(request)) as { path?: unknown }
+ if (typeof input.path !== 'string' || input.path === '' || input.path.includes('\0')) {
+ response.statusCode = 400
+ response.end(JSON.stringify({ error: 'Name the custom-elements.json to use.' }))
+ return
+ }
+ await readFile(resolve(found.dir, input.path), 'utf8')
+ await writeHeadless(found, input.path)
+ } else if (request.method !== 'GET') {
+ response.statusCode = 405
+ response.end(JSON.stringify({ error: 'The headless library is read, or chosen with PUT.' }))
+ return
+ }
+ const config: HeadlessConfig | undefined = found.manifest.headless
+ if (config === undefined) {
+ const candidates = await discoverHeadless(found.dir)
+ response.end(JSON.stringify({ path: null, candidates } satisfies HeadlessResponse))
+ return
+ }
+ const raw = await readFile(resolve(found.dir, config.manifest), 'utf8')
const library: unknown = JSON.parse(raw)
- response.end(JSON.stringify({ path: declared, library } satisfies HeadlessResponse))
+ response.end(
+ JSON.stringify({
+ path: config.manifest,
+ library,
+ ...(config.profile ? { profile: config.profile } : {}),
+ ...(config.bindings ? { bindings: config.bindings } : {}),
+ candidates: [],
+ } satisfies HeadlessResponse),
+ )
} catch (error) {
response.statusCode = 400
response.end(
JSON.stringify({
- error: `Could not read the headless library at ${declared}: ${
+ error: `Could not read the headless library: ${
error instanceof Error ? error.message : String(error)
}`,
}),
diff --git a/packages/server/test/headless.test.ts b/packages/server/test/headless.test.ts
index 8c2f562..de398d2 100644
--- a/packages/server/test/headless.test.ts
+++ b/packages/server/test/headless.test.ts
@@ -1,8 +1,9 @@
-import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { readManifest } from '../src/document.js'
+import { discoverHeadless } from '../src/headless.js'
import { createUidxServer, type UidxServer } from '../src/server.js'
/**
@@ -61,15 +62,71 @@ describe('uidx.json "headless"', () => {
join(dir, 'uidx.json'),
JSON.stringify({ id: 'a', files: ['*.uidx'], headless: 'vendor/custom-elements.json' }),
)
- expect((await readManifest(join(dir, 'uidx.json'))).headless).toBe(
- 'vendor/custom-elements.json',
- )
+ expect((await readManifest(join(dir, 'uidx.json'))).headless).toEqual({
+ manifest: 'vendor/custom-elements.json',
+ })
await writeFile(
join(dir, 'uidx.json'),
JSON.stringify({ id: 'a', files: ['*.uidx'], headless: 3 }),
)
await expect(readManifest(join(dir, 'uidx.json'))).rejects.toThrow(/"headless" must be a path/)
})
+
+ it('takes the object form with a profile and bindings, each validated', async () => {
+ const config = {
+ manifest: 'vendor/custom-elements.json',
+ profile: { props: 'data-attribute' },
+ bindings: { Checkbox: { tag: 'sl-checkbox', events: { change: 'sl-change' } } },
+ }
+ await writeFile(
+ join(dir, 'uidx.json'),
+ JSON.stringify({ id: 'a', files: ['*.uidx'], headless: config }),
+ )
+ expect((await readManifest(join(dir, 'uidx.json'))).headless).toEqual(config)
+ await writeFile(
+ join(dir, 'uidx.json'),
+ JSON.stringify({
+ id: 'a',
+ files: ['*.uidx'],
+ headless: { manifest: 'x.json', profile: { props: 3 } },
+ }),
+ )
+ await expect(readManifest(join(dir, 'uidx.json'))).rejects.toThrow(/"headless.profile"/)
+ })
+})
+
+describe('library discovery', () => {
+ it("finds dependencies that ship a manifest, by package.json's customElements field", async () => {
+ await writeFile(
+ join(dir, 'package.json'),
+ JSON.stringify({
+ name: 'app',
+ dependencies: { '@acme/kit': '1.0.0', lodash: '4.0.0', ghost: '1.0.0' },
+ }),
+ )
+ await mkdir(join(dir, 'node_modules/@acme/kit/dist'), { recursive: true })
+ await writeFile(
+ join(dir, 'node_modules/@acme/kit/package.json'),
+ JSON.stringify({ name: '@acme/kit', customElements: 'dist/custom-elements.json' }),
+ )
+ await writeFile(
+ join(dir, 'node_modules/@acme/kit/dist/custom-elements.json'),
+ JSON.stringify(LIBRARY),
+ )
+ await mkdir(join(dir, 'node_modules/lodash'), { recursive: true })
+ await writeFile(
+ join(dir, 'node_modules/lodash/package.json'),
+ JSON.stringify({ name: 'lodash' }),
+ )
+ // The document may sit below the project: the path comes back relative to uidx.json.
+ await mkdir(join(dir, 'design'))
+ expect(await discoverHeadless(join(dir, 'design'))).toEqual([
+ { package: '@acme/kit', path: '../node_modules/@acme/kit/dist/custom-elements.json' },
+ ])
+ expect(await discoverHeadless(dir)).toEqual([
+ { package: '@acme/kit', path: 'node_modules/@acme/kit/dist/custom-elements.json' },
+ ])
+ })
})
describe('the headless route', () => {
@@ -80,12 +137,52 @@ describe('the headless route', () => {
const response = await fetch(`${server!.url}/__uidx/headless`)
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('no-store')
- expect(await response.json()).toEqual({ path: 'vendor/custom-elements.json', library: LIBRARY })
+ expect(await response.json()).toEqual({
+ path: 'vendor/custom-elements.json',
+ library: LIBRARY,
+ candidates: [],
+ })
+ })
+
+ it('offers discovered libraries when none is declared, and writes the choice into uidx.json', async () => {
+ await writeFile(join(dir, 'package.json'), JSON.stringify({ dependencies: { kit: '1' } }))
+ await mkdir(join(dir, 'node_modules/kit'), { recursive: true })
+ await writeFile(
+ join(dir, 'node_modules/kit/package.json'),
+ JSON.stringify({ name: 'kit', customElements: 'custom-elements.json' }),
+ )
+ await writeFile(join(dir, 'node_modules/kit/custom-elements.json'), JSON.stringify(LIBRARY))
+ await start({})
+ expect(await (await fetch(`${server!.url}/__uidx/headless`)).json()).toEqual({
+ path: null,
+ candidates: [{ package: 'kit', path: 'node_modules/kit/custom-elements.json' }],
+ })
+ const chosen = await fetch(`${server!.url}/__uidx/headless`, {
+ method: 'PUT',
+ headers: { origin: server!.url!, 'content-type': 'application/json' },
+ body: JSON.stringify({ path: 'node_modules/kit/custom-elements.json' }),
+ })
+ expect(chosen.status).toBe(200)
+ expect(((await chosen.json()) as { path: string }).path).toBe(
+ 'node_modules/kit/custom-elements.json',
+ )
+ expect(JSON.parse(await readFile(join(dir, 'uidx.json'), 'utf8')).headless).toBe(
+ 'node_modules/kit/custom-elements.json',
+ )
+ const refused = await fetch(`${server!.url}/__uidx/headless`, {
+ method: 'PUT',
+ headers: { origin: 'https://evil.example', 'content-type': 'application/json' },
+ body: JSON.stringify({ path: 'x' }),
+ })
+ expect(refused.status).toBe(403)
})
it('answers a document that declares none with a null path, and names an unreadable one', async () => {
await start({})
- expect(await (await fetch(`${server!.url}/__uidx/headless`)).json()).toEqual({ path: null })
+ expect(await (await fetch(`${server!.url}/__uidx/headless`)).json()).toEqual({
+ path: null,
+ candidates: [],
+ })
await server!.close()
server = undefined
await start({ headless: 'missing/custom-elements.json' })
@@ -96,7 +193,7 @@ describe('the headless route', () => {
)
})
- it('is read-only', async () => {
+ it('answers only GET and PUT', async () => {
await start({})
const response = await fetch(`${server!.url}/__uidx/headless`, { method: 'POST' })
expect(response.status).toBe(405)
diff --git a/packages/viewer/src/App.vue b/packages/viewer/src/App.vue
index 0b1e9ea..7bceffb 100644
--- a/packages/viewer/src/App.vue
+++ b/packages/viewer/src/App.vue
@@ -49,7 +49,13 @@ import EditToolbar from './EditToolbar.vue'
import WorkspaceNav from './WorkspaceNav.vue'
import FontsPane from './FontsPane.vue'
import { fontGeneration, fontsInFileKey, openFontsKey } from './font-library'
-import { headlessError, headlessLibrary, refreshHeadless } from './headless'
+import {
+ chooseHeadless,
+ headlessCandidates,
+ headlessError,
+ headlessLibrary,
+ refreshHeadless,
+} from './headless'
import WorkspaceStatus from './WorkspaceStatus.vue'
import { canRemove, componentFrom, remapAddress } from './layer-moves'
import { componentRenamePlan, offerableComponents } from './component-rename'
@@ -1722,7 +1728,9 @@ onUnmounted(() => socket.close())
:can-make-component="componentSource !== null"
:headless="headlessLibrary"
:headless-error="headlessError"
+ :headless-candidates="headlessCandidates"
@select="selection = [$event]"
+ @choose-library="chooseHeadless($event)"
@edit-vector="editVector"
@finish-vector="canvasPane?.finishDrawing()"
@vector-action="canvasPane?.vectorAction($event)"
diff --git a/packages/viewer/src/ContractSection.vue b/packages/viewer/src/ContractSection.vue
index 9f8ef12..b0f2692 100644
--- a/packages/viewer/src/ContractSection.vue
+++ b/packages/viewer/src/ContractSection.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
+
+
DESIGN SYSTEM / DATA
+
+ Models {{ cards.length }}
+
+
+ What each component receives: declared once, named by a prop's type, sampled for the
+ canvas. Nothing here is computed — a derived value is a field the consumer supplies.
+
+
+
+
+
+
+
+ NAMED BY A CONTRACT, DECLARED NOWHERE
+
+
+ + {{ entry.name }} {{ entry.file }}
+
+
+
+
+
+ No models yet. A list prop typed Contact[] names one; declare it above and give
+ its fields samples, and the canvas draws the rows.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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'] }>()
@@ -57,6 +57,9 @@ const emit = defineEmits<{ home: []; face: [view: 'page' | 'tokens' | 'fonts'] }
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/74] =?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 =>
Repeat
- × {{ repeatable.repeat.count ?? repeatable.repeat.defaultCount }} × {{ repeatable.repeat.rows }}
@@ -996,36 +989,6 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>
/>
-
- Count
-
-
- ↺
-
-
-
Each item is a
` names the
* headless element it implements, a layer names the part it draws, and any
* layer names the list it repeats over. Three bindings — `implements`,
- * `part`, `repeat`/`as`/`count` — that the prop table deliberately does not
+ * `part`, `repeat`/`as` — that the prop table deliberately does not
* know, because they are bindings to the contract rather than scene fields.
*
* Pure, like `instance-prop-edits`: the view is computed from the document
@@ -48,7 +48,7 @@ export interface PartRow {
export interface SlotRow {
name: string
/** The `` providing it, with what it repeats over when it does, or null while missing. */
- provided: { address: string; repeat: { list: string; count: number | null } | null } | null
+ provided: { address: string; repeat: { list: string } | null } | null
}
export interface Candidate {
@@ -78,10 +78,8 @@ export interface RepeatBinding {
/** The alias target: `items`, or `person.tags` inside an outer repeat. */
list: string
as: string
- /** Rows the canvas draws when the file says; null leaves it to the model's samples. */
- count: number | null
- /** The rows drawn while `count` is null: the model's longest sample list. */
- defaultCount: number
+ /** Rows the canvas draws: the model's longest sample list, three when it has none. */
+ rows: number
/** The model of one item, when the contract can place the list; by type alone when unknown. */
model: string | null
/** True when the list is placed but its model is declared on no page in the index. */
@@ -226,7 +224,7 @@ function componentView(component: UidxNode, library: HeadlessLibrary | null): Co
if (row && !row.provided)
row.provided = {
address: child.address,
- repeat: repeat ? { list: repeat.list, count: repeat.count ?? null } : null,
+ repeat: repeat ? { list: repeat.list } : null,
}
}
if (child.element !== 'Instance') provide(child)
@@ -338,8 +336,7 @@ function repeatFacet(component: UidxNode | null, node: UidxNode, models?: ModelI
repeat: {
list: attrs.list,
as: attrs.as,
- count: attrs.count ?? null,
- defaultCount: sampleCount(model),
+ rows: sampleCount(model),
model: model?.name ?? (placed ? placed.slice(0, -2) : null),
unknownModel: !model && typeof placed === 'string',
},
@@ -438,13 +435,13 @@ export function bindPart(
}
/**
- * `repeat="{list}"` on a layer (ADR 0017 §2). Clearing it takes `as` and
- * `count` with it — those first, since the parser refuses either without a
- * repeat to ride on and every patch must leave a valid file.
+ * `repeat="{list}"` on a layer (ADR 0017 §2). Clearing it takes `as` with
+ * it — first, since the parser refuses `as` without a repeat to ride on and
+ * every patch must leave a valid file.
*/
export function setRepeat(node: UidxNode, list: string | null): UidxPatch[] {
const target = list?.trim().replace(/^\{|\}$/g, '') ?? ''
- if (target === '') return ['as', 'count', 'repeat'].flatMap((prop) => setAttr(node, prop, null))
+ if (target === '') return ['as', 'repeat'].flatMap((prop) => setAttr(node, prop, null))
return setAttr(node, 'repeat', toAlias(target))
}
@@ -456,13 +453,6 @@ export function setRepeatAs(node: UidxNode, as: string | null): UidxPatch[] {
return setAttr(node, 'as', name)
}
-/** How many rows the canvas draws; a non-negative integer, or null to let the model's samples say. */
-export function setRepeatCount(node: UidxNode, count: number | null): UidxPatch[] {
- if (count === null || Number.isNaN(count)) return setAttr(node, 'count', null)
- if (!Number.isInteger(count) || count < 0) return []
- return setAttr(node, 'count', count)
-}
-
/* ------------------------------------------------- the contract itself */
/** Writes one declaration of the contract (ADR 0013 §2), creating its list and region as needed. */
diff --git a/packages/viewer/src/repeat-edits.ts b/packages/viewer/src/repeat-edits.ts
index a641a3e..fc570bc 100644
--- a/packages/viewer/src/repeat-edits.ts
+++ b/packages/viewer/src/repeat-edits.ts
@@ -39,8 +39,8 @@ export function repeatTargetFor(
/**
* The gesture: `repeat="{list}"` lands on the layer, and nothing else moves.
- * The count is left to the model's samples; the Contract tab sets one when
- * the canvas should draw more or fewer.
+ * The rows are the model's samples; the Contract tab edits the list and the
+ * item's name from the layer.
*/
export function newRepeatFor(
doc: UidxDocument,
diff --git a/packages/viewer/test/contract-edits.test.ts b/packages/viewer/test/contract-edits.test.ts
index e483316..cccb3e1 100644
--- a/packages/viewer/test/contract-edits.test.ts
+++ b/packages/viewer/test/contract-edits.test.ts
@@ -14,7 +14,6 @@ import {
setPart,
setRepeat,
setRepeatAs,
- setRepeatCount,
} from '../src/contract-edits'
import { parseHeadless, type HeadlessLibrary } from '../src/headless'
@@ -83,7 +82,7 @@ const CHECKBOX = page(
const LIST = page(
'list',
`
-
+
@@ -177,7 +176,7 @@ describe('what the tab shows', () => {
if (view.kind !== 'slot') throw new Error(view.kind)
expect(view).toMatchObject({
declared: { accepts: 'hwc-row' },
- repeat: { list: 'items', as: 'item', count: 3, defaultCount: 3, model: 'Item' },
+ repeat: { list: 'items', as: 'item', rows: 3, model: 'Item' },
lists: ['items'],
})
const row = contractView(doc, resolve(doc.tree, 'List#option/row'), LIBRARY)
@@ -188,7 +187,7 @@ describe('what the tab shows', () => {
expect(component.slots).toEqual([
{
name: 'option',
- provided: { address: 'List#option', repeat: { list: 'items', count: 3 } },
+ provided: { address: 'List#option', repeat: { list: 'items' } },
},
{ name: 'empty', provided: { address: 'List#empty', repeat: null } },
{ name: 'control', provided: null },
@@ -285,7 +284,7 @@ describe('the writes', () => {
])
})
- it('repeats any layer over a list, names its item, and counts whole rows only', () => {
+ it('repeats any layer over a list and names its item', () => {
const list = parseOrThrow(LIST)
const slot = resolve(list.tree, 'List#option')!
const row = resolve(list.tree, 'List#option/row')!
@@ -300,20 +299,16 @@ describe('the writes', () => {
])
expect(setRepeatAs(slot, 'item')).toEqual([])
expect(setRepeatAs(slot, 'not a name')).toEqual([])
- expect(setRepeatCount(slot, 5)).toEqual([
- { op: 'set', address: 'List#option', prop: 'count', value: 5 },
- ])
- expect(setRepeatCount(slot, -1)).toEqual([])
- expect(setRepeatCount(slot, 2.5)).toEqual([])
- expect(setRepeatCount(slot, null)).toEqual([
- { op: 'remove', address: 'List#option', prop: 'count' },
- ])
- expect(applyPatches(LIST, setRepeatCount(slot, 5)).source).toContain(
- '',
+ expect(applyPatches(LIST, setRepeatAs(slot, 'person')).source).toContain(
+ '',
)
- // Clearing the repeat takes the count with it: the parser refuses one alone.
+ // Clearing the repeat takes `as` with it: the parser refuses one alone.
+ const named = parseOrThrow(applyPatches(LIST, setRepeatAs(slot, 'person')).source)
+ expect(setRepeat(resolve(named.tree, 'List#option')!, null)).toEqual([
+ { op: 'remove', address: 'List#option', prop: 'as' },
+ { op: 'remove', address: 'List#option', prop: 'repeat' },
+ ])
expect(setRepeat(slot, null)).toEqual([
- { op: 'remove', address: 'List#option', prop: 'count' },
{ op: 'remove', address: 'List#option', prop: 'repeat' },
])
expect(applyPatches(LIST, setRepeat(slot, null)).source).toContain('')
@@ -403,18 +398,16 @@ describe('the Contract section', () => {
it('edits a repeat from the layer it rides on', async () => {
const section = mountFor(LIST, 'List#option')
- await section.find('[data-field="count"] input').setValue('4')
+ await section.find('[data-field="as"] input').setValue('person')
expect(section.emitted('patches')).toEqual([
- [[{ op: 'set', address: 'List#option', prop: 'count', value: 4 }]],
+ [[{ op: 'add', address: 'List#option', prop: 'as', value: 'person' }]],
])
+ expect(section.findAll('.head .of').map((e) => e.text())).toContain('× 3')
const over = section.find('[data-field="repeat"] select')
expect(over.findAll('option').map((o) => o.text().trim())).toEqual(['Once', '{items}'])
await over.setValue('')
expect(section.emitted('patches')!.at(-1)).toEqual([
- [
- { op: 'remove', address: 'List#option', prop: 'count' },
- { op: 'remove', address: 'List#option', prop: 'repeat' },
- ],
+ [{ op: 'remove', address: 'List#option', prop: 'repeat' }],
])
// An instance below shows the same rows, unset.
const instance = mountFor(LIST, 'List#option/row')
From 8dca71f9d05b7eb5c16b4ddfa26e2d0ebbc1b859 Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 20:15:54 +0300
Subject: [PATCH 20/74] What an instance receives, and the tree walkthrough's
fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An instance inside a repeat shows what each contract prop of its definition
receives (ADR 0017 §2): the enclosing item whose model matches the prop's
type, nearest first, else the innermost item for the first model prop —
the rule the code target follows — and the use may say otherwise with
`props={{ node: '{child}' }}`, an item or a field of one, which the code
target now passes as written.
From the walkthrough that built a Tree over a TreeNode model: a Bind picker
beside a text's content offers the enclosing items' fields, a model prop's
fields and the component's props, and a `{…}` typed there is written as the
binding it is rather than resolved away; a slot the tree has and the
contract lacks is listed with Declare; a repeat on a container holding one
row offers to move onto the row; Make component warns when the page already
has a component; a new slot hugs; placing an instance with a slot selected
fills it; the picker leaves out the enclosing component; an emptied
`## Contract` heading goes; `uidx codegen` takes directories.
Fixed: the incremental patch path rebuilt a component without its spec after
a structural edit, so the Contract tab lost the lists until a restart; and
the symbol checker reported `repeat="{items}"` and `{item.field}` as unknown
component properties, in the shipped example too. A CLI test now checks the
example.
Co-Authored-By: Claude Fable 5.1
---
CHANGELOG.md | 18 ++
docs/decisions/0013-component-contract.md | 6 +-
.../0017-collections-and-code-targets.md | 9 +
packages/cli/src/commands/check.ts | 2 +-
packages/cli/src/commands/codegen.ts | 7 +-
packages/cli/test/check.test.ts | 15 ++
packages/cli/test/symbols.test.ts | 37 ++++
packages/codegen/src/react.ts | 40 +++-
packages/codegen/test/generate.test.ts | 70 ++++++
packages/format/src/incremental.ts | 26 ++-
packages/format/src/patch.ts | 15 ++
packages/format/test/contract-patch.test.ts | 5 +
packages/format/test/incremental.test.ts | 40 ++++
packages/server/src/symbols.ts | 29 ++-
packages/viewer/src/App.vue | 15 +-
packages/viewer/src/CanvasPane.vue | 13 +-
packages/viewer/src/ContractSection.vue | 145 +++++++++++-
packages/viewer/src/InstancePropsSection.vue | 8 +
packages/viewer/src/NameComponentDialog.vue | 6 +
packages/viewer/src/PropertiesPane.vue | 33 ++-
packages/viewer/src/PropertyField.vue | 59 ++++-
packages/viewer/src/component-rename.ts | 8 +-
packages/viewer/src/contract-edits.ts | 206 +++++++++++++++++-
packages/viewer/src/slot-edits.ts | 12 +-
packages/viewer/test/component-rename.test.ts | 2 +
packages/viewer/test/contract-edits.test.ts | 150 ++++++++++++-
packages/viewer/test/slot-editing.test.ts | 11 +-
27 files changed, 936 insertions(+), 51 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 658faac..dcaec27 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -50,6 +50,24 @@ describe migrations when an existing document or integration is affected.
declaring. The new `model` and `field` patch ops write one `` back
canonically and invert. A sample changed there redraws every repeat of it,
and a repeat's row in the Contract tab links to its model.
+- An instance inside a repeat shows what it receives: the Contract tab lists
+ its definition's contract props with the item each one receives, inferred
+ by type from the enclosing repeats the way the code target passes it, and
+ lets the use say otherwise (`props={{ node: '{child}' }}`). A text binds to
+ an item's field or a component prop from a picker beside its content, and
+ a `{…}` typed there is written as a binding rather than resolved away. A
+ slot the tree has and the contract lacks is listed with a Declare button;
+ a repeat on a container holding one row offers to move onto the row. Make
+ component warns when the page already has one, since the two would share
+ its contract. A new slot hugs what fills it; placing an instance with a
+ slot selected fills that slot, and the picker leaves out the component
+ the instance would sit inside. Removing the last declaration removes the
+ empty `## Contract` heading; `uidx codegen` takes directories as `check`
+ does.
+- Fixed: a component edited structurally kept its contract (the incremental
+ patch path dropped the spec until a full parse), and `uidx check` no longer
+ reports `repeat="{items}"` or `{item.field}` as unknown component
+ properties.
- The Contract tab edits the contract: each prop, event, slot, state and
part opens into a small form (description, type, default, sample, flags;
a slot's `accepts`), a row adds one, and "Fill from library"
diff --git a/docs/decisions/0013-component-contract.md b/docs/decisions/0013-component-contract.md
index f8ee5a1..485abdd 100644
--- a/docs/decisions/0013-component-contract.md
+++ b/docs/decisions/0013-component-contract.md
@@ -84,7 +84,11 @@ shorthand rule of the visual contract does not apply here.
layer; binding it from a new layer unbinds the old one, since one node per
part is the rule. Any layer inside a component chooses the list it repeats
over from the contract's list props and the list fields of enclosing items.
- The tab never edits the contract's prose; it shows it beside the bindings.
+ A text binds to a field of an enclosing item or a prop of the component
+ from a picker beside its content, and a typed `{…}` in the content is
+ written as the binding it is. A `` in the tree the contract does not
+ declare is listed beside the declared ones, one click from declared. The
+ tab never edits the contract's prose; it shows it beside the bindings.
When `uidx.json` names no library, the tab offers those the project's
dependencies ship — the Custom Elements Manifest convention of a
`customElements` field in `package.json` — and writes the choice.
diff --git a/docs/decisions/0017-collections-and-code-targets.md b/docs/decisions/0017-collections-and-code-targets.md
index 417530f..082adcf 100644
--- a/docs/decisions/0017-collections-and-code-targets.md
+++ b/docs/decisions/0017-collections-and-code-targets.md
@@ -66,6 +66,15 @@ 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 uses the contract.
+An instance inside a repeat receives the item. A contract prop of its
+definition typed by an enclosing item's model receives that item, nearest
+first; failing a match by type, the definition's first model prop receives
+the innermost item. The use may say otherwise in `props={{ … }}` — another
+item, or a field of one (`{child.owner}`). The Contract tab shows what each
+prop receives, marks what was inferred, and writes the choice; the code
+target passes the same. A repeat on a container holding one row repeats the
+container; the tab says so and offers to move the repeat onto the row.
+
### 3. Code targets
A new package, `@uidx/codegen`, renders identities to code. It reads the
diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts
index 4f232f5..a4a9d29 100644
--- a/packages/cli/src/commands/check.ts
+++ b/packages/cli/src/commands/check.ts
@@ -263,7 +263,7 @@ async function documentOrCwd(cwd: string): Promise {
* Accepts globs, directories and plain paths. A bare directory is expanded to
* every `.uidx` beneath it so `uidx check .` does the obvious thing.
*/
-async function expand(patterns: string[], cwd: string): Promise {
+export async function expand(patterns: string[], cwd: string): Promise {
const globs = await Promise.all(
patterns.map(async (pattern) => {
if (pattern.includes('*')) return pattern
diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts
index 2d67987..298105f 100644
--- a/packages/cli/src/commands/codegen.ts
+++ b/packages/cli/src/commands/codegen.ts
@@ -1,7 +1,7 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { parseArgs } from 'node:util'
-import { glob } from 'tinyglobby'
+import { expand } from './check.js'
import { formatDiagnostic, parse, type UidxDocument } from '@uidx/format'
import { generate, type LibraryBindings, type Manifest, type Target } from '@uidx/codegen'
import { findManifest } from '@uidx/server/document'
@@ -50,9 +50,8 @@ export async function runCodegen(argv: string[], io: Io): Promise {
return 1
}
const patterns = parsed.positionals.length ? parsed.positionals : ['**/*.uidx']
- const files = (
- await glob(patterns, { cwd, ignore: ['**/node_modules/**', '**/.uidx-agent/**'] })
- ).sort()
+ // Globs, directories and plain paths, as `uidx check` takes them.
+ const files = (await expand(patterns, cwd)).filter((file) => !file.includes('/.uidx-agent/'))
if (files.length === 0) {
io.err(`no .uidx files matched ${patterns.join(', ')}\n`)
return 1
diff --git a/packages/cli/test/check.test.ts b/packages/cli/test/check.test.ts
index 2992571..d4a6f43 100644
--- a/packages/cli/test/check.test.ts
+++ b/packages/cli/test/check.test.ts
@@ -61,6 +61,21 @@ const capture = () => {
}
describe('check', () => {
+ it('passes the shipped example design system, repeats and shared models included', async () => {
+ const example = join(
+ import.meta.dirname,
+ '..',
+ '..',
+ '..',
+ 'examples',
+ 'design-system',
+ '.uidx',
+ )
+ const result = await check(['.'], { cwd: example })
+ expect(renderText(result, example)).not.toMatch(/error/)
+ expect(result.errors).toBe(0)
+ })
+
it('passes a valid file with exit code 0', async () => {
const result = await check(['ok.uidx'], { cwd: dir })
expect(result.errors).toBe(0)
diff --git a/packages/cli/test/symbols.test.ts b/packages/cli/test/symbols.test.ts
index d9a0e38..1f6707b 100644
--- a/packages/cli/test/symbols.test.ts
+++ b/packages/cli/test/symbols.test.ts
@@ -335,6 +335,43 @@ describe('token aliases across modes (G8)', () => {
})
})
+describe('a repeat and its item are not component properties (ADR 0017 §2)', () => {
+ const list = parseOrThrow(`---
+id: list
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ Rows.
+
+`)
+
+ it('says nothing for repeat="{items}", {row.name} below it, and a nested {row.tags}', () => {
+ const { diagnostics } = buildSymbolTable([{ file: 'list.uidx', doc: list }])
+ expect(diagnostics.filter((d) => d.code === WORKSPACE_CODES.UNRESOLVED_REFERENCE)).toEqual([])
+ })
+
+ it('still refuses a bare name nothing declares', () => {
+ const bad = parseOrThrow(
+ list.source.replace('characters="{row.name}"', 'characters="{nobody}"'),
+ )
+ const { diagnostics } = buildSymbolTable([{ file: 'list.uidx', doc: bad }])
+ expect(diagnostics.map((d) => d.code)).toContain(WORKSPACE_CODES.UNRESOLVED_REFERENCE)
+ })
+})
+
describe('scope violations warn rather than fail (G8)', () => {
const scoped = tokenPage(
'scoped',
diff --git a/packages/codegen/src/react.ts b/packages/codegen/src/react.ts
index 2d96fc5..169554a 100644
--- a/packages/codegen/src/react.ts
+++ b/packages/codegen/src/react.ts
@@ -1,4 +1,10 @@
-import type { FieldSpec, JsonValue, PropSpec, UidxNode } from '@uidx/format'
+import {
+ aliasTarget,
+ type FieldSpec,
+ type JsonValue,
+ type PropSpec,
+ type UidxNode,
+} from '@uidx/format'
import { modelOfType, type ModelIndex, type RepeatScope } from '@uidx/schema/design-system'
import {
attributeName,
@@ -150,6 +156,15 @@ function svgFor(node: UidxNode): string {
}
/** `renderOption` for a repeating slot named `option` (ADR 0017 §2). */
+/** `{child}` or `{child.owner}` inside a repeat: the item, or a field of it, as a JS path. */
+function itemAlias(value: JsonValue, scopes: readonly RepeatScope[]): string | null {
+ if (typeof value !== 'string') return null
+ const target = aliasTarget(value)
+ if (target === null || target.includes('#')) return null
+ const head = target.split('.')[0]!
+ return scopes.some((scope) => scope.as === head) ? target : null
+}
+
const renderPropName = (repeat: RepeatInfo): string => `render${pascal(repeat.node.name)}`
/** The JSX expression a bound `characters` renders: a prop, a model path, or text. */
@@ -316,21 +331,30 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
if (declared && typeof declared === 'object' && !Array.isArray(declared)) {
for (const [key, value] of Object.entries(declared as Record)) {
const own = boundProp(model, value)
+ const item = itemAlias(value, scopes)
if (own) passed.push(`${key}={${own.name}}`)
+ else if (item !== null) passed.push(`${key}={${item}}`)
else if (typeof value === 'string' && !value.startsWith('{'))
passed.push(`${key}="${value}"`)
else if (typeof value !== 'string') passed.push(`${key}={${JSON.stringify(value)}}`)
}
}
- // Inside a repeat, the row's item is what the instance is of: it lands
- // on the target's model prop unless the use passed one (ADR 0017 §2).
- const scope = scopes[scopes.length - 1]
- if (scope) {
- const modelProp = target.contract?.props.find(
+ // Inside a repeat, the row's item is what the instance is of (ADR 0017
+ // §2): a prop typed by an enclosing item's model receives that item,
+ // nearest first; failing a match by type, the target's first model
+ // prop receives the innermost item. A use that passed one is left be.
+ if (scopes.length) {
+ const modelProps = (target.contract?.props ?? []).filter(
(entry) => modelOfType(entry.type, target.spec, target.models)?.list === false,
)
- if (modelProp && !passed.some((entry) => entry.startsWith(`${modelProp.name}=`)))
- passed.push(`${modelProp.name}={${scope.as}}`)
+ for (const prop of modelProps) {
+ if (passed.some((entry) => entry.startsWith(`${prop.name}=`))) continue
+ const typed = [...scopes]
+ .reverse()
+ .find((scope) => scope.model?.name === prop.type.trim())
+ const from = typed ?? (prop === modelProps[0] ? scopes[scopes.length - 1] : undefined)
+ if (from) passed.push(`${prop.name}={${from.as}}`)
+ }
}
const fills = node.children.filter((child) => child.element === 'Slot')
if (fills.length === 0)
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
index eade21d..60daa40 100644
--- a/packages/codegen/test/generate.test.ts
+++ b/packages/codegen/test/generate.test.ts
@@ -22,6 +22,76 @@ const file = (path: string) => {
return text
}
+describe('what an instance in a repeat receives (ADR 0017 §2)', () => {
+ const TREE = `---
+id: tree
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The top-level nodes.
+
+
+ One per node.
+
+`
+ const ITEM = `---
+id: tree-item
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+## Contract
+
+
+ The node this row shows.
+ How deep the row sits.
+
+
+## Models
+
+
+ One node.
+ Identity.
+ Words.
+ Level.
+ Below it.
+
+`
+ it('hands the nearest item of the right type to the model prop, and an explicit item field as written', () => {
+ const out = generate({
+ pages: [
+ { file: 'tree.uidx', doc: parseOrThrow(TREE) },
+ { file: 'tree-item.uidx', doc: parseOrThrow(ITEM) },
+ ],
+ tokens: [],
+ })
+ const react = out.files.get('react/Tree.tsx')!
+ expect(react).toContain(' ')
+ expect(react).toContain(' ')
+ })
+})
+
describe('generate', () => {
it('is deterministic: the same input renders the same bytes', () => {
const again = generate({ pages: PAGES, tokens: [TOKENS], manifest: MANIFEST })
diff --git a/packages/format/src/incremental.ts b/packages/format/src/incremental.ts
index 81f9df8..f0a74d6 100644
--- a/packages/format/src/incremental.ts
+++ b/packages/format/src/incremental.ts
@@ -109,11 +109,33 @@ export function applyPatchesIncremental(
if (current === doc) return { doc, changed: [], fellBack: false }
const out: UidxDocument = { ...current, sourceHash: fnv1a(current.source) }
delete (out as { predicted?: true }).predicted
- return { doc: out, changed, fellBack: false }
+ return { doc: withComponentSpecs(out), changed, fellBack: false }
}
if (current !== doc) current = { ...current, sourceHash: fnv1a(current.source) }
const relowered = relowerBatch(current, rest)
- return { ...relowered, changed: [...changed, ...relowered.changed] }
+ return {
+ ...relowered,
+ doc: withComponentSpecs(relowered.doc),
+ changed: [...changed, ...relowered.changed],
+ }
+}
+
+/**
+ * The spec rides on every `` of the page as well as on the
+ * document (ADR 0013), so an instance expanded elsewhere can read it. A
+ * re-lowered component is a fresh node without it, and the Contract tab then
+ * sees a component with no props until a full parse happens to run. Put back
+ * here, on the one path that builds nodes without the parser's last step.
+ */
+function withComponentSpecs(doc: UidxDocument): UidxDocument {
+ if (!doc.spec) return doc
+ let touched = false
+ const children = doc.tree.children.map((child) => {
+ if (child.element !== 'Component' || child.spec === doc.spec) return child
+ touched = true
+ return { ...child, spec: doc.spec }
+ })
+ return touched ? { ...doc, tree: { ...doc.tree, children } } : doc
}
type PlainAttributePatch = Extract
diff --git a/packages/format/src/patch.ts b/packages/format/src/patch.ts
index ce34c6e..9f9dd1a 100644
--- a/packages/format/src/patch.ts
+++ b/packages/format/src/patch.ts
@@ -774,6 +774,21 @@ function contractDeclaration(
if (open === -1 || close === -1)
throw new PatchError(`cannot find <${list}> around ${patch.name}`)
let from = open
+ // The last declaration of the whole region takes the heading with it
+ // too: a `## Contract` over nothing is a region the file does not need.
+ const contract = doc.spec?.contract
+ const emptied =
+ contract !== undefined &&
+ (['prop', 'event', 'slot', 'state', 'part'] as ContractKind[])
+ .filter((kind) => kind !== patch.kind)
+ .every((kind) => contractItems(doc, kind).length === 0) &&
+ contract.form === undefined &&
+ contract.accessibility === undefined &&
+ contract.composes.length === 0
+ if (emptied) {
+ const heading = source.lastIndexOf('## Contract', open)
+ if (heading !== -1) from = heading
+ }
while (from > 0 && /\s/.test(source[from - 1]!)) from--
s.remove(from, close + `${list}>`.length)
return { start: from, end: from }
diff --git a/packages/format/test/contract-patch.test.ts b/packages/format/test/contract-patch.test.ts
index 3265e6a..e519c4d 100644
--- a/packages/format/test/contract-patch.test.ts
+++ b/packages/format/test/contract-patch.test.ts
@@ -92,6 +92,11 @@ describe('the contract op', () => {
expect(noSlots).toContain('\n')
expect(parseOrThrow(noSlots).spec!.contract!.slots).toEqual([])
expect(() => applyPatches(noSlots, [remove('slot', 'control')])).toThrow(/not declared/)
+ // The last declaration of the whole region takes the heading with it.
+ const none = applyPatches(noSlots, [remove('prop', 'checked')]).source
+ expect(none).not.toContain('## Contract')
+ expect(none).toBe(page())
+ expect(parseOrThrow(none).spec?.contract).toBeUndefined()
})
it('inverts to the previous declaration, or to a removal', () => {
diff --git a/packages/format/test/incremental.test.ts b/packages/format/test/incremental.test.ts
index d1fbbd1..d415673 100644
--- a/packages/format/test/incremental.test.ts
+++ b/packages/format/test/incremental.test.ts
@@ -91,6 +91,46 @@ function agrees(source: string, patches: UidxPatch[], expectFast = true): void {
expect(shape(doc.tree)).toEqual(shape(parseOrThrow(source).tree))
}
+describe('a re-lowered component keeps its spec (ADR 0013)', () => {
+ const WITH_CONTRACT = `---
+id: list
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+## Contract
+
+
+ Rows.
+
+`
+ it('after a structural edit inside it, and after an attribute edit on it', () => {
+ const doc = parseOrThrow(WITH_CONTRACT)
+ const inserted = applyPatchesIncremental(doc, [
+ {
+ op: 'insert-node',
+ parent: 'List',
+ index: 1,
+ node: { element: 'Slot', attrs: { name: 'item' } },
+ },
+ ])
+ expect(inserted.fellBack).toBe(false)
+ const component = inserted.doc.tree.children[0]!
+ expect(component.spec?.contract?.props.map((p) => p.name)).toEqual(['items'])
+ expect(component.spec).toBe(inserted.doc.spec)
+ const renamed = applyPatchesIncremental(inserted.doc, [
+ { op: 'set', address: 'List', prop: 'layoutMode', value: 'HORIZONTAL' },
+ ])
+ expect(renamed.doc.tree.children[0]!.spec).toBe(renamed.doc.spec)
+ })
+})
+
describe('applyPatchesIncremental agrees with a full parse (spec §1)', () => {
it('set on an inner node', () => {
agrees(PAGE, [{ op: 'set', address: 'doc#section/cover', prop: 'visible', value: false }])
diff --git a/packages/server/src/symbols.ts b/packages/server/src/symbols.ts
index a741ddc..1f09510 100644
--- a/packages/server/src/symbols.ts
+++ b/packages/server/src/symbols.ts
@@ -753,6 +753,20 @@ function componentNodes(pages: readonly PageSource[]): Map {
return out
}
+/** The item names every repeat in the component declares (`as`, or `item`). */
+function repeatItemNames(component: UidxNode): Set {
+ const out = new Set()
+ const walk = (node: UidxNode): void => {
+ if (node.attrs.repeat !== undefined) {
+ const as = node.attrs.as?.value
+ out.add(typeof as === 'string' && as !== '' ? as : 'item')
+ }
+ for (const child of node.children) walk(child)
+ }
+ walk(component)
+ return out
+}
+
/**
* A `{label}` binding, checked against the component it sits inside (F6).
*
@@ -788,10 +802,17 @@ function checkPropertyBinding(
// `{item.name}` reads a field of a model prop (ADR 0015 §2): the head is
// the prop, and the audit checks the field against the model.
- if (reference.target.includes('.')) {
- const head = reference.target.slice(0, reference.target.indexOf('.'))
- if (component.spec?.contract?.props.some((prop) => prop.name === head)) return []
- }
+ const head = reference.target.includes('.')
+ ? reference.target.slice(0, reference.target.indexOf('.'))
+ : reference.target
+ const contractProps = component.spec?.contract?.props ?? []
+ if (reference.target.includes('.') && contractProps.some((prop) => prop.name === head)) return []
+ // ADR 0017 §2: `repeat="{items}"` names a list prop of the contract or a
+ // list field of an enclosing item, and `{item.label}` below a repeat reads
+ // the item — the audit checks both against the contract and its models.
+ // Neither is a component property, so neither is this check's to refuse.
+ if (reference.prop === 'repeat' && contractProps.some((prop) => prop.name === head)) return []
+ if (repeatItemNames(component).has(head)) return []
const declared = componentProps(component).declared
const declaration = declared.get(reference.target)
if (!declaration) {
diff --git a/packages/viewer/src/App.vue b/packages/viewer/src/App.vue
index ff0a80e..de5a593 100644
--- a/packages/viewer/src/App.vue
+++ b/packages/viewer/src/App.vue
@@ -64,6 +64,7 @@ import { routeDerivedPatches } from './derived-edits'
import WorkspaceStatus from './WorkspaceStatus.vue'
import { canRemove, componentFrom, remapAddress } from './layer-moves'
import { componentRenamePlan, offerableComponents } from './component-rename'
+import { enclosingComponent } from './component-prop-edits'
import { newSlotFor, slotTargetFor } from './slot-edits'
import { newRepeatFor, repeatTargetFor } from './repeat-edits'
import { isDeleteKey, isTypingTarget, toolFor } from './tool-keys'
@@ -1378,6 +1379,13 @@ function onCanvasPatches(patches: UidxPatch[]): void {
* other — because the canvas has one press to give.
*/
const placing = ref(null)
+/** The component the selection sits inside: not on offer, since it cannot hold itself. */
+const placingInto = computed(() => {
+ const doc_ = sceneDoc.value
+ const address = selection.value[0]
+ if (!doc_ || !address) return null
+ return enclosingComponent(doc_, address)?.name ?? null
+})
/** Open while the author is choosing which component to place. */
const picking = ref(false)
@@ -1451,6 +1459,10 @@ const componentSource = computed(() => {
/** Open while the author is naming the component (F10). Holds the address. */
const naming = ref(null)
+/** The first component on the open page: a second one would share its contract (ADR 0013). */
+const pageComponent = computed(
+ () => sceneDoc.value?.tree.children.find((child) => child.element === 'Component')?.name ?? null,
+)
function startMakeComponent(): void {
naming.value = componentSource.value
@@ -1605,7 +1617,7 @@ onUnmounted(() => socket.close())
-->
@@ -1642,6 +1654,7 @@ onUnmounted(() => socket.close())
v-if="naming !== null"
:suggested="naming.split(/[#/]/).at(-1) ?? 'Component'"
:taken="takenNames"
+ :shares-with="pageComponent"
@confirm="makeComponent"
@close="naming = null"
/>
diff --git a/packages/viewer/src/CanvasPane.vue b/packages/viewer/src/CanvasPane.vue
index 815113f..2a546a3 100644
--- a/packages/viewer/src/CanvasPane.vue
+++ b/packages/viewer/src/CanvasPane.vue
@@ -37,7 +37,7 @@ import { drawingHint } from './graphics-tools'
import { resizeVectorPaths } from './vector-resize'
import type { Point, Rect } from './gesture-model'
import { importSvg, type SvgProblem } from './svg-import'
-import { reorderFor } from './layer-moves'
+import { canInsert, reorderFor } from './layer-moves'
import { positioningWrites } from './position-writes'
import { resolvePins, withStrokeEndpoints, type ModelIndex } from '@uidx/schema'
import { strokeEdit } from './stroke-edits'
@@ -521,7 +521,16 @@ function createNode(
const chain = containerChainAt(built.graph, built.rootId, at, '')
.map((id) => built.addresses.addressOf(id))
.filter((address): address is string => address !== undefined)
- const parent = insertTargetFor(current, element, chain)
+ // Placing an instance with a slot selected fills the slot (ADR 0007): the
+ // hole is the target the author named, wherever the click landed.
+ const selected = props.selection?.length === 1 ? props.selection[0]! : null
+ const selectedNode = selected ? resolve(current.tree, selected) : null
+ const parent =
+ placing !== null &&
+ selectedNode?.element === 'Slot' &&
+ canInsert(current, selected!, 'Instance')
+ ? selected
+ : insertTargetFor(current, element, chain)
if (parent === null) return
const parentNode = resolve(current.tree, parent)
diff --git a/packages/viewer/src/ContractSection.vue b/packages/viewer/src/ContractSection.vue
index 998668e..45cfe07 100644
--- a/packages/viewer/src/ContractSection.vue
+++ b/packages/viewer/src/ContractSection.vue
@@ -17,6 +17,8 @@ import {
scaffoldFromLibrary,
setImplements,
setPart,
+ moveRepeatOnto,
+ setReceives,
setRepeat,
setRepeatAs,
undeclare,
@@ -54,6 +56,8 @@ const props = defineProps<{
codegen?: { out: string | null; running: boolean; notice: string }
/** Model name -> declaration across every page, so a list's model resolves wherever it is written. */
models?: ModelIndex
+ /** Component name -> definition across every page, for what an instance receives. */
+ components?: ReadonlyMap
writable: boolean
}>()
@@ -80,7 +84,9 @@ function chooseCandidate(path: string): void {
if (path) emit('chooseLibrary', path)
}
-const view = computed(() => contractView(props.doc, props.node, props.library, props.models))
+const view = computed(() =>
+ contractView(props.doc, props.node, props.library, props.models, props.components),
+)
const boundCount = computed(() =>
view.value.kind === 'component' ? view.value.parts.filter((row) => row.boundTo).length : 0,
@@ -137,6 +143,27 @@ function chooseAs(raw: string): void {
send(setRepeatAs(repeatable.value.node, raw))
}
+/** The repeat moves from the container onto the row it holds; the row is then selected. */
+function repeatRowInstead(): void {
+ const current = repeatable.value
+ if (!current?.wrapsOne || !props.doc) return
+ const row = findNode(props.doc.tree, current.wrapsOne.address)
+ if (!row) return
+ send(moveRepeatOnto(current.node, row))
+ emit('select', row.address)
+}
+
+/** What an instance hands one of its definition's props; empty leaves the inference to stand. */
+function chooseReceives(prop: string, alias: string): void {
+ if (view.value.kind !== 'instance') return
+ send(setReceives(view.value.node, prop, alias || null))
+}
+
+/** A `` in the tree the contract does not declare yet, declared in one click. */
+function declareSlot(name: string): void {
+ send(declare('slot', name, { attrs: {}, description: `${PLACEHOLDER}the slot "${name}".` }))
+}
+
function findNode(root: UidxNode, address: string): UidxNode | null {
if (root.address === address) return root
for (const child of root.children) {
@@ -384,8 +411,34 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>
-
+
+
+ {{ stray.name }}
+
+ Not declared
+
+
+ Declare
+
+
+
+ This repeats the whole “{{ repeatable.node.name }}”, one per item, with “{{
+ repeatable.wrapsOne.name
+ }}” inside each. To keep one container and repeat the row:
+
+ Repeat {{ repeatable.wrapsOne.name }} instead
+
+
Each item is a
()
const rows = computed(() => instancePropRows(props.instance, props.definition))
+/** The definition's contract props (ADR 0013), which the Contract tab binds rather than this section. */
+const contractProps = computed(() =>
+ (props.definition?.spec?.contract?.props ?? []).map((prop) => prop.name),
+)
const unused = computed(() => unusedInstanceProps(props.instance, props.definition))
const undeclared = computed(() => unused.value.filter((u) => u.reason === 'undeclared'))
const mistyped = computed(() => unused.value.filter((u) => u.reason === 'mistyped'))
@@ -74,6 +78,10 @@ function reset(name: string): void {
This document has no component called “{{ componentName }}”, so there is nothing to fill in.
+
+ “{{ componentName }}” declares no component properties. What it receives —
+ {{ contractProps.join(', ') }} — is bound on the Contract tab.
+
“{{ componentName }}” declares no properties. Select it to add some.
diff --git a/packages/viewer/src/NameComponentDialog.vue b/packages/viewer/src/NameComponentDialog.vue
index f951257..c223033 100644
--- a/packages/viewer/src/NameComponentDialog.vue
+++ b/packages/viewer/src/NameComponentDialog.vue
@@ -18,6 +18,8 @@ const props = defineProps<{
/** The name to start from — the node's own, which is usually most of it. */
suggested: string
taken: ReadonlySet
+ /** A component already on this page: the new one would share its contract (ADR 0013). */
+ sharesWith?: string | null
}>()
const emit = defineEmits<{
@@ -78,6 +80,10 @@ function confirm(): void {
{{ problem ?? 'Global to the document. Group with “/”, as in Icon/Check.' }}
+
+ This page already has “{{ sharesWith }}”. Components on one page share its contract, so for
+ props of its own, make this one on a new page.
+
Cancel
diff --git a/packages/viewer/src/PropertiesPane.vue b/packages/viewer/src/PropertiesPane.vue
index d85f383..5011117 100644
--- a/packages/viewer/src/PropertiesPane.vue
+++ b/packages/viewer/src/PropertiesPane.vue
@@ -8,10 +8,11 @@ import {
preserveLengthUnit,
hasLengthUnits,
isUnitLength,
+ isAlias,
} from '@uidx/format'
import { LENGTH_FIELD_CONTEXT } from './length-field-context'
import ContractSection from './ContractSection.vue'
-import { contractIssues, contractView } from './contract-edits'
+import { contractIssues, contractView, textBindingCandidates } from './contract-edits'
import { describe as describeDerived } from './derived-edits'
import type { CodegenState, HeadlessCandidate, HeadlessLibrary } from './headless'
@@ -265,7 +266,9 @@ const face = ref<'design' | 'contract'>('design')
/** Unbound or stray parts on the selected component, for the tab's badge. */
const contractIssueCount = computed(() =>
- contractIssues(contractView(props.doc, active.value, props.headless ?? null, props.models)),
+ contractIssues(
+ contractView(props.doc, active.value, props.headless ?? null, props.models, props.components),
+ ),
)
function onHover(prop: string | null): void {
@@ -1198,9 +1201,33 @@ function pinFields(address: string, writes: PinWrites): UidxPatch[] {
}))
}
+/**
+ * What the selected text may bind to (ADR 0015 §2): the enclosing items'
+ * fields and the component's props, offered beside Content so a binding is
+ * a pick rather than a spelling.
+ */
+const textBindings = computed(() => {
+ const node = active.value
+ if (!node || node.element !== 'Text' || !props.doc) return []
+ return textBindingCandidates(enclosingComponent(props.doc, node.address), node, props.models)
+})
+
function onCommit(prop: string, value: JsonValue): void {
if (!active.value) return
const address = active.value.address
+ /**
+ * A `{binding}` typed or picked into Content is a binding, not a text: the
+ * canvas route resolves it and writes the sample, or writes nothing when
+ * the sample already shows. It goes straight to the file, as a fill's
+ * alias does below.
+ */
+ if (prop === 'characters' && isAlias(value)) {
+ preview.value = null
+ emit('patches', [
+ { op: active.value.attrs[prop] === undefined ? 'add' : 'set', address, prop, value },
+ ])
+ return
+ }
/**
* Fills and strokes route structurally, not through the scene, whenever an
* alias is anywhere in play (the value just committed, or what the node
@@ -1370,6 +1397,7 @@ function onDetach(prop: string, value: JsonValue): void {
:candidates="headlessCandidates"
:codegen="codegen"
:models="models"
+ :components="components"
:writable="writable !== false"
@patches="emit('patches', $event)"
@select="emit('select', $event)"
@@ -1830,6 +1858,7 @@ function onDetach(prop: string, value: JsonValue): void {
:editable="editable(paired.field)"
:swatches="swatches"
:candidates="candidatesFor(paired.field)"
+ :bindings="paired.field.name === 'characters' ? textBindings : undefined"
:variables="variablesFor(paired.field)"
:component-name="componentName"
:tokens="tokens"
diff --git a/packages/viewer/src/PropertyField.vue b/packages/viewer/src/PropertyField.vue
index 94e4bc0..fc6fd33 100644
--- a/packages/viewer/src/PropertyField.vue
+++ b/packages/viewer/src/PropertyField.vue
@@ -3,7 +3,7 @@ import LengthFieldRoot from './LengthFieldRoot.vue'
import LengthUnitSelect from './LengthUnitSelect.vue'
import { computed, ref } from 'vue'
import { SegmentedControlItem, SegmentedControlRoot } from '@open-pencil/vue'
-import { LENGTH_PROPS, type JsonValue } from '@uidx/format'
+import { LENGTH_PROPS, type JsonValue, aliasTarget, toAlias } from '@uidx/format'
import { optionLabelFor, propUiFor, SEGMENTED_MAX_OPTIONS } from '@uidx/schema'
import type { EditableProp } from './editable'
import ConstraintsMatrix from './ConstraintsMatrix.vue'
@@ -44,6 +44,8 @@ const props = defineProps<{
* all; empty is "nothing declared yet", which still offers Create.
*/
candidates?: { name: string; declaration: { type: string; default: JsonValue } }[] | null
+ /** What a text may bind to, offered beside Content; absent for anything but a text. */
+ bindings?: { alias: string; label: string }[]
/**
* What a bound *number* row displays while scrubbing — number-typed
* because only a number field ever scrubs. A token pill's own display and
@@ -158,6 +160,17 @@ function onText(event: Event): void {
emit('commit', props.field.name, (event.target as HTMLInputElement | HTMLTextAreaElement).value)
}
+/** The alias Content holds, when it is one, so the picker shows it as chosen. */
+const boundTo = computed(() => {
+ const value = props.field.value
+ return typeof value === 'string' ? aliasTarget(value) : null
+})
+
+function onBind(alias: string): void {
+ if (!props.editable || alias === '') return
+ emit('commit', props.field.name, toAlias(alias))
+}
+
function onEnum(value: string | string[] | undefined): void {
if (!props.editable || typeof value !== 'string') return
emit('commit', props.field.name, value)
@@ -384,16 +397,35 @@ const segmented = computed(
{{ field.label }}
-
+
+
+
+
+ {{ boundTo ? 'Bound to…' : 'Bind to…' }}
+
+ {{ '{' + binding.alias + '}' }} — {{ binding.label }}
+
+
+
, address: string):
* deprecated. Existing instances of a deprecated one keep rendering — the
* badge is about new work, not old.
*/
-export function offerableComponents(components: ReadonlyMap): string[] {
+export function offerableComponents(
+ components: ReadonlyMap,
+ /** The component the instance would land inside: a component cannot hold itself. */
+ exclude: string | null = null,
+): string[] {
return [...components.entries()]
- .filter(([, node]) => node.attrs.deprecated?.value !== true)
+ .filter(([name, node]) => node.attrs.deprecated?.value !== true && name !== exclude)
.map(([name]) => name)
}
diff --git a/packages/viewer/src/contract-edits.ts b/packages/viewer/src/contract-edits.ts
index 66296f0..eeca83c 100644
--- a/packages/viewer/src/contract-edits.ts
+++ b/packages/viewer/src/contract-edits.ts
@@ -1,14 +1,17 @@
import {
+ aliasTarget,
resolve,
toAlias,
type ContractDeclaration,
type ContractKind,
+ type JsonValue,
type UidxDocument,
type UidxNode,
type UidxPatch,
} from '@uidx/format'
import {
derivedTarget,
+ modelByRef,
repeatListType,
repeatModel,
repeatOf,
@@ -71,6 +74,8 @@ export interface ComponentView {
slots: SlotRow[]
/** Layers bound to a part nothing declares. */
strayParts: { address: string; name: string; part: string }[]
+ /** ``s in the tree the contract does not declare; each is one click from declared. */
+ straySlots: { address: string; name: string }[]
}
/** How a layer repeats (ADR 0017 §2): the list it walks, its item's name, the canvas's rows. */
@@ -91,6 +96,24 @@ export interface RepeatFacet {
repeat: RepeatBinding | null
/** Lists a repeat here may walk: the contract's list props, then list fields of enclosing items. */
lists: string[]
+ /**
+ * A repeat on a container holding one row: the container repeats, which is
+ * rarely what was meant — the row is. Names the row so the tab can offer
+ * to move the repeat onto it.
+ */
+ wrapsOne: { address: string; name: string } | null
+}
+
+/** What one contract prop of an instance's definition receives (ADR 0017 §2). */
+export interface ReceiveRow {
+ prop: string
+ type: string
+ /** The alias it receives — `item`, `child.owner`, `nodes` — or null for nothing. */
+ from: string | null
+ /** True when the use says so in `props={{…}}`; false when the tab inferred it from the repeat. */
+ explicit: boolean
+ /** Every alias of the right type in scope: enclosing items, their fields, the component's props. */
+ options: string[]
}
export interface PartView extends RepeatFacet {
@@ -115,6 +138,10 @@ export interface InstanceView extends RepeatFacet {
kind: 'instance'
node: UidxNode
component: UidxNode | null
+ /** The component it is an instance of, when the document has it. */
+ definition: UidxNode | null
+ /** Its definition's contract props, each with what this use hands it. */
+ receives: ReceiveRow[]
}
export interface OtherView {
@@ -216,6 +243,7 @@ function componentView(component: UidxNode, library: HeadlessLibrary | null): Co
if (name !== '' && !slots.some((slot) => slot.name === name))
slots.push({ name, provided: null })
}
+ const straySlots: { address: string; name: string }[] = []
const provide = (node: UidxNode): void => {
for (const child of node.children) {
if (child.element === 'Slot') {
@@ -226,6 +254,7 @@ function componentView(component: UidxNode, library: HeadlessLibrary | null): Co
address: child.address,
repeat: repeat ? { list: repeat.list } : null,
}
+ else if (!row) straySlots.push({ address: child.address, name: child.name })
}
if (child.element !== 'Instance') provide(child)
}
@@ -242,6 +271,7 @@ function componentView(component: UidxNode, library: HeadlessLibrary | null): Co
candidates,
slots,
strayParts,
+ straySlots,
}
}
@@ -328,10 +358,15 @@ export function placeableLists(
function repeatFacet(component: UidxNode | null, node: UidxNode, models?: ModelIndex): RepeatFacet {
const lists = placeableLists(component, node, models)
const attrs = repeatOf(node)
- if (!attrs || !component) return { repeat: null, lists }
+ if (!attrs || !component) return { repeat: null, lists, wrapsOne: null }
const enclosing = enclosingRepeats(component, node, models)
const model = repeatModel(attrs, component.spec, enclosing, models)
const placed = model ? undefined : repeatListType(attrs, component.spec, enclosing, models)
+ const only = node.children.length === 1 ? node.children[0]! : null
+ const wrapsOne =
+ node.element !== 'Slot' && only && (only.element === 'Instance' || only.element === 'Frame')
+ ? { address: only.address, name: only.name }
+ : null
return {
repeat: {
list: attrs.list,
@@ -341,9 +376,160 @@ function repeatFacet(component: UidxNode | null, node: UidxNode, models?: ModelI
unknownModel: !model && typeof placed === 'string',
},
lists,
+ wrapsOne,
}
}
+/* ------------------------------------------------------------ receives */
+
+/** `Contact[]` → `Contact`, and whether it was a list. */
+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 }
+}
+
+/**
+ * What an instance hands each contract prop of its definition (ADR 0017 §2).
+ *
+ * Inside a repeat the row's item is what the instance is of, so a prop typed
+ * by the item's model receives the item without anyone writing it — the same
+ * rule the code target follows. The use may say otherwise in `props={{…}}`:
+ * another enclosing item, a field of one, or a prop of the component. The
+ * options are every alias in scope whose type matches.
+ */
+export function receivesFor(
+ instance: UidxNode,
+ definition: UidxNode | null,
+ component: UidxNode | null,
+ models?: ModelIndex,
+): ReceiveRow[] {
+ if (!definition || !component) return []
+ const spec = component.spec
+ const enclosing = enclosingRepeats(component, instance, models)
+ // An instance that repeats is its own row: its item is the innermost.
+ const own = repeatOf(instance)
+ if (own) enclosing.push({ as: own.as, model: repeatModel(own, spec, enclosing, models) })
+ const explicit = instance.attrs.props?.value
+ const passed: Record =
+ explicit && typeof explicit === 'object' && !Array.isArray(explicit)
+ ? (explicit as Record)
+ : {}
+ const modelProps = (definition.spec?.contract?.props ?? []).filter(
+ (prop) => modelByRef(definition.spec, typeName(prop.type).name, models) !== undefined,
+ )
+ return (definition.spec?.contract?.props ?? []).map((prop) => {
+ const wanted = typeName(prop.type)
+ const options: string[] = []
+ // Innermost first: the nearest item is the likeliest answer.
+ for (const scope of [...enclosing].reverse()) {
+ if (!wanted.list && scope.model?.name === wanted.name) options.push(scope.as)
+ for (const field of scope.model?.fields ?? [])
+ if (field.type.trim() === prop.type.trim()) options.push(`${scope.as}.${field.name}`)
+ }
+ for (const own of spec?.contract?.props ?? [])
+ if (own.type.trim() === prop.type.trim()) options.push(own.name)
+ const written = passed[prop.name]
+ const alias = typeof written === 'string' ? aliasTarget(written) : null
+ if (alias !== null) {
+ if (!options.includes(alias)) options.unshift(alias)
+ return { prop: prop.name, type: prop.type, from: alias, explicit: true, options }
+ }
+ // The code target's rule: the item lands on the definition's first model
+ // prop; a prop typed by the item's own model lands regardless of order.
+ const inner = enclosing[enclosing.length - 1]
+ const inferred =
+ options.find((option) => enclosing.some((scope) => scope.as === option)) ??
+ (inner && modelProps[0]?.name === prop.name ? inner.as : null)
+ return { prop: prop.name, type: prop.type, from: inferred, explicit: false, options }
+ })
+}
+
+/**
+ * Writes what an instance hands one prop: an alias into `props={{…}}`, or
+ * nothing, which leaves the inference to stand. A `props` left empty goes.
+ */
+export function setReceives(instance: UidxNode, prop: string, alias: string | null): UidxPatch[] {
+ const current = instance.attrs.props?.value
+ const next: Record =
+ current && typeof current === 'object' && !Array.isArray(current)
+ ? { ...(current as Record) }
+ : {}
+ if (alias === null || alias === '') delete next[prop]
+ else next[prop] = toAlias(alias)
+ if (Object.keys(next).length === 0)
+ return instance.attrs.props === undefined
+ ? []
+ : [{ op: 'remove', address: instance.address, prop: 'props' }]
+ return [
+ {
+ op: instance.attrs.props === undefined ? 'add' : 'set',
+ address: instance.address,
+ prop: 'props',
+ value: next,
+ },
+ ]
+}
+
+/**
+ * The repeat moves from a container onto the one row it holds (ADR 0017 §2):
+ * the row is what repeats, the container is the outer structure. The row
+ * gets the list and the item name; the container gives them up, `as` first
+ * so every step leaves a valid file.
+ */
+export function moveRepeatOnto(container: UidxNode, row: UidxNode): UidxPatch[] {
+ const repeat = repeatOf(container)
+ if (!repeat) return []
+ const out: UidxPatch[] = [
+ ...setAttr(row, 'repeat', toAlias(repeat.list)),
+ ...(container.attrs.as !== undefined ? setAttr(row, 'as', repeat.as) : []),
+ ]
+ for (const prop of ['as', 'repeat']) out.push(...setAttr(container, prop, null))
+ return out
+}
+
+/**
+ * What a text may bind to from the layer (ADR 0015 §2): the fields of every
+ * enclosing item, and the component's own props — anything a `{…}` in
+ * `characters` could name. Empty outside a component.
+ */
+export function textBindingCandidates(
+ component: UidxNode | null,
+ node: UidxNode,
+ models?: ModelIndex,
+): { alias: string; label: string }[] {
+ if (!component) return []
+ const out: { alias: string; label: string }[] = []
+ for (const scope of [...enclosingRepeats(component, node, models)].reverse()) {
+ for (const field of scope.model?.fields ?? []) {
+ if (modelByRef(component.spec, typeName(field.type).name, models)) continue
+ out.push({
+ alias: `${scope.as}.${field.name}`,
+ label: `${scope.as}.${field.name} · ${field.type}`,
+ })
+ }
+ }
+ for (const prop of component.spec?.contract?.props ?? []) {
+ const { name, list } = typeName(prop.type)
+ const model = modelByRef(component.spec, name, models)
+ if (!model) {
+ out.push({ alias: prop.name, label: `${prop.name} · ${prop.type}` })
+ continue
+ }
+ // A model prop's fields, one level deep (ADR 0015 §2): `{node.label}`.
+ if (list) continue
+ for (const field of model.fields) {
+ if (modelByRef(component.spec, typeName(field.type).name, models)) continue
+ out.push({
+ alias: `${prop.name}.${field.name}`,
+ label: `${prop.name}.${field.name} · ${field.type}`,
+ })
+ }
+ }
+ return out
+}
+
/**
* The tab's view of the selection.
*
@@ -355,6 +541,8 @@ export function contractView(
node: UidxNode | null,
library: HeadlessLibrary | null,
models?: ModelIndex,
+ /** Component name -> definition across every page, for what an instance receives. */
+ components?: ReadonlyMap,
): ContractView {
if (!doc || !node) return { kind: 'page', node: null }
if (node.element === 'Component') return componentView(node, library)
@@ -362,7 +550,7 @@ export function contractView(
if (from) {
// The default state draws the base tree (ADR 0016 §4): selected there, a
// layer is the authored one, and the component's root is the component.
- if (from.isDefault) return contractView(doc, from.base, library, models)
+ if (from.isDefault) return contractView(doc, from.base, library, models, components)
const state = Object.entries(from.keys)
.map(([axis, value]) => `${axis}=${value}`)
.join(', ')
@@ -379,8 +567,18 @@ export function contractView(
...repeatFacet(component, node, models),
}
}
- if (node.element === 'Instance')
- return { kind: 'instance', node, component, ...repeatFacet(component, node, models) }
+ if (node.element === 'Instance') {
+ const named = node.attrs.component?.value
+ const definition = typeof named === 'string' ? (components?.get(named) ?? null) : null
+ return {
+ kind: 'instance',
+ node,
+ component,
+ definition,
+ receives: receivesFor(node, definition, component, models),
+ ...repeatFacet(component, node, models),
+ }
+ }
if (component && BINDABLE.has(node.element)) return partView(node, component, library, models)
return { kind: 'other', node }
}
diff --git a/packages/viewer/src/slot-edits.ts b/packages/viewer/src/slot-edits.ts
index 1f16771..205d1e7 100644
--- a/packages/viewer/src/slot-edits.ts
+++ b/packages/viewer/src/slot-edits.ts
@@ -10,6 +10,7 @@ import {
type UidxDocument,
type UidxNode,
type UidxPatch,
+ type UidxNodeSpec,
} from '@uidx/format'
import { createSpec } from '@uidx/schema'
@@ -75,13 +76,22 @@ export function newSlotFor(
op: 'insert-node',
parent,
index: parentNode.children.length,
- node: createSpec('Slot', name, { at: null, size: null }),
+ // A hole hugs what fills it (ADR 0007): sized by its placeholder, and
+ // by the rows a repeat puts in it, rather than a fixed box they overflow.
+ node: withHug(createSpec('Slot', name, { at: null, size: null })),
},
],
address: addressOf(parent, name),
}
}
+function withHug(spec: UidxNodeSpec): UidxNodeSpec {
+ return {
+ ...spec,
+ attrs: { ...spec.attrs, primaryAxisSizingMode: 'AUTO', counterAxisSizingMode: 'AUTO' },
+ }
+}
+
/**
* Elements that are never a hole, whatever they carry.
*
diff --git a/packages/viewer/test/component-rename.test.ts b/packages/viewer/test/component-rename.test.ts
index 412c7b7..0642367 100644
--- a/packages/viewer/test/component-rename.test.ts
+++ b/packages/viewer/test/component-rename.test.ts
@@ -77,5 +77,7 @@ describe('offerableComponents', () => {
.map((node) => [node.name, node] as const),
)
expect(offerableComponents(components)).toEqual(['Card', 'Lonely'])
+ // A component cannot hold itself: placing inside Card leaves Card off the list.
+ expect(offerableComponents(components, 'Card')).toEqual(['Lonely'])
})
})
diff --git a/packages/viewer/test/contract-edits.test.ts b/packages/viewer/test/contract-edits.test.ts
index cccb3e1..4c6c2a5 100644
--- a/packages/viewer/test/contract-edits.test.ts
+++ b/packages/viewer/test/contract-edits.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { applyPatches, parseOrThrow, resolve } from '@uidx/format'
-import { defaultVariantAddress, derivedDocument } from '@uidx/schema'
+import { defaultVariantAddress, derivedDocument, modelIndex } from '@uidx/schema'
import ContractSection from '../src/ContractSection.vue'
import PropertiesPane from '../src/PropertiesPane.vue'
import {
@@ -12,8 +12,11 @@ import {
scaffoldFromLibrary,
setImplements,
setPart,
+ moveRepeatOnto,
+ setReceives,
setRepeat,
setRepeatAs,
+ textBindingCandidates,
} from '../src/contract-edits'
import { parseHeadless, type HeadlessLibrary } from '../src/headless'
@@ -109,6 +112,50 @@ const LIST = page(
const bare = page('bare', ` `)
+const TREE = page(
+ 'tree',
+ `
+
+
+
+
+
+
+ `,
+ `
+## Contract
+
+
+ The top-level nodes.
+
+`,
+)
+
+const ROW_PAGE = page(
+ 'tree-item',
+ `
+
+ `,
+ `
+## Contract
+
+
+ The node this row shows.
+ How deep the row sits.
+
+
+## Models
+
+
+ One node.
+ Identity.
+ Words.
+ Level.
+ Below it.
+
+`,
+)
+
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([
@@ -208,6 +255,105 @@ describe('what the tab shows', () => {
expect(contractView(doc, root, LIBRARY).kind).toBe('component')
})
+ it('for an instance inside a repeat: what each contract prop receives, inferred by type', () => {
+ const doc = parseOrThrow(TREE)
+ const row = parseOrThrow(ROW_PAGE)
+ const components = new Map(
+ [...doc.tree.children, ...row.tree.children]
+ .filter((node) => node.element === 'Component')
+ .map((node) => [node.name, node] as const),
+ )
+ const models = modelIndex([doc, row])
+ const view = contractView(doc, resolve(doc.tree, 'Tree#node/row'), null, models, components)
+ if (view.kind !== 'instance') throw new Error(view.kind)
+ expect(view.definition?.name).toBe('TreeItem')
+ expect(view.receives).toEqual([
+ { prop: 'node', type: 'TreeNode', from: 'item', explicit: false, options: ['item'] },
+ { prop: 'depth', type: 'number', from: null, explicit: false, options: ['item.depth'] },
+ ])
+ // Two levels down, the nearest item of the right type wins; a use may say otherwise.
+ const nested = contractView(
+ doc,
+ resolve(doc.tree, 'Tree#node/children/child-row'),
+ null,
+ models,
+ components,
+ )
+ if (nested.kind !== 'instance') throw new Error(nested.kind)
+ expect(nested.receives[0]).toMatchObject({ from: 'child', options: ['child', 'item'] })
+ const instance = resolve(doc.tree, 'Tree#node/children/child-row')!
+ expect(setReceives(instance, 'node', 'item')).toEqual([
+ {
+ op: 'add',
+ address: 'Tree#node/children/child-row',
+ prop: 'props',
+ value: { node: '{item}' },
+ },
+ ])
+ expect(setReceives(instance, 'node', null)).toEqual([])
+ const said = parseOrThrow(
+ applyPatches(TREE, setReceives(instance, 'depth', 'item.depth')).source,
+ )
+ const explicit = contractView(
+ said,
+ resolve(said.tree, 'Tree#node/children/child-row'),
+ null,
+ models,
+ components,
+ )
+ if (explicit.kind !== 'instance') throw new Error(explicit.kind)
+ expect(explicit.receives[1]).toMatchObject({ from: 'item.depth', explicit: true })
+ expect(setReceives(resolve(said.tree, 'Tree#node/children/child-row')!, 'depth', null)).toEqual(
+ [{ op: 'remove', address: 'Tree#node/children/child-row', prop: 'props' }],
+ )
+ })
+
+ it('lists a slot the tree has and the contract lacks, and a repeat that wraps its one row', () => {
+ const doc = parseOrThrow(TREE)
+ const component = contractView(doc, resolve(doc.tree, 'Tree'), null)
+ if (component.kind !== 'component') throw new Error(component.kind)
+ expect(component.straySlots).toEqual([{ address: 'Tree#node', name: 'node' }])
+ const wrapped = parseOrThrow(
+ TREE.replace(
+ ' ',
+ ' ',
+ ).replace(' repeat="{item.children}" as="child" />', ' />'),
+ )
+ const container = contractView(wrapped, resolve(wrapped.tree, 'Tree#node/children'), null)
+ if (container.kind !== 'part') throw new Error(container.kind)
+ expect(container.wrapsOne).toEqual({
+ address: 'Tree#node/children/child-row',
+ name: 'child-row',
+ })
+ const moved = moveRepeatOnto(
+ resolve(wrapped.tree, 'Tree#node/children')!,
+ resolve(wrapped.tree, 'Tree#node/children/child-row')!,
+ )
+ const after = applyPatches(wrapped.source, moved).source
+ expect(after).toContain(' ')
+ expect(after).toContain(
+ ' ',
+ )
+ })
+
+ it('offers a text the fields of enclosing items and the props of its component', () => {
+ const doc = parseOrThrow(ROW_PAGE)
+ const row = parseOrThrow(TREE)
+ const models = modelIndex([doc, row])
+ const label = resolve(doc.tree, 'TreeItem#label')!
+ expect(textBindingCandidates(resolve(doc.tree, 'TreeItem'), label, models)).toEqual([
+ { alias: 'node.id', label: 'node.id · string' },
+ { alias: 'node.label', label: 'node.label · string' },
+ { alias: 'node.depth', label: 'node.depth · number' },
+ { alias: 'depth', label: 'depth · number' },
+ ])
+ // Below the repeating slot, the item's fields; a model-typed field is not a text.
+ const inTree = resolve(row.tree, 'Tree#node/children')!
+ expect(
+ textBindingCandidates(resolve(row.tree, 'Tree'), inTree, models).map((b) => b.alias),
+ ).toEqual(['item.id', 'item.label', 'item.depth'])
+ })
+
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')
@@ -424,7 +570,7 @@ describe('the Contract section', () => {
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#option/row').text()).toContain('An instance renders')
+ expect(mountFor(LIST, 'List#option/row').text()).toContain('no component called “Row”')
})
})
diff --git a/packages/viewer/test/slot-editing.test.ts b/packages/viewer/test/slot-editing.test.ts
index 0438b70..5785534 100644
--- a/packages/viewer/test/slot-editing.test.ts
+++ b/packages/viewer/test/slot-editing.test.ts
@@ -172,7 +172,16 @@ describe('the "New slot" gesture', () => {
op: 'insert-node',
parent: 'Card#container',
index: 4,
- node: { element: 'Slot', attrs: { name: 'slot-1', layoutMode: 'VERTICAL' } },
+ // A hole hugs what fills it: sized by its placeholder and by a repeat's rows.
+ node: {
+ element: 'Slot',
+ attrs: {
+ name: 'slot-1',
+ layoutMode: 'VERTICAL',
+ primaryAxisSizingMode: 'AUTO',
+ counterAxisSizingMode: 'AUTO',
+ },
+ },
},
])
// ADR 0007 §6: with no empty-slot indicator, an unselected new slot is
From ca50b75f6deabc1aa6e04a498615bc36bda76bae Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 21:26:46 +0300
Subject: [PATCH 21/74] A tree row with a turning chevron: what the second
walkthrough fixed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A visual prop draws its state set as soon as it is declared, rows or no
rows, so the first style row is written by selecting the state on the
canvas rather than by hand (ADR 0016 §4). Rotation is a look a state may
set — a chevron turns when a row opens — and the CSS target already renders
it as a transform. A use's bound value chooses the state on the canvas
(`props={{ open: '{item.expanded}' }}` inside a repeat), resolved in the
consumer's scope and mapped onto the state axis, so rows repeat open or
closed as their samples say; the code target passed this already.
The canvas now receives the shared model index it was meant to — the
binding had landed on the layers rail — so a list's model on another page
draws its samples on the canvas, not only in thumbnails. A text bound to a
boolean or a number is spelled out rather than crashing the text layout.
Pen-drawn vectors stay stroked in both code targets. The Repeat tool offers
the enclosing item's lists first and names a nested item after its list
(`child` for `{item.children}`). A sized component's layout reads Free, so
choosing Column writes it. An instance's bound prop is no longer called
mistyped.
Co-Authored-By: Claude Fable 5.1
---
CHANGELOG.md | 14 +++++
docs/decisions/0016-variants-as-renders.md | 12 +++-
.../0017-collections-and-code-targets.md | 7 ++-
packages/codegen/src/html.ts | 4 +-
packages/codegen/src/model.ts | 24 ++++++++
packages/codegen/src/react.ts | 7 ++-
packages/codegen/test/generate.test.ts | 18 ++++++
packages/schema/src/design-system.ts | 9 +--
packages/schema/src/prop-table.ts | 17 +++++-
packages/schema/src/to-scene.ts | 40 +++++++++++--
packages/schema/test/design-system.test.ts | 56 +++++++++++++++++++
packages/viewer/src/App.vue | 2 +-
packages/viewer/src/contract-edits.ts | 13 +++--
packages/viewer/src/derived-edits.ts | 9 ++-
packages/viewer/src/editable.ts | 12 +++-
packages/viewer/src/instance-prop-edits.ts | 4 ++
packages/viewer/src/repeat-edits.ts | 35 +++++++++++-
packages/viewer/test/derived-edits.test.ts | 10 ++++
packages/viewer/test/editable.test.ts | 15 ++++-
packages/viewer/test/repeat-edits.test.ts | 7 ++-
20 files changed, 284 insertions(+), 31 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dcaec27..6708472 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -64,6 +64,20 @@ describe migrations when an existing document or integration is affected.
the instance would sit inside. Removing the last declaration removes the
empty `## Contract` heading; `uidx codegen` takes directories as `check`
does.
+- From a second walkthrough, a tree with a disclosure chevron: a visual prop
+ draws its state set at once, rows or no rows, so the first style row is
+ written by selecting the state on the canvas rather than by hand; rotation
+ is a look a state may set (a chevron turns when a row opens), rendered as a
+ transform by the CSS target; a use's bound value chooses the state on the
+ canvas, so rows repeat open or closed as their samples say; a text bound to
+ a boolean or a number is spelled out rather than crashing the layout; the
+ canvas receives the shared model index it was meant to (a list's model on
+ another page now draws its samples on the canvas, not only in thumbnails);
+ a pen-drawn vector stays stroked in the code targets; the Repeat tool names
+ a nested repeat's item after its list (`child` for `{item.children}`) and
+ offers the enclosing item's lists first; a sized component's layout reads
+ Free, so choosing Column writes it; an instance's bound prop is no longer
+ called mistyped.
- Fixed: a component edited structurally kept its contract (the incremental
patch path dropped the spec until a full parse), and `uidx check` no longer
reports `repeat="{items}"` or `{item.field}` as unknown component
diff --git a/docs/decisions/0016-variants-as-renders.md b/docs/decisions/0016-variants-as-renders.md
index 11f2db1..4e9b9ec 100644
--- a/docs/decisions/0016-variants-as-renders.md
+++ b/docs/decisions/0016-variants-as-renders.md
@@ -80,8 +80,16 @@ property, and the viewer writes the matching cell of the `state="hover"` row
edit to the default combination lands on the base tree, which is what the
default draws. What a state cannot change — where a variant sits, a layer's
name, the structure below it — is refused with the reason; structure is the
-base tree's or an authored ``'s. Code targets never expand; they emit
-one component whose CSS is keyed by attributes and states.
+base tree's or an authored ``'s. Rotation is a look, not a place: a
+chevron turns when a row opens, and the CSS target renders it as a
+transform, so a state may set it. The set is drawn as soon as an axis exists,
+rows or no rows — a visual prop is a state the moment it is declared — so the
+first row is written the same way as every other, by selecting the state on
+the canvas and changing something, never by hand. An instance chooses its
+state from what it is handed: `open={true}` on the use, or a bound
+`{item.expanded}` inside a repeat, resolved in the consumer's scope (ADR 0017
+§2). Code targets never expand; they emit one component whose CSS is keyed
+by attributes and states.
### 5. Audit
diff --git a/docs/decisions/0017-collections-and-code-targets.md b/docs/decisions/0017-collections-and-code-targets.md
index 082adcf..7596f6a 100644
--- a/docs/decisions/0017-collections-and-code-targets.md
+++ b/docs/decisions/0017-collections-and-code-targets.md
@@ -58,8 +58,11 @@ prop named after the slot (`renderItem(item, index)`), with the slot's
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 and `as` from the layer. The canvas
+`repeat="{…}"` on the selected layer with the nearest list it can place — an
+enclosing item's list field before the contract's own lists, so a layer
+inside a row walks the row — and names a nested item after its list
+(`child` for `{item.children}`), since `item` would hide the outer item; 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
diff --git a/packages/codegen/src/html.ts b/packages/codegen/src/html.ts
index aa8574b..274e3f5 100644
--- a/packages/codegen/src/html.ts
+++ b/packages/codegen/src/html.ts
@@ -15,6 +15,7 @@ import {
repeatFor,
type ComponentModel,
type PartInfo,
+ vectorPaint,
} from './model.js'
/**
@@ -338,7 +339,8 @@ function markup(
: undefined,
)
.filter((data): data is string => typeof data === 'string')
- const svg = `${d.map((data) => ` `).join('')} `
+ const paint = vectorPaint(node)
+ const svg = `${d.map((data) => ` `).join('')} `
const [open, close] = (info && partTags(info)) ?? [
``,
' ',
diff --git a/packages/codegen/src/model.ts b/packages/codegen/src/model.ts
index 5dc1158..65ac61e 100644
--- a/packages/codegen/src/model.ts
+++ b/packages/codegen/src/model.ts
@@ -192,6 +192,30 @@ export interface SlotInfo {
}
/** An element drawn once per item of a list (ADR 0017 §2). */
+/**
+ * How a vector's paths are painted in SVG: a stroked outline (a chevron, an
+ * icon drawn with the pen) keeps its stroke and weight, a filled shape keeps
+ * its fill; both take the text colour, as `currentColor`.
+ */
+export function vectorPaint(node: UidxNode): string {
+ const fills = node.attrs.fills?.value
+ const strokes = node.attrs.strokes?.value
+ const filled = Array.isArray(fills) && fills.length > 0
+ const stroked = Array.isArray(strokes) && strokes.length > 0
+ if (!stroked || filled) return 'fill="currentColor"'
+ const weight =
+ typeof node.attrs.strokeWeight?.value === 'number' ? node.attrs.strokeWeight.value : 1
+ const cap =
+ typeof node.attrs.strokeCap?.value === 'string'
+ ? node.attrs.strokeCap.value.toLowerCase()
+ : 'butt'
+ const join =
+ typeof node.attrs.strokeJoin?.value === 'string'
+ ? node.attrs.strokeJoin.value.toLowerCase()
+ : 'miter'
+ return `fill="none" stroke="currentColor" stroke-width="${weight}" stroke-linecap="${cap}" stroke-linejoin="${join}"`
+}
+
export interface RepeatInfo {
node: UidxNode
/** The alias target: `items`, or `item.children` for a nested repeat. */
diff --git a/packages/codegen/src/react.ts b/packages/codegen/src/react.ts
index 169554a..6dd2044 100644
--- a/packages/codegen/src/react.ts
+++ b/packages/codegen/src/react.ts
@@ -15,6 +15,7 @@ import {
repeatFor,
type ComponentModel,
type RepeatInfo,
+ vectorPaint,
} from './model.js'
/**
@@ -152,7 +153,11 @@ function svgFor(node: UidxNode): string {
: undefined,
)
.filter((data): data is string => typeof data === 'string')
- return `${d.map((data) => ` `).join('')} `
+ const paint = vectorPaint(node)
+ .replace(/stroke-width="/g, 'strokeWidth="')
+ .replace(/stroke-linecap="/g, 'strokeLinecap="')
+ .replace(/stroke-linejoin="/g, 'strokeLinejoin="')
+ return `${d.map((data) => ` `).join('')} `
}
/** `renderOption` for a repeating slot named `option` (ADR 0017 §2). */
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
index 60daa40..3698dfb 100644
--- a/packages/codegen/test/generate.test.ts
+++ b/packages/codegen/test/generate.test.ts
@@ -78,6 +78,24 @@ id: tree-item
Below it.
`
+ it('keeps a pen-drawn chevron stroked rather than filled, in HTML and React', () => {
+ const stroked = ITEM.replace(
+ ' ',
+ `
+ `,
+ )
+ const out = generate({
+ pages: [{ file: 'tree-item.uidx', doc: parseOrThrow(stroked) }],
+ tokens: [],
+ })
+ expect(out.files.get('html/tree-item.html')).toContain(
+ ' ',
+ )
+ expect(out.files.get('react/TreeItem.tsx')).toContain(
+ 'fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"',
+ )
+ })
+
it('hands the nearest item of the right type to the model prop, and an explicit item field as written', () => {
const out = generate({
pages: [
diff --git a/packages/schema/src/design-system.ts b/packages/schema/src/design-system.ts
index 3c92007..bb8cf27 100644
--- a/packages/schema/src/design-system.ts
+++ b/packages/schema/src/design-system.ts
@@ -363,11 +363,12 @@ export function styleTarget(tree: UidxNode, target: string): UidxNode | undefine
/** Whether a component's variants are derived from its styles table. */
export function derivesVariants(component: UidxNode): boolean {
+ // A visual prop is a state (ADR 0016 §1), so a component that declares one
+ // draws its set at once, rows or no rows: the author selects the state on
+ // the canvas and the first change writes the first row. Waiting for a row
+ // to exist left the first one to be written by hand.
return (
- component.element === 'Component' &&
- !hasVariants(component) &&
- (component.spec?.styles?.length ?? 0) > 0 &&
- axesOf(component.spec).size > 0
+ component.element === 'Component' && !hasVariants(component) && axesOf(component.spec).size > 0
)
}
diff --git a/packages/schema/src/prop-table.ts b/packages/schema/src/prop-table.ts
index 400664e..2eaf4c7 100644
--- a/packages/schema/src/prop-table.ts
+++ b/packages/schema/src/prop-table.ts
@@ -132,7 +132,22 @@ export const PROP_TABLE: readonly PropMapping[] = [
// --- renames -----------------------------------------------------------
rename('primaryAxisAlignItems', 'primaryAxisAlign'),
rename('counterAxisAlignItems', 'counterAxisAlign'),
- rename('characters', 'text'),
+ {
+ // A text's words. A binding may resolve to a number or a boolean —
+ // `{item.count}`, `{item.expanded}` — and the text layout takes only a
+ // string, so anything else is spelled out rather than crashing the page.
+ uidx: 'characters',
+ sceneFields: ['text'],
+ toScene: (value) => ({
+ text:
+ typeof value === 'string'
+ ? value
+ : value === null || value === undefined
+ ? ''
+ : String(value),
+ }),
+ fromScene: (node) => node.text as JsonValue | undefined,
+ },
// Figma calls this layoutAlign on the child; SceneNode spells it out.
rename('layoutAlign', 'layoutAlignSelf'),
// Figma exposes per-side stroke weights as strokeTopWeight and friends;
diff --git a/packages/schema/src/to-scene.ts b/packages/schema/src/to-scene.ts
index 467d735..9eb06a1 100644
--- a/packages/schema/src/to-scene.ts
+++ b/packages/schema/src/to-scene.ts
@@ -668,15 +668,43 @@ function parseCoordinates(name: string): Map {
* an error here; `uidx check` reports it at the use site, which is where the
* author can do something about it.
*/
-export function variantFor(definition: UidxNode, instance: UidxNode | null): UidxNode | undefined {
+export function variantFor(
+ definition: UidxNode,
+ instance: UidxNode | null,
+ /**
+ * The consumer's scope, for a value the use binds rather than states:
+ * `props={{ open: '{item.expanded}' }}` in a repeat asks for the state the
+ * row's sample says (ADR 0017 §2), and an unbound alias asks for nothing.
+ */
+ resolveAlias?: AliasResolver,
+): UidxNode | undefined {
if (!hasVariants(definition)) return undefined
const { axes } = componentVariants(definition)
const asked = defaultCombination(axes)
- const assigned = instance ? declaredInstanceValues(instance).values : new Map()
+ const assigned = new Map()
+ for (const [name, raw] of instance ? declaredInstanceValues(instance).values : new Map()) {
+ const target = aliasTarget(raw)
+ const value = target === null ? raw : resolveAlias?.(target)
+ if (value !== undefined) assigned.set(name, value)
+ }
for (const [axis, domain] of axes) {
const value = assigned.get(axis)
if (typeof value === 'string' && domain.includes(value)) asked.set(axis, value)
}
+ // A derived set (ADR 0016) has a `state` axis whose values are the visual
+ // boolean props: `open={true}` on the use asks for `state=open`. The first
+ // one set wins, since a styles table draws one state at a time.
+ const states = axes.get('state')
+ if (states && !assigned.has('state')) {
+ for (const prop of definition.spec?.contract?.props ?? []) {
+ if (prop.visual && prop.type === 'boolean' && assigned.get(prop.name) === true) {
+ if (states.includes(prop.name)) {
+ asked.set('state', prop.name)
+ break
+ }
+ }
+ }
+ }
const wanted = variantName(asked)
return definition.children.find((child) => child.name === wanted)
}
@@ -855,7 +883,7 @@ function instanceProps(
// A component with states *is* whichever state this use asked for (ADR 0005
// §4), so the base is the chosen variant rather than the set — the set is a
// container for four looks and has none of its own.
- const source = variantFor(definition, node) ?? definition
+ const source = variantFor(definition, node, options.resolveAlias) ?? definition
return {
...scenePropsFor(
source,
@@ -884,7 +912,7 @@ export function instanceRootProps(
options: SceneOptions,
warnings: string[] = [],
): Partial {
- const source = variantFor(definition, instance) ?? definition
+ const source = variantFor(definition, instance, options.resolveAlias) ?? definition
return {
...scenePropsFor(
source,
@@ -1151,7 +1179,7 @@ function expandInstance(
* the override by path and a consuming file never embeds which state it was
* written against (§3, and ADR 0003's rationale 2).
*/
- const chosen = variantFor(definition, node)
+ const chosen = variantFor(definition, node, options.resolveAlias)
if (hasVariants(definition) && !chosen) {
warnings.push(
`${node.address}: "${name}" has no variant for the combination this instance asks for`,
@@ -1224,7 +1252,7 @@ export function generatedChildProps(
]),
),
}
- const root = variantFor(definition, instance) ?? definition
+ const root = variantFor(definition, instance, options.resolveAlias) ?? definition
const inherited = relative.includes('/') ? {} : frameSizing(instance, root)
const changed = overrideMap(instance).get(relative)
return {
diff --git a/packages/schema/test/design-system.test.ts b/packages/schema/test/design-system.test.ts
index e506df8..63b2c9e 100644
--- a/packages/schema/test/design-system.test.ts
+++ b/packages/schema/test/design-system.test.ts
@@ -398,6 +398,62 @@ describe('repeat draws an element once per item (ADR 0017 §2)', () => {
})
})
+describe('a use asks for a state with a bound value (ADR 0017 §2, ADR 0016)', () => {
+ const LIST = page(
+ 'todo',
+ `
+
+ `,
+ `
+## Contract
+
+
+ Rows.
+
+
+## Models
+
+
+ One task.
+ Identity.
+ Whether it is checked.
+
+`,
+ )
+ it('spells out a text bound to a boolean or a number rather than crashing the layout', () => {
+ const source = LIST.replace(
+ ` `,
+ ` `,
+ )
+ const scene = toSceneGraph(parseOrThrow(source), { resolveAlias })
+ expect(scene.graph.getNode('Todo#row/flag')!.text).toBe('true')
+ expect(scene.graph.getNode('Todo#row-2/flag')!.text).toBe('false')
+ })
+
+ it('draws the set of a component with a visual prop before any style row exists', () => {
+ const bare = CHECKBOX_SOURCE.replace(/[\s\S]*?<\/Styles>\n/, '')
+ const doc = parseOrThrow(bare)
+ expect(doc.spec?.styles ?? []).toEqual([])
+ const scene = toSceneGraph(doc, { resolveAlias })
+ expect(scene.graph.getNode('Checkbox')!.type).toBe('COMPONENT_SET')
+ const set = scene.graph.getNode('Checkbox')!
+ const names = (set.childIds as string[]).map((id) => id.slice('Checkbox#'.length))
+ expect(names.some((name) => name.includes('state=checked'))).toBe(true)
+ })
+
+ it('draws each row in the state its sample says', () => {
+ const checkbox = parseOrThrow(CHECKBOX_SOURCE)
+ const index = componentIndex(checkbox, parseOrThrow(LIST))
+ const scene = toSceneGraph(parseOrThrow(LIST), {
+ resolveAlias,
+ resolveComponent: (name) => index.get(name),
+ })
+ // `state="checked"` shows the indicator; the default hides it.
+ expect(scene.graph.getNode('Todo#row/root/check')!.visible).toBe(true)
+ expect(scene.graph.getNode('Todo#row-2/root/check')!.visible).toBe(false)
+ })
+})
+
describe('auditDesignSystem', () => {
const codes = (source: string) => auditDesignSystem(parseOrThrow(source)).map((d) => d.code)
diff --git a/packages/viewer/src/App.vue b/packages/viewer/src/App.vue
index de5a593..0a263ce 100644
--- a/packages/viewer/src/App.vue
+++ b/packages/viewer/src/App.vue
@@ -1727,7 +1727,6 @@ onUnmounted(() => socket.close())
:doc="sceneDoc"
:selection="railSelection"
:components="components"
- :models="models"
:vector-editing="vertexEditing"
:writable="connection === 'open'"
@edit-vector="editVector"
@@ -1825,6 +1824,7 @@ onUnmounted(() => socket.close())
:placing="placing"
:writable="connection === 'open'"
:components="components"
+ :models="models"
@selection="selection = $event"
@patches="onCanvasPatches"
@moved="onMoved"
diff --git a/packages/viewer/src/contract-edits.ts b/packages/viewer/src/contract-edits.ts
index eeca83c..3675072 100644
--- a/packages/viewer/src/contract-edits.ts
+++ b/packages/viewer/src/contract-edits.ts
@@ -336,9 +336,10 @@ function enclosingRepeats(component: UidxNode, node: UidxNode, models?: ModelInd
}
/**
- * The lists a repeat on this layer may walk (ADR 0017 §2): the contract's
- * list props as `items`, and the list fields of every enclosing item as
- * `person.tags` — what a tree or a grouped list nests on.
+ * The lists a repeat on this layer may walk (ADR 0017 §2): the list fields
+ * of every enclosing item as `person.tags`, nearest first — what a tree or
+ * a grouped list nests on, and the likeliest answer for a layer inside a
+ * row — then the contract's list props as `items`.
*/
export function placeableLists(
component: UidxNode | null,
@@ -347,11 +348,11 @@ export function placeableLists(
): string[] {
if (!component) return []
const out: string[] = []
- for (const prop of component.spec?.contract?.props ?? [])
- if (prop.type.trim().endsWith('[]')) out.push(prop.name)
- for (const scope of enclosingRepeats(component, node, models))
+ for (const scope of [...enclosingRepeats(component, node, models)].reverse())
for (const field of scope.model?.fields ?? [])
if (field.type.trim().endsWith('[]')) out.push(`${scope.as}.${field.name}`)
+ for (const prop of component.spec?.contract?.props ?? [])
+ if (prop.type.trim().endsWith('[]')) out.push(prop.name)
return out
}
diff --git a/packages/viewer/src/derived-edits.ts b/packages/viewer/src/derived-edits.ts
index 69de592..be2dfa3 100644
--- a/packages/viewer/src/derived-edits.ts
+++ b/packages/viewer/src/derived-edits.ts
@@ -15,8 +15,13 @@ import { derivedTarget, type DerivedTarget } from '@uidx/schema'
* Pure, like every edit helper here: patches in, patches or a refusal out.
*/
-/** Properties a state may not change: where a variant sits is the arrangement's. */
-const NOT_A_STYLE: ReadonlySet = new Set(['x', 'y', 'rotation', 'name', 'part'])
+/**
+ * Properties a state may not change: where a variant sits is the arrangement's,
+ * and what a layer is named or binds is the base tree's. Rotation is a look —
+ * a chevron turns when a row opens, and the CSS target renders it as a
+ * transform — so a state may set it.
+ */
+const NOT_A_STYLE: ReadonlySet = new Set(['x', 'y', 'name', 'part'])
export type Routed = { patches: UidxPatch[] } | { refused: string }
diff --git a/packages/viewer/src/editable.ts b/packages/viewer/src/editable.ts
index f832898..d0a72d1 100644
--- a/packages/viewer/src/editable.ts
+++ b/packages/viewer/src/editable.ts
@@ -340,8 +340,18 @@ export function editableProps(node: UidxNode, parent: UidxNode | null = null): E
// default-shaped list would offer a paint the file does not have.
// A loose edge shows a dash, not a number: the pin does not hold it, so
// any value would be an offer the resolver ignores.
+ // A component that states a size is a fixed box, laid out free; the
+ // hugging column `componentSizing` gives a sizeless one is not its
+ // default, and showing Column pressed would make the click that means
+ // "make it a column" write nothing.
value:
- looseEdge || LIST_CONTROLS.has(ui.control) ? null : (defaultFor(element, name) ?? null),
+ looseEdge || LIST_CONTROLS.has(ui.control)
+ ? null
+ : name === 'layoutMode' &&
+ element === 'Component' &&
+ (node.attrs.width !== undefined || node.attrs.height !== undefined)
+ ? 'NONE'
+ : (defaultFor(element, name) ?? null),
raw: '',
boundTo: null,
readonlyReason: looseEdge ? LOOSE_EDGE_REASON : (shapeReason ?? reasonFor(name)),
diff --git a/packages/viewer/src/instance-prop-edits.ts b/packages/viewer/src/instance-prop-edits.ts
index d562818..91ccdb2 100644
--- a/packages/viewer/src/instance-prop-edits.ts
+++ b/packages/viewer/src/instance-prop-edits.ts
@@ -9,6 +9,7 @@ import {
type UidxDocument,
type UidxNode,
type UidxPatch,
+ aliasTarget,
} from '@uidx/format'
/**
@@ -220,6 +221,9 @@ export function unusedInstanceProps(
}
continue
}
+ // `{item.expanded}` is a binding to the enclosing item (ADR 0017 §2), not
+ // a value of the wrong type: the Contract tab shows what it receives.
+ if (typeof value === 'string' && aliasTarget(value) !== null) continue
const declaration = declared.get(name)
if (!declaration) out.push({ name, reason: 'undeclared', value })
else if (!matchesType(declaration.type, value)) out.push({ name, reason: 'mistyped', value })
diff --git a/packages/viewer/src/repeat-edits.ts b/packages/viewer/src/repeat-edits.ts
index fc570bc..289e9dd 100644
--- a/packages/viewer/src/repeat-edits.ts
+++ b/packages/viewer/src/repeat-edits.ts
@@ -17,6 +17,21 @@ export interface RepeatTarget {
component: UidxNode
/** The first list the layer may walk. */
list: string
+ /**
+ * The item's name when the default `item` would hide an enclosing item:
+ * `child` for `{item.children}`, `tag` for `{item.tags}`. Absent at the
+ * top level, where `item` is the name and is not written.
+ */
+ as?: string
+}
+
+/** `children` → `child`, `tags` → `tag`, `entries` → `entry`; the last segment of the list. */
+function singular(list: string): string {
+ const last = list.split('.').at(-1) ?? 'item'
+ if (last === 'children') return 'child'
+ if (last.endsWith('ies')) return `${last.slice(0, -3)}y`
+ if (last.endsWith('s') && last.length > 2) return last.slice(0, -1)
+ return `${last}Item`
}
/** Elements that are structure rather than layers: a repeat rides on what they hold. */
@@ -34,7 +49,22 @@ export function repeatTargetFor(
const component = enclosingComponent(doc, address)
if (!component) return null
const [list] = placeableLists(component, node, models)
- return list ? { node, component, list } : null
+ if (!list) return null
+ // Nested in another repeat (the list is an item's field), the row needs a
+ // name of its own; `item` would hide the outer item's bindings.
+ if (!list.includes('.')) return { node, component, list }
+ const taken = new Set()
+ const walk = (current: UidxNode): void => {
+ if (current.attrs.repeat !== undefined) {
+ const as = current.attrs.as?.value
+ taken.add(typeof as === 'string' && as !== '' ? as : 'item')
+ }
+ for (const child of current.children) walk(child)
+ }
+ walk(component)
+ let as = singular(list)
+ for (let n = 2; taken.has(as); n++) as = `${singular(list)}${n}`
+ return { node, component, list, as }
}
/**
@@ -52,6 +82,9 @@ export function newRepeatFor(
return {
patches: [
{ op: 'add', address: target.node.address, prop: 'repeat', value: toAlias(target.list) },
+ ...(target.as
+ ? [{ op: 'add' as const, address: target.node.address, prop: 'as', value: target.as }]
+ : []),
],
address: target.node.address,
}
diff --git a/packages/viewer/test/derived-edits.test.ts b/packages/viewer/test/derived-edits.test.ts
index a12f803..73e8368 100644
--- a/packages/viewer/test/derived-edits.test.ts
+++ b/packages/viewer/test/derived-edits.test.ts
@@ -93,6 +93,16 @@ describe('routing an edit on a derived variant', () => {
expect(
routeDerivedPatches(doc, [{ op: 'remove-node', address: 'Checkbox#state=hover/root/ring' }]),
).toEqual({ refused: expect.stringContaining('drawn from the base tree') })
+ // Rotation is a look: a chevron turns when a row opens, and CSS renders it.
+ expect(
+ routeDerivedPatches(doc, [
+ { op: 'set', address: 'Checkbox#state=hover/root/ring', prop: 'rotation', value: 90 },
+ ]),
+ ).toEqual({
+ patches: [
+ { op: 'style', keys: { state: 'hover' }, target: 'ring', prop: 'rotation', value: 90 },
+ ],
+ })
})
it('round-trips: the routed edit lands in the file as a row cell', () => {
diff --git a/packages/viewer/test/editable.test.ts b/packages/viewer/test/editable.test.ts
index 92cfc24..f5af151 100644
--- a/packages/viewer/test/editable.test.ts
+++ b/packages/viewer/test/editable.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { parseOrThrow, type UidxNode } from '@uidx/format'
+import { parseOrThrow, resolve, type UidxNode } from '@uidx/format'
import { editableProps, isMapped, parentOf, sectionsFor, selectedNode } from '../src/editable'
const PAGE = parseOrThrow(`---
@@ -39,6 +39,19 @@ const node = (address: string): UidxNode => selectedNode(PAGE.tree, [address])!
const field = (address: string, name: string) =>
editableProps(node(address)).find((f) => f.name === name)!
+describe('a component that states a size', () => {
+ it('shows Free as its layout mode, so choosing Column writes it', () => {
+ const doc = parseOrThrow(
+ `---\nid: p\n---\n\n## Visual Contract\n\n\n \n \n \n`,
+ )
+ const mode = (name: string) =>
+ editableProps(resolve(doc.tree, name)!).find((field) => field.name === 'layoutMode')?.value
+ expect(mode('Box')).toBe('NONE')
+ // A sizeless component hugs a column (`componentSizing`), and says so.
+ expect(mode('Hug')).toBe('VERTICAL')
+ })
+})
+
describe('selectedNode', () => {
it('finds a node by address', () => {
expect(node('Card#root').name).toBe('root')
diff --git a/packages/viewer/test/repeat-edits.test.ts b/packages/viewer/test/repeat-edits.test.ts
index 24904ca..f5eb66e 100644
--- a/packages/viewer/test/repeat-edits.test.ts
+++ b/packages/viewer/test/repeat-edits.test.ts
@@ -65,8 +65,11 @@ describe('where a repeat may go', () => {
),
)
expect(repeatTargetFor(repeated, ['List#group'])).toBeNull()
- // Inside a repeat the item's own lists are on offer, after the contract's.
- expect(repeatTargetFor(repeated, ['List#group/tag'])).toMatchObject({ list: 'items' })
+ // Inside a repeat the item's own lists come first: a nested layer walks the item.
+ expect(repeatTargetFor(repeated, ['List#group/tag'])).toMatchObject({
+ list: 'item.tags',
+ as: 'tag',
+ })
const noList = parseOrThrow(
page(
` `,
From f846d4b7e7a62de7b655ddd14e818363f5fc9f80 Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 22:23:21 +0300
Subject: [PATCH 22/74] For designers who never open the file: errors surface,
any field binds
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A page that cannot be drawn keeps its last frame and says why in a
banner instead of going blank. A number field commits on Enter (by key,
so the numpad's too). Declaring a prop offers its type, and a boolean
comes with default={false}. A checkbox, a number and the visibility pill
bind to an enclosing item's field or a prop from a picker beside the
control, the way a text already did; any {…} committed from the
inspector is written as the binding it is rather than as a value.
The visibility pill's picker previously listed only declared BOOLEAN
props, so a tree's children frame had no way to follow item.expanded
from the inspector.
Co-Authored-By: Claude Fable 5.1
---
CHANGELOG.md | 7 ++
docs/decisions/0013-component-contract.md | 7 +-
packages/viewer/src/CanvasPane.vue | 6 ++
packages/viewer/src/ContractSection.vue | 35 +++++++-
packages/viewer/src/PropertiesPane.vue | 76 ++++++++++++++--
packages/viewer/src/PropertyField.vue | 39 ++++++++-
packages/viewer/src/contract-edits.ts | 46 +++++++---
packages/viewer/test/contract-edits.test.ts | 96 +++++++++++++++++++++
8 files changed, 286 insertions(+), 26 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6708472..ff6f171 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -64,6 +64,13 @@ describe migrations when an existing document or integration is affected.
the instance would sit inside. Removing the last declaration removes the
empty `## Contract` heading; `uidx codegen` takes directories as `check`
does.
+- For designers who never open the file: a page that cannot be drawn keeps
+ its last frame and says why in a banner instead of going blank; a number
+ field commits on Enter as well as on leaving it; declaring a prop offers
+ its type, and a boolean comes with `default={false}`; a checkbox, a
+ number or the visibility pill binds to an item's field or a prop from a
+ picker beside it, as a text does, and any `{…}` committed from the inspector is
+ written as the binding it is.
- From a second walkthrough, a tree with a disclosure chevron: a visual prop
draws its state set at once, rows or no rows, so the first style row is
written by selecting the state on the canvas rather than by hand; rotation
diff --git a/docs/decisions/0013-component-contract.md b/docs/decisions/0013-component-contract.md
index 485abdd..15bc105 100644
--- a/docs/decisions/0013-component-contract.md
+++ b/docs/decisions/0013-component-contract.md
@@ -84,9 +84,10 @@ shorthand rule of the visual contract does not apply here.
layer; binding it from a new layer unbinds the old one, since one node per
part is the rule. Any layer inside a component chooses the list it repeats
over from the contract's list props and the list fields of enclosing items.
- A text binds to a field of an enclosing item or a prop of the component
- from a picker beside its content, and a typed `{…}` in the content is
- written as the binding it is. A `` in the tree the contract does not
+ A field binds to a field of an enclosing item or a prop of the component
+ from a picker beside its control — a text to words, a checkbox to a
+ boolean, a number to a number — and a typed `{…}` is written as the
+ binding it is. A `` in the tree the contract does not
declare is listed beside the declared ones, one click from declared. The
tab never edits the contract's prose; it shows it beside the bindings.
When `uidx.json` names no library, the tab offers those the project's
diff --git a/packages/viewer/src/CanvasPane.vue b/packages/viewer/src/CanvasPane.vue
index 2a546a3..f829150 100644
--- a/packages/viewer/src/CanvasPane.vue
+++ b/packages/viewer/src/CanvasPane.vue
@@ -1157,6 +1157,12 @@ function render(doc: UidxDocument | null, rebuild = false): void {
// `replaceGraph` drops the editor's selection, and a rebuild is not the
// author deselecting anything — the shell still holds what they picked.
applySelection(props.selection ?? [])
+ } catch (error) {
+ // A page that cannot be drawn says so and keeps the last frame it drew,
+ // rather than going blank with the reason in a console nobody has open.
+ // The file is still the file; the next edit tries again.
+ const reason = error instanceof Error ? error.message : String(error)
+ emit('notice', `The page could not be drawn (${reason}). The last drawn state is kept.`)
} finally {
applyingRemote = false
// The file's new state is on the canvas — including the file's values for
diff --git a/packages/viewer/src/ContractSection.vue b/packages/viewer/src/ContractSection.vue
index 45cfe07..4cac3e0 100644
--- a/packages/viewer/src/ContractSection.vue
+++ b/packages/viewer/src/ContractSection.vue
@@ -213,6 +213,11 @@ function redeclare(
if (value === undefined || value === false) delete attrs[key]
else attrs[key] = value
}
+ // A boolean prop is an axis the moment it is visual (ADR 0016 §1), and an
+ // axis needs a default to order its values by; one retyped to boolean
+ // gets `false` unless the form said otherwise.
+ if (kind === 'prop' && change.attrs?.type === 'boolean' && attrs.default === undefined)
+ attrs.default = false
const description =
change.description === undefined || change.description === ''
? current.description
@@ -225,11 +230,19 @@ function remove(kind: ContractKind, name: string): void {
if (open.value === `${kind}:${name}`) open.value = null
}
+/** The type a new prop gets; a boolean is a state the moment it is visual, so it comes with a default. */
+const addType = ref<'string' | 'number' | 'boolean'>('string')
+
function add(): void {
const name = addName.value.trim()
if (!name) return
const kind = addKind.value
- const attrs: Record = kind === 'prop' ? { type: 'string' } : {}
+ const attrs: Record =
+ kind === 'prop'
+ ? addType.value === 'boolean'
+ ? { type: 'boolean', default: false }
+ : { type: addType.value }
+ : {}
send(declare(kind, name, { attrs, description: `${PLACEHOLDER}the ${kind} "${name}".` }))
addName.value = ''
open.value = `${kind}:${name}`
@@ -846,7 +859,7 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>
-
+
Prop
Event
@@ -862,6 +875,18 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>
placeholder="name"
@keydown.enter="add"
/>
+
+ text
+ number
+ boolean
+
b.alias === name)) {
+ const op = node.attrs[prop] === undefined ? 'add' : 'set'
+ emit('patches', [{ op, address: node.address, prop, value: toAlias(name) }])
+ }
+}
+
+/** The enclosing repeats' fields of one kind — `item.expanded`, never a bare prop. */
+function itemFieldBindings(
+ node: UidxNode,
+ kind: 'boolean' | 'number' | 'text',
+): { alias: string; label: string }[] {
+ if (!props.doc) return []
+ return fieldBindingCandidates(
+ enclosingComponent(props.doc, node.address),
+ node,
+ kind,
+ props.models,
+ ).filter((b) => b.alias.includes('.'))
}
/**
@@ -1212,6 +1250,30 @@ const textBindings = computed(() => {
return textBindingCandidates(enclosingComponent(props.doc, node.address), node, props.models)
})
+/**
+ * What any field may bind to, by the kind of value it takes: a checkbox
+ * takes an item's booleans (`visible="{item.done}"`), a number its numbers.
+ * Structural and paint fields take none; the token pill is theirs.
+ */
+/** Paint fields route through their own alias path below; a fill alias is a colour, not a binding. */
+const PAINT_PROPS: ReadonlySet = new Set(['fills', 'strokes'])
+
+function bindingsFor(field: EditableProp): { alias: string; label: string }[] | undefined {
+ const node = active.value
+ if (!node || !props.doc) return undefined
+ if (field.name === 'characters') return textBindings.value
+ const kind =
+ field.control === 'boolean' ? 'boolean' : field.control === 'number' ? 'number' : null
+ if (!kind || STRUCTURAL_PROPS.includes(field.name)) return undefined
+ const found = fieldBindingCandidates(
+ enclosingComponent(props.doc, node.address),
+ node,
+ kind,
+ props.models,
+ )
+ return found.length ? found : undefined
+}
+
function onCommit(prop: string, value: JsonValue): void {
if (!active.value) return
const address = active.value.address
@@ -1221,7 +1283,7 @@ function onCommit(prop: string, value: JsonValue): void {
* the sample already shows. It goes straight to the file, as a fill's
* alias does below.
*/
- if (prop === 'characters' && isAlias(value)) {
+ if (isAlias(value) && !PAINT_PROPS.has(prop)) {
preview.value = null
emit('patches', [
{ op: active.value.attrs[prop] === undefined ? 'add' : 'set', address, prop, value },
@@ -1858,7 +1920,7 @@ function onDetach(prop: string, value: JsonValue): void {
:editable="editable(paired.field)"
:swatches="swatches"
:candidates="candidatesFor(paired.field)"
- :bindings="paired.field.name === 'characters' ? textBindings : undefined"
+ :bindings="bindingsFor(paired.field)"
:variables="variablesFor(paired.field)"
:component-name="componentName"
:tokens="tokens"
diff --git a/packages/viewer/src/PropertyField.vue b/packages/viewer/src/PropertyField.vue
index fc6fd33..cf068e5 100644
--- a/packages/viewer/src/PropertyField.vue
+++ b/packages/viewer/src/PropertyField.vue
@@ -44,7 +44,7 @@ const props = defineProps<{
* all; empty is "nothing declared yet", which still offers Create.
*/
candidates?: { name: string; declaration: { type: string; default: JsonValue } }[] | null
- /** What a text may bind to, offered beside Content; absent for anything but a text. */
+ /** What this field may bind to — an item's field, a prop — offered beside the control. */
bindings?: { alias: string; label: string }[]
/**
* What a bound *number* row displays while scrubbing — number-typed
@@ -349,6 +349,7 @@ const segmented = computed(
:disabled="!editable"
@input="actions.input($event)"
@keydown="actions.keydown($event)"
+ @keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
@blur="actions.commitEdit($event)"
/>
@@ -369,6 +370,20 @@ const segmented = computed(
@change="selectUnit"
/>
+
+ { }
+
+ {{ '{' + binding.alias + '}' }} — {{ binding.label }}
+
+
{{ field.label }}
+
+
+ { }
+
+ {{ '{' + binding.alias + '}' }} — {{ binding.label }}
+
+
@@ -642,6 +672,13 @@ const segmented = computed(
font-size: 11px;
color: var(--text-dim);
}
+.bind.narrow {
+ margin-top: 0;
+ margin-left: 4px;
+ max-width: 3.2em;
+ padding: 2px 2px;
+ flex: none;
+}
.number {
display: flex;
align-items: center;
diff --git a/packages/viewer/src/contract-edits.ts b/packages/viewer/src/contract-edits.ts
index 3675072..d080d74 100644
--- a/packages/viewer/src/contract-edits.ts
+++ b/packages/viewer/src/contract-edits.ts
@@ -490,47 +490,67 @@ export function moveRepeatOnto(container: UidxNode, row: UidxNode): UidxPatch[]
return out
}
+/** What kind of value a field takes, for offering bindings of that kind. */
+export type BindingKind = 'text' | 'number' | 'boolean'
+
+/** Whether a declared type (`string`, `number`, `boolean`, `'a' | 'b'`) fits a field of this kind. */
+function fits(type: string, kind: BindingKind): boolean {
+ const text = type.trim()
+ if (kind === 'boolean') return text === 'boolean'
+ if (kind === 'number') return text === 'number'
+ return text !== 'boolean' && !text.endsWith('[]')
+}
+
/**
- * What a text may bind to from the layer (ADR 0015 §2): the fields of every
- * enclosing item, and the component's own props — anything a `{…}` in
- * `characters` could name. Empty outside a component.
+ * What a field may bind to from the layer (ADR 0015 §2): the fields of every
+ * enclosing item, a model prop's fields one level deep, and the component's
+ * own props — anything a `{…}` in the attribute could name, of the kind the
+ * field takes. A text takes words and numbers; a checkbox takes booleans; a
+ * number takes numbers. Empty outside a component.
*/
-export function textBindingCandidates(
+export function fieldBindingCandidates(
component: UidxNode | null,
node: UidxNode,
+ kind: BindingKind,
models?: ModelIndex,
): { alias: string; label: string }[] {
if (!component) return []
const out: { alias: string; label: string }[] = []
+ const offer = (alias: string, type: string): void => {
+ if (fits(type, kind)) out.push({ alias, label: `${alias} · ${type.trim()}` })
+ }
for (const scope of [...enclosingRepeats(component, node, models)].reverse()) {
for (const field of scope.model?.fields ?? []) {
if (modelByRef(component.spec, typeName(field.type).name, models)) continue
- out.push({
- alias: `${scope.as}.${field.name}`,
- label: `${scope.as}.${field.name} · ${field.type}`,
- })
+ offer(`${scope.as}.${field.name}`, field.type)
}
}
for (const prop of component.spec?.contract?.props ?? []) {
const { name, list } = typeName(prop.type)
const model = modelByRef(component.spec, name, models)
if (!model) {
- out.push({ alias: prop.name, label: `${prop.name} · ${prop.type}` })
+ offer(prop.name, prop.type)
continue
}
// A model prop's fields, one level deep (ADR 0015 §2): `{node.label}`.
if (list) continue
for (const field of model.fields) {
if (modelByRef(component.spec, typeName(field.type).name, models)) continue
- out.push({
- alias: `${prop.name}.${field.name}`,
- label: `${prop.name}.${field.name} · ${field.type}`,
- })
+ offer(`${prop.name}.${field.name}`, field.type)
}
}
return out
}
+/** The text form of `fieldBindingCandidates`, kept for the Content field. */
+export function textBindingCandidates(
+ component: UidxNode | null,
+ node: UidxNode,
+ models?: ModelIndex,
+): { alias: string; label: string }[] {
+ return fieldBindingCandidates(component, node, 'text', models)
+}
+
/**
* The tab's view of the selection.
*
diff --git a/packages/viewer/test/contract-edits.test.ts b/packages/viewer/test/contract-edits.test.ts
index 4c6c2a5..33c25e9 100644
--- a/packages/viewer/test/contract-edits.test.ts
+++ b/packages/viewer/test/contract-edits.test.ts
@@ -12,6 +12,7 @@ import {
scaffoldFromLibrary,
setImplements,
setPart,
+ fieldBindingCandidates,
moveRepeatOnto,
setReceives,
setRepeat,
@@ -354,6 +355,24 @@ describe('what the tab shows', () => {
).toEqual(['item.id', 'item.label', 'item.depth'])
})
+ it('offers a checkbox the booleans in scope and a number the numbers, and nothing else', () => {
+ const row = parseOrThrow(TREE)
+ const models = modelIndex([row, parseOrThrow(ROW_PAGE)])
+ const inTree = resolve(row.tree, 'Tree#node/children')!
+ const tree = resolve(row.tree, 'Tree')
+ expect(fieldBindingCandidates(tree, inTree, 'boolean', models).map((b) => b.alias)).toEqual([])
+ expect(fieldBindingCandidates(tree, inTree, 'number', models).map((b) => b.alias)).toEqual([
+ 'item.depth',
+ ])
+ const item = parseOrThrow(ROW_PAGE)
+ const label = resolve(item.tree, 'TreeItem#label')!
+ expect(
+ fieldBindingCandidates(resolve(item.tree, 'TreeItem'), label, 'number', models).map(
+ (b) => b.alias,
+ ),
+ ).toEqual(['node.depth', 'depth'])
+ })
+
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')
@@ -560,6 +579,28 @@ describe('the Contract section', () => {
expect(instance.find('[data-field="repeat"]').attributes('data-set')).toBe('false')
})
+ it('declares a boolean prop with its default, so the state axis is well formed', async () => {
+ const section = mountFor(CHECKBOX, 'Checkbox')
+ await section
+ .find('[data-field="add-declaration"] select[aria-label="Type to add"]')
+ .setValue('boolean')
+ await section.find('[data-field="add-declaration"] input').setValue('open')
+ await section.find('[aria-label="Add declaration"]').trigger('click')
+ expect(section.emitted('patches')!.at(-1)).toEqual([
+ [
+ {
+ op: 'contract',
+ kind: 'prop',
+ name: 'open',
+ declaration: {
+ attrs: { type: 'boolean', default: false },
+ description: 'Describe the prop "open".',
+ },
+ },
+ ],
+ ])
+ })
+
it('goes read-only with the socket', () => {
const section = mountFor(CHECKBOX, 'Checkbox', false)
expect(section.find('[data-field="implements"] select').attributes('disabled')).toBeDefined()
@@ -782,3 +823,58 @@ describe('the inspector tabs', () => {
expect(pane.emitted('select')).toEqual([['Checkbox#check']])
})
})
+
+/**
+ * A tree's children frame shows only while its node is open. Visibility is
+ * a header pill, not a checkbox row, so the pill has to offer the enclosing
+ * item's booleans as the body's checkboxes do — and write the alias itself,
+ * since `item.expanded` is no declared prop.
+ */
+describe('visibility follows an item field from the Appearance pill', () => {
+ const OPEN_TREE = page(
+ 'open-tree',
+ `
+
+
+
+ `,
+ `
+## Contract
+
+
+ The top-level nodes.
+
+
+## Models
+
+
+ One node.
+ Identity.
+ Open?
+
+`,
+ )
+
+ it('offers the item boolean and writes it as the binding it is', async () => {
+ const doc = parseOrThrow(OPEN_TREE)
+ const pane = mount(PropertiesPane, {
+ props: {
+ doc,
+ selection: ['Tree#node/children'],
+ writable: true,
+ headless: LIBRARY,
+ models: modelIndex([doc]),
+ },
+ })
+ const header = pane
+ .findAll('.section')
+ .find((s) => s.find('.section-title').text() === 'Appearance')!
+ await header.find('.section-link .apply-property').trigger('click')
+ const rows = pane.findAll('.assign-popup .popup-row:not(.variable-row)')
+ expect(rows.map((r) => r.find('.row-name').text())).toEqual(['item.expanded'])
+ await rows[0]!.trigger('click')
+ expect(pane.emitted('patches')?.[0]?.[0]).toEqual([
+ { op: 'add', address: 'Tree#node/children', prop: 'visible', value: '{item.expanded}' },
+ ])
+ })
+})
From 5109be43a43a510584138d1f5e0b6ccb429e59b8 Mon Sep 17 00:00:00 2001
From: Guy Behar
Date: Wed, 30 Sep 2026 23:21:56 +0300
Subject: [PATCH 23/74] A designer's first hour, measured against Builder,
Plasmic, Framer, Webflow, Figma and Penpot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Text is typed where it is drawn: the Text tool opens the new node for
typing as soon as the file echoes it, a double-click on a text reopens
it, and Escape or ⌘Enter keeps the words as one characters write. A
right-click selects what is under the pointer and opens the toolbar's
actions with their shortcuts, disabled where the selection cannot do
them. The status strip says "Saving…" while edits are in flight and
"All changes saved" after. An empty page says how to start. The
"Inside …" breadcrumb names the container by its current name rather
than the id it was entered by.
The Contract tab leads with properties and slots; the code binding and
the code library fold under "optional · for developers". The overview
counts pages "with content" and "problems" instead of "drawable" and
"parse"; the file-error overlay leads with plain words and keeps the
diagnostic beneath; the Models face explains itself without jargon.
Co-Authored-By: Claude Fable 5.1
---
CHANGELOG.md | 11 +
packages/viewer/src/App.vue | 67 ++-
packages/viewer/src/CanvasPane.vue | 291 +++++++++-
packages/viewer/src/ContextMenu.vue | 145 +++++
packages/viewer/src/ContractSection.vue | 505 ++++++++++--------
packages/viewer/src/HomePane.vue | 9 +-
packages/viewer/src/ModelsPane.vue | 12 +-
packages/viewer/src/PageThumb.vue | 7 +-
packages/viewer/src/WorkspaceStatus.vue | 45 ++
packages/viewer/src/useCanvasControls.ts | 9 +
packages/viewer/test/canvas-controls.test.ts | 21 +-
packages/viewer/test/context-menu.test.ts | 48 ++
packages/viewer/test/contract-edits.test.ts | 19 +-
packages/viewer/test/home-pane.test.ts | 4 +-
packages/viewer/test/workspace-status.test.ts | 33 ++
15 files changed, 970 insertions(+), 256 deletions(-)
create mode 100644 packages/viewer/src/ContextMenu.vue
create mode 100644 packages/viewer/test/context-menu.test.ts
create mode 100644 packages/viewer/test/workspace-status.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff6f171..f5e4ff5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -64,6 +64,17 @@ describe migrations when an existing document or integration is affected.
the instance would sit inside. Removing the last declaration removes the
empty `## Contract` heading; `uidx codegen` takes directories as `check`
does.
+- Reviewed against Builder.io, Plasmic, Framer, Webflow, Figma and Penpot,
+ for a designer's first hour: a text is typed where it is drawn (the Text
+ tool opens it for typing, a double-click reopens it; Escape or ⌘Enter
+ keeps the words); a right-click menu offers the toolbar's actions with
+ their shortcuts; the status strip says "Saving…" and "All changes saved";
+ an empty page says how to start; the "Inside …" breadcrumb names the
+ container by its current name; the Contract tab leads with properties
+ and slots and folds the code binding and code library under "for
+ developers"; the overview counts pages "with content" and "problems"
+ instead of "drawable" and "parse"; the file-error overlay leads with what
+ happened in plain words; the Models face explains itself without jargon.
- For designers who never open the file: a page that cannot be drawn keeps
its last frame and says why in a banner instead of going blank; a number
field commits on Enter as well as on leaving it; declaring a prop offers
diff --git a/packages/viewer/src/App.vue b/packages/viewer/src/App.vue
index 0a263ce..ec108a2 100644
--- a/packages/viewer/src/App.vue
+++ b/packages/viewer/src/App.vue
@@ -62,6 +62,7 @@ import {
} from './headless'
import { routeDerivedPatches } from './derived-edits'
import WorkspaceStatus from './WorkspaceStatus.vue'
+import ContextMenu, { type MenuItem } from './ContextMenu.vue'
import { canRemove, componentFrom, remapAddress } from './layer-moves'
import { componentRenamePlan, offerableComponents } from './component-rename'
import { enclosingComponent } from './component-prop-edits'
@@ -422,6 +423,55 @@ function dismissNotice(): void {
editorNotice.value = null
}
+/**
+ * What the status strip says about the file: edits in flight, or all of
+ * them landed. Every peer tool shows this — Webflow's check mark, Penpot's
+ * file status — because a designer who cannot see the file needs to know
+ * their last change reached it. Counted, not flagged: two quick edits are
+ * two confirmations.
+ */
+const savesInFlight = ref(0)
+const savedOnce = ref(false)
+function saveStarted(): void {
+ savesInFlight.value += 1
+}
+function saveSettled(): void {
+ savesInFlight.value = Math.max(0, savesInFlight.value - 1)
+ savedOnce.value = true
+}
+
+/**
+ * The right-click menu: the toolbar's actions, at the pointer, with their
+ * shortcuts beside them.
+ */
+const menu = ref<{ x: number; y: number } | null>(null)
+const menuItems = (): MenuItem[] => [
+ {
+ label: 'Make component',
+ shortcut: '⌘⌥K',
+ disabled: componentSource.value === null,
+ run: startMakeComponent,
+ },
+ {
+ label: 'Place instance…',
+ disabled: components.value.size === 0,
+ run: () => (picking.value = true),
+ },
+ { label: 'New slot', disabled: slotTarget.value === null, run: addSlot },
+ { label: 'Repeat over a list', disabled: repeatTarget.value === null, run: addRepeat },
+ { kind: 'separator' },
+ { label: 'Undo', shortcut: '⌘Z', disabled: !undoStack.canUndo, run: undo },
+ { label: 'Redo', shortcut: '⇧⌘Z', disabled: !undoStack.canRedo, run: redo },
+ { kind: 'separator' },
+ {
+ label: 'Delete',
+ shortcut: '⌫',
+ danger: true,
+ disabled: deletable.value === null,
+ run: removeSelection,
+ },
+]
+
const socket = createUidxSocket({
onState: (state) => {
connection.value = state
@@ -522,6 +572,7 @@ const socket = createUidxSocket({
if (message.patchId) {
// Our own write landing: the prediction is now the document.
inFlight.settle(message.patchId)
+ if (own) saveSettled()
historyPatchIds.delete(message.patchId)
const pending = pendingInverse.get(message.patchId)
if (pending) {
@@ -1054,7 +1105,10 @@ const channel = createPatchChannel({
doc.value = null
doc.value = authoritative
},
- onNotice: (next) => (patchNotice.value = next),
+ onNotice: (next) => {
+ patchNotice.value = next
+ if (next?.kind === 'rejected') saveSettled()
+ },
/**
* E4. The file is the source of truth, so it changing underneath an edit is
* routine, not an error. The stale answer always arrives after the
@@ -1171,6 +1225,7 @@ function dispatch(file: string, patches: readonly UidxPatch[], record: boolean):
}
const patchId = channel.dispatch(file, at, patches, base)
if (!patchId) return null
+ saveStarted()
if (file === entry.value) inFlight.push(patchId, patches)
if (!record) historyPatchIds.add(patchId)
else if (inverse) undoStack.pushAuthor(file, [...patches], inverse, labelFor(patches))
@@ -1650,6 +1705,8 @@ onUnmounted(() => socket.close())
+
+
socket.close())
@moved="onMoved"
/>
-
+
+
+
+
+
+
This page is empty
+
+ Press F and drag to draw a frame, T to place text, or R for
+ a rectangle. Right-click a layer for more.
+
+
unwatchGraph?.())
-
{{ diagnostics.length }} problem{{ diagnostics.length === 1 ? '' : 's' }}
+
+ The file has {{ diagnostics.length }} problem{{ diagnostics.length === 1 ? '' : 's' }} the
+ canvas cannot draw
+
+
+ {{ hasRendered ? 'This is the last good version.' : 'Nothing has been drawn yet.' }}
+ Fix the file where it says below — or undo the last change to it — and the canvas recovers
+ on its own.
+
{{ d.line }}:{{ d.column }}
@@ -2078,10 +2299,6 @@ onUnmounted(() => unwatchGraph?.())
{{ d.message }}
-
- {{ hasRendered ? 'showing the last valid render' : 'nothing has rendered yet' }} — fix the
- file and it recovers on its own
-
- Select a component to choose the headless element it implements, or a layer inside one to
- name the part it draws.
+ Select a component to declare what its instances can change — properties, slots and states —
+ or a layer inside one to name the part it draws.
- Implements
- {{ view.component.name }}
-
-
-
- Element
-
-
- None
-
- {{ option.tag }}{{ option.known ? '' : ' · not in library' }}
-
-
-
-
- ↺
-
-
-
-
- Rendered as {{ boundTag }} — uidx.json binds this component to it.
-
-
-
- Attributes: {{ view.element.attributes.join(', ') }}.
-
-
- Events: {{ view.element.events.join(', ') }}.
-
-
-
-
- Parts
- {{ boundCount }} of {{ view.parts.length }} bound
-
-
-
- Choose an element above to see the parts it offers.
-
-
- “{{ view.implementsValue }}” is not in the library, and the contract declares no parts.
-
- “{{ view.implementsValue }}” has no parts to bind.
-
-
-
-
- {{ row.name }}
- ?
- shadow
-
-
-
-
-
- {{ row.boundTo.name }}
-
-
-
- {{ view.candidates.length ? 'Bind a layer…' : 'No layer to bind' }}
-
-
- {{ indent(c.depth) }}{{ c.name }}
-
-
-
- ×
-
-
-
-
- “{{ stray.name }}” is bound to “{{ stray.part }}”, which nothing declares.
-
- Show it
-
-
-
-
-
-
- {{ stray.name }}
-
- Not declared
-
-
- Declare
-
-
-
-
- {{ slot.name }}
- repeats
-
-
-
-
-
-
- {{ slot.provided.repeat ? 'Slot, one per item' : 'Slot in tree' }}
-
-
-
No slot in the tree yet
-
-
-
-
-
- Contract
- the file's
+ Properties
+ of {{ view.component.name }}
- No ## Contract yet. Fill it from the library, or add a prop below.
+ Nothing declared yet. A property is what an instance can change without reaching inside; a
+ slot is where it can put its own content. Add one below.
@@ -898,6 +677,241 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>
+
+
+
+
+
+ {{ stray.name }}
+
+ Not declared
+
+
+ Declare
+
+
+
+
+ {{ slot.name }}
+ repeats
+
+
+
+
+
+
+ {{ slot.provided.repeat ? 'Slot, one per item' : 'Slot in tree' }}
+
+
+
No slot in the tree yet
+
+
+
+
+
+
+
+ Code binding
+ {{
+ view.implementsValue ? view.implementsValue : 'optional · for developers'
+ }}
+
+
+ Implements
+ {{ view.component.name }}
+
+
+
+ Element
+
+
+ None
+
+ {{ option.tag }}{{ option.known ? '' : ' · not in library' }}
+
+
+
+
+ ↺
+
+
+
+
+ Rendered as {{ boundTag }} — uidx.json binds this component to it.
+
+
+
+ Attributes: {{ view.element.attributes.join(', ') }}.
+
+
+ Events: {{ view.element.events.join(', ') }}.
+
+
+
+
+ Parts
+ {{ boundCount }} of {{ view.parts.length }} bound
+
+
+
+ Choose an element above to see the parts it offers.
+
+
+ “{{ view.implementsValue }}” is not in the library, and the contract declares no parts.
+
+ “{{ view.implementsValue }}” has no parts to bind.
+
+
+
+
+ {{ row.name }}
+ ?
+ shadow
+
+
+
+
+
+ {{ row.boundTo.name }}
+
+
+
+ {{ view.candidates.length ? 'Bind a layer…' : 'No layer to bind' }}
+
+
+ {{ indent(c.depth) }}{{ c.name }}
+
+
+
+ ×
+
+
+
+
+ “{{ stray.name }}” is bound to “{{ stray.part }}”, which nothing declares.
+
+ Show it
+
+
+
@@ -1206,8 +1220,14 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>
{{ codegen.notice }}
-
-
No headless library yet. Choose one to pick elements and parts from a list.
+
+
+ Code library
+ optional · for developers
+
+
+ Connect a component library so the code binding can pick elements and parts from a list.
+
From a dependency
Written into uidx.json as "headless", relative to it.
-
+
@@ -1252,6 +1272,31 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>
.contract {
padding: var(--pad);
}
+.code-binding {
+ margin-top: 16px;
+ border-top: 1px solid var(--line);
+}
+.code-binding > summary {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ padding: 10px 0 4px;
+ cursor: pointer;
+ list-style: none;
+}
+.code-binding > summary::-webkit-details-marker {
+ display: none;
+}
+.code-binding > summary::before {
+ content: '›';
+ display: inline-block;
+ width: 10px;
+ color: var(--text-dim);
+ transition: transform 120ms;
+}
+.code-binding[open] > summary::before {
+ transform: rotate(90deg);
+}
.head {
display: flex;
align-items: baseline;
diff --git a/packages/viewer/src/HomePane.vue b/packages/viewer/src/HomePane.vue
index caef6f4..ded157a 100644
--- a/packages/viewer/src/HomePane.vue
+++ b/packages/viewer/src/HomePane.vue
@@ -54,7 +54,7 @@ function created(file: string): void {
const kpis = computed(() => {
const { stats } = props.model
return [
- { label: 'Pages', value: stats.pages, note: `${stats.scenes} drawable` },
+ { label: 'Pages', value: stats.pages, note: `${stats.scenes} with content` },
{
label: 'Components',
value: stats.components,
@@ -66,9 +66,12 @@ const kpis = computed(() => {
note: `${stats.collections.length} collections`,
},
{
- label: 'Errors',
+ label: 'Problems',
value: stats.brokenPages,
- note: stats.brokenPages === 0 ? 'all pages parse' : 'pages will not parse',
+ note:
+ stats.brokenPages === 0
+ ? 'every page opens'
+ : `${stats.brokenPages === 1 ? 'a page' : 'pages'} cannot be drawn`,
alarm: stats.brokenPages > 0,
},
]
diff --git a/packages/viewer/src/ModelsPane.vue b/packages/viewer/src/ModelsPane.vue
index 716ac82..a145f9d 100644
--- a/packages/viewer/src/ModelsPane.vue
+++ b/packages/viewer/src/ModelsPane.vue
@@ -131,8 +131,8 @@ watch(
Models {{ cards.length }}
- What each component receives: declared once, named by a prop's type, sampled for the
- canvas. Nothing here is computed — a derived value is a field the consumer supplies.
+ The data your components show. Declare a model once, give each field sample values, and
+ every list that repeats over it draws those samples on the canvas.
@@ -161,8 +161,8 @@ watch(
+ New model
- A model belongs on the page of the component that shows it, or on a page called
- models that every page shares.
+ Keep a model on the page of the component that shows it, or on a page called
+ models when several pages share it.
@@ -188,8 +188,8 @@ watch(
- No models yet. A list prop typed Contact[] names one; declare it above and give
- its fields samples, and the canvas draws the rows.
+ No models yet. Name one above — a list of contacts is a Contact model — then
+ add its fields with sample values, and the canvas draws the rows.
{{ card.label }}
{{ card.errors }}
- rev {{ card.revision }}
+ v{{ card.revision }}
+
+
+
+import { onMounted, ref, watch } from 'vue'
+import type { JsonValue } from '@uidx/format'
+
+import type { ComponentDocs, DocsExample } from './docs-model'
+
+/**
+ * The Docs face: one component's documentation page, read from its identity
+ * — the words above the visual contract, the contract, the behaviour rules
+ * and the examples, each example drawn by the canvas's own renderer. It is
+ * the page a designer, reviewer or product owner reads without opening the
+ * file, and the one that is shared when somebody asks what a component does.
+ */
+const props = defineProps<{
+ docs: ComponentDocs | null
+ /** Draws one example; injected so a test can mount the page without CanvasKit. */
+ render: (example: DocsExample) => Promise
+ /** Redraw stamp: moves when anything an example draws through moves. */
+ stamp: string
+}>()
+
+const emit = defineEmits<{
+ /** Open a page on the canvas, selecting an address when one is given. */
+ open: [file: string, address?: string]
+}>()
+
+const pictures = ref>({})
+
+async function draw(): Promise {
+ const docs = props.docs
+ if (!docs) return
+ const next: Record = {}
+ for (const example of docs.examples) next[example.name] = await props.render(example)
+ pictures.value = next
+}
+onMounted(draw)
+watch(() => [props.docs, props.stamp], draw)
+
+function show(value: JsonValue | undefined): string {
+ if (value === undefined) return ''
+ return typeof value === 'string' ? value : JSON.stringify(value)
+}
+
+/** Paragraphs and bullet lists of the intent, with `code` spans kept apart. */
+function blocks(text: string): { kind: 'p' | 'ul'; lines: string[] }[] {
+ const out: { kind: 'p' | 'ul'; lines: string[] }[] = []
+ for (const chunk of text.split(/\n\s*\n/)) {
+ const lines = chunk.split('\n').filter((line) => line.trim() !== '')
+ if (!lines.length) continue
+ if (lines.every((line) => /^\s*[-*]\s/.test(line)))
+ out.push({ kind: 'ul', lines: lines.map((line) => line.replace(/^\s*[-*]\s+/, '')) })
+ else out.push({ kind: 'p', lines: [lines.join(' ')] })
+ }
+ return out
+}
+
+function spans(text: string): { code: boolean; text: string }[] {
+ return text
+ .split(/(`[^`]+`)/)
+ .filter(Boolean)
+ .map((part) =>
+ part.startsWith('`') && part.endsWith('`')
+ ? { code: true, text: part.slice(1, -1) }
+ : { code: false, text: part },
+ )
+}
+
+function flags(prop: { controllable?: boolean; visual?: boolean }): string[] {
+ const out: string[] = []
+ if (prop.visual) out.push('visual')
+ if (prop.controllable) out.push('controllable')
+ return out
+}
+
+
+
+
+
+
+ This page declares no component, so it has no documentation. Components live one per page,
+ as <Component>.
+
+
+
+
+ DESIGN SYSTEM / COMPONENT
+
+ {{ docs.name }}
+ {{ docs.status }}
+
+
+ Implements {{ docs.implements }}
+
+
+ Open on the canvas
+
+
+
+
+
+
+
+ {{ span.text }}
+ {{ span.text }}
+
+
+
+
+
+ {{ span.text }}
+ {{ span.text }}
+
+
+
+
+
+
+ No description yet. Write what the component is for above
+ ## Visual Contract.
+
+
+
+ EXAMPLES
+
+
+
+
+
Drawing…
+
+
+ {{ example.name }}
+
+ {{ JSON.stringify(example.props) }}
+
+
+ not drawn here: {{ set }}
+
+
+
+
+
+
+
+
+ PROPERTIES
+
+
+
+ Name
+ Type
+ Default
+ Description
+
+
+
+
+
+ {{ prop.name }}
+ {{ flag }}
+
+
+ {{ prop.type }}
+
+
+ {{ show(prop.default) }}
+
+ {{ prop.description }}
+
+
+
+
+
+ EVENTS
+
+
+
+
+ {{ event.name }}
+
+
+ {{ event.detail ?? '' }}
+
+ {{ event.description }}
+
+
+
+
+
+ SLOTS, PARTS AND STATES
+
+
+
+
+ slot {{ slot.name }}
+
+
+ accepts {{ slot.accepts }}
+
+ {{ slot.description }}
+
+
+
+ part {{ part.name }}
+
+
+ {{ part.description }}
+
+
+
+ state {{ state.name }}
+
+
+ {{ state.description }}
+
+
+
+
+
+ ACCESSIBILITY, FORMS AND COMPOSITION
+
+
+ {{ key }}
+ {{ show(value) }}
+
+
+ form
+
+ {{ docs.contract.form.participates ? 'participates' : 'does not participate' }}
+
+ — submits {{ docs.contract.form.submits }}
+
+
+
+ composes with
+ {{ docs.contract.composes.join(', ') }}
+
+
+
+
+
+
+ BEHAVIOUR
+
+
+ {{ rule.id }}
+
+
+ {{ span.text }}
+ {{ span.text }}
+
+
+
+
+
+
+
+ USED IN
+ No page uses this component yet.
+
+
+
+ {{ use.file }} · {{ use.address }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/viewer/src/WorkspaceNav.vue b/packages/viewer/src/WorkspaceNav.vue
index 2d90572..e68d4b4 100644
--- a/packages/viewer/src/WorkspaceNav.vue
+++ b/packages/viewer/src/WorkspaceNav.vue
@@ -2,12 +2,15 @@
defineProps<{
title: string
page?: string | null
- view: 'home' | 'page' | 'tokens' | 'fonts' | 'models'
+ view: 'home' | 'page' | 'tokens' | 'fonts' | 'models' | 'docs'
canGoHome: boolean
renderable: boolean
}>()
-const emit = defineEmits<{ home: []; face: [view: 'page' | 'tokens' | 'fonts' | 'models'] }>()
+const emit = defineEmits<{
+ home: []
+ face: [view: 'page' | 'tokens' | 'fonts' | 'models' | 'docs']
+}>()
@@ -51,6 +54,9 @@ const emit = defineEmits<{ home: []; face: [view: 'page' | 'tokens' | 'fonts' |
>
Elements
+
+ Docs
+
Tokens
diff --git a/packages/viewer/src/docs-model.ts b/packages/viewer/src/docs-model.ts
new file mode 100644
index 0000000..8b51299
--- /dev/null
+++ b/packages/viewer/src/docs-model.ts
@@ -0,0 +1,127 @@
+import {
+ parse,
+ STATE_AXIS,
+ type BehaviorRule,
+ type ContractSpec,
+ type ExampleSpec,
+ type JsonValue,
+ type UidxDocument,
+} from '@uidx/format'
+import type { DependentsIndex } from '@uidx/schema'
+
+/**
+ * What a component's documentation page shows (the Docs face): the words, the
+ * contract, the behaviour rules and the examples of one identity, read from
+ * its file and nothing else. The canvas shows what a component looks like;
+ * this shows what it *is*, for the designer, developer or reviewer who never
+ * opens the file.
+ *
+ * Pure, so the page's shape is tested without mounting anything.
+ */
+export interface ComponentDocs {
+ file: string
+ name: string
+ status: string | null
+ implements: string | null
+ /** The Markdown above `## Visual Contract`, trimmed; empty when there is none. */
+ intent: string
+ contract: ContractSpec | null
+ behavior: BehaviorRule[]
+ examples: DocsExample[]
+ /** Where the component is used: one entry per instance, file first. */
+ usedIn: { file: string; address: string }[]
+}
+
+export interface DocsExample {
+ name: string
+ /** The instance's `props`, from the example's ``s. */
+ props: Record
+ /** ``s the page cannot draw as one instance, said rather than dropped. */
+ notDrawn: string[]
+}
+
+export function componentDocs(
+ file: string,
+ doc: UidxDocument,
+ deps: DependentsIndex,
+): ComponentDocs | null {
+ const component = doc.tree.children.find((node) => node.element === 'Component')
+ if (!component) return null
+ const contract = doc.spec?.contract ?? null
+ const text = (name: string): string | null => {
+ const value = component.attrs[name]?.value
+ return typeof value === 'string' && value !== '' ? value : null
+ }
+ return {
+ file,
+ name: component.name,
+ status: text('status'),
+ implements: text('implements'),
+ intent: intentText(doc.intent.raw),
+ contract,
+ behavior: doc.spec?.behavior ?? [],
+ examples: (doc.spec?.examples ?? []).map((example) => docsExample(example, contract)),
+ usedIn: (deps.ofComponent.get(component.name) ?? [])
+ .map((dependent) => ({ file: dependent.file, address: dependent.address }))
+ .sort((a, b) => a.file.localeCompare(b.file) || a.address.localeCompare(b.address)),
+ }
+}
+
+/** The intent without the `## Core Intent`-style heading a starter page carries. */
+function intentText(raw: string): string {
+ return raw
+ .split('\n')
+ .filter((line) => !/^#{1,6}\s/.test(line))
+ .join('\n')
+ .trim()
+}
+
+/**
+ * An example as one instance (ADR 0015 §3): `at="root" state="…"` chooses the
+ * state — a visual boolean prop set true, any other name the `state` axis —
+ * and `at="" value={…}` sets a contract prop. Counts and per-row
+ * overrides need the rows themselves, and are listed as not drawn.
+ */
+export function docsExample(example: ExampleSpec, contract: ContractSpec | null): DocsExample {
+ const props: Record = {}
+ const notDrawn: string[] = []
+ const booleans = new Set(
+ (contract?.props ?? [])
+ .filter((prop) => prop.visual && prop.type === 'boolean')
+ .map((prop) => prop.name),
+ )
+ const declared = new Set((contract?.props ?? []).map((prop) => prop.name))
+ for (const set of example.sets) {
+ if (set.at === 'root' && set.state !== undefined) {
+ if (booleans.has(set.state)) props[set.state] = true
+ else props[STATE_AXIS] = set.state
+ continue
+ }
+ if (set.at !== undefined && declared.has(set.at) && set.value !== undefined) {
+ props[set.at] = set.value
+ continue
+ }
+ notDrawn.push(describeSet(set))
+ }
+ return { name: example.name, props, notDrawn }
+}
+
+function describeSet(set: ExampleSpec['sets'][number]): string {
+ const parts: string[] = []
+ if (set.slot !== undefined) parts.push(`slot="${set.slot}"`)
+ if (set.at !== undefined) parts.push(`at="${set.at}"`)
+ if (set.count !== undefined) parts.push(`count={${set.count}}`)
+ if (set.state !== undefined) parts.push(`state="${set.state}"`)
+ if (set.value !== undefined) parts.push(`value={${JSON.stringify(set.value)}}`)
+ return ` `
+}
+
+/**
+ * A one-instance page drawing an example, for the thumbnailer — which takes
+ * any document, so an example is drawn by the same renderer as the canvas.
+ */
+export function exampleDocument(component: string, example: DocsExample): UidxDocument | null {
+ const props = Object.keys(example.props).length ? ` props={${JSON.stringify(example.props)}}` : ''
+ const source = `---\nid: example\n---\n\n## Visual Contract\n\n\n \n \n`
+ return parse(source).doc
+}
diff --git a/packages/viewer/src/page-url.ts b/packages/viewer/src/page-url.ts
index a996d2e..ad8458e 100644
--- a/packages/viewer/src/page-url.ts
+++ b/packages/viewer/src/page-url.ts
@@ -69,6 +69,8 @@ export type View =
| { kind: 'fonts'; file: string }
/** The document's models (ADR 0015 §1), gathered from every page like tokens are. */
| { kind: 'models'; file: string }
+ /** The page's component as a documentation page: words, contract, behaviour, examples. */
+ | { kind: 'docs'; file: string }
export const HOME: View = { kind: 'home' }
@@ -78,7 +80,12 @@ 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' || view.kind === 'models')
+ if (
+ view.kind === 'tokens' ||
+ view.kind === 'fonts' ||
+ view.kind === 'models' ||
+ view.kind === 'docs'
+ )
url.searchParams.set(VIEW_PARAM, view.kind)
else url.searchParams.delete(VIEW_PARAM)
return url.toString()
@@ -103,7 +110,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' || view === 'models'
+ return view === 'tokens' || view === 'fonts' || view === 'models' || view === 'docs'
? { kind: view, file: wanted }
: { kind: 'page', file: wanted }
}
diff --git a/packages/viewer/test/docs-model.test.ts b/packages/viewer/test/docs-model.test.ts
new file mode 100644
index 0000000..3dd472e
--- /dev/null
+++ b/packages/viewer/test/docs-model.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest'
+import { parseOrThrow } from '@uidx/format'
+import { buildDependentsIndex } from '@uidx/schema'
+
+import { componentDocs, docsExample, exampleDocument } from '../src/docs-model'
+
+const CHECKBOX = parseOrThrow(`---
+id: checkbox
+---
+
+Lets a user toggle one option. Uses \`checked\`.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ Whether it is on.
+ Words.
+
+
+## Behavior
+
+- toggle: click flips \`checked\`.
+
+## Examples
+
+
+
+
+
+
+`)
+
+const FORM = parseOrThrow(`---
+id: form
+---
+
+## Visual Contract
+
+
+
+
+
+
+`)
+
+const pages = new Map([
+ ['checkbox.uidx', CHECKBOX],
+ ['form.uidx', FORM],
+])
+
+describe('componentDocs', () => {
+ const docs = componentDocs('checkbox.uidx', CHECKBOX, buildDependentsIndex(pages))!
+
+ it('reads the identity: words, contract, behaviour and uses', () => {
+ expect(docs).toMatchObject({
+ name: 'Checkbox',
+ status: 'stable',
+ implements: 'x-checkbox',
+ intent: 'Lets a user toggle one option. Uses `checked`.',
+ })
+ expect(docs.contract?.props.map((prop) => prop.name)).toEqual(['checked', 'label'])
+ expect(docs.behavior.map((rule) => rule.id)).toEqual(['toggle'])
+ expect(docs.usedIn).toEqual([{ file: 'form.uidx', address: 'form#remember' }])
+ })
+
+ it('turns an example into one instance, naming what it cannot draw', () => {
+ expect(docs.examples).toEqual([
+ {
+ name: 'on',
+ props: { checked: true, label: 'Remember me' },
+ notDrawn: [' '],
+ },
+ ])
+ })
+
+ it('chooses the state axis for a state that is not a visual boolean', () => {
+ const example = docsExample(
+ {
+ name: 'hovered',
+ sets: [{ at: 'root', state: 'hover', loc: { start: 0, end: 0 } }],
+ loc: { start: 0, end: 0 },
+ },
+ CHECKBOX.spec!.contract!,
+ )
+ expect(example.props).toEqual({ state: 'hover' })
+ })
+
+ it('draws an example as a one-instance page', () => {
+ const doc = exampleDocument('Checkbox', docs.examples[0]!)!
+ const instance = doc.tree.children[0]!
+ expect(instance.element).toBe('Instance')
+ expect(instance.attrs.props?.value).toEqual({ checked: true, label: 'Remember me' })
+ })
+
+ it('is null for a page without a component', () => {
+ expect(componentDocs('form.uidx', FORM, buildDependentsIndex(pages))).toBeNull()
+ })
+})
From 2d06b5406d94a7acbb2a0977d4f8c6bf798ddd99 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 30 Sep 2026 22:11:44 +0000
Subject: [PATCH 28/74] =?UTF-8?q?codegen:=20a=20stories=20target=20?=
=?UTF-8?q?=E2=80=94=20Storybook=20CSF=20from=20each=20identity?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ADR 0015 §3 says an example is exactly a Storybook story; nothing emitted
one. `"stories"` (with `react`) writes .stories.tsx beside each
React wrapper: the default story with every prop given its sample, its
default or its model's sample rows; one story per value of each visual
prop; and one per ## Examples scene the args can express (a visual
boolean state, a prop value, a count trimming a repeating slot's list).
Verified to type-check against @storybook/react 9 and React 19.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
README.md | 2 +-
packages/cli/src/commands/codegen.ts | 4 +-
packages/codegen/src/generate.ts | 7 +-
packages/codegen/src/stories.ts | 152 +++++++++++++++++++++++++
packages/codegen/test/generate.test.ts | 31 +++++
packages/server/src/document.ts | 6 +-
6 files changed, 196 insertions(+), 6 deletions(-)
create mode 100644 packages/codegen/src/stories.ts
diff --git a/README.md b/README.md
index 60660b0..80180e7 100644
--- a/README.md
+++ b/README.md
@@ -134,7 +134,7 @@ things differently, without editing a design:
(`element`, `data-part`). `bindings` maps a component's identity names to the
library's. Both default to the conventions `@hwc/components` follows.
-`codegen` is optional too: `{ "out": "../generated", "targets": ["html", "react", "contract"] }`
+`codegen` is optional too: `{ "out": "../generated", "targets": ["html", "react", "contract"] }` (add `"stories"` for Storybook CSF files beside the React components)
says where `uidx codegen` writes without `--out`, and gives the viewer's
Contract tab a Generate button that renders the same output from the server.
diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts
index 298105f..e4fa7f5 100644
--- a/packages/cli/src/commands/codegen.ts
+++ b/packages/cli/src/commands/codegen.ts
@@ -96,8 +96,8 @@ export async function runCodegen(argv: string[], io: Io): Promise {
: parsed.values.target!.split(',').map((t) => t.trim())
) as Target[]
for (const target of targets) {
- if (!['html', 'react', 'contract'].includes(target)) {
- io.err(`unknown target "${target}"; choose from html, react, contract\n`)
+ if (!['html', 'react', 'contract', 'stories'].includes(target)) {
+ io.err(`unknown target "${target}"; choose from html, react, contract, stories\n`)
return 1
}
}
diff --git a/packages/codegen/src/generate.ts b/packages/codegen/src/generate.ts
index 922f39e..c3151ee 100644
--- a/packages/codegen/src/generate.ts
+++ b/packages/codegen/src/generate.ts
@@ -11,8 +11,9 @@ import {
} from './model.js'
import { modelIndex } from '@uidx/schema/design-system'
import { emitElementTypes, emitIndex, emitModels, emitReact, emitRuntime } from './react.js'
+import { emitStories } from './stories.js'
-export type Target = 'html' | 'react' | 'contract'
+export type Target = 'html' | 'react' | 'contract' | 'stories'
export interface GenerateInput {
/** Component pages: every `` with an `implements` or a contract is rendered. */
@@ -91,6 +92,10 @@ export function generate(input: GenerateInput): GenerateOutput {
files.set('react/runtime.ts', emitRuntime())
files.set('react/elements.d.ts', emitElementTypes([...tags]))
files.set('react/index.ts', emitIndex(rendered))
+ // Stories import the React wrappers, so they ride with the React target.
+ if (targets.has('stories'))
+ for (const model of rendered)
+ files.set(`react/${model.identifier}.stories.tsx`, emitStories(model, Boolean(tokens)))
}
if (targets.has('contract')) {
diff --git a/packages/codegen/src/stories.ts b/packages/codegen/src/stories.ts
new file mode 100644
index 0000000..f0985f2
--- /dev/null
+++ b/packages/codegen/src/stories.ts
@@ -0,0 +1,152 @@
+import { enumValues, type JsonValue, type ModelSpec, type PropSpec } from '@uidx/format'
+import { modelOfType, sampleAt, type ModelIndex } from '@uidx/schema/design-system'
+
+import type { ComponentModel } from './model.js'
+
+/**
+ * Storybook stories from an identity (ADR 0015 §3: "an example is exactly a
+ * Storybook story or a visual regression fixture, and may be emitted as
+ * one"). One CSF 3 file per React component: the default render with every
+ * prop given its sample, one story per value of each visual prop, and one per
+ * `## Examples` scene the args can express.
+ *
+ * Written beside the React target it imports, and like it a pure function of
+ * the file. Storybook itself is the consumer's: the file only imports types
+ * from `@storybook/react`.
+ */
+export function emitStories(model: ComponentModel, withTokens = false): string {
+ const contract = model.contract
+ const args: Record = {}
+ for (const prop of contract?.props ?? []) {
+ const value = sampleArg(prop, model.models)
+ if (value !== undefined) args[prop.name] = value
+ }
+
+ const stories: { name: string; args: Record }[] = []
+ const taken = new Set(['Default'])
+ const add = (name: string, extra: Record): void => {
+ let unique = name
+ for (let n = 2; taken.has(unique); n++) unique = `${name}${n}`
+ taken.add(unique)
+ stories.push({ name: unique, args: extra })
+ }
+
+ for (const prop of contract?.props ?? []) {
+ if (!prop.visual) continue
+ if (prop.type === 'boolean') {
+ if (prop.default !== true) add(pascal(prop.name), { [prop.name]: true })
+ continue
+ }
+ for (const value of enumValues(prop.type) ?? []) {
+ if (value === prop.default) continue
+ add(`${pascal(prop.name)}${pascal(value)}`, { [prop.name]: value })
+ }
+ }
+
+ const declared = new Map((contract?.props ?? []).map((prop) => [prop.name, prop]))
+ for (const example of model.spec?.examples ?? []) {
+ const extra: Record = {}
+ for (const set of example.sets) {
+ if (set.at === 'root' && set.state !== undefined) {
+ const prop = declared.get(set.state)
+ if (prop?.visual && prop.type === 'boolean') extra[set.state] = true
+ } else if (set.at !== undefined && declared.has(set.at) && set.value !== undefined) {
+ extra[set.at] = set.value
+ } else if (set.slot !== undefined && set.count !== undefined) {
+ // A count trims the list the repeating slot is filled from.
+ const list = model.repeats.find((repeat) => repeat.node.name === set.slot)?.list
+ const rows = list ? args[list] : undefined
+ if (list && Array.isArray(rows)) extra[list] = rows.slice(0, set.count)
+ }
+ }
+ add(`Example${pascal(example.name)}`, extra)
+ }
+
+ const lines = [
+ `// ${model.name} — generated by uidx codegen from its .uidx file. Do not edit; change the file.`,
+ '',
+ `import type { Meta, StoryObj } from '@storybook/react'`,
+ `import { ${model.identifier} } from './${model.identifier}'`,
+ ...(withTokens ? [`import './tokens.css'`] : []),
+ '',
+ `const meta = {`,
+ ` title: 'Design system/${model.name}',`,
+ ` component: ${model.identifier},`,
+ ` args: ${literal(args, 2)},`,
+ `} satisfies Meta`,
+ '',
+ `export default meta`,
+ `type Story = StoryObj`,
+ '',
+ `export const Default: Story = {}`,
+ ]
+ for (const story of stories) {
+ const inline = Object.entries(story.args)
+ .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
+ .join(', ')
+ lines.push('', `export const ${story.name}: Story = { args: { ${inline} } }`)
+ }
+ return `${lines.join('\n')}\n`
+}
+
+/** What a prop is given in the default story: its sample, its default, or its model's samples. */
+function sampleArg(prop: PropSpec, models: ModelIndex): JsonValue | undefined {
+ if (prop.sample !== undefined) return prop.sample
+ if (prop.default !== undefined) return prop.default
+ const found = modelOfType(prop.type?.trim(), undefined, models)
+ if (!found) return undefined
+ const rows = sampleRows(found.model, models, 0)
+ return found.list ? rows : (rows[0] ?? null)
+}
+
+/** A model's sample rows: as many as its longest sample list, three when none is a list. */
+export function sampleRows(
+ model: ModelSpec,
+ models: ModelIndex,
+ depth: number,
+): Record[] {
+ const count = Math.max(
+ 0,
+ ...model.fields.map((field) => (Array.isArray(field.sample) ? field.sample.length : 0)),
+ )
+ const rows: Record[] = []
+ for (let index = 0; index < (count || 3); index++) {
+ const row: Record = {}
+ for (const field of model.fields) {
+ const nested = depth < 2 ? modelOfType(field.type.trim(), undefined, models) : undefined
+ if (nested) {
+ const inner = sampleRows(nested.model, models, depth + 1)
+ row[field.name] = nested.list ? inner : (inner[0] ?? null)
+ continue
+ }
+ const raw = Array.isArray(field.sample)
+ ? field.sample[index % field.sample.length]
+ : field.sample
+ // An absent optional is left out, the way the adapter would omit it.
+ if (raw === null && field.optional) continue
+ const value = sampleAt(field, index)
+ if (value !== undefined) row[field.name] = value
+ }
+ rows.push(row)
+ }
+ return rows
+}
+
+function pascal(text: string): string {
+ return text
+ .split(/[^A-Za-z0-9]+/)
+ .filter(Boolean)
+ .map((word) => word[0]!.toUpperCase() + word.slice(1))
+ .join('')
+}
+
+/** A JSON value as a TypeScript literal, keys unquoted where they can be. */
+function literal(value: JsonValue, indent: number): string {
+ const json = JSON.stringify(value, null, 2)
+ if (json === undefined) return 'undefined'
+ return json
+ .replace(/"([A-Za-z_$][\w$]*)":/g, '$1:')
+ .split('\n')
+ .map((line, at) => (at === 0 ? line : ' '.repeat(indent) + line))
+ .join('\n')
+}
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
index f8a0bdc..ef0be43 100644
--- a/packages/codegen/test/generate.test.ts
+++ b/packages/codegen/test/generate.test.ts
@@ -575,3 +575,34 @@ describe('the empty state of a list (ADR 0017 §4)', () => {
)
})
})
+
+describe('the stories target (ADR 0015 §3)', () => {
+ const stories = generate({
+ pages: PAGES,
+ tokens: [TOKENS],
+ manifest: MANIFEST,
+ targets: ['react', 'stories'],
+ }).files
+
+ it('writes one CSF file per React component', () => {
+ expect([...stories.keys()].filter((path) => path.endsWith('.stories.tsx')).sort()).toEqual([
+ 'react/Checkbox.stories.tsx',
+ 'react/ContactItem.stories.tsx',
+ 'react/ContactList.stories.tsx',
+ 'react/Field.stories.tsx',
+ ])
+ })
+
+ it('gives a list prop its model samples and each visual value a story', () => {
+ const list = stories.get('react/ContactList.stories.tsx')!
+ expect(list).toContain(`import type { Meta, StoryObj } from '@storybook/react'`)
+ expect(list).toContain('component: ContactList,')
+ expect(list).toMatch(/items: \[\s*\{/)
+ expect(list).toContain('export const Disabled: Story = { args: { disabled: true } }')
+ })
+
+ it('is not written without the React target', () => {
+ const only = generate({ pages: PAGES, tokens: [TOKENS], targets: ['stories'] }).files
+ expect([...only.keys()]).toEqual([])
+ })
+})
diff --git a/packages/server/src/document.ts b/packages/server/src/document.ts
index 412f92f..ff4d4a1 100644
--- a/packages/server/src/document.ts
+++ b/packages/server/src/document.ts
@@ -194,9 +194,11 @@ export async function readManifest(path: string): Promise {
if (targets !== undefined) {
if (
!Array.isArray(targets) ||
- targets.some((target) => !['html', 'react', 'contract'].includes(target as string))
+ targets.some(
+ (target) => !['html', 'react', 'contract', 'stories'].includes(target as string),
+ )
)
- problems.push(`${path}: "codegen.targets" may hold html, react and contract`)
+ problems.push(`${path}: "codegen.targets" may hold html, react, contract and stories`)
else codegen.targets = targets as string[]
}
}
From 4771abfe97c053b8cba6a4f80e8833a394733e64 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 30 Sep 2026 22:13:44 +0000
Subject: [PATCH 29/74] Journeys: the first hour walked in a real browser on
every canary run
scripts/journeys.mjs serves a scratch copy of the example design system
with the built CLI and walks it in Chromium: the overview, a page on the
canvas, a layer's contract, the Docs face drawing an example, the Models
face, and an agent declaring a prop and a state style through `uidx
apply` that the Docs face then shows, with the check still clean and no
uncaught page errors. `pnpm test:journeys`, run in the canvas canary job.
needs-review.md says how a green run can sign entries off.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
.github/workflows/canvas-canary.yml | 6 +
docs/needs-review.md | 6 +
package.json | 3 +-
scripts/journeys.mjs | 172 ++++++++++++++++++++++++++++
4 files changed, 186 insertions(+), 1 deletion(-)
create mode 100644 scripts/journeys.mjs
diff --git a/.github/workflows/canvas-canary.yml b/.github/workflows/canvas-canary.yml
index 3b6cebc..d111809 100644
--- a/.github/workflows/canvas-canary.yml
+++ b/.github/workflows/canvas-canary.yml
@@ -48,6 +48,12 @@ jobs:
- name: Canvas canary
run: pnpm test:canvas
+ # A designer's and an agent's first hour, walked in the same browser:
+ # overview, canvas, contract, docs, models, and an agent edit through
+ # the CLI that the viewer then shows.
+ - name: Journeys
+ run: pnpm test:journeys
+
# Uploaded whether the canary passed or failed, so a human can look at
# what it actually saw instead of trusting a byte-count heuristic blind.
- name: Upload canvas screenshot
diff --git a/docs/needs-review.md b/docs/needs-review.md
index dd8979b..5541a11 100644
--- a/docs/needs-review.md
+++ b/docs/needs-review.md
@@ -14,6 +14,12 @@ doing at the time — a defect in something already called done outranks new wor
**Status key.** ⬜ waiting on you · 🔁 you found something, I am on it ·
✅ you signed it off (moved down).
+**Automated journeys.** `pnpm test:journeys` (after `pnpm build:cli`) walks
+the overview, the canvas, the Contract tab, the Docs and Models faces and an
+agent edit through the CLI in a real browser, on every canary run. An entry
+whose checks it covers can be signed off on a green run; add a journey when
+you sign one off by hand, so the check keeps running.
+
**Before any live check.** A fresh clone fails five `packages/cli` tests until
`pnpm build:cli` runs, and the viewer needs a document, not a bare file:
diff --git a/package.json b/package.json
index 444d639..c31b9b7 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,8 @@
"audit:release": "node scripts/audit-release.mjs",
"test:security": "node scripts/check-dependency-fixes.mjs",
"test:release": "node --test scripts/test-release.mjs",
- "test:canvas": "node scripts/canvas-canary.mjs"
+ "test:canvas": "node scripts/canvas-canary.mjs",
+ "test:journeys": "node scripts/journeys.mjs"
},
"devDependencies": {
"@eslint/js": "^9",
diff --git a/scripts/journeys.mjs b/scripts/journeys.mjs
new file mode 100644
index 0000000..784f5d9
--- /dev/null
+++ b/scripts/journeys.mjs
@@ -0,0 +1,172 @@
+import assert from 'node:assert/strict'
+import { execFile, spawn } from 'node:child_process'
+import { once } from 'node:events'
+import { cp, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+import { chromium } from 'playwright'
+
+/**
+ * Journeys: what a designer and an agent do in their first hour, walked in a
+ * real browser against the built CLI, over a scratch copy of the example
+ * design system. Each journey is one of the needs-review checks written down
+ * so it runs on every change instead of waiting for someone at a keyboard.
+ *
+ * Needs `pnpm build:cli`. Set UIDX_CHROMIUM to use an installed Chromium.
+ */
+
+const root = fileURLToPath(new URL('..', import.meta.url))
+const cli = resolve(root, 'packages/cli/dist/uidx.js')
+const port = 4971
+const url = `http://localhost:${port}`
+const exec = promisify(execFile)
+
+const work = await realpath(await mkdtemp(join(tmpdir(), 'uidx-journeys-')))
+const project = join(work, 'design-system')
+const docroot = join(project, '.uidx')
+let server
+let browser
+let log = ''
+const results = []
+
+async function until(check, timeoutMs = 20_000) {
+ const deadline = Date.now() + timeoutMs
+ do {
+ const result = await check()
+ if (result) return result
+ if (server?.exitCode !== null && server?.exitCode !== undefined) throw new Error(log)
+ await new Promise((done) => setTimeout(done, 100))
+ } while (Date.now() < deadline)
+ throw new Error(`timed out\n${log}`)
+}
+
+async function journey(name, run) {
+ const started = Date.now()
+ try {
+ await run()
+ results.push({ name, ok: true, ms: Date.now() - started })
+ console.log(`✔ ${name}`)
+ } catch (error) {
+ results.push({ name, ok: false, error })
+ console.log(`✖ ${name}\n ${String(error.message).split('\n').join('\n ')}`)
+ }
+}
+
+async function uidx(args) {
+ return (await exec(process.execPath, [cli, ...args], { cwd: project })).stdout
+}
+
+try {
+ assert.ok(existsSync(cli), 'run `pnpm build:cli` first')
+ await mkdir(project, { recursive: true })
+ for (const entry of ['.uidx', 'vendor', 'package.json'])
+ await cp(join(root, 'examples/design-system', entry), join(project, entry), { recursive: true })
+
+ server = spawn(process.execPath, [cli, 'open', docroot, '--port', String(port), '--no-open'], {
+ cwd: project,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ })
+ server.stdout.on('data', (data) => (log += data))
+ server.stderr.on('data', (data) => (log += data))
+ await until(() => log.includes(`localhost:${port}`))
+
+ const executablePath =
+ process.env.UIDX_CHROMIUM ??
+ (existsSync('/opt/pw-browsers/chromium') ? '/opt/pw-browsers/chromium' : undefined)
+ browser = await chromium.launch({ executablePath })
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
+ const errors = []
+ page.on('pageerror', (error) => errors.push(error.message))
+ const open = async (query) => {
+ await page.goto(`${url}/${query}`, { waitUntil: 'networkidle' })
+ await page.waitForTimeout(500)
+ }
+
+ await journey('the overview lists every page and reports no problems', async () => {
+ await open('')
+ for (const name of ['button', 'checkbox', 'contact-list', 'tokens'])
+ await page.getByText(name, { exact: true }).first().waitFor({ timeout: 10_000 })
+ const problems = page.locator('text=PROBLEMS').locator('..')
+ assert.match(await problems.innerText(), /\b0\b/)
+ })
+
+ await journey('a page opens on the canvas and its layers are listed', async () => {
+ await open('?page=button.uidx')
+ await page.locator('canvas').first().waitFor({ state: 'visible' })
+ await page.getByText('label', { exact: true }).first().waitFor()
+ })
+
+ await journey('selecting a layer shows its properties, then its contract', async () => {
+ await open('?page=button.uidx')
+ await page.getByText('Button', { exact: true }).first().click()
+ await page.getByRole('button', { name: 'Contract' }).first().click()
+ await page.getByText('Properties', { exact: false }).first().waitFor()
+ await page.getByText('variant', { exact: true }).first().waitFor()
+ })
+
+ await journey('the Docs face documents a component and draws its example', async () => {
+ await open('?page=checkbox.uidx&view=docs')
+ await page.getByRole('heading', { name: /Checkbox/ }).waitFor()
+ await page.getByText('toggle', { exact: true }).first().waitFor()
+ await page.locator('.example img').first().waitFor({ timeout: 15_000 })
+ })
+
+ await journey('the Models face lists the Contact model and who receives it', async () => {
+ await open('?page=contact-list.uidx&view=models')
+ await page.getByText('Contact', { exact: true }).first().waitFor()
+ await page.getByText('ContactList.items[]').first().waitFor()
+ })
+
+ await journey('an agent declares a prop through the CLI and the Docs face shows it', async () => {
+ const ops = join(work, 'ops.json')
+ await writeFile(
+ ops,
+ JSON.stringify([
+ {
+ kind: 'declare',
+ contractKind: 'prop',
+ name: 'loading',
+ attrs: { type: 'boolean', default: false, visual: true },
+ description: 'Shows progress and blocks activation.',
+ },
+ {
+ kind: 'set_style',
+ keys: { state: 'loading' },
+ target: 'root',
+ prop: 'opacity',
+ value: 0.7,
+ },
+ ]),
+ )
+ const out = await uidx(['apply', 'button.uidx', '--ops', ops])
+ assert.match(out, /applied|changed/i, out)
+ const source = await readFile(join(docroot, 'button.uidx'), 'utf8')
+ assert.match(source, / /)
+ await open('?page=button.uidx&view=docs')
+ await page.getByText('Shows progress and blocks activation.').waitFor()
+ })
+
+ await journey('the check stays clean after the agent edit', async () => {
+ await uidx(['check', join(docroot, '*.uidx')])
+ })
+
+ await journey('no page raised an uncaught error', async () => {
+ assert.deepEqual(errors, [])
+ })
+} finally {
+ await browser?.close()
+ if (server && server.exitCode === null) {
+ const closed = once(server, 'close')
+ server.kill('SIGTERM')
+ await closed
+ }
+ await rm(work, { recursive: true, force: true })
+}
+
+const failed = results.filter((result) => !result.ok)
+console.log(`\n${results.length - failed.length}/${results.length} journeys passed`)
+if (failed.length) process.exitCode = 1
From 9010b509f24cb4a310e24d844017d1824172f02b Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 30 Sep 2026 22:18:18 +0000
Subject: [PATCH 30/74] Canvas: a variant set names its columns and rows
A derived or authored component set drew its grid with no headers: a
seventy-two cell Button was a wall of buttons nobody could name. The
canvas now labels each column with the first axis's value and each row,
on the right where the fitted page has room, with the other axes' values,
following pan and zoom and hiding below 35%.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
packages/viewer/src/CanvasPane.vue | 88 ++++++++++++++++++++-
packages/viewer/src/variant-labels.ts | 71 +++++++++++++++++
packages/viewer/test/variant-labels.test.ts | 35 ++++++++
3 files changed, 191 insertions(+), 3 deletions(-)
create mode 100644 packages/viewer/src/variant-labels.ts
create mode 100644 packages/viewer/test/variant-labels.test.ts
diff --git a/packages/viewer/src/CanvasPane.vue b/packages/viewer/src/CanvasPane.vue
index 79d4a7a..7917efc 100644
--- a/packages/viewer/src/CanvasPane.vue
+++ b/packages/viewer/src/CanvasPane.vue
@@ -34,6 +34,7 @@ import {
type VectorEditInfo,
} from './vertex-edit'
import { drawingHint } from './graphics-tools'
+import { variantHeaders, type VariantCell } from './variant-labels'
import { resizeVectorPaths } from './vector-resize'
import type { Point, Rect } from './gesture-model'
import { importSvg, type SvgProblem } from './svg-import'
@@ -1075,6 +1076,14 @@ const hasRendered = ref(false)
/** Keep the zoom control in sync with both gestures and programmatic fitting. */
const cameraZoom = ref(1)
+/** The camera's pan, read with the zoom so pane-space overlays follow a pan. */
+const cameraPan = ref({ x: 0, y: 0 })
+/** Moves whenever the scene is rebuilt or patched, for overlays that read it. */
+const sceneTick = ref(0)
+function readCamera(): void {
+ cameraZoom.value = editor.state.zoom
+ cameraPan.value = { x: editor.state.panX, y: editor.state.panY }
+}
function fitCanvas(): void {
editor.zoomToFit()
scheduleCameraReadout()
@@ -1091,13 +1100,13 @@ function chooseZoom(event: Event): void {
let readoutFrame = 0
function scheduleCameraReadout(): void {
if (typeof requestAnimationFrame !== 'function') {
- cameraZoom.value = editor.state.zoom
+ readCamera()
return
}
if (readoutFrame) return
readoutFrame = requestAnimationFrame(() => {
readoutFrame = 0
- cameraZoom.value = editor.state.zoom
+ readCamera()
})
}
onUnmounted(() => {
@@ -1276,7 +1285,8 @@ function render(doc: UidxDocument | null, rebuild = false): void {
// same-page definition the diff just pushed into its copies — so the
// components watch below has nothing further to rebuild for.
renderedWith = props.components
- cameraZoom.value = editor.state.zoom
+ readCamera()
+ sceneTick.value += 1
canvas.renderNow()
// A rename or a reparent reaches here as a remove plus an insert, and
// the shell remapped its selection to the new addresses before this
@@ -1331,6 +1341,8 @@ function render(doc: UidxDocument | null, rebuild = false): void {
// Opening another page *is* the author asking to be taken somewhere, and
// the camera they left on the last page frames nothing on this one.
if (!hasRendered.value || pageChanged) editor.zoomToFit()
+ readCamera()
+ sceneTick.value += 1
cameraZoom.value = editor.state.zoom
canvas.renderNow()
hasRendered.value = true
@@ -1720,6 +1732,48 @@ const rotationReadout = ref<{ x: number; y: number; degrees: number } | null>(nu
const formatDegrees = (degrees: number): string => `${Math.round(degrees * 10) / 10}°`
+/**
+ * Column and row headers for every component set on the page, in pane px:
+ * which value each column and row of a derived or authored variant grid is.
+ * Hidden when zoomed out too far to read them.
+ */
+const setHeaders = computed(() => {
+ void sceneTick.value
+ const zoom = cameraZoom.value
+ const pan = cameraPan.value
+ const result = scene.value
+ if (!result || zoom < 0.35) return []
+ const graph = result.graph
+ const page = graph.getNode(result.rootId)
+ if (!page) return []
+ const place = (point: Point): Point => ({ x: point.x * zoom + pan.x, y: point.y * zoom + pan.y })
+ const out: { key: string; kind: 'column' | 'row'; text: string; x: number; y: number }[] = []
+ for (const id of page.childIds) {
+ const set = graph.getNode(id)
+ if (set?.type !== 'COMPONENT_SET') continue
+ const cells: VariantCell[] = []
+ for (const childId of set.childIds) {
+ const child = graph.getNode(childId)
+ if (!child) continue
+ const at = getAbsolutePosition(child, graph)
+ cells.push({ name: child.name, x: at.x, y: at.y, width: child.width, height: child.height })
+ }
+ const headers = variantHeaders(cells)
+ for (const column of headers.columns) {
+ const at = place(column)
+ out.push({ key: `${id}:c:${column.text}`, kind: 'column', text: column.text, ...at })
+ }
+ // Rows are named on the right, where the canvas has room; the left edge
+ // sits against the rulers once the page is fitted.
+ const origin = getAbsolutePosition(set, graph)
+ for (const row of headers.rows) {
+ const at = place({ x: origin.x + set.width, y: row.y })
+ out.push({ key: `${id}:r:${row.text}`, kind: 'row', text: row.text, ...at })
+ }
+ }
+ return out
+})
+
/** Canvas units to pane px — the camera the SDK's own overlays map through. */
const toPane = (point: Point): Point => ({
x: point.x * editor.state.zoom + editor.state.panX,
@@ -2272,6 +2326,16 @@ onUnmounted(() => unwatchGraph?.())
+
+
unwatchGraph?.())
z-index: 1;
color: var(--text-dim);
}
+.set-header {
+ position: absolute;
+ z-index: 3;
+ max-width: 220px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--component, #9747ff);
+ font-size: 10px;
+ line-height: 12px;
+ white-space: nowrap;
+ pointer-events: none;
+}
+.set-header[data-kind='column'] {
+ transform: translateY(calc(-100% - 3px));
+}
+.set-header[data-kind='row'] {
+ transform: translate(8px, -50%);
+}
.rotation-readout {
position: absolute;
z-index: 4;
diff --git a/packages/viewer/src/variant-labels.ts b/packages/viewer/src/variant-labels.ts
new file mode 100644
index 0000000..a82ee18
--- /dev/null
+++ b/packages/viewer/src/variant-labels.ts
@@ -0,0 +1,71 @@
+/**
+ * Headers for a component set drawn on the canvas: which value each column
+ * and each row of the grid is. `arrangeVariants` lays the first axis out as
+ * columns and the remaining axes, read together, as rows; without headers a
+ * seventy-two cell button is a wall of buttons nobody can name.
+ *
+ * Pure: cells in canvas units in, label anchors in canvas units out.
+ */
+export interface VariantCell {
+ /** The variant's name, `size=sm, state=hover` (ADR 0005 §3). */
+ name: string
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+export interface VariantLabel {
+ text: string
+ /** Canvas units: the column's left edge, or the row's vertical centre. */
+ x: number
+ y: number
+}
+
+export interface VariantHeaders {
+ columns: VariantLabel[]
+ rows: VariantLabel[]
+}
+
+/** `a=1, b=2` → [['a','1'], ['b','2']]; a name without `=` is not a variant name. */
+export function variantCoordinates(name: string): [string, string][] | null {
+ const pairs = name.split(',').map((part) => part.trim().split('='))
+ if (!pairs.length || pairs.some((pair) => pair.length !== 2 || !pair[0])) return null
+ return pairs.map(([axis, value]) => [axis!.trim(), value!.trim()])
+}
+
+export function variantHeaders(cells: readonly VariantCell[]): VariantHeaders {
+ const parsed = cells
+ .map((cell) => ({ cell, coords: variantCoordinates(cell.name) }))
+ .filter((entry): entry is { cell: VariantCell; coords: [string, string][] } => !!entry.coords)
+ if (parsed.length < 2) return { columns: [], rows: [] }
+ const top = Math.min(...parsed.map(({ cell }) => cell.y))
+ const left = Math.min(...parsed.map(({ cell }) => cell.x))
+
+ const columns = new Map()
+ const rows = new Map()
+ for (const { cell, coords } of parsed) {
+ const [first, ...rest] = coords
+ const column = `${first![0]}=${first![1]}`
+ const known = columns.get(column)
+ if (!known || cell.x < known.x) columns.set(column, { text: column, x: cell.x, y: top })
+ if (!rest.length) continue
+ const row = rest.map(([axis, value]) => `${axis}=${value}`).join(', ')
+ const span = rows.get(row)
+ const cellTop = cell.y
+ const cellBottom = cell.y + cell.height
+ if (!span)
+ rows.set(row, { label: { text: row, x: left, y: 0 }, top: cellTop, bottom: cellBottom })
+ else {
+ span.top = Math.min(span.top, cellTop)
+ span.bottom = Math.max(span.bottom, cellBottom)
+ }
+ }
+ return {
+ // One column means the set has one value on its first axis: nothing to tell apart.
+ columns: columns.size > 1 ? [...columns.values()].sort((a, b) => a.x - b.x) : [],
+ rows: [...rows.values()]
+ .map(({ label, top: t, bottom }) => ({ ...label, y: (t + bottom) / 2 }))
+ .sort((a, b) => a.y - b.y),
+ }
+}
diff --git a/packages/viewer/test/variant-labels.test.ts b/packages/viewer/test/variant-labels.test.ts
new file mode 100644
index 0000000..ba72f78
--- /dev/null
+++ b/packages/viewer/test/variant-labels.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+
+import { variantCoordinates, variantHeaders } from '../src/variant-labels'
+
+const cell = (name: string, x: number, y: number) => ({ name, x, y, width: 40, height: 20 })
+
+describe('variantHeaders', () => {
+ it('names each column by the first axis and each row by the rest', () => {
+ const headers = variantHeaders([
+ cell('size=sm, state=default', 10, 10),
+ cell('size=md, state=default', 60, 10),
+ cell('size=sm, state=hover', 10, 40),
+ cell('size=md, state=hover', 60, 40),
+ ])
+ expect(headers.columns).toEqual([
+ { text: 'size=sm', x: 10, y: 10 },
+ { text: 'size=md', x: 60, y: 10 },
+ ])
+ expect(headers.rows).toEqual([
+ { text: 'state=default', x: 10, y: 20 },
+ { text: 'state=hover', x: 10, y: 50 },
+ ])
+ })
+
+ it('labels a one-axis set by its columns alone', () => {
+ const headers = variantHeaders([cell('state=default', 0, 0), cell('state=checked', 50, 0)])
+ expect(headers.columns.map((c) => c.text)).toEqual(['state=default', 'state=checked'])
+ expect(headers.rows).toEqual([])
+ })
+
+ it('says nothing for a lone cell or names that are not variant names', () => {
+ expect(variantHeaders([cell('state=default', 0, 0)])).toEqual({ columns: [], rows: [] })
+ expect(variantCoordinates('Primary')).toBeNull()
+ })
+})
From 8e0c44ecda05fa26da74bb2364e80ce91a36c9fe Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 30 Sep 2026 22:24:09 +0000
Subject: [PATCH 31/74] Inspector: a state says what it sets, and a value can
go back to the base
Selecting a derived state showed every property with no hint of which
ones its style rows set, and offered x and y a state cannot change. The
Design tab now opens with "This state sets": each cell of every row
that applies, the winning row named, and Reset removing the cell so the
base value returns. A state's root no longer offers x and y. Column
headers carry the value alone, capped to their column, with the axis
named once beside them. A journey walks select-state-and-reset.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
packages/viewer/src/CanvasPane.vue | 34 ++++-
packages/viewer/src/PropertiesPane.vue | 133 +++++++++++++++++++-
packages/viewer/src/variant-labels.ts | 22 +++-
packages/viewer/test/variant-labels.test.ts | 7 +-
scripts/journeys.mjs | 23 ++++
5 files changed, 208 insertions(+), 11 deletions(-)
diff --git a/packages/viewer/src/CanvasPane.vue b/packages/viewer/src/CanvasPane.vue
index 7917efc..b4639c5 100644
--- a/packages/viewer/src/CanvasPane.vue
+++ b/packages/viewer/src/CanvasPane.vue
@@ -1747,7 +1747,14 @@ const setHeaders = computed(() => {
const page = graph.getNode(result.rootId)
if (!page) return []
const place = (point: Point): Point => ({ x: point.x * zoom + pan.x, y: point.y * zoom + pan.y })
- const out: { key: string; kind: 'column' | 'row'; text: string; x: number; y: number }[] = []
+ const out: {
+ key: string
+ kind: 'column' | 'row'
+ text: string
+ x: number
+ y: number
+ width?: number
+ }[] = []
for (const id of page.childIds) {
const set = graph.getNode(id)
if (set?.type !== 'COMPONENT_SET') continue
@@ -1761,11 +1768,25 @@ const setHeaders = computed(() => {
const headers = variantHeaders(cells)
for (const column of headers.columns) {
const at = place(column)
- out.push({ key: `${id}:c:${column.text}`, kind: 'column', text: column.text, ...at })
+ out.push({
+ key: `${id}:c:${column.text}`,
+ kind: 'column',
+ text: column.text,
+ ...at,
+ ...(column.width === undefined || !Number.isFinite(column.width)
+ ? {}
+ : { width: column.width * zoom }),
+ })
}
// Rows are named on the right, where the canvas has room; the left edge
- // sits against the rulers once the page is fitted.
+ // sits against the rulers once the page is fitted. The column axis is
+ // named there too, level with the column values.
const origin = getAbsolutePosition(set, graph)
+ const firstColumn = headers.columns[0]
+ if (headers.columnAxis && firstColumn) {
+ const at = place({ x: origin.x + set.width, y: firstColumn.y })
+ out.push({ key: `${id}:axis`, kind: 'column', text: `← ${headers.columnAxis}`, ...at })
+ }
for (const row of headers.rows) {
const at = place({ x: origin.x + set.width, y: row.y })
out.push({ key: `${id}:r:${row.text}`, kind: 'row', text: row.text, ...at })
@@ -2331,7 +2352,12 @@ onUnmounted(() => unwatchGraph?.())
:key="header.key"
class="set-header"
:data-kind="header.kind"
- :style="{ left: `${header.x}px`, top: `${header.y}px` }"
+ :style="{
+ left: `${header.x}px`,
+ top: `${header.y}px`,
+ ...(header.width === undefined ? {} : { maxWidth: `${Math.max(header.width, 14)}px` }),
+ }"
+ :title="header.text"
>
{{ header.text }}
diff --git a/packages/viewer/src/PropertiesPane.vue b/packages/viewer/src/PropertiesPane.vue
index 1129f94..7318a7f 100644
--- a/packages/viewer/src/PropertiesPane.vue
+++ b/packages/viewer/src/PropertiesPane.vue
@@ -454,8 +454,70 @@ const sections = computed(() => {
? { ...field, value: node.attrs.strokeCap?.value ?? 'NONE' }
: field,
)
- return sectionsFor(node, displayFields, parent.value)
+ const all = sectionsFor(node, displayFields, parent.value)
+ // A state's root sits where the arrangement puts it (ADR 0016 §4): its
+ // x and y are not the author's, so they are not offered.
+ const state = derived.value
+ if (!state || state.isDefault || state.target !== 'root') return all
+ const placed = (name: string | undefined): boolean => name === 'x' || name === 'y'
+ return all
+ .map((section) => ({
+ ...section,
+ fields: section.fields.filter(
+ (paired) => !placed(paired.field.name) && !placed(paired.pairedWith?.name),
+ ),
+ }))
+ .filter((section) => section.fields.length > 0)
})
+
+/**
+ * What the selected state sets on this layer (ADR 0016 §2): every cell of
+ * every row that applies to the state, least specific first, the last word
+ * winning. Shown above the properties so the author sees what the state
+ * changes and can hand a value back to the base.
+ */
+const stateCells = computed(() => {
+ const state = derived.value
+ const rows = props.doc?.spec?.styles ?? []
+ if (!state || state.isDefault || !rows.length) return []
+ const applies = rows
+ .map((row, order) => ({ row, order }))
+ .filter(({ row }) =>
+ Object.entries(row.keys).every(([axis, value]) => state.keys[axis] === value),
+ )
+ .sort(
+ (a, b) =>
+ Object.keys(a.row.keys).length - Object.keys(b.row.keys).length || a.order - b.order,
+ )
+ const cells = new Map<
+ string,
+ { prop: string; value: JsonValue; keys: Record; row: string }
+ >()
+ for (const { row } of applies) {
+ for (const [prop, value] of Object.entries(row.values[state.target] ?? {})) {
+ cells.set(prop, {
+ prop,
+ value,
+ keys: row.keys,
+ row: Object.entries(row.keys)
+ .map(([axis, v]) => `${axis}=${v}`)
+ .join(', '),
+ })
+ }
+ }
+ return [...cells.values()]
+})
+
+function showCell(value: JsonValue): string {
+ return typeof value === 'string' ? value : JSON.stringify(value)
+}
+
+/** Hands a value back to the base: the cell leaves its row. */
+function resetCell(cell: { prop: string; keys: Record }): void {
+ const state = derived.value
+ if (!state) return
+ emit('patches', [{ op: 'style', keys: { ...cell.keys }, target: state.target, prop: cell.prop }])
+}
/**
* The fill this node is, or null (story F5, ADR 0007 §2).
*
@@ -1481,6 +1543,27 @@ function onDetach(prop: string, value: JsonValue): void {
Reconnect to edit. You can still inspect properties and export.
+
+ This state sets
+
+
+ {{ cell.prop }}
+ {{
+ showCell(cell.value)
+ }}
+ {{ cell.row }}
+
+ Reset
+
+
+
+
+
'
+const GUIDE_END = ''
+
+/** What a coding agent in this repository should know about its design system. */
+export function agentGuide(script: string): string {
+ return `${GUIDE_START}
+## Design system (uidx)
+
+This project's designs and design system live in \`.uidx/\` as text files, one
+component per page, and are versioned with the code.
+
+When you build or change UI:
+
+- **Use the design system's components and tokens.** Ask first: \`uidx_components\`
+ and \`uidx_component\` (MCP) or \`npx uidx components\` and \`npx uidx component \`
+ give each component's props, states, slots, behaviour rules, import line and a
+ usage line. Never invent a prop, and never hard-code a colour, space or radius
+ a token names — \`uidx_tokens\` / \`npx uidx tokens list\` gives each token's CSS variable.
+- **Change a component in its \`.uidx\` file, not in generated code.** Generated
+ files say so in their first line; regenerate with \`npx uidx codegen\`.
+- **Check your work.** \`npx uidx check\` must pass; \`npx uidx render -o out.png\`
+ shows a page as the canvas draws it. Designers review in the viewer: \`npm run ${script}\`.
+- Editing designs: load the \`uidx-design-system\` and \`uidx-authoring\` skills first.
+${GUIDE_END}
+`
+}
+
+/**
+ * Writes the guide into AGENTS.md (and CLAUDE.md when the project has one),
+ * replacing a previous uidx block and leaving everything else as it was.
+ */
+async function writeAgentGuide(root: string, script: string): Promise {
+ const guide = agentGuide(script)
+ const targets = ['AGENTS.md']
+ if (await exists(resolve(root, 'CLAUDE.md'))) targets.push('CLAUDE.md')
+ for (const name of targets) {
+ const path = resolve(root, name)
+ const current = (await exists(path)) ? await readFile(path, 'utf8') : ''
+ const start = current.indexOf(GUIDE_START)
+ const end = current.indexOf(GUIDE_END)
+ const next =
+ start !== -1 && end > start
+ ? current.slice(0, start) + guide.trimEnd() + current.slice(end + GUIDE_END.length)
+ : `${current}${current && !current.endsWith('\n\n') ? (current.endsWith('\n') ? '\n' : '\n\n') : ''}${guide}`
+ if (next !== current) await writeFile(path, next)
+ }
+}
diff --git a/packages/cli/src/commands/tokens.ts b/packages/cli/src/commands/tokens.ts
index 95f5c08..fd618a3 100644
--- a/packages/cli/src/commands/tokens.ts
+++ b/packages/cli/src/commands/tokens.ts
@@ -6,10 +6,12 @@ import { fromDtcg, toDtcg } from '@uidx/schema/dtcg'
import { buildTokenIndex } from '@uidx/schema/token-index'
import { expand } from './check.js'
import type { Io } from '../cli.js'
+import { runTokenList } from './components.js'
const USAGE =
'usage: uidx tokens import [--out tokens.uidx] [--modes light,dark] [--id tokens]\n' +
- ' uidx tokens export [glob...] --out \n'
+ ' uidx tokens export [glob...] --out \n' +
+ ' uidx tokens list [--mode collection=mode]\n'
/**
* `uidx tokens import|export`: the document's tokens as Design Tokens (DTCG
@@ -20,6 +22,7 @@ export async function runTokens(argv: string[], io: Io): Promise {
const [verb, ...rest] = argv
if (verb === 'import') return importTokens(rest, io)
if (verb === 'export') return exportTokens(rest, io)
+ if (verb === 'list') return runTokenList(rest, io)
io.err(USAGE)
return 1
}
diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts
index 35ca241..6779033 100644
--- a/packages/cli/test/init.test.ts
+++ b/packages/cli/test/init.test.ts
@@ -238,3 +238,23 @@ describe('init --design-system', () => {
}
})
})
+
+describe('the agent guide', () => {
+ it('is written into AGENTS.md once, and CLAUDE.md when there is one', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'uidx-guide-'))
+ try {
+ await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'guide', private: true }))
+ await writeFile(join(dir, 'CLAUDE.md'), '# Project\n\nOur rules.\n')
+ await initProject(dir)
+ await initProject(dir)
+ const agents = await readFile(join(dir, 'AGENTS.md'), 'utf8')
+ const claude = await readFile(join(dir, 'CLAUDE.md'), 'utf8')
+ expect(agents.match(/uidx:start/g)).toHaveLength(1)
+ expect(agents).toContain('uidx_components')
+ expect(claude.startsWith('# Project\n\nOur rules.\n\n')).toBe(true)
+ expect(claude.match(/uidx:start/g)).toHaveLength(1)
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+})
From be828bd8e77057e74060587fdece31150808700a Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 30 Sep 2026 22:55:51 +0000
Subject: [PATCH 37/74] `uidx lint`: application code checked against the
design system
Keeping AI-written UI on-system needs a deterministic gate, not a prompt.
`uidx lint [path...]` (default src/) reads the document's tokens and
components and reports, with no model in the loop:
- a colour literal (hex or rgb) that equals a token's value, naming the
token and its CSS variable;
- a raw element a component stands for in markup files ( where
the system has a Button, for a Checkbox).
Generated files are skipped by their header. Exits 1 on any finding, so
it gates CI; --format json for tools. AGENTS.md and the project skill
ask agents to run it.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
packages/agent/src/core/app-lint.ts | 111 ++++++++++++++++++++++
packages/agent/src/core/index.ts | 2 +
packages/agent/test/app-lint.test.ts | 70 ++++++++++++++
packages/cli/skills/uidx-project/SKILL.md | 3 +
packages/cli/src/cli.ts | 4 +
packages/cli/src/commands/components.ts | 2 +-
packages/cli/src/commands/init.ts | 2 +-
packages/cli/src/commands/lint.ts | 50 ++++++++++
8 files changed, 242 insertions(+), 2 deletions(-)
create mode 100644 packages/agent/src/core/app-lint.ts
create mode 100644 packages/agent/test/app-lint.test.ts
create mode 100644 packages/cli/src/commands/lint.ts
diff --git a/packages/agent/src/core/app-lint.ts b/packages/agent/src/core/app-lint.ts
new file mode 100644
index 0000000..916ab59
--- /dev/null
+++ b/packages/agent/src/core/app-lint.ts
@@ -0,0 +1,111 @@
+import type { UidxDocument } from '@uidx/format'
+import { listComponents, listTokens } from './design-system.js'
+
+/**
+ * The design system's rules applied to application code (`uidx lint`): the
+ * checks that keep AI-written UI on-system, deterministic and with no model
+ * in the loop.
+ *
+ * - A colour literal that equals a token's value — the token's CSS variable
+ * should be there instead.
+ * - A raw element a design-system component stands for — `` where
+ * the system has a Button.
+ *
+ * Generated code is skipped: it says so in its first line.
+ */
+export interface AppFinding {
+ file: string
+ line: number
+ column: number
+ rule: 'token-literal' | 'raw-element'
+ message: string
+}
+
+export interface AppLintContext {
+ /** Lower-cased colour → the tokens that resolve to it. */
+ colours: Map
+ /** Raw tag → the component that replaces it. */
+ elements: Map
+}
+
+/** Which HTML element a component with this name stands in for, by convention. */
+const STANDS_FOR: Record = {
+ button: 'button',
+ checkbox: 'input type="checkbox"',
+ radio: 'input type="radio"',
+ textinput: 'input',
+ input: 'input',
+ select: 'select',
+ textarea: 'textarea',
+ link: 'a',
+ dialog: 'dialog',
+}
+
+export function appLintContext(docs: ReadonlyMap): AppLintContext {
+ const colours = new Map()
+ for (const row of listTokens(docs)) {
+ if (row.type !== 'COLOR' || typeof row.value !== 'string') continue
+ const key = row.value.toLowerCase()
+ colours.set(key, [...(colours.get(key) ?? []), `${row.token} (${row.css})`])
+ }
+ const elements = new Map()
+ for (const component of listComponents(docs)) {
+ const tag = STANDS_FOR[component.name.toLowerCase()]
+ if (tag && !elements.has(tag)) elements.set(tag, component.name)
+ }
+ return { colours, elements }
+}
+
+const GENERATED = /generated by uidx codegen/
+const HEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g
+const RGB = /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(1|1\.0+))?\s*\)/g
+
+function normaliseHex(hex: string): string {
+ const digits = hex.slice(1).toLowerCase()
+ if (digits.length === 3) return `#${[...digits].map((d) => d + d).join('')}`
+ if (digits.length === 8 && digits.endsWith('ff')) return `#${digits.slice(0, 6)}`
+ return `#${digits}`
+}
+
+export function lintAppSource(file: string, text: string, context: AppLintContext): AppFinding[] {
+ if (GENERATED.test(text.slice(0, 400))) return []
+ const findings: AppFinding[] = []
+ const lines = text.split('\n')
+ const isMarkup = /\.(tsx|jsx|vue|svelte|html|astro)$/.test(file)
+ lines.forEach((line, at) => {
+ const push = (column: number, rule: AppFinding['rule'], message: string) =>
+ findings.push({ file, line: at + 1, column: column + 1, rule, message })
+ for (const match of line.matchAll(HEX)) {
+ const tokens = context.colours.get(normaliseHex(match[0]))
+ if (tokens)
+ push(match.index, 'token-literal', `${match[0]} is ${tokens.join(', ')}; use the token`)
+ }
+ for (const match of line.matchAll(RGB)) {
+ const hex = `#${[match[1], match[2], match[3]]
+ .map((c) => Number(c).toString(16).padStart(2, '0'))
+ .join('')}`
+ const tokens = context.colours.get(hex)
+ if (tokens)
+ push(match.index, 'token-literal', `${match[0]} is ${tokens.join(', ')}; use the token`)
+ }
+ if (!isMarkup) return
+ for (const [tag, component] of context.elements) {
+ const [name, ...attrs] = tag.split(' ')
+ const pattern = new RegExp(
+ `<${name}\\b${attrs.length ? `[^>]*${escapeRegex(attrs.join(' '))}` : ''}`,
+ )
+ const match = pattern.exec(line)
+ if (match)
+ push(
+ match.index,
+ 'raw-element',
+ `<${tag}> has a design-system component: use <${component}>`,
+ )
+ }
+ })
+ return findings
+}
+
+function escapeRegex(text: string): string {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
diff --git a/packages/agent/src/core/index.ts b/packages/agent/src/core/index.ts
index e0fc322..b7faaa6 100644
--- a/packages/agent/src/core/index.ts
+++ b/packages/agent/src/core/index.ts
@@ -222,6 +222,8 @@ export { buildIndex } from '../index/build.js'
export { viewerSelection } from './viewer.js'
export { describeComponent, listComponents, listTokens } from './design-system.js'
export type { ComponentDetail, ComponentSummary, TokenRow } from './design-system.js'
+export { appLintContext, lintAppSource } from './app-lint.js'
+export type { AppFinding, AppLintContext } from './app-lint.js'
export type { ViewerSelection, ViewerSelectionResult } from './viewer.js'
export { readProjectConfig, validatePort } from '../workspace/config.js'
diff --git a/packages/agent/test/app-lint.test.ts b/packages/agent/test/app-lint.test.ts
new file mode 100644
index 0000000..63a3d53
--- /dev/null
+++ b/packages/agent/test/app-lint.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest'
+import { parseOrThrow } from '@uidx/format'
+
+import { appLintContext, lintAppSource } from '../src/core/app-lint.js'
+
+const docs = new Map([
+ [
+ 'tokens.uidx',
+ parseOrThrow(`---
+id: tokens
+---
+
+## Visual Contract
+
+
+
+
+
+
+`),
+ ],
+ [
+ 'button.uidx',
+ parseOrThrow(`---
+id: button
+---
+
+## Visual Contract
+
+
+
+
+`),
+ ],
+])
+
+describe('lintAppSource', () => {
+ const context = appLintContext(docs)
+
+ it('names the token a colour literal equals, in hex or rgb', () => {
+ const found = lintAppSource(
+ 'a.css',
+ '.x { color: #0066FF; border-color: rgb(0, 102, 255); }',
+ context,
+ )
+ expect(found.map((f) => [f.rule, f.column])).toEqual([
+ ['token-literal', 13],
+ ['token-literal', 36],
+ ])
+ expect(found[0]!.message).toContain('color#accent (var(--color-accent))')
+ })
+
+ it('names the component a raw element stands for, in markup only', () => {
+ expect(lintAppSource('a.tsx', 'return Go ', context)[0]?.message).toBe(
+ ' has a design-system component: use ',
+ )
+ expect(lintAppSource('a.ts', "const tag = ''", context)).toEqual([])
+ })
+
+ it('leaves generated code and unrelated colours alone', () => {
+ expect(
+ lintAppSource(
+ 'B.tsx',
+ '// Button — generated by uidx codegen\n ',
+ context,
+ ),
+ ).toEqual([])
+ expect(lintAppSource('a.css', '.x { color: #123456 }', context)).toEqual([])
+ })
+})
diff --git a/packages/cli/skills/uidx-project/SKILL.md b/packages/cli/skills/uidx-project/SKILL.md
index adec1b7..65d6f8b 100644
--- a/packages/cli/skills/uidx-project/SKILL.md
+++ b/packages/cli/skills/uidx-project/SKILL.md
@@ -70,3 +70,6 @@ Before writing UI code, ask what the design system offers: `uidx_components`,
components with the props their contracts declare, and the tokens' CSS
variables, never literals a token names. Change a component in its `.uidx`
file and regenerate with `npx uidx codegen`; never edit generated files.
+
+After writing UI, `npx uidx lint src` reports colour literals a token names
+and raw elements a component stands for; fix what it names.
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 15ac601..d5be276 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -23,6 +23,7 @@ import { runCodegen } from './commands/codegen.js'
import { runTokens } from './commands/tokens.js'
import { runAdopt } from './commands/adopt.js'
import { runComponent, runComponents } from './commands/components.js'
+import { runLint } from './commands/lint.js'
import { projectFiles } from './project.js'
import { version } from '../package.json'
import { BootError, open } from './commands/open.js'
@@ -40,6 +41,7 @@ Usage:
uidx contract print a page's contract, behaviour, models and styles as JSON
uidx codegen --out
render HTML/CSS, React and contract JSON from the pages
+ uidx lint [path...] app code against the design system: token literals, raw elements
uidx components [--json] the design system's components, one line each
uidx component one component's contract, import line and usage
uidx tokens list [--mode collection=mode]
@@ -168,6 +170,8 @@ export async function run(argv: string[], io: Io = processIo): Promise {
return await runAdopt(rest, io)
case 'components':
return await runComponents(rest, io)
+ case 'lint':
+ return await runLint(rest, io)
case 'component':
return await runComponent(rest, io)
case 'dev':
diff --git a/packages/cli/src/commands/components.ts b/packages/cli/src/commands/components.ts
index 91debee..8e65ab0 100644
--- a/packages/cli/src/commands/components.ts
+++ b/packages/cli/src/commands/components.ts
@@ -12,7 +12,7 @@ import { expand } from './check.js'
import type { Io } from '../cli.js'
/** Every page of the document around `cwd`, parsed; unparseable pages are left out. */
-async function loadDocs(
+export async function loadDocs(
cwd: string,
rootArg?: string,
): Promise<{ root: string; docs: Map }> {
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index 30c006d..0cb53b0 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -163,7 +163,7 @@ When you build or change UI:
a token names — \`uidx_tokens\` / \`npx uidx tokens list\` gives each token's CSS variable.
- **Change a component in its \`.uidx\` file, not in generated code.** Generated
files say so in their first line; regenerate with \`npx uidx codegen\`.
-- **Check your work.** \`npx uidx check\` must pass; \`npx uidx render -o out.png\`
+- **Check your work.** \`npx uidx check\` and \`npx uidx lint src\` must pass; \`npx uidx render -o out.png\`
shows a page as the canvas draws it. Designers review in the viewer: \`npm run ${script}\`.
- Editing designs: load the \`uidx-design-system\` and \`uidx-authoring\` skills first.
${GUIDE_END}
diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts
new file mode 100644
index 0000000..92d2a23
--- /dev/null
+++ b/packages/cli/src/commands/lint.ts
@@ -0,0 +1,50 @@
+import { readFile } from 'node:fs/promises'
+import { relative, resolve } from 'node:path'
+import { parseArgs } from 'node:util'
+import { glob } from 'tinyglobby'
+import { appLintContext, lintAppSource, type AppFinding } from '@uidx/agent/core'
+import { loadDocs } from './components.js'
+import type { Io } from '../cli.js'
+
+const SOURCES = '**/*.{ts,tsx,js,jsx,mjs,css,scss,vue,svelte,html,astro}'
+
+/**
+ * `uidx lint [path...] [--root dir] [--format json]`: application code checked
+ * against the design system — colour literals a token names, raw elements a
+ * component stands for. Exits 1 on any finding, so it can gate CI.
+ */
+export async function runLint(argv: string[], io: Io): Promise {
+ const { values, positionals } = parseArgs({
+ args: argv,
+ allowPositionals: true,
+ options: { root: { type: 'string' }, format: { type: 'string', default: 'text' } },
+ })
+ const cwd = io.cwd ?? process.cwd()
+ const { docs } = await loadDocs(cwd, values.root)
+ const context = appLintContext(docs)
+ const bases = positionals.length ? positionals : ['src']
+ const findings: AppFinding[] = []
+ let scanned = 0
+ for (const base of bases) {
+ const dir = resolve(cwd, base)
+ const files = await glob([SOURCES], {
+ cwd: dir,
+ ignore: ['**/node_modules/**', '**/dist/**', '**/.uidx/**', '**/*.d.ts'],
+ })
+ for (const file of files.sort()) {
+ const path = resolve(dir, file)
+ scanned++
+ findings.push(...lintAppSource(relative(cwd, path), await readFile(path, 'utf8'), context))
+ }
+ }
+ if (values.format === 'json') io.out(`${JSON.stringify(findings, null, 2)}\n`)
+ else {
+ for (const f of findings) io.out(`${f.file}:${f.line}:${f.column} ${f.rule} ${f.message}\n`)
+ io.out(
+ findings.length
+ ? `✖ ${findings.length} finding${findings.length === 1 ? '' : 's'} in ${scanned} files\n`
+ : `✔ ${scanned} files on-system\n`,
+ )
+ }
+ return findings.length ? 1 : 0
+}
From b326a627d10b676ffeddcf18503bfaa811a3cc00 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 30 Sep 2026 23:00:00 +0000
Subject: [PATCH 38/74] Design review in pull requests: `uidx diff` and `uidx
init --ci`
A design system's changes had no review surface beyond the raw text
diff. @uidx/schema/design-diff compares two versions of the document:
components, contract props, events, slots, parts and states, model fields
and tokens, marking removals and retypes breaking (ADR 0013). `uidx diff
--base [` reads the base from git and prints the Markdown a PR
comment carries (or JSON); --fail-on-breaking exits 1. `uidx init --ci`
writes a pull-request workflow that checks the design files, lints the
application code, posts (and updates) the diff as one PR comment, and
fails on breaking changes.
Co-Authored-By: Claude Opus 5.5 ]
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
packages/cli/src/cli.ts | 6 +
packages/cli/src/commands/diff.ts | 48 +++++++
packages/cli/src/commands/init.ts | 13 +-
packages/cli/src/starter.ts | 48 +++++++
packages/cli/test/init.test.ts | 19 +++
packages/schema/package.json | 5 +
packages/schema/src/design-diff.ts | 159 +++++++++++++++++++++++
packages/schema/test/design-diff.test.ts | 69 ++++++++++
8 files changed, 365 insertions(+), 2 deletions(-)
create mode 100644 packages/cli/src/commands/diff.ts
create mode 100644 packages/schema/src/design-diff.ts
create mode 100644 packages/schema/test/design-diff.test.ts
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index d5be276..746440e 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -24,6 +24,7 @@ import { runTokens } from './commands/tokens.js'
import { runAdopt } from './commands/adopt.js'
import { runComponent, runComponents } from './commands/components.js'
import { runLint } from './commands/lint.js'
+import { runDiff } from './commands/diff.js'
import { projectFiles } from './project.js'
import { version } from '../package.json'
import { BootError, open } from './commands/open.js'
@@ -41,6 +42,8 @@ Usage:
uidx contract print a page's contract, behaviour, models and styles as JSON
uidx codegen --out
render HTML/CSS, React and contract JSON from the pages
+ uidx diff [--base ref] [--fail-on-breaking]
+ design-system changes since a git ref, breaking ones marked
uidx lint [path...] app code against the design system: token literals, raw elements
uidx components [--json] the design system's components, one line each
uidx component one component's contract, import line and usage
@@ -88,6 +91,7 @@ Options for init:
--script run script name (default: uidx)
--port save the shared viewer/MCP port in .uidx/config.json
--design-system start with a design system: tiered tokens and a Button
+ --ci add a pull-request workflow: check, lint, diff posted on the PR
Options for dev / open:
--port override config.json port (default: 4400, auto-increments)
@@ -172,6 +176,8 @@ export async function run(argv: string[], io: Io = processIo): Promise {
return await runComponents(rest, io)
case 'lint':
return await runLint(rest, io)
+ case 'diff':
+ return await runDiff(rest, io)
case 'component':
return await runComponent(rest, io)
case 'dev':
diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts
new file mode 100644
index 0000000..f6bbdb1
--- /dev/null
+++ b/packages/cli/src/commands/diff.ts
@@ -0,0 +1,48 @@
+import { execFile } from 'node:child_process'
+import { relative } from 'node:path'
+import { parseArgs } from 'node:util'
+import { promisify } from 'node:util'
+import { parse, type UidxDocument } from '@uidx/format'
+import { designDiff, designDiffMarkdown } from '@uidx/schema/design-diff'
+import { loadDocs } from './components.js'
+import type { Io } from '../cli.js'
+
+const exec = promisify(execFile)
+
+/**
+ * `uidx diff [--base origin/main] [--format md|json] [--fail-on-breaking]`:
+ * the design system's changes since a git ref — removed or retyped props,
+ * events, slots, model fields and tokens marked breaking — as the Markdown a
+ * pull request comment carries.
+ */
+export async function runDiff(argv: string[], io: Io): Promise {
+ const { values } = parseArgs({
+ args: argv,
+ options: {
+ base: { type: 'string', default: 'origin/main' },
+ root: { type: 'string' },
+ format: { type: 'string', default: 'md' },
+ 'fail-on-breaking': { type: 'boolean', default: false },
+ },
+ })
+ const cwd = io.cwd ?? process.cwd()
+ const { root, docs: after } = await loadDocs(cwd, values.root)
+ const git = async (...args: string[]) =>
+ (await exec('git', args, { cwd: root, maxBuffer: 64 * 1024 * 1024 })).stdout
+ const top = (await git('rev-parse', '--show-toplevel')).trim()
+ const prefix = relative(top, root).replace(/\\/g, '/')
+ const listed = await git('ls-tree', '-r', '--name-only', '--full-name', values.base!, '--', '.')
+ const before = new Map()
+ for (const path of listed.split('\n').filter((line) => line.endsWith('.uidx'))) {
+ if (path.includes('/.uidx-agent/')) continue
+ const { doc } = parse(await git('show', `${values.base}:${path}`))
+ if (doc) before.set(prefix ? path.slice(prefix.length + 1) : path, doc)
+ }
+ const changes = designDiff(before, after)
+ io.out(
+ values.format === 'json'
+ ? `${JSON.stringify(changes, null, 2)}\n`
+ : designDiffMarkdown(changes),
+ )
+ return values['fail-on-breaking'] && changes.some((change) => change.breaking) ? 1 : 0
+}
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index 0cb53b0..1e639b0 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -7,7 +7,7 @@ import { readManifest } from '@uidx/server/document'
import { configureMcp, installSkills, readMcpConfig } from '../setup.js'
import type { Io } from '../cli.js'
import { exists, findProjectRoot, PROJECT_DIR, readPackage } from '../project.js'
-import { STARTER_BUTTON, STARTER_TOKENS } from '../starter.js'
+import { CI_WORKFLOW, STARTER_BUTTON, STARTER_TOKENS } from '../starter.js'
const STARTER = `---
id: welcome
@@ -33,7 +33,7 @@ Edits in the viewer are saved back to these files.
export async function initProject(
cwd: string,
script = 'uidx',
- options: { port?: number; designSystem?: boolean } = {},
+ options: { port?: number; designSystem?: boolean; ci?: boolean } = {},
): Promise {
const root = await findProjectRoot(cwd)
if (!root) throw new Error('No package.json found. Run uidx init inside an npm project.')
@@ -105,6 +105,13 @@ export async function initProject(
}
await installSkills(root)
await writeAgentGuide(root, script)
+ if (options.ci) {
+ const workflow = resolve(root, '.github/workflows/uidx.yml')
+ if (!(await exists(workflow))) {
+ await mkdir(resolve(root, '.github/workflows'), { recursive: true })
+ await writeFile(workflow, CI_WORKFLOW)
+ }
+ }
await configureMcp(mcp, script)
if (Object.entries(additions).some(([name, command]) => scripts[name] !== command)) {
data.scripts = { ...scripts, ...additions }
@@ -126,12 +133,14 @@ export async function runInit(argv: string[], io: Io): Promise {
script: { type: 'string', default: 'uidx' },
port: { type: 'string' },
'design-system': { type: 'boolean', default: false },
+ ci: { type: 'boolean', default: false },
},
})
const script = parsed.values.script!
const root = await initProject(io.cwd ?? process.cwd(), script, {
port: parsed.values.port === undefined ? undefined : Number(parsed.values.port),
designSystem: parsed.values['design-system'] === true,
+ ci: parsed.values.ci === true,
})
io.out(
`uidx ready in ${resolve(root, PROJECT_DIR)}\nRun npm run ${script} to open your design workspace.\nMCP: npm run --silent ${script}:mcp (.mcp.json).\nSkills: .agents/skills, .claude/skills, and .uidx/.uidx-agent/skills.\n`,
diff --git a/packages/cli/src/starter.ts b/packages/cli/src/starter.ts
index 835f01f..7111761 100644
--- a/packages/cli/src/starter.ts
+++ b/packages/cli/src/starter.ts
@@ -137,3 +137,51 @@ change it to write its row.
`
+
+/** The pull-request check `uidx init --ci` writes to `.github/workflows/uidx.yml`. */
+export const CI_WORKFLOW = `name: Design system
+
+# Written by \`uidx init --ci\`. On every pull request: the design files must
+# check clean, application code must stay on the design system, and the
+# design-system changes — breaking ones marked — are posted on the PR.
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ uidx:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ - run: npm ci
+ - name: Check the design files
+ run: npx --no-install uidx check .uidx
+ - name: Keep application code on the design system
+ run: npx --no-install uidx lint src
+ - name: Design-system changes
+ run: npx --no-install uidx diff --base origin/\${{ github.base_ref }} > uidx-diff.md
+ - name: Post the changes on the pull request
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const body = require('fs').readFileSync('uidx-diff.md', 'utf8')
+ const marker = ''
+ const { owner, repo } = context.repo
+ const issue_number = context.issue.number
+ const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number })
+ const mine = comments.find((c) => c.body?.includes(marker))
+ const text = marker + '\\n' + body
+ if (mine) await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body: text })
+ else await github.rest.issues.createComment({ owner, repo, issue_number, body: text })
+ - name: Fail on breaking changes
+ run: npx --no-install uidx diff --base origin/\${{ github.base_ref }} --fail-on-breaking > /dev/null
+`
diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts
index 6779033..56e5610 100644
--- a/packages/cli/test/init.test.ts
+++ b/packages/cli/test/init.test.ts
@@ -258,3 +258,22 @@ describe('the agent guide', () => {
}
})
})
+
+describe('init --ci', () => {
+ it('writes the pull-request workflow once and keeps an edited one', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'uidx-ci-'))
+ try {
+ await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'ci', private: true }))
+ await initProject(dir, 'uidx', { ci: true })
+ const path = join(dir, '.github/workflows/uidx.yml')
+ const workflow = await readFile(path, 'utf8')
+ expect(workflow).toContain('uidx diff --base origin/${{ github.base_ref }}')
+ expect(workflow).toContain('uidx lint src')
+ await writeFile(path, 'edited\n')
+ await initProject(dir, 'uidx', { ci: true })
+ expect(await readFile(path, 'utf8')).toBe('edited\n')
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/packages/schema/package.json b/packages/schema/package.json
index 252c867..6f987dd 100644
--- a/packages/schema/package.json
+++ b/packages/schema/package.json
@@ -44,6 +44,11 @@
"development": "./src/adopt.ts",
"types": "./dist/adopt.d.ts",
"default": "./dist/adopt.js"
+ },
+ "./design-diff": {
+ "development": "./src/design-diff.ts",
+ "types": "./dist/design-diff.d.ts",
+ "default": "./dist/design-diff.js"
}
},
"files": [
diff --git a/packages/schema/src/design-diff.ts b/packages/schema/src/design-diff.ts
new file mode 100644
index 0000000..c9b5004
--- /dev/null
+++ b/packages/schema/src/design-diff.ts
@@ -0,0 +1,159 @@
+import type { ContractSpec, JsonValue, UidxDocument } from '@uidx/format'
+
+import { buildTokenIndex } from './token-index.js'
+
+/**
+ * What changed in a design system between two versions of its document, as
+ * a reviewer and a release need it (ADR 0013, "removing or renaming a Prop,
+ * Event or Slot is a breaking change"): component contracts, models and
+ * tokens, each change marked breaking or not.
+ *
+ * Pure: two sets of parsed pages in, a list of changes out.
+ */
+export interface DesignChange {
+ breaking: boolean
+ /** `Button`, `model Contact`, `token color#accent`. */
+ subject: string
+ change: string
+}
+
+type Pages = ReadonlyMap
+
+function contracts(pages: Pages): Map {
+ const out = new Map()
+ for (const doc of pages.values())
+ for (const node of doc.tree.children)
+ if (node.element === 'Component') out.set(node.name, doc.spec?.contract)
+ return out
+}
+
+function models(pages: Pages) {
+ const out = new Map>()
+ for (const doc of pages.values())
+ for (const model of doc.spec?.models ?? [])
+ out.set(
+ model.name,
+ new Map(
+ model.fields.map((field) => [field.name, { type: field.type, optional: field.optional }]),
+ ),
+ )
+ return out
+}
+
+const show = (value: JsonValue | undefined): string =>
+ value === undefined ? 'nothing' : JSON.stringify(value)
+
+export function designDiff(before: Pages, after: Pages): DesignChange[] {
+ const changes: DesignChange[] = []
+ const add = (breaking: boolean, subject: string, change: string) =>
+ changes.push({ breaking, subject, change })
+
+ const was = contracts(before)
+ const now = contracts(after)
+ for (const [name, old] of was) {
+ if (!now.has(name)) {
+ add(true, name, 'component removed')
+ continue
+ }
+ const next = now.get(name)
+ const lists = [
+ ['prop', old?.props ?? [], next?.props ?? []],
+ ['event', old?.events ?? [], next?.events ?? []],
+ ['slot', old?.slots ?? [], next?.slots ?? []],
+ ['part', old?.parts ?? [], next?.parts ?? []],
+ ['state', old?.states ?? [], next?.states ?? []],
+ ] as const
+ for (const [kind, oldList, newList] of lists) {
+ const newNames = new Map(
+ newList.map((entry) => [entry.name, entry]),
+ )
+ for (const entry of oldList)
+ if (!newNames.has(entry.name)) add(true, name, `${kind} "${entry.name}" removed`)
+ const oldNames = new Set(oldList.map((entry) => entry.name))
+ for (const entry of newList)
+ if (!oldNames.has(entry.name)) {
+ const required =
+ kind === 'prop' &&
+ (entry as { default?: JsonValue }).default === undefined &&
+ !(entry as { type?: string }).type?.includes('undefined')
+ add(false, name, `${kind} "${entry.name}" added${required ? ' (no default)' : ''}`)
+ }
+ }
+ for (const prop of old?.props ?? []) {
+ const next_ = next?.props.find((entry) => entry.name === prop.name)
+ if (!next_) continue
+ if ((prop.type ?? '') !== (next_.type ?? ''))
+ add(true, name, `prop "${prop.name}" type ${prop.type} → ${next_.type}`)
+ if (JSON.stringify(prop.default) !== JSON.stringify(next_.default))
+ add(
+ false,
+ name,
+ `prop "${prop.name}" default ${show(prop.default)} → ${show(next_.default)}`,
+ )
+ }
+ }
+ for (const name of now.keys()) if (!was.has(name)) add(false, name, 'component added')
+
+ const oldModels = models(before)
+ const newModels = models(after)
+ for (const [name, fields] of oldModels) {
+ const next = newModels.get(name)
+ if (!next) {
+ add(true, `model ${name}`, 'removed')
+ continue
+ }
+ for (const [field, info] of fields) {
+ const nextField = next.get(field)
+ if (!nextField) add(true, `model ${name}`, `field "${field}" removed`)
+ else if (nextField.type !== info.type)
+ add(true, `model ${name}`, `field "${field}" type ${info.type} → ${nextField.type}`)
+ }
+ for (const [field, info] of next)
+ if (!fields.has(field))
+ add(
+ !info.optional,
+ `model ${name}`,
+ `field "${field}" added${info.optional ? '' : ' (required)'}`,
+ )
+ }
+ for (const name of newModels.keys())
+ if (!oldModels.has(name)) add(false, `model ${name}`, 'added')
+
+ const oldTokens = buildTokenIndex([...before.values()]).entries
+ const newTokens = buildTokenIndex([...after.values()]).entries
+ for (const [address, entry] of oldTokens) {
+ const next = newTokens.get(address)
+ if (!next) {
+ add(true, `token ${address}`, 'removed')
+ continue
+ }
+ for (const [mode, value] of Object.entries(entry.valuesByMode)) {
+ const nextValue = next.valuesByMode[mode]
+ if (JSON.stringify(value) !== JSON.stringify(nextValue))
+ add(false, `token ${address}`, `${mode}: ${show(value)} → ${show(nextValue)}`)
+ }
+ if (!entry.deprecated && next.deprecated) add(false, `token ${address}`, 'deprecated')
+ }
+ for (const address of newTokens.keys())
+ if (!oldTokens.has(address)) add(false, `token ${address}`, 'added')
+ return changes
+}
+
+/** The changes as the Markdown a pull-request comment carries. */
+export function designDiffMarkdown(changes: readonly DesignChange[]): string {
+ if (!changes.length) return '### Design system\n\nNo contract, model or token changes.\n'
+ const breaking = changes.filter((change) => change.breaking)
+ const rest = changes.filter((change) => !change.breaking)
+ const lines = ['### Design system', '']
+ if (breaking.length) {
+ lines.push(`**${breaking.length} breaking change${breaking.length === 1 ? '' : 's'}**`, '')
+ for (const change of breaking) lines.push(`- ⚠️ \`${change.subject}\`: ${change.change}`)
+ lines.push('')
+ }
+ if (rest.length) {
+ lines.push(`${rest.length} other change${rest.length === 1 ? '' : 's'}`, '')
+ for (const change of rest) lines.push(`- \`${change.subject}\`: ${change.change}`)
+ lines.push('')
+ }
+ return `${lines.join('\n')}`
+}
diff --git a/packages/schema/test/design-diff.test.ts b/packages/schema/test/design-diff.test.ts
new file mode 100644
index 0000000..be2f3e9
--- /dev/null
+++ b/packages/schema/test/design-diff.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest'
+import { parseOrThrow } from '@uidx/format'
+
+import { designDiff, designDiffMarkdown } from '../src/design-diff.js'
+
+const button = (props: string, tokens: string) =>
+ new Map([
+ [
+ 'button.uidx',
+ parseOrThrow(`---
+id: button
+---
+
+## Visual Contract
+
+
+
+
+
+## Contract
+
+
+${props}
+
+`),
+ ],
+ [
+ 'tokens.uidx',
+ parseOrThrow(`---
+id: tokens
+---
+
+## Visual Contract
+
+
+
+${tokens}
+
+
+`),
+ ],
+ ])
+
+describe('designDiff', () => {
+ it('marks removed and retyped props and removed tokens as breaking', () => {
+ const before = button(
+ ` Words.
+ Size. `,
+ `
+ `,
+ )
+ const after = button(
+ ` Size.
+ Icon. `,
+ ` `,
+ )
+ const changes = designDiff(before, after)
+ expect(changes.filter((c) => c.breaking).map((c) => `${c.subject}: ${c.change}`)).toEqual([
+ 'Button: prop "label" removed',
+ `Button: prop "size" type 'sm' | 'md' → 'sm' | 'md' | 'lg'`,
+ 'token color#danger: removed',
+ ])
+ expect(changes.filter((c) => !c.breaking).map((c) => c.change)).toEqual([
+ 'prop "icon" added',
+ 'default: {"r":0,"g":0,"b":1,"a":1} → {"r":0,"g":0.5,"b":1,"a":1}',
+ ])
+ expect(designDiffMarkdown(changes)).toContain('**3 breaking changes**')
+ })
+})
From 668c85eae2c627d1ce6aee44ef59a588d224253c Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 30 Sep 2026 23:06:44 +0000
Subject: [PATCH 39/74] codegen: map identities onto an existing React library;
fix untagged output
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Teams with a React component library could not use uidx's code target,
which only wraps headless custom elements. uidx.json's `codegen.react`
maps a component onto a library export — module, export name, and how
contract props, enum values and events are spelled there, which prop is
children, which to omit — and the React target writes a typed adapter
(enum tables `as const`, so the library's unions type-check) and no
stylesheet. Verified against a stand-in library under React 19 types.
Checking that output found three bugs in components with no `implements`:
the root took an HTMLElement ref (a type error), props were emitted
as unknown DOM attributes, and the root never carried the class its CSS
selects. Untagged components now reflect props and states as data
attributes and parts as data-part, and carry their class; models.ts is a
module even when no model is declared.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
README.md | 21 ++++
packages/cli/src/commands/codegen.ts | 17 ++-
packages/codegen/src/generate.ts | 9 ++
packages/codegen/src/index.ts | 1 +
packages/codegen/src/model.ts | 7 +-
packages/codegen/src/react-adapter.ts | 145 +++++++++++++++++++++++++
packages/codegen/src/react.ts | 11 +-
packages/codegen/test/generate.test.ts | 61 +++++++++++
packages/server/src/codegen.ts | 10 +-
packages/server/src/document.ts | 20 ++++
10 files changed, 295 insertions(+), 7 deletions(-)
create mode 100644 packages/codegen/src/react-adapter.ts
diff --git a/README.md b/README.md
index 80180e7..c5b3e2d 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,27 @@ library's. Both default to the conventions `@hwc/components` follows.
says where `uidx codegen` writes without `--out`, and gives the viewer's
Contract tab a Generate button that renders the same output from the server.
+A team whose components already exist in a React library maps identities
+onto them instead of onto custom elements: `codegen.react` names, per
+component, the module and export, and how contract props, values and events
+are spelled there. `uidx codegen` then writes a typed adapter, not a new
+component:
+
+```json
+"codegen": {
+ "out": "../generated",
+ "react": {
+ "Button": {
+ "from": "@acme/ui",
+ "props": { "emphasis": "appearance" },
+ "values": { "emphasis": { "primary": "solid" } },
+ "events": { "press": "onClick" },
+ "children": "label"
+ }
+ }
+}
+```
+
### CLI and MCP
`npm run uidx` starts the local viewer, file synchronization server, and MCP HTTP
diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts
index e4fa7f5..6a7cc54 100644
--- a/packages/cli/src/commands/codegen.ts
+++ b/packages/cli/src/commands/codegen.ts
@@ -3,7 +3,13 @@ import { dirname, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { expand } from './check.js'
import { formatDiagnostic, parse, type UidxDocument } from '@uidx/format'
-import { generate, type LibraryBindings, type Manifest, type Target } from '@uidx/codegen'
+import {
+ generate,
+ type GenerateInput,
+ type LibraryBindings,
+ type Manifest,
+ type Target,
+} from '@uidx/codegen'
import { findManifest } from '@uidx/server/document'
import type { Io } from '../cli.js'
@@ -102,7 +108,14 @@ export async function runCodegen(argv: string[], io: Io): Promise {
}
}
- const result = generate({ pages, tokens, manifest, library, targets })
+ const result = generate({
+ pages,
+ tokens,
+ manifest,
+ library,
+ targets,
+ react: configured?.manifest.codegen?.react as GenerateInput['react'],
+ })
for (const d of result.diagnostics) io.err(`${formatDiagnostic(d, d.file)}\n`)
if (result.diagnostics.some((d) => d.severity === 'error')) return 1
diff --git a/packages/codegen/src/generate.ts b/packages/codegen/src/generate.ts
index c3151ee..e298cd0 100644
--- a/packages/codegen/src/generate.ts
+++ b/packages/codegen/src/generate.ts
@@ -12,6 +12,7 @@ import {
import { modelIndex } from '@uidx/schema/design-system'
import { emitElementTypes, emitIndex, emitModels, emitReact, emitRuntime } from './react.js'
import { emitStories } from './stories.js'
+import { emitReactAdapter, type ReactBinding } from './react-adapter.js'
export type Target = 'html' | 'react' | 'contract' | 'stories'
@@ -25,6 +26,8 @@ export interface GenerateInput {
/** How the library spells things and its names for this document's (`uidx.json` `headless`). */
library?: LibraryBindings
targets?: readonly Target[]
+ /** `uidx.json`'s `codegen.react`: components rendered onto an existing React library. */
+ react?: Record
}
export interface GenerateOutput {
@@ -83,6 +86,12 @@ export function generate(input: GenerateInput): GenerateOutput {
if (tokens) files.set('react/tokens.css', tokens)
const tags = new Set()
for (const model of rendered) {
+ const mapped = input.react?.[model.name]
+ if (mapped) {
+ // The library styles itself: the adapter carries no stylesheet.
+ files.set(`react/${model.identifier}.tsx`, emitReactAdapter(model, mapped))
+ continue
+ }
files.set(`react/${model.identifier}.tsx`, emitReact(model, { components: byName }))
files.set(`react/${model.stem}.css`, emitCss(model))
if (model.tag) tags.add(model.tag)
diff --git a/packages/codegen/src/index.ts b/packages/codegen/src/index.ts
index 930abea..39da3d2 100644
--- a/packages/codegen/src/index.ts
+++ b/packages/codegen/src/index.ts
@@ -24,3 +24,4 @@ export {
export { emitCss, emitHtml, stateSelector } from './html.js'
export { emitElementTypes, emitIndex, emitModels, emitReact, emitRuntime, tsType } from './react.js'
export { checkConformance } from './conformance.js'
+export { emitReactAdapter, type ReactBinding } from './react-adapter.js'
diff --git a/packages/codegen/src/model.ts b/packages/codegen/src/model.ts
index 65ac61e..5e9bb55 100644
--- a/packages/codegen/src/model.ts
+++ b/packages/codegen/src/model.ts
@@ -316,12 +316,17 @@ export function componentModel(
const tags = manifest ? new Set(manifestTags(manifest).keys()) : undefined
const spec = component.spec ?? doc.spec
const contract = spec?.contract
- const profile = libraryProfile(library)
const binding = library?.components?.[component.name] ?? {}
const implemented =
typeof component.attrs.implements?.value === 'string'
? component.attrs.implements.value
: undefined
+ // With no headless element the root is a plain : props and states
+ // reflect as data attributes and parts as data-part, which a div can carry.
+ const profile: LibraryProfile =
+ implemented === undefined
+ ? { props: 'data-attribute', customStates: 'data-attribute', parts: 'data-part' }
+ : libraryProfile(library)
const tag = implemented === undefined ? undefined : (binding.tag ?? implemented)
const parts: PartInfo[] = []
const slots: SlotInfo[] = []
diff --git a/packages/codegen/src/react-adapter.ts b/packages/codegen/src/react-adapter.ts
new file mode 100644
index 0000000..1c0d8a0
--- /dev/null
+++ b/packages/codegen/src/react-adapter.ts
@@ -0,0 +1,145 @@
+import { enumValues } from '@uidx/format'
+import { modelOfType } from '@uidx/schema/design-system'
+
+import type { ComponentModel } from './model.js'
+import { tsType } from './react.js'
+
+/**
+ * An identity rendered onto a React component the team already ships
+ * (`uidx.json` `codegen.react`), instead of onto a headless custom element:
+ * the design system's contract stays the design system's, and a small
+ * generated adapter translates it into the library's spelling — the way
+ * Figma's Code Connect or Builder's mappers map a design component onto a
+ * code one, but checked into the repo as ordinary source.
+ *
+ * ```json
+ * "react": {
+ * "Button": {
+ * "from": "@acme/ui", "export": "Button",
+ * "props": { "variant": "appearance" },
+ * "values": { "variant": { "primary": "solid" } },
+ * "events": { "press": "onClick" },
+ * "children": "label"
+ * }
+ * }
+ * ```
+ */
+export interface ReactBinding {
+ /** The module the component is imported from. */
+ from: string
+ /** Its export name; the component's own name when absent. */
+ export?: string
+ /** Contract prop → the library's prop name; unmapped props pass under their own name. */
+ props?: Record
+ /** Contract prop → contract value → the library's value. */
+ values?: Record>
+ /** Contract event → the library's callback prop; `on` when absent. */
+ events?: Record
+ /** The contract prop (or slot) passed as the library component's children. */
+ children?: string
+ /** Contract props the library has no equivalent for, left out of the call. */
+ omit?: string[]
+}
+
+function pascal(text: string): string {
+ return text
+ .split(/[^A-Za-z0-9]+/)
+ .filter(Boolean)
+ .map((word) => word[0]!.toUpperCase() + word.slice(1))
+ .join('')
+}
+
+export function emitReactAdapter(model: ComponentModel, binding: ReactBinding): string {
+ const contract = model.contract
+ const exported = binding.export ?? model.identifier
+ const models = new Set()
+ const lines: string[] = []
+ const names: string[] = []
+ const call: string[] = []
+ const tables: string[] = []
+
+ for (const prop of contract?.props ?? []) {
+ const found = modelOfType(prop.type, model.spec, model.models)
+ if (found) models.add(pascal(found.model.name))
+ const optional = prop.default !== undefined || !found
+ lines.push(
+ ` /** ${prop.description} */`,
+ ` ${prop.name}${optional ? '?' : ''}: ${tsType(model, prop.type)}`,
+ )
+ const fallback =
+ prop.default !== undefined && prop.default !== null && typeof prop.default !== 'boolean'
+ ? ` = ${JSON.stringify(prop.default)}`
+ : ''
+ names.push(`${prop.name}${fallback}`)
+ if (binding.omit?.includes(prop.name) || binding.children === prop.name) continue
+ const target = binding.props?.[prop.name] ?? prop.name
+ const mapped = binding.values?.[prop.name]
+ if (mapped) {
+ const table = `${prop.name}Values`
+ // Every value of an enum is listed, unmapped ones as themselves, so the
+ // table's literal types say exactly what the library receives.
+ const domain = enumValues(prop.type)
+ const full = domain
+ ? Object.fromEntries(domain.map((value) => [value, mapped[value] ?? value]))
+ : mapped
+ tables.push(
+ domain
+ ? `const ${table} = ${JSON.stringify(full)} as const`
+ : `const ${table}: Record = ${JSON.stringify(full)}`,
+ )
+ call.push(
+ domain
+ ? `${target}={${prop.name} === undefined ? undefined : ${table}[${prop.name}]}`
+ : `${target}={${prop.name} === undefined ? undefined : (${table}[${prop.name}] ?? ${prop.name})}`,
+ )
+ } else call.push(`${target}={${prop.name}}`)
+ }
+ for (const slot of contract?.slots ?? []) {
+ const name = slot.name === 'default' ? 'children' : slot.name
+ lines.push(` /** ${slot.description} */`, ` ${name}?: ReactNode`)
+ names.push(name)
+ if (binding.children === slot.name || slot.name === 'default') continue
+ call.push(`${binding.props?.[slot.name] ?? slot.name}={${slot.name}}`)
+ }
+ for (const event of contract?.events ?? []) {
+ const handler = `on${pascal(event.name)}`
+ lines.push(
+ ` /** ${event.description} */`,
+ ` ${handler}?: (detail: ${event.detail ?? 'unknown'}) => void`,
+ )
+ names.push(handler)
+ call.push(`${binding.events?.[event.name] ?? handler}={${handler}}`)
+ }
+ const childExpression =
+ binding.children !== undefined
+ ? binding.children === 'default'
+ ? 'children'
+ : binding.children
+ : (contract?.slots ?? []).some((slot) => slot.name === 'default')
+ ? 'children'
+ : null
+
+ const needsNode = (contract?.slots ?? []).length > 0
+ const imports = [
+ ...(needsNode ? [`import type { ReactNode } from 'react'`] : []),
+ `import { ${exported} as Library } from '${binding.from}'`,
+ ]
+ if (models.size) imports.push(`import type { ${[...models].sort().join(', ')} } from './models'`)
+ const open = ` {${childExpression}} ` : `${open} />`,
+ ` )`,
+ `}`,
+ ].join('\n')}\n`
+}
diff --git a/packages/codegen/src/react.ts b/packages/codegen/src/react.ts
index 9fd301b..163fcc0 100644
--- a/packages/codegen/src/react.ts
+++ b/packages/codegen/src/react.ts
@@ -70,6 +70,8 @@ export function emitModels(models: ModelIndex): string {
}
lines.push('}', '')
}
+ // No models is still a module, so `index.ts` may re-export it.
+ if (models.size === 0) lines.push('export {}')
return `${lines.join('\n')}\n`
}
@@ -434,14 +436,17 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
: ''
const rootAttributes = [attributes, emptyAttr].filter(Boolean).join(' ')
const classNameExpr = classed.length
- ? `{[className, ${classed
+ ? `{[${model.tag ? '' : `'${model.stem}', `}className, ${classed
.map((prop) =>
prop.type === 'boolean'
? `${prop.name} ? '${attributeName(model, prop.name)}' : undefined`
: `${prop.name} !== undefined ? \`${attributeName(model, prop.name)}-\${${prop.name}}\` : undefined`,
)
.join(', ')}].filter(Boolean).join(' ')}`
- : '{className}'
+ : model.tag
+ ? '{className}'
+ : // Without a tag the stylesheet's root is the component's class.
+ `{['${model.stem}', className].filter(Boolean).join(' ')}`
const events = (contract?.events ?? []).map(
(event) => ` useElementEvent(ref, '${eventName(model, event.name)}', on${pascal(event.name)})`,
)
@@ -489,7 +494,7 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
` )`,
]
: [
- ` const ref = useRef(null)`,
+ ` const ref = useRef<${model.tag ? 'HTMLElement' : 'HTMLDivElement'}>(null)`,
...events,
` return (`,
` <${rootTag} ref={ref} className=${classNameExpr} style={style}${rootAttributes ? ` ${rootAttributes}` : ''}>`,
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
index c2537f8..8dab198 100644
--- a/packages/codegen/test/generate.test.ts
+++ b/packages/codegen/test/generate.test.ts
@@ -634,3 +634,64 @@ id: chip
expect(css).toMatch(/\[data-node="bar"\] \{\n {2}display: flex;/)
})
})
+
+describe('a component with no headless element', () => {
+ const doc = parseOrThrow(`---
+id: tag
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ Words.
+ Tone.
+
+`)
+ const files = generate({ pages: [{ file: 'tag.uidx', doc }], targets: ['react'] }).files
+
+ it('renders a div carrying its class and data attributes, as its CSS selects', () => {
+ const tsx = files.get('react/Tag.tsx')!
+ expect(tsx).toContain('useRef(null)')
+ expect(tsx).toContain(`className={['tag', className].filter(Boolean).join(' ')}`)
+ expect(tsx).toContain('data-tone={tone}')
+ expect(files.get('react/tag.css')).toContain('.tag[data-tone="warn"]')
+ })
+
+ it('writes models.ts as a module even when no model is declared', () => {
+ expect(files.get('react/models.ts')).toContain('export {}')
+ })
+})
+
+describe('mapping onto an existing React library (codegen.react)', () => {
+ it('writes an adapter that speaks the library, and no stylesheet', () => {
+ const files = generate({
+ pages: [{ file: 'checkbox.uidx', doc: CHECKBOX }],
+ targets: ['react'],
+ react: {
+ Checkbox: {
+ from: '@acme/ui',
+ export: 'Toggle',
+ props: { checked: 'isOn' },
+ events: { change: 'onToggle' },
+ },
+ },
+ }).files
+ const tsx = files.get('react/Checkbox.tsx')!
+ expect(tsx).toContain("import { Toggle as Library } from '@acme/ui'")
+ expect(tsx).toContain('isOn={checked}')
+ expect(tsx).toContain('onToggle={onChange}')
+ expect(files.has('react/checkbox.css')).toBe(false)
+ })
+})
diff --git a/packages/server/src/codegen.ts b/packages/server/src/codegen.ts
index 230ede6..8a0410c 100644
--- a/packages/server/src/codegen.ts
+++ b/packages/server/src/codegen.ts
@@ -4,6 +4,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
import { parse, type Diagnostic, type UidxDocument } from '@uidx/format'
import {
generate,
+ type GenerateInput,
type LibraryBindings,
type Manifest as ElementsManifest,
type Target,
@@ -79,7 +80,14 @@ async function renderDocument(
components: headless.bindings as LibraryBindings['components'],
}
}
- const result = generate({ pages, tokens, manifest, library, targets })
+ const result = generate({
+ pages,
+ tokens,
+ manifest,
+ library,
+ targets,
+ react: found.manifest.codegen?.react as GenerateInput['react'],
+ })
diagnostics.push(...result.diagnostics)
if (result.diagnostics.some((d) => d.severity === 'error')) return { ok: false, diagnostics }
return { ok: true, result, diagnostics }
diff --git a/packages/server/src/document.ts b/packages/server/src/document.ts
index ff4d4a1..7be353c 100644
--- a/packages/server/src/document.ts
+++ b/packages/server/src/document.ts
@@ -39,6 +39,8 @@ export interface Manifest {
export interface CodegenConfig {
out: string
targets?: string[]
+ /** Components rendered onto an existing React library (codegen `react-adapter`). */
+ react?: Record
}
/**
@@ -201,6 +203,24 @@ export async function readManifest(path: string): Promise {
problems.push(`${path}: "codegen.targets" may hold html, react, contract and stories`)
else codegen.targets = targets as string[]
}
+ const react = config!.react
+ if (react !== undefined) {
+ if (
+ typeof react !== 'object' ||
+ react === null ||
+ Array.isArray(react) ||
+ Object.values(react).some(
+ (entry) =>
+ typeof entry !== 'object' ||
+ entry === null ||
+ typeof (entry as Record).from !== 'string',
+ )
+ )
+ problems.push(
+ `${path}: "codegen.react" must map component names to { "from": module, "export"?, "props"?, "values"?, "events"?, "children"?, "omit"? }`,
+ )
+ else codegen.react = react as Record
+ }
}
}
From 53f365d473ed5e138f0ae8f38c569488190ba957 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 30 Sep 2026 23:08:41 +0000
Subject: [PATCH 40/74] =?UTF-8?q?codegen:=20a=20cem=20target=20=E2=80=94?=
=?UTF-8?q?=20a=20Custom=20Elements=20Manifest=20from=20the=20identities?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Tooling that reads Custom Elements Manifests (IDE completion, Storybook
for web components, wrapper generators, linters) could not see the design
system's elements. The `cem` target writes one schema 2.1.0 manifest:
attributes from contract props with their words and defaults, events with
detail types, slots, parts as cssParts, declared states as cssStates. CEM
has no accessibility or behaviour fields, so status, accessibility, form,
behaviour rules and composition travel in an `x-uidx` extension.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
packages/cli/src/commands/codegen.ts | 4 +-
packages/codegen/src/cem.ts | 82 ++++++++++++++++++++++++++
packages/codegen/src/generate.ts | 5 +-
packages/codegen/test/generate.test.ts | 14 +++++
packages/server/src/document.ts | 6 +-
5 files changed, 106 insertions(+), 5 deletions(-)
create mode 100644 packages/codegen/src/cem.ts
diff --git a/packages/cli/src/commands/codegen.ts b/packages/cli/src/commands/codegen.ts
index 6a7cc54..fe9f6d8 100644
--- a/packages/cli/src/commands/codegen.ts
+++ b/packages/cli/src/commands/codegen.ts
@@ -102,8 +102,8 @@ export async function runCodegen(argv: string[], io: Io): Promise {
: parsed.values.target!.split(',').map((t) => t.trim())
) as Target[]
for (const target of targets) {
- if (!['html', 'react', 'contract', 'stories'].includes(target)) {
- io.err(`unknown target "${target}"; choose from html, react, contract, stories\n`)
+ if (!['html', 'react', 'contract', 'stories', 'cem'].includes(target)) {
+ io.err(`unknown target "${target}"; choose from html, react, contract, stories, cem\n`)
return 1
}
}
diff --git a/packages/codegen/src/cem.ts b/packages/codegen/src/cem.ts
new file mode 100644
index 0000000..a299e2c
--- /dev/null
+++ b/packages/codegen/src/cem.ts
@@ -0,0 +1,82 @@
+import { enumValues } from '@uidx/format'
+
+import type { ComponentModel } from './model.js'
+
+/**
+ * A Custom Elements Manifest (schema 2.1.0) written from the identities: what
+ * tooling that reads CEM — IDE completions, Storybook for web components,
+ * `@wc-toolkit` wrappers, linters — learns about the design system's elements.
+ * CEM has no field for accessibility or behaviour, so those travel in a
+ * namespaced `x-uidx` extension beside each declaration rather than being
+ * dropped.
+ */
+export function emitCem(models: readonly ComponentModel[]): string {
+ const modules = models
+ .filter((model) => model.tag !== undefined)
+ .map((model) => {
+ const contract = model.contract
+ const declaration: Record = {
+ kind: 'class',
+ name: model.identifier,
+ tagName: model.tag,
+ customElement: true,
+ ...(model.doc.intent.raw.trim()
+ ? { description: model.doc.intent.raw.replace(/^#{1,6}\s.*$/gm, '').trim() }
+ : {}),
+ attributes: (contract?.props ?? [])
+ .filter(
+ (prop) =>
+ enumValues(prop.type) || ['boolean', 'string', 'number'].includes(prop.type ?? ''),
+ )
+ .map((prop) => ({
+ name: prop.name,
+ type: {
+ text: enumValues(prop.type)
+ ? enumValues(prop.type)!
+ .map((v) => `'${v}'`)
+ .join(' | ')
+ : prop.type,
+ },
+ description: prop.description,
+ ...(prop.default === undefined ? {} : { default: JSON.stringify(prop.default) }),
+ })),
+ events: (contract?.events ?? []).map((event) => ({
+ name: event.name,
+ description: event.description,
+ ...(event.detail ? { type: { text: `CustomEvent<${event.detail}>` } } : {}),
+ })),
+ slots: (contract?.slots ?? []).map((slot) => ({
+ name: slot.name === 'default' ? '' : slot.name,
+ description: slot.description,
+ })),
+ cssParts: model.parts.map((part) => ({
+ name: part.libraryName,
+ description: contract?.parts.find((entry) => entry.name === part.name)?.description ?? '',
+ })),
+ cssStates: (contract?.states ?? []).map((state) => ({
+ name: state.name,
+ description: state.description,
+ })),
+ 'x-uidx': {
+ status: model.node.attrs.status?.value ?? null,
+ accessibility: contract?.accessibility ?? null,
+ form: contract?.form ?? null,
+ behavior: (model.spec?.behavior ?? []).map((rule) => ({ id: rule.id, text: rule.text })),
+ composes: contract?.composes ?? [],
+ },
+ }
+ return {
+ kind: 'javascript-module',
+ path: `${model.stem}.js`,
+ declarations: [declaration],
+ exports: [
+ {
+ kind: 'custom-element-definition',
+ name: model.tag,
+ declaration: { name: model.identifier },
+ },
+ ],
+ }
+ })
+ return `${JSON.stringify({ schemaVersion: '2.1.0', readme: '', modules }, null, 2)}\n`
+}
diff --git a/packages/codegen/src/generate.ts b/packages/codegen/src/generate.ts
index e298cd0..6f2194b 100644
--- a/packages/codegen/src/generate.ts
+++ b/packages/codegen/src/generate.ts
@@ -12,9 +12,10 @@ import {
import { modelIndex } from '@uidx/schema/design-system'
import { emitElementTypes, emitIndex, emitModels, emitReact, emitRuntime } from './react.js'
import { emitStories } from './stories.js'
+import { emitCem } from './cem.js'
import { emitReactAdapter, type ReactBinding } from './react-adapter.js'
-export type Target = 'html' | 'react' | 'contract' | 'stories'
+export type Target = 'html' | 'react' | 'contract' | 'stories' | 'cem'
export interface GenerateInput {
/** Component pages: every `` with an `implements` or a contract is rendered. */
@@ -107,6 +108,8 @@ export function generate(input: GenerateInput): GenerateOutput {
files.set(`react/${model.identifier}.stories.tsx`, emitStories(model, Boolean(tokens)))
}
+ if (targets.has('cem')) files.set('custom-elements.json', emitCem(rendered))
+
if (targets.has('contract')) {
const seen = new Set()
for (const model of rendered) {
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
index 8dab198..e61e2d9 100644
--- a/packages/codegen/test/generate.test.ts
+++ b/packages/codegen/test/generate.test.ts
@@ -695,3 +695,17 @@ describe('mapping onto an existing React library (codegen.react)', () => {
expect(files.has('react/checkbox.css')).toBe(false)
})
})
+
+describe('the cem target', () => {
+ it('writes a 2.1.0 manifest with accessibility and behaviour in x-uidx', () => {
+ const files = generate({ pages: PAGES, tokens: [TOKENS], targets: ['cem'] }).files
+ const cem = JSON.parse(files.get('custom-elements.json')!)
+ expect(cem.schemaVersion).toBe('2.1.0')
+ const checkbox = cem.modules
+ .flatMap((module: { declarations: { tagName: string }[] }) => module.declarations)
+ .find((declaration: { tagName: string }) => declaration.tagName === 'hwc-checkbox')
+ expect(checkbox.attributes.map((a: { name: string }) => a.name)).toContain('checked')
+ expect(checkbox.cssParts.map((p: { name: string }) => p.name)).toContain('checked-indicator')
+ expect(checkbox['x-uidx']).toHaveProperty('accessibility')
+ })
+})
diff --git a/packages/server/src/document.ts b/packages/server/src/document.ts
index 7be353c..425ca59 100644
--- a/packages/server/src/document.ts
+++ b/packages/server/src/document.ts
@@ -197,10 +197,12 @@ export async function readManifest(path: string): Promise {
if (
!Array.isArray(targets) ||
targets.some(
- (target) => !['html', 'react', 'contract', 'stories'].includes(target as string),
+ (target) => !['html', 'react', 'contract', 'stories', 'cem'].includes(target as string),
)
)
- problems.push(`${path}: "codegen.targets" may hold html, react, contract and stories`)
+ problems.push(
+ `${path}: "codegen.targets" may hold html, react, contract, stories and cem`,
+ )
else codegen.targets = targets as string[]
}
const react = config!.react
From ae8c67ec6925eb20bcd5f1e9ffb6edb4418d3202 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 30 Sep 2026 23:11:40 +0000
Subject: [PATCH 41/74] `uidx export fig`: the design system as Figma files
(F2, first slice)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Designers could not take the system into Figma. `uidx export fig` draws
every page with the canvas's own scene build — derived variant sets,
instances of components on other pages, resolved tokens, model samples —
and writes one .fig per page (scene ids are page addresses, which two
pages may share, so a single multi-page file needs an id scheme first).
Each file parses back through the SDK's reader. Not yet carried: tokens
as Figma variables and component-set properties.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
packages/agent/src/core/fig.ts | 36 ++++++++++++++++++++++++++
packages/agent/src/core/index.ts | 1 +
packages/agent/test/fig-export.test.ts | 34 ++++++++++++++++++++++++
packages/cli/src/cli.ts | 5 ++++
packages/cli/src/commands/export.ts | 32 +++++++++++++++++++++++
5 files changed, 108 insertions(+)
create mode 100644 packages/agent/src/core/fig.ts
create mode 100644 packages/agent/test/fig-export.test.ts
create mode 100644 packages/cli/src/commands/export.ts
diff --git a/packages/agent/src/core/fig.ts b/packages/agent/src/core/fig.ts
new file mode 100644
index 0000000..d2ac7a1
--- /dev/null
+++ b/packages/agent/src/core/fig.ts
@@ -0,0 +1,36 @@
+import type { UidxDocument, UidxNode } from '@uidx/format'
+import { buildTokenIndex, resolveTokenValues, toSceneGraph, TokenResolver } from '@uidx/schema'
+import { exportFigFile } from '@open-pencil/core/io/formats/fig'
+
+/**
+ * The document as Figma files (backlog F2, first slice): one `.fig` per page,
+ * drawn by the same scene build the canvas uses — derived variant sets,
+ * instances of components on other pages, tokens resolved to their values,
+ * model samples in repeats. One file per page because scene ids are the
+ * page's own addresses, which two pages may share.
+ *
+ * Not yet carried: tokens as Figma variables (values arrive resolved), and
+ * the component-set properties Figma builds from `state=hover` names.
+ */
+export async function exportFig(
+ docs: ReadonlyMap,
+): Promise> {
+ const all = [...docs.values()]
+ const index = buildTokenIndex(all)
+ const literals = resolveTokenValues(all)
+ const components = new Map()
+ for (const doc of all)
+ for (const child of doc.tree.children)
+ if (child.element === 'Component') components.set(child.name, child)
+ const out = new Map()
+ for (const [file, doc] of docs) {
+ if (doc.tree.element !== 'Page') continue
+ const scene = toSceneGraph(doc, {
+ resolveAlias: (address) => literals.get(address),
+ resolveComponent: (name) => components.get(name),
+ tokens: { resolver: new TokenResolver(index), index },
+ })
+ out.set(file.replace(/\.uidx$/, '.fig'), await exportFigFile(scene.graph))
+ }
+ return out
+}
diff --git a/packages/agent/src/core/index.ts b/packages/agent/src/core/index.ts
index b7faaa6..72bb4bd 100644
--- a/packages/agent/src/core/index.ts
+++ b/packages/agent/src/core/index.ts
@@ -223,6 +223,7 @@ export { viewerSelection } from './viewer.js'
export { describeComponent, listComponents, listTokens } from './design-system.js'
export type { ComponentDetail, ComponentSummary, TokenRow } from './design-system.js'
export { appLintContext, lintAppSource } from './app-lint.js'
+export { exportFig } from './fig.js'
export type { AppFinding, AppLintContext } from './app-lint.js'
export type { ViewerSelection, ViewerSelectionResult } from './viewer.js'
diff --git a/packages/agent/test/fig-export.test.ts b/packages/agent/test/fig-export.test.ts
new file mode 100644
index 0000000..ba04b1a
--- /dev/null
+++ b/packages/agent/test/fig-export.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { parseOrThrow } from '@uidx/format'
+import { parseFigFile } from '@open-pencil/core/io/formats/fig'
+
+import { exportFig } from '../src/core/fig.js'
+
+describe('exportFig', () => {
+ it('writes one .fig per page that parses back', async () => {
+ const docs = new Map([
+ [
+ 'card.uidx',
+ parseOrThrow(`---
+id: card
+---
+
+## Visual Contract
+
+
+
+
+
+
+`),
+ ],
+ ])
+ const files = await exportFig(docs)
+ expect([...files.keys()]).toEqual(['card.fig'])
+ const bytes = files.get('card.fig')!
+ const parsed = await parseFigFile(
+ bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
+ )
+ expect(parsed).toBeTruthy()
+ })
+})
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 746440e..9ccc53c 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -25,6 +25,7 @@ import { runAdopt } from './commands/adopt.js'
import { runComponent, runComponents } from './commands/components.js'
import { runLint } from './commands/lint.js'
import { runDiff } from './commands/diff.js'
+import { runExport } from './commands/export.js'
import { projectFiles } from './project.js'
import { version } from '../package.json'
import { BootError, open } from './commands/open.js'
@@ -42,6 +43,8 @@ Usage:
uidx contract print a page's contract, behaviour, models and styles as JSON
uidx codegen --out
render HTML/CSS, React and contract JSON from the pages
+ uidx export fig [--out dir]
+ one Figma file per page, drawn as the canvas draws it
uidx diff [--base ref] [--fail-on-breaking]
design-system changes since a git ref, breaking ones marked
uidx lint [path...] app code against the design system: token literals, raw elements
@@ -178,6 +181,8 @@ export async function run(argv: string[], io: Io = processIo): Promise {
return await runLint(rest, io)
case 'diff':
return await runDiff(rest, io)
+ case 'export':
+ return await runExport(rest, io)
case 'component':
return await runComponent(rest, io)
case 'dev':
diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts
new file mode 100644
index 0000000..1281600
--- /dev/null
+++ b/packages/cli/src/commands/export.ts
@@ -0,0 +1,32 @@
+import { mkdir, writeFile } from 'node:fs/promises'
+import { dirname, join, resolve } from 'node:path'
+import { parseArgs } from 'node:util'
+import { exportFig } from '@uidx/agent/core'
+import { loadDocs } from './components.js'
+import type { Io } from '../cli.js'
+
+/** `uidx export fig [--root dir] --out `: one Figma file per page. */
+export async function runExport(argv: string[], io: Io): Promise {
+ const [format, ...rest] = argv
+ if (format !== 'fig') {
+ io.err('usage: uidx export fig [--root dir] --out \n')
+ return 1
+ }
+ const { values } = parseArgs({
+ args: rest,
+ options: { root: { type: 'string' }, out: { type: 'string', default: 'figma' } },
+ })
+ const cwd = io.cwd ?? process.cwd()
+ const { docs } = await loadDocs(cwd, values.root)
+ const files = await exportFig(docs)
+ const out = resolve(cwd, values.out!)
+ for (const [name, bytes] of files) {
+ const path = join(out, name)
+ await mkdir(dirname(path), { recursive: true })
+ await writeFile(path, bytes)
+ }
+ io.out(
+ `wrote ${files.size} .fig file${files.size === 1 ? '' : 's'} to ${values.out} — import them in Figma\n`,
+ )
+ return 0
+}
From f7ff8fd071fbe6e93c8ee37c51054dcf2f8356ec Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 30 Sep 2026 23:15:16 +0000
Subject: [PATCH 42/74] `uidx share`: a static, read-only site of the design
system
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
People who review a design system — product owners, stakeholders, a pull
request's reviewers — had to run the viewer to see it. `uidx share --out
` writes one self-contained page: every page drawn as the canvas
draws it (PNG, offline), each component's intent, props, events and
behaviour rules, and every token with its value, swatch and CSS variable,
in light and dark. The `uidx init --ci` workflow now builds it on every
pull request and uploads it as an artifact, so a PR shows the pages it
changes.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
packages/cli/src/cli.ts | 4 +
packages/cli/src/commands/share.ts | 123 +++++++++++++++++++++++++++++
packages/cli/src/starter.ts | 9 ++-
packages/cli/test/init.test.ts | 22 ++++++
4 files changed, 157 insertions(+), 1 deletion(-)
create mode 100644 packages/cli/src/commands/share.ts
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 9ccc53c..1841c2e 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -26,6 +26,7 @@ import { runComponent, runComponents } from './commands/components.js'
import { runLint } from './commands/lint.js'
import { runDiff } from './commands/diff.js'
import { runExport } from './commands/export.js'
+import { runShare } from './commands/share.js'
import { projectFiles } from './project.js'
import { version } from '../package.json'
import { BootError, open } from './commands/open.js'
@@ -43,6 +44,7 @@ Usage:
uidx contract print a page's contract, behaviour, models and styles as JSON
uidx codegen --out
render HTML/CSS, React and contract JSON from the pages
+ uidx share [--out dir] a static read-only site: page pictures, component docs, tokens
uidx export fig [--out dir]
one Figma file per page, drawn as the canvas draws it
uidx diff [--base ref] [--fail-on-breaking]
@@ -183,6 +185,8 @@ export async function run(argv: string[], io: Io = processIo): Promise {
return await runDiff(rest, io)
case 'export':
return await runExport(rest, io)
+ case 'share':
+ return await runShare(rest, io)
case 'component':
return await runComponent(rest, io)
case 'dev':
diff --git a/packages/cli/src/commands/share.ts b/packages/cli/src/commands/share.ts
new file mode 100644
index 0000000..38b2826
--- /dev/null
+++ b/packages/cli/src/commands/share.ts
@@ -0,0 +1,123 @@
+import { mkdir, writeFile } from 'node:fs/promises'
+import { join, resolve } from 'node:path'
+import { parseArgs } from 'node:util'
+import type { UidxDocument } from '@uidx/format'
+import { listComponents, listTokens, renderToPng } from '@uidx/agent/core'
+import { loadDocs } from './components.js'
+import type { Io } from '../cli.js'
+
+/**
+ * `uidx share --out `: the document as a static, read-only site — every
+ * page drawn as the canvas draws it, every component's documentation, every
+ * token — for the people who review a design system without running it: a
+ * product owner, a stakeholder, a pull request's preview deployment. No
+ * server and no account; any static host serves it.
+ */
+export async function runShare(argv: string[], io: Io): Promise {
+ const { values } = parseArgs({
+ args: argv,
+ options: { root: { type: 'string' }, out: { type: 'string', default: 'uidx-site' } },
+ })
+ const cwd = io.cwd ?? process.cwd()
+ const { root, docs } = await loadDocs(cwd, values.root)
+ const out = resolve(cwd, values.out!)
+ await mkdir(join(out, 'pages'), { recursive: true })
+
+ const pictures = new Map()
+ for (const [file, doc] of docs) {
+ if (doc.tree.element !== 'Page') continue
+ const name = `pages/${file.replace(/\.uidx$/, '').replace(/[\\/]/g, '__')}.png`
+ try {
+ await writeFile(join(out, name), await renderToPng({ docs, file, scale: 1 }))
+ pictures.set(file, name)
+ } catch (error) {
+ io.err(`${file}: not drawn — ${(error as Error).message}\n`)
+ }
+ }
+
+ const components = listComponents(docs)
+ const title = String(root.split(/[\\/]/).at(-2) ?? 'Design system')
+ const sections = components.map((component) => componentSection(component.name, docs, pictures))
+ const tokens = listTokens(docs)
+ const html = `
+
+${escape(title)} — design system
+
+
+${escape(title)}
+${components.length} components · ${tokens.length} tokens · generated by uidx share, read-only
+${components.map((c) => `${escape(c.name)} `).join('')}Tokens
+${sections.join('\n')}
+Tokens
+Token Value CSS Description
+${tokens
+ .map(
+ (token) =>
+ `${escape(token.token)}${
+ token.type === 'COLOR' && typeof token.value === 'string'
+ ? ` `
+ : ''
+ }${escape(JSON.stringify(token.value))} ${escape(token.css)}${escape(token.description)} `,
+ )
+ .join('\n')}
+
+
+`
+ await writeFile(join(out, 'index.html'), html)
+ io.out(
+ `wrote ${values.out}/index.html with ${components.length} components and ${pictures.size} page pictures\n`,
+ )
+ return 0
+}
+
+function componentSection(
+ name: string,
+ docs: ReadonlyMap,
+ pictures: ReadonlyMap,
+): string {
+ const [file, doc] = [...docs].find(([, d]) => d.tree.children.some((n) => n.name === name))!
+ const contract = doc.spec?.contract
+ const intent = doc.intent.raw.replace(/^#{1,6}\s.*$/gm, '').trim()
+ const picture = pictures.get(file)
+ const rows = (contract?.props ?? [])
+ .map(
+ (prop) =>
+ `${escape(prop.name)}${prop.visual ? ' visual ' : ''}${escape(prop.type ?? '')}${escape(prop.default === undefined ? '' : JSON.stringify(prop.default))}${escape(prop.description)} `,
+ )
+ .join('\n')
+ const events = (contract?.events ?? [])
+ .map((event) => `${escape(event.name)} — ${escape(event.description)} `)
+ .join('')
+ const behavior = (doc.spec?.behavior ?? [])
+ .map((rule) => `${escape(rule.id)} — ${escape(rule.text)} `)
+ .join('')
+ return `${escape(name)}
+${intent ? `${escape(intent).replace(/\n\n+/g, '
')}
` : ''}
+${picture ? ` ` : ''}
+${rows ? `Prop Type Default Description ${rows}
` : ''}
+${events ? `Events ` : ''}
+${behavior ? `Behaviour ` : ''}`
+}
+
+function anchor(name: string): string {
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-')
+}
+
+function escape(text: string): string {
+ return text.replace(
+ /[&<>"]/g,
+ (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!,
+ )
+}
diff --git a/packages/cli/src/starter.ts b/packages/cli/src/starter.ts
index 7111761..b07b413 100644
--- a/packages/cli/src/starter.ts
+++ b/packages/cli/src/starter.ts
@@ -143,7 +143,8 @@ export const CI_WORKFLOW = `name: Design system
# Written by \`uidx init --ci\`. On every pull request: the design files must
# check clean, application code must stay on the design system, and the
-# design-system changes — breaking ones marked — are posted on the PR.
+# design-system changes — breaking ones marked — are posted on the PR, and
+# every page is drawn into a static review site uploaded as an artifact.
on:
pull_request:
@@ -169,6 +170,12 @@ jobs:
run: npx --no-install uidx lint src
- name: Design-system changes
run: npx --no-install uidx diff --base origin/\${{ github.base_ref }} > uidx-diff.md
+ - name: Draw every page for review
+ run: npx --no-install uidx share --out uidx-site
+ - uses: actions/upload-artifact@v4
+ with:
+ name: uidx-site
+ path: uidx-site
- name: Post the changes on the pull request
uses: actions/github-script@v7
with:
diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts
index 56e5610..b2f3094 100644
--- a/packages/cli/test/init.test.ts
+++ b/packages/cli/test/init.test.ts
@@ -277,3 +277,25 @@ describe('init --ci', () => {
}
})
})
+
+describe('uidx share', () => {
+ it('writes a static site with every component and token', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'uidx-share-'))
+ try {
+ await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'share', private: true }))
+ await initProject(dir, 'uidx', { designSystem: true })
+ const out: string[] = []
+ const code = await run(['share', '--out', 'site'], {
+ cwd: join(dir, '.uidx'),
+ out: (text: string) => out.push(text),
+ err: () => {},
+ } as never)
+ expect(code).toBe(0)
+ const html = await readFile(join(dir, '.uidx/site/index.html'), 'utf8')
+ expect(html).toContain('id="button"')
+ expect(html).toContain('var(--color-accent)')
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+})
From a08dbaa2375dd0a1bcf70434f2bff0858b28b8f4 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 30 Sep 2026 23:20:44 +0000
Subject: [PATCH 43/74] Changelog and README: the design-system workflow end to
end
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
CHANGELOG.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++
README.md | 14 ++++++++++++++
2 files changed, 68 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5e4ff5..00ce706 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,60 @@ describe migrations when an existing document or integration is affected.
## Unreleased
+- **Fixed: token refactors missed the styles table.** A token used only in
+ `
diff --git a/packages/viewer/test/contract-edits.test.ts b/packages/viewer/test/contract-edits.test.ts
index 06c8f0f..1c6ceae 100644
--- a/packages/viewer/test/contract-edits.test.ts
+++ b/packages/viewer/test/contract-edits.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
-import { applyPatches, parseOrThrow, resolve } from '@uidx/format'
+import { applyPatches, parseOrThrow, resolve, type UidxPatch } from '@uidx/format'
import { defaultVariantAddress, derivedDocument, modelIndex } from '@uidx/schema'
import ContractSection from '../src/ContractSection.vue'
import PropertiesPane from '../src/PropertiesPane.vue'
@@ -537,6 +537,108 @@ describe('the Contract section', () => {
expect(bound.find('details[data-field="code-binding"]').attributes('open')).toBeDefined()
})
+ it('adds a choice prop as a visual enum, refusing fewer than two choices', async () => {
+ const section = mountFor(
+ page('badge', ` `),
+ 'Badge',
+ )
+ await section.find('[aria-label="Name to add"]').setValue('tone')
+ await section.find('[aria-label="Type to add"]').setValue('choice')
+ const choices = section.find('[aria-label="Choices, separated by commas"]')
+ const plus = section.find('[aria-label="Add declaration"]')
+ await choices.setValue('info')
+ expect(plus.attributes('disabled')).toBeDefined()
+ await choices.setValue('info, warning , danger')
+ await plus.trigger('click')
+ expect(section.emitted('patches')).toEqual([
+ [
+ [
+ {
+ op: 'contract',
+ kind: 'prop',
+ name: 'tone',
+ declaration: {
+ attrs: { type: "'info' | 'warning' | 'danger'", default: 'info', visual: true },
+ description: 'Describe the prop "tone".',
+ },
+ },
+ ],
+ ],
+ ])
+ })
+
+ it('turns a built-in state on as an empty style row, and off with every row it has', async () => {
+ const source = page(
+ 'badge',
+ ` `,
+ `
+
+
+
+
+
+## Contract
+
+
+ Tone.
+
+`,
+ )
+ const section = mountFor(source, 'Badge')
+ const hover = section.find('[data-state="hover"] input')
+ const focus = section.find('[data-state="focus"] input')
+ expect((hover.element as HTMLInputElement).checked).toBe(false)
+ expect((focus.element as HTMLInputElement).checked).toBe(true)
+ // No list prop, so there is no empty state to offer.
+ expect(section.find('[data-state="empty"]').exists()).toBe(false)
+ await hover.setValue(true)
+ await focus.setValue(false)
+ const [on, off] = section.emitted('patches')! as UidxPatch[][][]
+ expect(on![0]).toEqual([
+ { op: 'style', keys: { state: 'hover' }, target: '', prop: '', value: {} },
+ ])
+ expect(off![0]).toEqual([
+ { op: 'style', keys: { state: 'focus' }, target: '', prop: '' },
+ { op: 'style', keys: { state: 'focus', tone: 'info' }, target: '', prop: '' },
+ ])
+ const next = applyPatches(applyPatches(source, on![0]!).source, off![0]!).source
+ expect(next).toContain('\n \n ')
+ })
+
+ it('keeps a default of false, and puts a cleared description back to the placeholder', async () => {
+ const source = page(
+ 'chip',
+ ` `,
+ `
+## Contract
+
+
+ Whether it is on.
+
+`,
+ )
+ const section = mountFor(source, 'Chip')
+ await section.find('[data-prop="on"] .name').trigger('click')
+ const form = section.find('[data-editor="prop:on"]')
+ await form.find('[aria-label="Default"]').setValue('false')
+ await form.find('.field input').setValue('')
+ const [defaulted, cleared] = section.emitted('patches')! as UidxPatch[][][]
+ expect(defaulted![0]).toEqual([
+ {
+ op: 'contract',
+ kind: 'prop',
+ name: 'on',
+ declaration: {
+ attrs: { type: 'boolean', default: false },
+ description: 'Whether it is on.',
+ },
+ },
+ ])
+ expect(
+ (cleared![0]![0] as { declaration: { description: string } }).declaration.description,
+ ).toBe('Describe the prop "on".')
+ })
+
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')
From 37b8c25351df8a8d3dcae0820357274a1fd2e243 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 1 Oct 2026 03:18:06 +0000
Subject: [PATCH 46/74] Tokens editing: types, modes, collection names and
tiers, descriptions, new files
What a user could not do from the editor before, and now can:
- choose a new token's type (Color, Number, Text, Toggle); a new
collection could only ever hold numbers
- add, rename and remove a collection's modes, so dark mode can be made
in the editor; values start from an existing mode and node overrides
follow a rename (schema setCollectionModes)
- rename a collection, with every reference and override repointed
(schema renameCollection); a new collection offers its name at once
- set a collection's tier
- edit a token's description and the pickers it is offered in (scopes)
- create a component identity (with a starter contract) or a tokens file
from the New file dialog, and a tokens file from the empty Tokens tab
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
packages/schema/src/index.ts | 3 +
packages/schema/src/refactor.ts | 186 ++++++++++
packages/schema/test/refactor.test.ts | 109 +++++-
packages/server/src/pages.ts | 38 +-
packages/server/test/pages.test.ts | 19 +-
packages/viewer/src/App.vue | 122 ++++++-
packages/viewer/src/NewPageDialog.vue | 62 +++-
packages/viewer/src/TokenDetailPane.vue | 125 ++++++-
packages/viewer/src/TokensPane.vue | 337 +++++++++++++++++-
packages/viewer/test/new-page-dialog.test.ts | 30 +-
.../viewer/test/token-detail-pane.test.ts | 30 ++
packages/viewer/test/tokens-pane.test.ts | 61 +++-
12 files changed, 1082 insertions(+), 40 deletions(-)
diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts
index c5f7e96..8707f68 100644
--- a/packages/schema/src/index.ts
+++ b/packages/schema/src/index.ts
@@ -78,9 +78,12 @@ export { buildDependentsIndex, type Dependent, type DependentsIndex } from './sy
export {
deleteCollection,
deleteToken,
+ renameCollection,
renameComponent,
renameToken,
rewriteAliases,
+ setCollectionModes,
+ type ModeShape,
type DeletePlan,
type DeleteCollectionPlan,
type RefactorPlan,
diff --git a/packages/schema/src/refactor.ts b/packages/schema/src/refactor.ts
index 6280dc1..df2e041 100644
--- a/packages/schema/src/refactor.ts
+++ b/packages/schema/src/refactor.ts
@@ -2,6 +2,7 @@ import {
aliasTarget,
isAlias,
toAlias,
+ toNodeSpec,
type JsonValue,
type UidxDocument,
type UidxNode,
@@ -126,6 +127,191 @@ export function deleteCollection(
throw new Error(`no collection named ${JSON.stringify(name)} in this document`)
}
+/**
+ * Renames a collection: every reference to any of its tokens is repointed to
+ * the new `{collection#token}`, every node's `modes={{ old: … }}` override
+ * re-keyed, then the collection itself renamed — references first, as for a
+ * token.
+ */
+export function renameCollection(
+ pages: ReadonlyMap,
+ deps: DependentsIndex,
+ name: string,
+ newName: string,
+): RefactorPlan {
+ const found = declaringCollection(pages, name)
+ if (!found) throw new Error(`no collection named ${JSON.stringify(name)} in this document`)
+ const { file: declaringFile, node: collection } = found
+ const byFile = new Map()
+ const batchFor = (file: string): UidxPatch[] => {
+ const existing = byFile.get(file)
+ if (existing) return existing
+ const fresh: UidxPatch[] = []
+ byFile.set(file, fresh)
+ return fresh
+ }
+ // Several tokens of the collection may share one attribute (a fills array,
+ // a style cell); each rewrite builds on the one before it.
+ const rewrites = new Map()
+ for (const variable of collection.children) {
+ if (variable.element !== 'Variable') continue
+ const replacement = toAlias(`${newName}#${variable.name}`)
+ for (const dependent of deps.ofToken.get(variable.address) ?? []) {
+ const key = dependentKey(dependent)
+ const previous = rewrites.get(key)?.patch
+ const patch =
+ previous?.op === 'set' || (previous?.op === 'style' && previous.value !== undefined)
+ ? { ...previous, value: rewriteAliases(previous.value!, variable.address, replacement) }
+ : rewritePatch(pages, dependent, variable.address, replacement)
+ rewrites.set(key, { dependent, patch })
+ }
+ }
+ for (const { dependent, patch } of rewrites.values()) batchFor(dependent.file).push(patch)
+ for (const [file, patch] of modeOverridePatches(pages, name, (modes) => {
+ const { [name]: mode, ...rest } = modes
+ return { ...rest, [newName]: mode! }
+ }))
+ batchFor(file).push(patch)
+ batchFor(declaringFile).push({
+ op: 'set',
+ address: collection.address,
+ prop: 'name',
+ value: newName,
+ })
+ return { byFile, dependents: [...rewrites.values()].map((entry) => entry.dependent) }
+}
+
+/** One mode of a reshaped collection: its name, and the mode its values start from. */
+export interface ModeShape {
+ name: string
+ /** An existing mode of the collection (`IMPLICIT_MODE` for a modeless one). */
+ from: string
+}
+
+/**
+ * Gives a collection a new list of modes — adding one (`from` an existing
+ * mode, so every token starts complete, UIDX127), renaming one, removing one
+ * or reordering them. Each variable is rewritten with one `` child per
+ * mode, or a plain `value` when one mode is left; node overrides naming a
+ * renamed mode follow it, and ones naming a removed mode are dropped.
+ */
+export function setCollectionModes(
+ pages: ReadonlyMap,
+ name: string,
+ modes: readonly ModeShape[],
+): RefactorPlan {
+ const found = declaringCollection(pages, name)
+ if (!found) throw new Error(`no collection named ${JSON.stringify(name)} in this document`)
+ if (modes.length === 0) throw new Error('a collection keeps at least one mode')
+ const names = modes.map((mode) => mode.name)
+ if (new Set(names).size !== names.length) throw new Error('two modes cannot share a name')
+ const { file: declaringFile, node: collection } = found
+ const moded = modes.length > 1
+ // Every step of a batch must leave a valid document, and a moded collection
+ // with a plain-valued variable is not one; so the collection is written
+ // again whole, in one remove and one insert at the same place.
+ const { modes: _modes, ...collectionAttrs } = toNodeSpec(collection).attrs
+ void _modes
+ const variables = collection.children.map((variable) => {
+ const spec = toNodeSpec(variable)
+ if (variable.element !== 'Variable') return spec
+ const valueIn = (mode: string): JsonValue =>
+ variable.attrs.value?.value ??
+ variable.children.find(
+ (child) => child.element === 'Mode' && child.attrs.name?.value === mode,
+ )?.attrs.value?.value ??
+ variable.children.find((child) => child.element === 'Mode')?.attrs.value?.value ??
+ null
+ const { value: _value, ...attrs } = spec.attrs
+ void _value
+ return moded
+ ? {
+ element: 'Variable' as const,
+ attrs,
+ children: modes.map((mode) => ({
+ element: 'Mode' as const,
+ attrs: { name: mode.name, value: valueIn(mode.from) },
+ })),
+ }
+ : { element: 'Variable' as const, attrs: { ...attrs, value: valueIn(modes[0]!.from) } }
+ })
+ const parent = pages.get(declaringFile)!.tree
+ const batch: UidxPatch[] = [
+ { op: 'remove-node', address: collection.address },
+ {
+ op: 'insert-node',
+ parent: parent.address,
+ index: parent.children.indexOf(collection),
+ node: {
+ element: 'Collection',
+ attrs: moded ? { ...collectionAttrs, modes: names } : collectionAttrs,
+ children: variables,
+ },
+ },
+ ]
+ const renamed = new Map(modes.map((mode) => [mode.from, mode.name]))
+ const byFile = new Map()
+ for (const [file, patch] of modeOverridePatches(pages, name, (overrides) => {
+ const next = renamed.get(String(overrides[name]))
+ const { [name]: _gone, ...rest } = overrides
+ void _gone
+ return next !== undefined && moded ? { ...rest, [name]: next } : rest
+ })) {
+ const list = byFile.get(file) ?? []
+ list.push(patch)
+ byFile.set(file, list)
+ }
+ byFile.set(declaringFile, [...(byFile.get(declaringFile) ?? []), ...batch])
+ return { byFile, dependents: [] }
+}
+
+function declaringCollection(
+ pages: ReadonlyMap,
+ name: string,
+): { file: string; node: UidxNode } | null {
+ for (const [file, doc] of pages) {
+ if (doc.tree.element !== 'Tokens') continue
+ const node = doc.tree.children.find(
+ (child) => child.element === 'Collection' && child.name === name,
+ )
+ if (node) return { file, node }
+ }
+ return null
+}
+
+/** `[file, patch]` for every node whose `modes` override names `collection`, rewritten by `change`. */
+function modeOverridePatches(
+ pages: ReadonlyMap,
+ collection: string,
+ change: (modes: Record) => Record,
+): [string, UidxPatch][] {
+ const out: [string, UidxPatch][] = []
+ for (const [file, doc] of pages) {
+ if (doc.tree.element === 'Tokens') continue
+ const walk = (node: UidxNode): void => {
+ const modes = node.attrs.modes?.value
+ if (
+ modes &&
+ typeof modes === 'object' &&
+ !Array.isArray(modes) &&
+ Object.hasOwn(modes, collection)
+ ) {
+ const next = change(modes)
+ if (JSON.stringify(next) !== JSON.stringify(modes))
+ out.push([
+ file,
+ Object.keys(next).length
+ ? { op: 'set', address: node.address, prop: 'modes', value: next }
+ : { op: 'remove', address: node.address, prop: 'modes' },
+ ])
+ }
+ node.children.forEach(walk)
+ }
+ walk(doc.tree)
+ }
+ return out
+}
+
function dependentKey(dependent: Dependent): string {
return JSON.stringify([
dependent.file,
diff --git a/packages/schema/test/refactor.test.ts b/packages/schema/test/refactor.test.ts
index 35860f2..8ebe391 100644
--- a/packages/schema/test/refactor.test.ts
+++ b/packages/schema/test/refactor.test.ts
@@ -1,9 +1,22 @@
import { describe, expect, it } from 'vitest'
-import { applyPatches, inversePatches, parseOrThrow, type UidxDocument } from '@uidx/format'
+import {
+ applyPatches,
+ inversePatches,
+ parseOrThrow,
+ type UidxDocument,
+ type UidxPatch,
+} from '@uidx/format'
import { buildTokenIndex } from '../src/token-index.js'
import { buildDependentsIndex } from '../src/symbol-deps.js'
-import { deleteCollection, deleteToken, renameComponent, renameToken } from '../src/refactor.js'
+import {
+ deleteCollection,
+ deleteToken,
+ renameCollection,
+ renameComponent,
+ renameToken,
+ setCollectionModes,
+} from '../src/refactor.js'
import { defaultTuple, TokenResolver } from '../src/resolve-modes.js'
const doc = (id: string, body: string) =>
@@ -460,3 +473,95 @@ id: button
expect(parseOrThrow(next).spec?.styles?.[0]?.values.root?.fills).not.toBeUndefined()
})
})
+
+describe('collection rename and modes', () => {
+ const apply = (plan: { byFile: ReadonlyMap }) =>
+ new Map(
+ [...pages].map(([file, page]) => [
+ file,
+ applyPatches(page.source, [...(plan.byFile.get(file) ?? [])]).source,
+ ]),
+ )
+
+ it('renames a collection, repointing every reference and override', () => {
+ const themed = doc(
+ 'themed',
+ `
+
+ `,
+ )
+ const all = new Map([...pages, ['themed.uidx', themed]])
+ const plan = renameCollection(all, buildDependentsIndex(all), 'palette', 'base')
+ const core = applyPatches(CORE.source, [...plan.byFile.get('core.uidx')!]).source
+ expect(core).toContain('')
+ expect(core).toContain('value="{base#blue-500}"')
+ const home = applyPatches(HOME.source, [...plan.byFile.get('home.uidx')!]).source
+ expect(home).toContain("color: '{base#blue-500}'")
+ expect(home).toContain('opacity="{base#blue-500}"')
+
+ const moded = renameCollection(all, buildDependentsIndex(all), 'semantic', 'theme')
+ const page = applyPatches(themed.source, [...moded.byFile.get('themed.uidx')!]).source
+ expect(page).toContain("modes={{ theme: 'dark' }}")
+ expect(page).toContain('{theme#brand}')
+ expect(parseOrThrow(page)).toBeTruthy()
+ })
+
+ it('adds a mode copied from another, renames one, and folds back to a plain value', () => {
+ const added = apply(
+ setCollectionModes(pages, 'palette', [
+ { name: 'light', from: 'default' },
+ { name: 'dark', from: 'default' },
+ ]),
+ )
+ const core = parseOrThrow(added.get('core.uidx')!)
+ const index = buildTokenIndex([core])
+ expect(index.collections.get('palette')!.modes).toEqual(['light', 'dark'])
+ expect(index.entries.get('palette#lonely')!.valuesByMode).toEqual({ light: 1, dark: 1 })
+ // The semantic alias into the palette survives the rewrite.
+ expect(index.entries.get('semantic#brand')!.valuesByMode.light).toBe('{palette#blue-500}')
+
+ const renamed = apply(
+ setCollectionModes(pages, 'semantic', [
+ { name: 'day', from: 'light' },
+ { name: 'night', from: 'dark' },
+ ]),
+ ).get('core.uidx')!
+ const night = buildTokenIndex([parseOrThrow(renamed)])
+ expect(night.collections.get('semantic')!.modes).toEqual(['day', 'night'])
+ expect(night.entries.get('semantic#brand')!.valuesByMode).toEqual({
+ day: '{palette#blue-500}',
+ night: { r: 1, g: 1, b: 1, a: 1 },
+ })
+
+ const folded = apply(
+ setCollectionModes(pages, 'semantic', [{ name: 'light', from: 'light' }]),
+ ).get('core.uidx')!
+ const plain = buildTokenIndex([parseOrThrow(folded)])
+ expect(plain.collections.get('semantic')!.modes).toEqual(['default'])
+ expect(plain.entries.get('semantic#brand')!.valuesByMode).toEqual({
+ default: '{palette#blue-500}',
+ })
+ expect(() => setCollectionModes(pages, 'semantic', [])).toThrow(/at least one/)
+ })
+
+ it('re-keys node overrides when a mode is renamed and drops them when it goes', () => {
+ const themed = doc(
+ 'themed',
+ `
+
+ `,
+ )
+ const all = new Map([...pages, ['themed.uidx', themed]])
+ const renamed = setCollectionModes(all, 'semantic', [
+ { name: 'light', from: 'light' },
+ { name: 'night', from: 'dark' },
+ ])
+ expect(renamed.byFile.get('themed.uidx')).toEqual([
+ { op: 'set', address: 'card', prop: 'modes', value: { semantic: 'night' } },
+ ])
+ const removed = setCollectionModes(all, 'semantic', [{ name: 'light', from: 'light' }])
+ expect(removed.byFile.get('themed.uidx')).toEqual([
+ { op: 'remove', address: 'card', prop: 'modes' },
+ ])
+ })
+})
diff --git a/packages/server/src/pages.ts b/packages/server/src/pages.ts
index 2c642aa..b7148c2 100644
--- a/packages/server/src/pages.ts
+++ b/packages/server/src/pages.ts
@@ -14,8 +14,36 @@ class PageError extends Error {
}
}
+/** What a new file holds: a canvas, one component's identity, or the tokens. */
+export type NewFileKind = 'page' | 'component' | 'tokens'
+
+const pascal = (text: string): string =>
+ text
+ .split(/[^A-Za-z0-9]+/)
+ .filter(Boolean)
+ .map((word) => word[0]!.toUpperCase() + word.slice(1))
+ .join('')
+ .replace(/^[0-9]+/, '')
+
+/** The starting source for each kind — valid, and saying what to write next. */
+export function starterSource(kind: NewFileKind, stem: string, name: string): string {
+ const front = `---\nid: ${JSON.stringify(stem)}\n---\n\n`
+ if (kind === 'tokens') {
+ return `${front}The design system's foundations: name each collection after what it holds, and give a collection modes (light and dark) when its values change with them.\n\n## Visual Contract\n\n\n \n \n \n \n \n \n \n`
+ }
+ if (kind === 'component') {
+ const component = pascal(name) || 'Component'
+ return `${front}Describe what ${component} is for, and when to reach for something else.\n\n## Visual Contract\n\n\n \n \n \n \n\n## Contract\n\n\n The words it shows. \n \n`
+ }
+ return `${front}## Core Intent\n\nDescribe what this page is for.\n\n## Visual Contract\n\n\n \n`
+}
+
/** Page names become filenames; callers never supply a write path. */
-async function createPage(workspace: Workspace, value: unknown): Promise {
+async function createPage(
+ workspace: Workspace,
+ value: unknown,
+ kind: NewFileKind = 'page',
+): Promise {
if (typeof value !== 'string' || !value.trim() || value.trim().length > 100) {
throw new PageError(400, 'Enter a page name of 1–100 characters.')
}
@@ -75,7 +103,7 @@ async function createPage(workspace: Workspace, value: unknown): Promise
)
}
}
- const source = `---\nid: ${JSON.stringify(stem)}\n---\n\n## Core Intent\n\nDescribe what this page is for.\n\n## Visual Contract\n\n\n \n`
+ const source = starterSource(kind, stem, name)
parseOrThrow(source)
try {
// Exclusive creation also prevents overwriting a file created by another client.
@@ -124,7 +152,11 @@ export function pagesRoutePlugin(holder: { current: Workspace | null }): ViewerP
} catch {
return answer(400, { error: 'The body must be JSON.' })
}
- void createPage(workspace, (body as { name?: unknown } | null)?.name).then(
+ const request_ = body as { name?: unknown; kind?: unknown } | null
+ const kind = request_?.kind ?? 'page'
+ if (kind !== 'page' && kind !== 'component' && kind !== 'tokens')
+ return answer(400, { error: 'A new file is a page, a component or tokens.' })
+ void createPage(workspace, request_?.name, kind).then(
(file) => answer(201, { file }),
(error: unknown) =>
answer(error instanceof PageError ? error.status : 500, {
diff --git a/packages/server/test/pages.test.ts b/packages/server/test/pages.test.ts
index 2e02148..1eced0d 100644
--- a/packages/server/test/pages.test.ts
+++ b/packages/server/test/pages.test.ts
@@ -31,14 +31,29 @@ describe.each(['built', 'development'] as const)('%s page creation', (mode) => {
port: 4840,
})
}
- function post(name: unknown, origin = server!.url!) {
+ function post(name: unknown, origin = server!.url!, kind?: unknown) {
return fetch(`${server!.url}/__uidx/pages`, {
method: 'POST',
headers: { origin, 'content-type': 'application/json' },
- body: JSON.stringify({ name }),
+ body: JSON.stringify(kind === undefined ? { name } : { name, kind }),
})
}
+ it('creates a component identity or a tokens file when asked for one', async () => {
+ await start()
+ expect((await post('Primary button', undefined, 'component')).status).toBe(201)
+ const component = parseOrThrow(await readFile(join(dir, 'primary-button.uidx'), 'utf8'))
+ expect(component.tree.children[0]).toMatchObject({
+ element: 'Component',
+ name: 'PrimaryButton',
+ })
+ expect(component.spec!.contract!.props.map((prop) => prop.name)).toEqual(['label'])
+ expect((await post('Tokens', undefined, 'tokens')).status).toBe(201)
+ const tokens = parseOrThrow(await readFile(join(dir, 'tokens.uidx'), 'utf8'))
+ expect(tokens.tree.element).toBe('Tokens')
+ expect((await post('Other', undefined, 'script')).status).toBe(400)
+ })
+
it('creates a valid blank page and announces it before responding', async () => {
await start()
const messages: ServerMessage[] = []
diff --git a/packages/viewer/src/App.vue b/packages/viewer/src/App.vue
index 0b8e0a7..8440afe 100644
--- a/packages/viewer/src/App.vue
+++ b/packages/viewer/src/App.vue
@@ -18,10 +18,14 @@ import {
defaultTuple,
deleteCollection,
deleteToken,
+ IMPLICIT_MODE,
+ renameCollection,
renameToken,
+ setCollectionModes,
TokenResolver,
tupleAt,
type Dependent,
+ type ModeShape,
modelIndex,
derivedTarget,
} from '@uidx/schema'
@@ -85,6 +89,7 @@ import { tokensViewModel } from './tokens-view-model'
import { tokenAliasCandidates } from './token-alias-candidates'
import { addCollectionPatch, addTokenPatch, editCellPatch } from './token-edits'
import HomePane from './HomePane.vue'
+import NewPageDialog from './NewPageDialog.vue'
import { homeModel, type PageCard } from './home-model'
import { createThumbnailer } from './thumbnails'
import { createUidxSocket, type ConnectionState } from './socket'
@@ -959,13 +964,13 @@ const TOKEN_SEED: Record = {
* shape from there. (The design studies put the row straight into edit state;
* that refinement rides on the rename flow rather than a dialog here.)
*/
-function onAddToken(collection: string): void {
+function onAddToken(collection: string, chosen?: VariableType): void {
const group = tokenGroups.value.find((g) => g.name === collection)
if (!group) return
const declaring = pages.value.get(group.file)
const node = declaring?.tree.children.find((c) => c.name === collection)
if (!declaring || !node) return
- const type = group.rows[0]?.type ?? 'FLOAT'
+ const type = chosen ?? group.rows[0]?.type ?? 'FLOAT'
const name = freshName('new-token', (candidate) =>
tokenIndex.value.entries.has(`${collection}#${candidate}`),
)
@@ -1042,6 +1047,91 @@ function onTokenDelete(): void {
selection.value = []
}
+const creatingTokensFile = ref(false)
+function onTokensFileCreated(file: string): void {
+ creatingTokensFile.value = false
+ openPage(file)
+}
+
+/** A refactor plan sent file by file, or its refusal shown, never half-built. */
+function commitPlan(build: () => { byFile: ReadonlyMap }): boolean {
+ try {
+ commitAcrossPages(build().byFile)
+ return true
+ } catch (error) {
+ onRefused(error instanceof Error ? error.message : String(error))
+ return false
+ }
+}
+
+function onRenameCollection(name: string, newName: string): void {
+ if (!commitPlan(() => renameCollection(pages.value, dependents.value, name, newName))) return
+ selection.value = selection.value.map((address) =>
+ address.startsWith(`${name}#`) ? `${newName}${address.slice(name.length)}` : address,
+ )
+}
+
+function onSetTier(name: string, tier: string | null): void {
+ const group = tokenGroups.value.find((candidate) => candidate.name === name)
+ const node = group && pages.value.get(group.file)?.tree.children.find((c) => c.name === name)
+ if (!group || !node) return
+ const has = node.attrs.tier !== undefined
+ if (tier === null && !has) return
+ dispatch(
+ group.file,
+ [
+ tier === null
+ ? { op: 'remove', address: node.address, prop: 'tier' }
+ : { op: has ? 'set' : 'add', address: node.address, prop: 'tier', value: tier },
+ ],
+ true,
+ )
+}
+
+/** The collection's modes as authored: the implicit one when it declares none. */
+function modeShapes(name: string): ModeShape[] {
+ const modes = tokenIndex.value.collections.get(name)?.modes ?? [IMPLICIT_MODE]
+ return modes.map((mode) => ({ name: mode, from: mode }))
+}
+
+function onAddMode(name: string, mode: string): void {
+ const current = modeShapes(name)
+ // A collection without modes gets two: its values become the first, named
+ // as the opposite of the new one when that is obvious.
+ const first =
+ current.length === 1
+ ? [
+ {
+ name: mode === 'dark' ? 'light' : mode === 'light' ? 'dark' : current[0]!.name,
+ from: current[0]!.from,
+ },
+ ]
+ : current
+ commitPlan(() =>
+ setCollectionModes(pages.value, name, [...first, { name: mode, from: first[0]!.from }]),
+ )
+}
+
+function onRenameMode(name: string, mode: string, newName: string): void {
+ commitPlan(() =>
+ setCollectionModes(
+ pages.value,
+ name,
+ modeShapes(name).map((shape) => (shape.name === mode ? { ...shape, name: newName } : shape)),
+ ),
+ )
+}
+
+function onRemoveMode(name: string, mode: string): void {
+ commitPlan(() =>
+ setCollectionModes(
+ pages.value,
+ name,
+ modeShapes(name).filter((shape) => shape.name !== mode),
+ ),
+ )
+}
+
const removingCollection = ref(null)
watch(view, () => {
removingCollection.value = null
@@ -1094,6 +1184,21 @@ function onTokenDeprecate(value: boolean): void {
commitAcrossPages(new Map([[row.file, [patch]]]))
}
+function onTokenAttr(prop: string, value: JsonValue | null): void {
+ const row = selectedTokenRow.value
+ if (!row) return
+ const declaring = pages.value.get(row.file)
+ const node = declaring ? findTokenNode(declaring, row.address) : null
+ if (!node) return
+ const has = node.attrs[prop] !== undefined
+ if (value === null && !has) return
+ const patch: UidxPatch =
+ value === null
+ ? { op: 'remove', address: row.address, prop }
+ : { op: has ? 'set' : 'add', address: row.address, prop, value }
+ commitAcrossPages(new Map([[row.file, [patch]]]))
+}
+
function findTokenNode(pageDoc: UidxDocument, address: string) {
for (const collection of pageDoc.tree.children) {
for (const variable of collection.children) {
@@ -1906,6 +2011,18 @@ onUnmounted(() => socket.close())
@add-token="onAddToken"
@add-collection="onAddCollection"
@remove-collection="onRemoveCollection"
+ @rename-collection="onRenameCollection"
+ @set-tier="onSetTier"
+ @add-mode="onAddMode"
+ @rename-mode="onRenameMode"
+ @remove-mode="onRemoveMode"
+ @new-tokens-file="creatingTokensFile = true"
+ />
+
@@ -1920,6 +2037,7 @@ onUnmounted(() => socket.close())
@rename="onTokenRename"
@delete="onTokenDelete"
@deprecate="onTokenDeprecate"
+ @attr="onTokenAttr"
/>
diff --git a/packages/viewer/src/NewPageDialog.vue b/packages/viewer/src/NewPageDialog.vue
index 6a5f18c..aed7697 100644
--- a/packages/viewer/src/NewPageDialog.vue
+++ b/packages/viewer/src/NewPageDialog.vue
@@ -1,8 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+ {{ page }}
+
+
+
+
+
+
+
+
+
+ {{ face.label }}
+
+
+
+
+
+
+
+
+
+ {{ status.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ask AI
+
+
+
+
+
+
+
+ {{ codeRunning ? 'Generating…' : 'Get code' }}
+
+
+
+ {{ codeRunning ? 'Generating…' : 'Code' }}
+
+ {{
+ codeNotice ||
+ `Writes the headless elements, the React adapters, tokens and types into ${codeOut}.`
+ }}
+
+
+
+ Choose where code goes
+
+ Add "codegen": {{ '{ "out": "src/ds" }' }} to uidx.json, or
+ run npx uidx codegen in a terminal.
+
+
+
Close
+
+
+
+
+
+
+
diff --git a/packages/viewer/src/CanvasPane.vue b/packages/viewer/src/CanvasPane.vue
index f6a2720..9c96188 100644
--- a/packages/viewer/src/CanvasPane.vue
+++ b/packages/viewer/src/CanvasPane.vue
@@ -70,6 +70,7 @@ import { coverageForFont, fontGeneration, fontLibraryError, projectFonts } from
import { collapseBurst, novelPatches } from './patch-burst'
import { useCanvasControls } from './useCanvasControls'
import { hoverTargetFor } from './hover-map'
+import { theme } from './theme'
const props = defineProps<{
doc: UidxDocument | null
@@ -175,8 +176,34 @@ const onSurfaceReady = (): void => {
ready.value = true
void renderWithAssets(props.doc)
}
+/**
+ * The rulers in the chrome's theme. They are drawn by the renderer, not CSS,
+ * so the colours are handed over as floats when the theme changes.
+ */
+const RULERS = {
+ dark: {
+ background: { r: 0.157, g: 0.157, b: 0.169, a: 1 },
+ tick: { r: 0.4, g: 0.4, b: 0.42, a: 1 },
+ text: { r: 0.6, g: 0.6, b: 0.62, a: 1 },
+ // Drawn on the accent-coloured selection badges.
+ label: { r: 1, g: 1, b: 1, a: 1 },
+ },
+ light: {
+ background: { r: 1, g: 1, b: 1, a: 1 },
+ tick: { r: 0.8, g: 0.81, b: 0.83, a: 1 },
+ text: { r: 0.45, g: 0.47, b: 0.52, a: 1 },
+ label: { r: 1, g: 1, b: 1, a: 1 },
+ },
+} as const
+editor.state.rulerTheme = RULERS[theme.value]
+
const sceneCanvas = useCanvas(sceneEl, editor, { layer: 'scene', onReady: onSurfaceReady })
const overlayCanvas = useCanvas(canvasEl, editor, { layer: 'overlays', onReady: onSurfaceReady })
+// The renderer reads the theme on every frame; a switch only needs one drawn.
+watch(theme, (value) => {
+ editor.state.rulerTheme = RULERS[value]
+ if (ready.value) overlayCanvas.renderNow()
+})
/** Both layers at once, lower first — every caller here means the whole picture. */
const canvas = {
renderNow: (): void => {
@@ -1038,10 +1065,18 @@ const RASTER_FILE = /\.(png|jpe?g|gif|webp|avif)$/i
* server, then placed as a rectangle filled with it at its own size (halved
* for a 2x export, capped so a camera photo does not cover the page).
*/
-async function dropRaster(file: File, event: DragEvent): Promise {
+/** An image chosen in the Insert panel: placed at the middle of what is on screen. */
+function placeImage(file: File): Promise {
+ const el = canvasEl.value
+ const box = el?.getBoundingClientRect()
+ const at = box ? editor.screenToCanvas(box.width / 2, box.height / 2) : { x: 0, y: 0 }
+ return dropRaster(file, at)
+}
+
+async function dropRaster(file: File, point: DragEvent | { x: number; y: number }): Promise {
const built = scene.value
if (!built || !current) return
- const at = toCanvasPoint(event)
+ const at = 'clientX' in point ? toCanvasPoint(point) : point
const chain = containerChainAt(built.graph, built.rootId, at, '')
.map((id) => built.addresses.addressOf(id))
.filter((address): address is string => address !== undefined)
@@ -2161,6 +2196,7 @@ function invalidateAsset(src: string): void {
}
defineExpose({
+ placeImage,
editVector,
vectorAction: (action: VectorAction) => controls.vectorAction(action),
finishDrawing,
diff --git a/packages/viewer/src/ContractSection.vue b/packages/viewer/src/ContractSection.vue
index 0216798..270248a 100644
--- a/packages/viewer/src/ContractSection.vue
+++ b/packages/viewer/src/ContractSection.vue
@@ -1754,7 +1754,6 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>
.pick {
width: 100%;
cursor: pointer;
- color-scheme: dark;
}
.row[data-set='false'] .pick,
.row[data-set='false'] .text {
diff --git a/packages/viewer/src/DocsPane.vue b/packages/viewer/src/DocsPane.vue
index 886f946..ac59ca4 100644
--- a/packages/viewer/src/DocsPane.vue
+++ b/packages/viewer/src/DocsPane.vue
@@ -614,7 +614,6 @@ function flags(prop: { controllable?: boolean; visual?: boolean }): string[] {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 5px;
- color-scheme: dark;
}
.editor textarea {
width: 100%;
diff --git a/packages/viewer/src/FontsPane.vue b/packages/viewer/src/FontsPane.vue
index 35e1016..76d0ec4 100644
--- a/packages/viewer/src/FontsPane.vue
+++ b/packages/viewer/src/FontsPane.vue
@@ -640,7 +640,6 @@ input::placeholder {
color: var(--text-faint);
}
select {
- color-scheme: dark;
}
.library-toolbar select {
min-width: 140px;
diff --git a/packages/viewer/src/HomePane.vue b/packages/viewer/src/HomePane.vue
index e7c0391..c76db87 100644
--- a/packages/viewer/src/HomePane.vue
+++ b/packages/viewer/src/HomePane.vue
@@ -186,48 +186,52 @@ const kpis = computed(() => {
flex: 1;
min-width: 0;
overflow: auto;
- padding: var(--section-pad);
+ padding: 36px max(24px, calc((100% - 1200px) / 2)) 64px;
background: var(--bg);
}
.masthead {
- margin-bottom: var(--section-pad);
+ margin-bottom: 28px;
}
.doc {
margin: 0;
- font-size: 18px;
- font-weight: 600;
- line-height: 24px;
+ font-size: 26px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ line-height: 32px;
}
.sub {
- margin: 2px 0 0;
- color: var(--text-faint);
+ margin: 6px 0 0;
+ color: var(--text-dim);
+ font-size: 13px;
}
.kpis {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
- gap: var(--gap);
- margin: 0 0 var(--section-pad);
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+ margin: 0 0 36px;
padding: 0;
list-style: none;
}
.kpi {
display: flex;
flex-direction: column;
- gap: 2px;
- padding: var(--pad) var(--gap);
+ gap: 4px;
+ padding: 16px 18px;
border: 1px solid var(--line);
- border-radius: var(--radius-lg);
+ border-radius: 12px;
background: var(--panel);
+ box-shadow: var(--shadow-sm);
}
.kpi-label {
- color: var(--text-faint);
- font-size: var(--ui-size-sm);
- text-transform: uppercase;
- letter-spacing: 0.08em;
+ color: var(--text-dim);
+ font-size: 12px;
+ font-weight: 500;
}
.kpi-value {
- font-size: 22px;
- line-height: 28px;
+ font-size: 28px;
+ font-weight: 600;
+ line-height: 34px;
+ letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.kpi[data-alarm='true'] .kpi-value {
@@ -235,10 +239,10 @@ const kpis = computed(() => {
}
.kpi-note {
color: var(--text-faint);
- font-size: var(--ui-size-sm);
+ font-size: 11px;
}
.block {
- margin-bottom: var(--section-pad);
+ margin-bottom: 36px;
}
.pages-heading {
display: flex;
@@ -250,14 +254,24 @@ const kpis = computed(() => {
margin: 0;
}
.new-page {
- border: 1px solid var(--line);
- border-radius: var(--radius);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ border: 0;
+ border-radius: 7px;
background: var(--accent);
- color: var(--text);
+ color: var(--on-accent);
font: inherit;
- padding: 6px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 0 14px;
+ box-shadow: var(--shadow-sm);
cursor: pointer;
}
+.new-page:hover:not(:disabled) {
+ filter: brightness(1.08);
+}
.new-page:disabled {
opacity: 0.5;
cursor: default;
@@ -268,13 +282,20 @@ const kpis = computed(() => {
.block-title {
display: flex;
align-items: center;
- gap: var(--gap-sm);
- margin: 0 0 var(--gap);
- color: var(--text-faint);
- font-size: var(--ui-size-sm);
- font-weight: 400;
- text-transform: uppercase;
- letter-spacing: 0.08em;
+ gap: 8px;
+ margin: 0 0 12px;
+ color: var(--text);
+ font-size: 15px;
+ font-weight: 600;
+}
+.block-title .count {
+ padding: 0 7px;
+ border-radius: 10px;
+ background: var(--raised);
+ color: var(--text-dim);
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 18px;
}
.count {
font-variant-numeric: tabular-nums;
@@ -282,7 +303,7 @@ const kpis = computed(() => {
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
- gap: var(--gap);
+ gap: 16px;
margin: 0;
padding: 0;
list-style: none;
@@ -292,15 +313,17 @@ const kpis = computed(() => {
padding: 0;
list-style: none;
border: 1px solid var(--line);
- border-radius: var(--radius-lg);
+ border-radius: 12px;
overflow: hidden;
+ box-shadow: var(--shadow-sm);
}
.collection {
display: flex;
align-items: center;
gap: var(--gap);
- height: var(--field-h);
- padding: 0 var(--gap);
+ height: 40px;
+ padding: 0 16px;
+ font-size: 12px;
background: var(--panel);
border: none;
color: inherit;
@@ -310,7 +333,7 @@ const kpis = computed(() => {
width: 100%;
}
.collection:hover {
- background: var(--panel-raised, var(--panel));
+ background: var(--raised);
}
li + li .collection {
border-top: 1px solid var(--line);
diff --git a/packages/viewer/src/InsertPanel.vue b/packages/viewer/src/InsertPanel.vue
new file mode 100644
index 0000000..03be9a4
--- /dev/null
+++ b/packages/viewer/src/InsertPanel.vue
@@ -0,0 +1,385 @@
+
+
+
+
+
+
+
+ Draw
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+ Image
+
+
+
+
+
+
+ Blocks
+
+
+
+
+
+
+
+
+ {{ block.label }}
+ {{ block.hint }}
+
+ +
+
+
+
+
+
+
+ Components {{ components.size }}
+
+
+ No components yet. Select a frame and press ⌘⌥K, or create one from New file.
+
+ Nothing matches “{{ query }}”.
+
+
+
+
+
+
+
+
+ {{ row.name }}
+ {{ row.summary || 'Component' }}
+
+ {{ row.status }}
+
+
+
+
+
+
+
diff --git a/packages/viewer/src/PageThumb.vue b/packages/viewer/src/PageThumb.vue
index e228472..838d05e 100644
--- a/packages/viewer/src/PageThumb.vue
+++ b/packages/viewer/src/PageThumb.vue
@@ -237,15 +237,20 @@ onUnmounted(stopObserving)
.tile {
display: flex;
flex-direction: column;
- gap: var(--gap-sm);
- padding: var(--gap);
+ gap: 8px;
+ padding: 8px 8px 12px;
border: 1px solid var(--line);
- border-radius: var(--radius-lg);
+ border-radius: 12px;
background: var(--panel);
+ box-shadow: var(--shadow-sm);
cursor: pointer;
+ transition:
+ box-shadow 120ms,
+ transform 120ms;
}
.tile:hover {
- background: var(--raised);
+ box-shadow: var(--shadow-float);
+ transform: translateY(-1px);
}
.tile[aria-selected='true'] {
border-color: var(--accent);
@@ -260,7 +265,7 @@ onUnmounted(stopObserving)
justify-content: center;
/* The canvas ground, so a tile reads as a window onto the page. */
background: var(--canvas-bg);
- border-radius: var(--radius);
+ border-radius: 8px;
aspect-ratio: 16 / 10;
overflow: hidden;
}
@@ -279,6 +284,9 @@ onUnmounted(stopObserving)
display: flex;
align-items: center;
gap: var(--gap-sm);
+ padding: 0 4px;
+ font-size: 12px;
+ font-weight: 500;
}
.label {
flex: 1;
diff --git a/packages/viewer/src/PropertiesPane.vue b/packages/viewer/src/PropertiesPane.vue
index 61f1303..fbba1f9 100644
--- a/packages/viewer/src/PropertiesPane.vue
+++ b/packages/viewer/src/PropertiesPane.vue
@@ -2626,7 +2626,6 @@ h2 {
font: inherit;
font-size: var(--ui-size-sm);
cursor: pointer;
- color-scheme: dark;
}
.meta[data-meta='status'][data-value='stable'] {
border-color: var(--ok);
diff --git a/packages/viewer/src/TokensPane.vue b/packages/viewer/src/TokensPane.vue
index 4655be0..0a2d730 100644
--- a/packages/viewer/src/TokensPane.vue
+++ b/packages/viewer/src/TokensPane.vue
@@ -1446,7 +1446,6 @@ th:hover .icon-action,
cursor: pointer;
font: inherit;
font-size: 9px;
- color-scheme: dark;
}
.remove-collection {
display: grid;
diff --git a/packages/viewer/src/WorkspaceNav.vue b/packages/viewer/src/WorkspaceNav.vue
deleted file mode 100644
index e68d4b4..0000000
--- a/packages/viewer/src/WorkspaceNav.vue
+++ /dev/null
@@ -1,163 +0,0 @@
-
-
-
-
-
-
-
diff --git a/packages/viewer/src/insert-blocks.ts b/packages/viewer/src/insert-blocks.ts
new file mode 100644
index 0000000..1dc9265
--- /dev/null
+++ b/packages/viewer/src/insert-blocks.ts
@@ -0,0 +1,177 @@
+import type { UidxNodeSpec } from '@uidx/format'
+
+/**
+ * The Insert panel's ready-made blocks: small, well-formed starting points a
+ * designer drops in and restyles, the way Builder's and Webflow's insert
+ * panels offer a Box, Columns or a Button before any component exists.
+ * Plain frames and text with auto layout — nothing here is a component until
+ * the author makes it one.
+ */
+export interface Block {
+ id: string
+ label: string
+ hint: string
+ /** A 16-unit icon path. */
+ icon: string
+ node: () => UidxNodeSpec
+}
+
+const solid = (r: number, g: number, b: number) => [{ type: 'SOLID', color: { r, g, b, a: 1 } }]
+const text = (name: string, characters: string, size = 14, weight?: string): UidxNodeSpec => ({
+ element: 'Text',
+ attrs: { name, characters, fontSize: size, ...(weight ? { fontWeight: weight } : {}) },
+})
+/** Text that fills its auto-layout parent's width and wraps, growing in height. */
+const wrapping = (spec: UidxNodeSpec): UidxNodeSpec => ({
+ ...spec,
+ attrs: { ...spec.attrs, layoutAlign: 'STRETCH', textAutoResize: 'HEIGHT' },
+})
+
+export const BLOCKS: readonly Block[] = [
+ {
+ id: 'stack',
+ label: 'Stack',
+ hint: 'A vertical auto-layout frame',
+ icon: 'M3 2.5h10v4H3zM3 9.5h10v4H3z',
+ node: () => ({
+ element: 'Frame',
+ attrs: {
+ name: 'stack',
+ layoutMode: 'VERTICAL',
+ primaryAxisSizingMode: 'AUTO',
+ counterAxisSizingMode: 'AUTO',
+ itemSpacing: 12,
+ paddingLeft: 16,
+ paddingRight: 16,
+ paddingTop: 16,
+ paddingBottom: 16,
+ },
+ children: [text('first', 'First item'), text('second', 'Second item')],
+ }),
+ },
+ {
+ id: 'row',
+ label: 'Row',
+ hint: 'A horizontal auto-layout frame',
+ icon: 'M2.5 3h4v10h-4zM9.5 3h4v10h-4z',
+ node: () => ({
+ element: 'Frame',
+ attrs: {
+ name: 'row',
+ layoutMode: 'HORIZONTAL',
+ primaryAxisSizingMode: 'AUTO',
+ counterAxisSizingMode: 'AUTO',
+ counterAxisAlignItems: 'CENTER',
+ itemSpacing: 12,
+ },
+ children: [text('start', 'Start'), text('end', 'End')],
+ }),
+ },
+ {
+ id: 'card',
+ label: 'Card',
+ hint: 'A padded surface with a title and body',
+ icon: 'M2.5 3h11v10h-11zM5 6h6M5 8.5h4',
+ node: () => ({
+ element: 'Frame',
+ attrs: {
+ name: 'card',
+ layoutMode: 'VERTICAL',
+ primaryAxisSizingMode: 'AUTO',
+ counterAxisSizingMode: 'FIXED',
+ width: 280,
+ itemSpacing: 8,
+ paddingLeft: 20,
+ paddingRight: 20,
+ paddingTop: 20,
+ paddingBottom: 20,
+ cornerRadius: 12,
+ fills: solid(1, 1, 1),
+ strokes: solid(0.886, 0.898, 0.918),
+ strokeWeight: 1,
+ },
+ children: [
+ wrapping(text('title', 'Card title', 16, 'SEMI_BOLD')),
+ wrapping(text('body', 'A short description of what this card is about.', 14)),
+ ],
+ }),
+ },
+ {
+ id: 'button',
+ label: 'Button',
+ hint: 'A label in a filled, rounded frame',
+ icon: 'M2 5h12v6H2zM5.5 8h5',
+ node: () => ({
+ element: 'Frame',
+ attrs: {
+ name: 'button',
+ layoutMode: 'HORIZONTAL',
+ primaryAxisSizingMode: 'AUTO',
+ counterAxisSizingMode: 'AUTO',
+ primaryAxisAlignItems: 'CENTER',
+ counterAxisAlignItems: 'CENTER',
+ paddingLeft: 16,
+ paddingRight: 16,
+ paddingTop: 10,
+ paddingBottom: 10,
+ cornerRadius: 8,
+ fills: solid(0.145, 0.388, 0.922),
+ },
+ children: [
+ {
+ element: 'Text',
+ attrs: {
+ name: 'label',
+ characters: 'Button',
+ fontSize: 14,
+ fontWeight: 'MEDIUM',
+ fills: solid(1, 1, 1),
+ },
+ },
+ ],
+ }),
+ },
+ {
+ id: 'input',
+ label: 'Input',
+ hint: 'A field frame with placeholder text',
+ icon: 'M2 5h12v6H2zM4.5 6.5v3',
+ node: () => ({
+ element: 'Frame',
+ attrs: {
+ name: 'input',
+ layoutMode: 'HORIZONTAL',
+ primaryAxisSizingMode: 'FIXED',
+ counterAxisSizingMode: 'AUTO',
+ counterAxisAlignItems: 'CENTER',
+ width: 240,
+ paddingLeft: 12,
+ paddingRight: 12,
+ paddingTop: 10,
+ paddingBottom: 10,
+ cornerRadius: 8,
+ fills: solid(1, 1, 1),
+ strokes: solid(0.788, 0.808, 0.847),
+ strokeWeight: 1,
+ },
+ children: [
+ {
+ element: 'Text',
+ attrs: {
+ name: 'placeholder',
+ characters: 'Placeholder',
+ fontSize: 14,
+ fills: solid(0.42, 0.447, 0.502),
+ },
+ },
+ ],
+ }),
+ },
+ {
+ id: 'heading',
+ label: 'Heading',
+ hint: 'Large, bold text',
+ icon: 'M3.5 3v10M12.5 3v10M3.5 8h9',
+ node: () => text('heading', 'Heading', 28, 'BOLD'),
+ },
+]
diff --git a/packages/viewer/src/inspector-controls.css b/packages/viewer/src/inspector-controls.css
index 6de89b9..187b096 100644
--- a/packages/viewer/src/inspector-controls.css
+++ b/packages/viewer/src/inspector-controls.css
@@ -1,7 +1,6 @@
/* Inspector-wide control rules. Component styles keep their layout; these
shared surfaces and states also reach fragment roots and SDK primitives. */
.properties {
- color-scheme: dark;
}
.properties
@@ -36,7 +35,6 @@
width: 100%;
padding: 2px 6px;
cursor: pointer;
- color-scheme: dark;
}
.properties .editor :is(.field-caption, .smoothing-label, .effect-label) {
@@ -280,7 +278,6 @@
border: 1px solid transparent;
border-radius: var(--radius-lg);
padding: 2px 8px;
- color-scheme: dark;
}
.properties .graphics-section :is(button, select):hover:not(:disabled) {
border-color: var(--line);
diff --git a/packages/viewer/src/theme.css b/packages/viewer/src/theme.css
index 726a4f8..82c1ce6 100644
--- a/packages/viewer/src/theme.css
+++ b/packages/viewer/src/theme.css
@@ -39,6 +39,7 @@
}
:root {
+ color-scheme: dark;
/* Surfaces, back to front. */
--bg: #1e1e1e;
--panel: #28282b;
@@ -54,6 +55,8 @@
/* Selection and state. */
--accent: #0d99ff;
--accent-dim: #0d99ff33;
+ /* Text and icons drawn on the accent. */
+ --on-accent: #ffffff;
--danger: #f24822;
--warn: #ffcd29;
--ok: #14ae5c;
@@ -87,6 +90,32 @@
--shadow-float: 0 8px 32px rgb(0 0 0 / 0.24), 0 2px 8px rgb(0 0 0 / 0.16);
}
+/*
+ * The light chrome: the same tokens, redefined. Chosen in the top bar and
+ * remembered per browser (theme.ts); components never ask which is on.
+ */
+:root[data-theme='light'] {
+ color-scheme: light;
+ --bg: #f4f5f7;
+ --panel: #ffffff;
+ --canvas-bg: #eceef1;
+ --raised: #eef0f3;
+ --line: #e1e4e9;
+ --text: #16181d;
+ --text-dim: #555b67;
+ --text-faint: #858b97;
+ --accent: #0b7de0;
+ --accent-dim: #0b7de026;
+ --danger: #d93a1a;
+ --warn: #b77900;
+ --ok: #0f9150;
+ --bound: #8a3ffc;
+ --overlay: rgba(250, 251, 252, 0.94);
+ --shadow: 0 4px 16px rgb(16 24 40 / 0.12);
+ --shadow-sm: 0 1px 2px rgb(16 24 40 / 0.08);
+ --shadow-float: 0 12px 32px rgb(16 24 40 / 0.14), 0 2px 6px rgb(16 24 40 / 0.06);
+}
+
* {
box-sizing: border-box;
}
diff --git a/packages/viewer/src/theme.ts b/packages/viewer/src/theme.ts
new file mode 100644
index 0000000..3cead4d
--- /dev/null
+++ b/packages/viewer/src/theme.ts
@@ -0,0 +1,47 @@
+import { ref, watch } from 'vue'
+
+/**
+ * Light or dark chrome. The choice is the viewer's, remembered per browser;
+ * until one is made the system's preference decides. Applied as
+ * `data-theme` on the root, where `theme.css` redefines its tokens, so no
+ * component knows which theme it is in.
+ */
+export type ThemeChoice = 'light' | 'dark'
+
+const KEY = 'uidx.theme'
+
+function stored(): ThemeChoice | null {
+ try {
+ const value = localStorage.getItem(KEY)
+ return value === 'light' || value === 'dark' ? value : null
+ } catch {
+ return null
+ }
+}
+
+function system(): ThemeChoice {
+ try {
+ return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
+ } catch {
+ return 'dark'
+ }
+}
+
+export const theme = ref(stored() ?? system())
+
+export function setTheme(next: ThemeChoice): void {
+ theme.value = next
+ try {
+ localStorage.setItem(KEY, next)
+ } catch {
+ // A browser that refuses storage still switches for this visit.
+ }
+}
+
+watch(
+ theme,
+ (value) => {
+ if (typeof document !== 'undefined') document.documentElement.dataset.theme = value
+ },
+ { immediate: true },
+)
diff --git a/packages/viewer/src/thumbnails.ts b/packages/viewer/src/thumbnails.ts
index 0d0d075..460df5e 100644
--- a/packages/viewer/src/thumbnails.ts
+++ b/packages/viewer/src/thumbnails.ts
@@ -12,6 +12,7 @@ import type { JsonValue, UidxDocument, UidxNode } from '@uidx/format'
import { createAssetStore, type AssetFetch, type AssetStore } from './asset-store'
import { seedFonts } from './fonts'
+import { theme } from './theme'
/**
* A page, drawn small.
@@ -50,7 +51,10 @@ import { seedFonts } from './fonts'
* rasteriser, so it cannot be a custom property, and the alternative is reading
* a computed style off an element the renderer does not have.
*/
-const TILE_GROUND = { r: 0x1e / 255, g: 0x1e / 255, b: 0x1e / 255, a: 1 }
+const TILE_GROUND = {
+ dark: { r: 0x1e / 255, g: 0x1e / 255, b: 0x1e / 255, a: 1 },
+ light: { r: 0xec / 255, g: 0xee / 255, b: 0xf1 / 255, a: 1 },
+}
export interface ThumbnailRequest {
/** The page id, which is also the cache key's first half. */
@@ -172,7 +176,8 @@ export function thumbnailKey(request: ThumbnailRequest): string {
const definitions = request.definitions === null ? '' : `+defs${request.definitions}`
const assets = request.assets === null ? '' : `+art${request.assets}`
const fonts = request.fonts ? `+fonts${request.fonts}` : ''
- return `${request.file}@${revision}${definitions}${assets}${fonts}@${request.width}x${request.height}`
+ // The ground follows the chrome's theme, so a tile drawn in one is not reused in the other.
+ return `${request.file}@${revision}${definitions}${assets}${fonts}@${request.width}x${request.height}@${theme.value}`
}
/**
@@ -277,7 +282,7 @@ export function createThumbnailer(options: ThumbnailerOptions = {}): Thumbnailer
// 1440x3616 — arrives as a slab of white in a dark well, which reads as a
// broken render rather than a shape. The canvas ground makes the bars
// disappear into the tile.
- renderer.pageColor = TILE_GROUND
+ renderer.pageColor = TILE_GROUND[theme.value]
await renderer.loadFonts()
return { ck, renderer }
})()
@@ -326,6 +331,7 @@ export function createThumbnailer(options: ThumbnailerOptions = {}): Thumbnailer
if (request.doc.tree.element === 'Tokens') return null
const { ck, renderer } = await ready()
+ renderer.pageColor = TILE_GROUND[theme.value]
if (disposed) return null
const scene = sceneForThumbnail(request, assets)
diff --git a/packages/viewer/test/app-bar.test.ts b/packages/viewer/test/app-bar.test.ts
new file mode 100644
index 0000000..e033ef9
--- /dev/null
+++ b/packages/viewer/test/app-bar.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { applyPatches, parseOrThrow, resolve } from '@uidx/format'
+
+import AppBar from '../src/AppBar.vue'
+import InsertPanel from '../src/InsertPanel.vue'
+import { BLOCKS } from '../src/insert-blocks'
+import { theme } from '../src/theme'
+
+/**
+ * The editor's top bar and its Insert panel: where you are, which face is
+ * on, the session's controls, and what can be added.
+ */
+const bar = (over: Record = {}) =>
+ mount(AppBar, {
+ props: {
+ title: 'acme',
+ page: 'button',
+ view: 'page',
+ renderable: true,
+ connection: 'open',
+ saved: true,
+ canUndo: true,
+ canRedo: false,
+ codeOut: 'src/ds',
+ codeRunning: false,
+ codeNotice: '',
+ agentOnline: false,
+ agentOpen: false,
+ ...over,
+ },
+ })
+
+describe('the top bar', () => {
+ it('shows document › page, the faces, and which one is on', async () => {
+ const wrapper = bar()
+ expect(wrapper.findAll('.crumb').map((c) => c.text())).toEqual(['acme', 'button'])
+ const faces = wrapper.findAll('.face')
+ expect(faces.map((f) => f.text())).toEqual(['Design', 'Docs', 'Tokens', 'Fonts', 'Models'])
+ expect(faces[0]!.attributes('aria-pressed')).toBe('true')
+ await faces[2]!.trigger('click')
+ expect(wrapper.emitted('face')).toEqual([['tokens']])
+ await wrapper.find('.crumb').trigger('click')
+ expect(wrapper.emitted('home')).toHaveLength(1)
+ })
+
+ it('offers undo when there is history, says it saved, and hides the assistant when offline', async () => {
+ const wrapper = bar()
+ const [undo, redo] = wrapper.findAll('.history button')
+ expect(redo!.attributes('disabled')).toBeDefined()
+ await undo!.trigger('click')
+ expect(wrapper.emitted('undo')).toHaveLength(1)
+ expect(wrapper.find('.status').text()).toBe('Saved')
+ expect(wrapper.find('[data-action="agent"]').exists()).toBe(false)
+ expect(bar({ agentOnline: true }).find('[data-action="agent"]').text()).toBe('Ask AI')
+ expect(bar({ connection: 'reconnecting' }).find('.status').text()).toBe('Reconnecting…')
+ })
+
+ it('generates code where uidx.json says, and explains how when it says nowhere', async () => {
+ const configured = bar()
+ await configured.find('[data-action="code"]').trigger('click')
+ expect(configured.emitted('code')).toHaveLength(1)
+ const unconfigured = bar({ codeOut: null })
+ await unconfigured.find('[data-action="code"]').trigger('click')
+ expect(unconfigured.emitted('code')).toBeUndefined()
+ expect(unconfigured.find('.popover').text()).toContain('codegen')
+ })
+
+ it('switches theme and remembers it', async () => {
+ const wrapper = bar()
+ const before = theme.value
+ await wrapper.find('[data-action="theme"]').trigger('click')
+ expect(theme.value).not.toBe(before)
+ expect(document.documentElement.dataset.theme).toBe(theme.value)
+ expect(localStorage.getItem('uidx.theme')).toBe(theme.value)
+ })
+
+ it('hides the faces on the overview', () => {
+ expect(bar({ view: 'home' }).find('.faces').exists()).toBe(false)
+ })
+})
+
+describe('the Insert panel', () => {
+ const page = parseOrThrow(`---
+id: lib
+---
+
+## Visual Contract
+
+
+
+
+
+`)
+ const components = new Map([
+ ['Card', resolve(page.tree, 'Card')!],
+ ['Avatar', resolve(page.tree, 'Avatar')!],
+ ])
+ const panel = () =>
+ mount(InsertPanel, { props: { components, writable: true, tool: null, placing: null } })
+
+ it('arms drawing tools, inserts blocks and places components', async () => {
+ const wrapper = panel()
+ await wrapper.find('[data-tool="Rectangle"]').trigger('click')
+ expect(wrapper.emitted('tool')).toEqual([['Rectangle']])
+ await wrapper.find('[data-block="card"]').trigger('click')
+ expect((wrapper.emitted('insert')![0]![0] as { element: string }).element).toBe('Frame')
+ expect(wrapper.findAll('[data-component]').map((c) => c.attributes('data-component'))).toEqual([
+ 'Avatar',
+ 'Card',
+ ])
+ await wrapper.find('[data-component="Card"]').trigger('click')
+ expect(wrapper.emitted('place')).toEqual([['Card']])
+ })
+
+ it('filters blocks and components by search', async () => {
+ const wrapper = panel()
+ await wrapper.find('input[type="search"]').setValue('car')
+ expect(wrapper.findAll('[data-block]').map((b) => b.attributes('data-block'))).toEqual(['card'])
+ expect(wrapper.findAll('[data-component]').map((c) => c.attributes('data-component'))).toEqual([
+ 'Card',
+ ])
+ expect(wrapper.find('[data-tool="Frame"]').exists()).toBe(false)
+ })
+
+ it('offers only blocks the file accepts', () => {
+ const source = `---\nid: p\n---\n\n## Visual Contract\n\n\n \n`
+ for (const block of BLOCKS) {
+ const next = applyPatches(source, [
+ { op: 'insert-node', parent: '', index: 0, node: block.node() },
+ ]).source
+ expect(parseOrThrow(next).tree.children[0]!.name, block.id).toBe(block.node().attrs.name)
+ }
+ })
+})
From e78d8bdef5832786df99d021f7bb0b6d901494c2 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 1 Oct 2026 05:32:36 +0000
Subject: [PATCH 58/74] Figma-style panels with Builder-style code connection;
dark by default
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Dark is the default again; light stays one click away in the top bar.
- Left panel: the pages list is always on top on every face, so moving
between files never hides behind a tab; Layers and Assets (blocks,
images, components) sit below it. The page name in the top bar opens
a menu of every file, with New file….
- Right panel: Design | Contract | Code. Code shows the selected
component (or the one an instance uses, or the one a layer sits in) as
the code it becomes — React, HTML, CSS, the contract JSON — rendered by
a new GET /__uidx/code route in memory and written nowhere, refreshing
as the design changes, with Copy and Write to .
- Bottom bar: a + button opens the Insert panel floating above it; it
closes on Escape or after inserting. Menus and popovers in the top bar
close on Escape or a click outside.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
packages/server/src/codegen.ts | 49 ++++
packages/server/test/codegen-route.test.ts | 17 ++
packages/viewer/src/App.vue | 129 +++++++--
packages/viewer/src/AppBar.vue | 143 ++++++++-
packages/viewer/src/CodeSection.vue | 304 ++++++++++++++++++++
packages/viewer/src/EditToolbar.vue | 20 ++
packages/viewer/src/PropertiesPane.vue | 29 +-
packages/viewer/src/theme.ts | 12 +-
packages/viewer/test/code-section.test.ts | 97 +++++++
packages/viewer/test/contract-edits.test.ts | 2 +-
packages/viewer/test/edit-toolbar.test.ts | 1 +
11 files changed, 758 insertions(+), 45 deletions(-)
create mode 100644 packages/viewer/src/CodeSection.vue
create mode 100644 packages/viewer/test/code-section.test.ts
diff --git a/packages/server/src/codegen.ts b/packages/server/src/codegen.ts
index 8a0410c..18dc208 100644
--- a/packages/server/src/codegen.ts
+++ b/packages/server/src/codegen.ts
@@ -14,6 +14,7 @@ import type { MiddlewareHost, ViewerPlugin } from './static-viewer.js'
export const CODEGEN_ROUTE = '/__uidx/codegen'
export const PREVIEW_ROUTE = '/__uidx/preview'
+export const CODE_ROUTE = '/__uidx/code'
export interface CodegenRun {
/** Where the files went, as `uidx.json` spells it. */
@@ -122,6 +123,32 @@ ${fragment}
`
}
+/**
+ * One component's generated code, rendered in memory and written nowhere:
+ * what the Code tab shows beside the design, the way Builder shows the code
+ * a block becomes. Every target's files for the component — React, the
+ * HTML/CSS markup and styles, the contract JSON — matched by the
+ * component's file stem or name.
+ */
+export async function componentCode(
+ found: FoundManifest,
+ component: string,
+): Promise<{ files: { path: string; text: string }[]; diagnostics: CodegenRun['diagnostics'] }> {
+ const rendered = await renderDocument(found, undefined)
+ if (!rendered.ok) return { files: [], diagnostics: rendered.diagnostics }
+ const stem = component
+ .replace(/[/\s]+/g, '-')
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
+ .toLowerCase()
+ const files = [...rendered.result.files]
+ .filter(([path]) => {
+ const base = path.slice(path.lastIndexOf('/') + 1).replace(/\.[^.]+$/, '')
+ return base === stem || base === component
+ })
+ .map(([path, text]) => ({ path, text }))
+ return { files, diagnostics: rendered.diagnostics }
+}
+
/** `GET` says whether generation is configured and where; `POST` runs it. */
export function codegenRoutePlugin(manifest: { current: FoundManifest | null }): ViewerPlugin {
const handle = async (request: IncomingMessage, response: ServerResponse): Promise => {
@@ -185,7 +212,29 @@ export function codegenRoutePlugin(manifest: { current: FoundManifest | null }):
response.setHeader('content-type', 'text/html; charset=utf-8')
response.end(page)
}
+ const code = async (request: IncomingMessage, response: ServerResponse): Promise => {
+ response.setHeader('cache-control', 'no-store')
+ response.setHeader('content-type', 'application/json')
+ const found = manifest.current
+ const name = new URL(request.url ?? '/', 'http://local').searchParams.get('component') ?? ''
+ if (!found || !/^[\w/ -]+$/.test(name)) {
+ response.statusCode = 404
+ response.end(JSON.stringify({ error: 'Name a component of this document.' }))
+ return
+ }
+ try {
+ response.end(JSON.stringify(await componentCode(found, name)))
+ } catch (error) {
+ response.statusCode = 400
+ response.end(
+ JSON.stringify({ error: error instanceof Error ? error.message : String(error) }),
+ )
+ }
+ }
const configure = (server: MiddlewareHost): void => {
+ server.middlewares.use(CODE_ROUTE, (req, res) => {
+ void code(req, res)
+ })
server.middlewares.use(CODEGEN_ROUTE, (req, res) => {
void handle(req, res)
})
diff --git a/packages/server/test/codegen-route.test.ts b/packages/server/test/codegen-route.test.ts
index 6cb3cc5..e31bc1a 100644
--- a/packages/server/test/codegen-route.test.ts
+++ b/packages/server/test/codegen-route.test.ts
@@ -149,3 +149,20 @@ describe('the codegen route', () => {
expect((await fetch(`${server!.url}/__uidx/codegen`, { method: 'DELETE' })).status).toBe(405)
})
})
+
+describe('one component as code', () => {
+ it('renders its files in memory, for every target, writing nothing', async () => {
+ await start({})
+ const response = await fetch(`${server!.url}/__uidx/code?component=Box`)
+ expect(response.status).toBe(200)
+ const body = (await response.json()) as { files: { path: string; text: string }[] }
+ const paths = body.files.map((file) => file.path).sort()
+ expect(paths).toContain('react/Box.tsx')
+ expect(paths).toContain('html/box.html')
+ expect(paths).toContain('contract/box.json')
+ expect(body.files.find((file) => file.path === 'react/Box.tsx')!.text).toContain('label')
+ expect(
+ (await fetch(`${server!.url}/__uidx/code?component=${encodeURIComponent('')}`)).status,
+ ).toBe(404)
+ })
+})
diff --git a/packages/viewer/src/App.vue b/packages/viewer/src/App.vue
index a0b4227..ea12080 100644
--- a/packages/viewer/src/App.vue
+++ b/packages/viewer/src/App.vue
@@ -992,6 +992,8 @@ watch(
/** The left panel's tab on a page: what is on it, or what can be added. */
const railTab = ref<'layers' | 'insert'>('layers')
+/** The Insert panel floated above the bottom bar, Figma's quick-insert. */
+const floatingInsert = ref(false)
/** A face chosen in the top bar; from the overview it opens on the page the canvas holds. */
function faceFromBar(kind: 'page' | 'tokens' | 'fonts' | 'models' | 'docs'): void {
@@ -1247,6 +1249,8 @@ async function onDeletePage(file: string): Promise {
}
const creatingTokensFile = ref(false)
+/** "New file…" from the top bar's file switcher. */
+const creatingFile = ref(false)
function onTokensFileCreated(file: string): void {
creatingTokensFile.value = false
openPage(file)
@@ -2004,6 +2008,10 @@ function removeSelection(): void {
* still edits the hex.
*/
function onKeyDown(event: KeyboardEvent): void {
+ if (event.key === 'Escape' && floatingInsert.value) {
+ floatingInsert.value = false
+ return
+ }
if (!doc.value || event.defaultPrevented) return
// ⌘Z / ⇧⌘Z (spec §5). The canvas SDK's command registry binds the same keys
// but the viewer never mounts it — only `provideEditor`, `useCanvas` and the
@@ -2083,7 +2091,11 @@ onUnmounted(() => socket.close())
:code-notice="codegenState.notice"
:agent-online="agent.status.value.online"
:agent-open="agent.open.value"
+ :files="pageList"
+ :current="entry"
@home="openHome"
+ @open="openPage"
+ @new-file="creatingFile = true"
@face="faceFromBar"
@undo="undo"
@redo="redo"
@@ -2130,6 +2142,12 @@ onUnmounted(() => socket.close())
+ ((creatingFile = false), openPage(file))"
+ @close="creatingFile = false"
+ />
+
socket.close())
}"
>
+
+
+
+
socket.close())
data-rail-tab="insert"
@click="railTab = 'insert'"
>
- Insert
+ Assets
@@ -2223,34 +2258,20 @@ onUnmounted(() => socket.close())
@place="placeInstance"
/>
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {{ name }}
+ →
+ <{{ names.tag || implementsTag }}>
+ {{ react.export || name }} from {{ react.from }}
+ not connected to code yet
+
+
{{ counts }}
+
+
+
+
+
+ Headless element
+ in {{ name }}.uidx
+
+
+ The web component or headless element that gives {{ name }} its behaviour and
+ accessibility. Generated code renders it.
+
+
+ None
+
+ {{ implementsTag }} (not in the library)
+
+
+ {{ root.tag }}
+
+
+
+ {{ element.description }}
+
+ {{ partsBound }} of {{ partsDeclared.length }} parts are bound to layers.
+ Bind parts
+
+
+
+
+
+
+
+
+
+ Select a component, or a layer inside one, to connect it to code.
+
+
+
+
+ Project
+ uidx.json · every component
+
+
+ Headless library
+
+ {{ config.headless.manifest }}
+ · {{ library.roots.length }} elements
+
+ No library yet: the custom-elements.json your components implement.
+
+
+ From your dependencies…
+
+ {{ candidate.package }}
+
+
+
+
+ {{ config.headless ? 'Change' : 'Use' }}
+
+
+
+
+ How the library spells things
+
+ {{ entry.label }}
+
+
+ {{ label }}
+
+
+
+
+
+ Output
+
+ Folder
+
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
diff --git a/packages/viewer/src/PropertiesPane.vue b/packages/viewer/src/PropertiesPane.vue
index c4091a8..42e55bf 100644
--- a/packages/viewer/src/PropertiesPane.vue
+++ b/packages/viewer/src/PropertiesPane.vue
@@ -14,6 +14,8 @@ import {
import { LENGTH_FIELD_CONTEXT } from './length-field-context'
import ContractSection from './ContractSection.vue'
import CodeSection from './CodeSection.vue'
+import ConnectSection from './ConnectSection.vue'
+import { connection, connectionError, connectionGeneration, saveConnection } from './headless'
import {
contractIssues,
contractView,
@@ -270,7 +272,7 @@ const emit = defineEmits<{
* Design / Dev split is the precedent authors already know. Pane-local: the
* choice is about how to look, not what is open.
*/
-const face = ref<'design' | 'contract' | 'code'>('design')
+const face = ref<'design' | 'contract' | 'connect' | 'code'>('design')
/** Unbound or stray parts on the selected component, for the tab's badge. */
const contractIssueCount = computed(() =>
@@ -303,6 +305,15 @@ const codeComponent = computed(() => {
return top?.element === 'Component' ? top.name : null
})
+/** The component the Connect tab edits: one declared in this file, holding the selection. */
+const connectComponent = computed(() => {
+ const node = active.value
+ if (!node || !props.doc) return null
+ const entity = node.address.split('#')[0]!
+ const top = props.doc.tree.children.find((child) => child.address === entity)
+ return top?.element === 'Component' ? top : null
+})
+
function definitionFor(node: UidxNode): UidxNode | undefined {
const named = node.attrs.component?.value
return typeof named === 'string' ? props.components?.get(named) : undefined
@@ -1528,6 +1539,9 @@ function onDetach(prop: string, value: JsonValue): void {
>{{ contractIssueCount }}
+
+ Connect
+
Code
Read only
@@ -1560,10 +1574,24 @@ function onDetach(prop: string, value: JsonValue): void {
+
{
const data = (await response.json()) as HeadlessPayload
if (!response.ok) throw new Error(data.error ?? 'Could not choose the headless library.')
adopt(data)
+ void loadConnection()
+ connectionGeneration.value += 1
} catch (error) {
headlessError.value = error instanceof Error ? error.message : String(error)
}
@@ -137,9 +139,88 @@ export async function refreshHeadless(): Promise {
const data = (await response.json()) as HeadlessPayload
if (!response.ok) throw new Error(data.error ?? 'Could not read the headless library.')
adopt(data)
+ void loadConnection()
} catch (error) {
headlessLibrary.value = null
headlessCandidates.value = []
headlessError.value = error instanceof Error ? error.message : String(error)
}
}
+
+/**
+ * The code connection as `uidx.json` holds it (`/__uidx/config`): the
+ * library's naming profile, each component's names in the library, the
+ * React component an identity renders onto, and where code goes. The
+ * Connect tab edits it; the Code tab redraws when it moves.
+ */
+export interface ConnectionConfig {
+ headless: {
+ manifest: string
+ profile: Record
+ bindings: Record
+ } | null
+ codegen: {
+ out: string
+ targets: string[] | null
+ react: Record
+ } | null
+}
+export interface ComponentNames {
+ tag?: string
+ parts?: Record
+ attributes?: Record
+ events?: Record
+}
+export interface ReactMapping {
+ from?: string
+ export?: string
+ props?: Record
+ events?: Record
+ children?: string
+ omit?: string[]
+}
+export type ConfigChange =
+ | { key: 'profile'; value: Record | null }
+ | { key: 'binding'; component: string; value: ComponentNames | null }
+ | { key: 'react'; component: string; value: ReactMapping | null }
+ | { key: 'codegen'; value: { out: string; targets?: string[] } }
+
+export const connection = shallowRef({ headless: null, codegen: null })
+/** Moves on every saved change, so what renders code from it refreshes. */
+export const connectionGeneration = shallowRef(0)
+export const connectionError = shallowRef('')
+
+export async function loadConnection(): Promise {
+ try {
+ const response = await fetch('/__uidx/config', { signal: AbortSignal.timeout(15_000) })
+ unavailable(response)
+ const data = (await response.json()) as ConnectionConfig & { error?: string }
+ if (!response.ok) throw new Error(data.error ?? 'Could not read the code connection.')
+ connection.value = { headless: data.headless, codegen: data.codegen }
+ } catch (error) {
+ connectionError.value = error instanceof Error ? error.message : String(error)
+ }
+}
+
+/** Writes one change to `uidx.json`; on success the library and codegen state follow. */
+export async function saveConnection(change: ConfigChange): Promise {
+ connectionError.value = ''
+ try {
+ const response = await fetch('/__uidx/config', {
+ method: 'PATCH',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(change),
+ signal: AbortSignal.timeout(15_000),
+ })
+ unavailable(response)
+ const data = (await response.json()) as ConnectionConfig & { error?: string }
+ if (!response.ok) throw new Error(data.error ?? 'Could not save the change.')
+ connection.value = { headless: data.headless, codegen: data.codegen }
+ connectionGeneration.value += 1
+ await refreshHeadless()
+ return true
+ } catch (error) {
+ connectionError.value = error instanceof Error ? error.message : String(error)
+ return false
+ }
+}
diff --git a/packages/viewer/test/connect-section.test.ts b/packages/viewer/test/connect-section.test.ts
new file mode 100644
index 0000000..a4e0e58
--- /dev/null
+++ b/packages/viewer/test/connect-section.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { parseOrThrow, resolve } from '@uidx/format'
+
+import ConnectSection from '../src/ConnectSection.vue'
+import { parseHeadless, type ConnectionConfig } from '../src/headless'
+
+/**
+ * The Connect tab: the element a component implements (in its file), its
+ * names in the library and the React component it renders onto (in
+ * uidx.json), and the project's library, profile and output.
+ */
+const SOURCE = `---
+id: button
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+## Contract
+
+
+ Words.
+ Loud.
+
+
+ Fires once.
+
+
+ The words.
+ An icon.
+
+`
+const doc = parseOrThrow(SOURCE)
+const component = resolve(doc.tree, 'Button')!
+const library = parseHeadless('custom-elements.json', {
+ modules: [
+ {
+ declarations: [
+ { tagName: 'x-button', attributes: [{ name: 'primary' }], events: [{ name: 'click' }] },
+ { tagName: 'x-link' },
+ ],
+ },
+ ],
+})
+const CONFIG: ConnectionConfig = {
+ headless: { manifest: 'custom-elements.json', profile: {}, bindings: {} },
+ codegen: { out: 'src/ds', targets: null, react: {} },
+}
+const mounted = (config: ConnectionConfig = CONFIG) =>
+ mount(ConnectSection, { props: { doc, component, library, config, writable: true } })
+
+describe('the Connect tab', () => {
+ it('says what the component is in code, and how many parts are bound', () => {
+ const tab = mounted()
+ expect(tab.find('.status-line').text()).toContain('Button→')
+ expect(tab.find('.status-card').text()).toContain('1/2 parts bound')
+ })
+
+ it('writes the element into the component file', async () => {
+ const tab = mounted()
+ await tab.find('[aria-label="Implements"]').setValue('x-link')
+ expect(tab.emitted('patches')).toEqual([
+ [[{ op: 'set', address: 'Button', prop: 'implements', value: 'x-link' }]],
+ ])
+ })
+
+ it("saves the library's names for props and events, empty keeping the identity's", async () => {
+ const tab = mounted()
+ expect(tab.find('[data-attribute="isPrimary"] input').attributes('placeholder')).toBe(
+ 'is-primary',
+ )
+ await tab.find('[data-attribute="isPrimary"] input').setValue('primary')
+ await tab.find('[data-event="press"] input').setValue('click')
+ expect(tab.emitted('save')).toEqual([
+ [{ key: 'binding', component: 'Button', value: { attributes: { isPrimary: 'primary' } } }],
+ [{ key: 'binding', component: 'Button', value: { events: { press: 'click' } } }],
+ ])
+ })
+
+ it('maps the component onto an existing React component', async () => {
+ const tab = mounted()
+ await tab.find('[data-action="map-react"]').trigger('click')
+ await tab.find('[aria-label="Module"]').setValue('@acme/ui')
+ expect(tab.emitted('save')![0]).toEqual([
+ { key: 'react', component: 'Button', value: { from: '@acme/ui' } },
+ ])
+ const mapped = mounted({
+ ...CONFIG,
+ codegen: { ...CONFIG.codegen!, react: { Button: { from: '@acme/ui' } } },
+ })
+ await mapped.find('[data-react-prop="label"] input').setValue('children')
+ await mapped.find('[aria-label="Omit isPrimary"]').setValue(true)
+ expect(mapped.emitted('save')).toEqual([
+ [
+ {
+ key: 'react',
+ component: 'Button',
+ value: { from: '@acme/ui', props: { label: 'children' } },
+ },
+ ],
+ [{ key: 'react', component: 'Button', value: { from: '@acme/ui', omit: ['isPrimary'] } }],
+ ])
+ })
+
+ it('manages the project: naming profile, output and targets', async () => {
+ const tab = mounted()
+ await tab.find('[data-profile="props"] select').setValue('data-attribute')
+ await tab.find('[data-target="cem"] input').setValue(true)
+ expect(tab.emitted('save')).toEqual([
+ [{ key: 'profile', value: { props: 'data-attribute' } }],
+ [{ key: 'codegen', value: { out: 'src/ds', targets: ['html', 'react', 'contract', 'cem'] } }],
+ ])
+ const fresh = mounted({ headless: null, codegen: null })
+ expect(fresh.find('[data-action="map-react"]').attributes('disabled')).toBeDefined()
+ await fresh.find('[aria-label="Library path"]').setValue('lib/custom-elements.json')
+ await fresh.find('[aria-label="Library path"]').trigger('keydown', { key: 'Enter' })
+ expect(fresh.emitted('chooseLibrary')).toEqual([['lib/custom-elements.json']])
+ })
+})
diff --git a/packages/viewer/test/contract-edits.test.ts b/packages/viewer/test/contract-edits.test.ts
index 90b6be9..cd1c0b3 100644
--- a/packages/viewer/test/contract-edits.test.ts
+++ b/packages/viewer/test/contract-edits.test.ts
@@ -1029,7 +1029,12 @@ describe('the inspector tabs', () => {
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', 'Code'])
+ expect(tabs.map((t) => t.text().replace(/\s+/g, ' '))).toEqual([
+ 'Design',
+ 'Contract 1',
+ 'Connect',
+ 'Code',
+ ])
expect(tabs[0]!.attributes('aria-pressed')).toBe('true')
expect(pane.find('.contract').exists()).toBe(false)
await tabs[1]!.trigger('click')
From 925c8c9b226f01c0f9917dff1a6a1a248d536e6c Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 1 Oct 2026 08:18:41 +0000
Subject: [PATCH 60/74] Add an end-to-end Shoelace example and the codegen
fixes it needed
examples/shoelace: four identities (Button, Input, Checkbox, Switch) that
implement Shoelace's sl-* elements and style them through ::part(), with
light/dark tokens, connected in uidx.json through the Connect tab. A demo
page mounts the generated HTML beside the generated React components, logs
their events, and flips the token mode with the generated Switch. Its test
audits every page, checks conformance against Shoelace's manifest, and
asserts generated/ is current.
What a general-purpose library surfaced, fixed with tests:
- adopt: a part and a slot sharing a name (sl-input prefix) no longer clash
- profile coverage 'subset' checks only what the contract declares; native
DOM events count as dispatched
- shadow parts pass slot content through with slot="name"; empty library
slots print nothing; parts resolve to the root's own cssParts first
- text an element renders from an attribute is written as that attribute
(HTML) and typed string (React)
- React booleans reach custom elements as true/false (Lit keeps a reflected
attribute set to undefined)
- focus on a shadow host is :focus-within, which it matches
- an outlined vector's stroke is its colour, not a border
- tokens.css carries every mode as [data-=""] blocks
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
.../generated/contract/button.json | 4 +-
.../generated/contract/checkbox.json | 8 +-
.../generated/contract/contact-list.json | 2 +-
.../generated/contract/contact-option.json | 2 +-
.../generated/contract/field.json | 2 +-
.../design-system/generated/react/Button.tsx | 2 +-
.../generated/react/Checkbox.tsx | 2 +-
.../generated/react/ContactList.tsx | 2 +-
.../generated/react/ContactOption.tsx | 2 +-
.../design-system/generated/react/Field.tsx | 2 +-
examples/shoelace/.uidx/button.uidx | 77 ++++++
examples/shoelace/.uidx/checkbox.uidx | 61 +++++
examples/shoelace/.uidx/config.json | 1 +
examples/shoelace/.uidx/input.uidx | 65 +++++
examples/shoelace/.uidx/switch.uidx | 59 +++++
examples/shoelace/.uidx/tokens.uidx | 82 ++++++
examples/shoelace/.uidx/uidx.json | 48 ++++
examples/shoelace/README.md | 68 +++++
.../shoelace/generated/contract/button.json | 240 ++++++++++++++++++
.../shoelace/generated/contract/checkbox.json | 137 ++++++++++
.../shoelace/generated/contract/input.json | 151 +++++++++++
.../shoelace/generated/contract/switch.json | 135 ++++++++++
examples/shoelace/generated/html/button.css | 97 +++++++
examples/shoelace/generated/html/button.html | 6 +
examples/shoelace/generated/html/checkbox.css | 63 +++++
.../shoelace/generated/html/checkbox.html | 6 +
examples/shoelace/generated/html/input.css | 62 +++++
examples/shoelace/generated/html/input.html | 3 +
examples/shoelace/generated/html/switch.css | 60 +++++
examples/shoelace/generated/html/switch.html | 6 +
examples/shoelace/generated/html/tokens.css | 44 ++++
examples/shoelace/generated/react/Button.tsx | 46 ++++
.../shoelace/generated/react/Checkbox.tsx | 38 +++
examples/shoelace/generated/react/Input.tsx | 41 +++
examples/shoelace/generated/react/Switch.tsx | 38 +++
examples/shoelace/generated/react/button.css | 97 +++++++
.../shoelace/generated/react/checkbox.css | 63 +++++
.../shoelace/generated/react/elements.d.ts | 20 ++
examples/shoelace/generated/react/index.ts | 8 +
examples/shoelace/generated/react/input.css | 62 +++++
examples/shoelace/generated/react/models.ts | 4 +
examples/shoelace/generated/react/runtime.ts | 21 ++
examples/shoelace/generated/react/switch.css | 60 +++++
examples/shoelace/generated/react/tokens.css | 44 ++++
examples/shoelace/index.html | 107 ++++++++
examples/shoelace/package.json | 32 +++
examples/shoelace/scripts/generate.mjs | 66 +++++
examples/shoelace/src/log.ts | 8 +
examples/shoelace/src/main.ts | 92 +++++++
examples/shoelace/src/react-demo.tsx | 61 +++++
examples/shoelace/test/generated.test.ts | 66 +++++
examples/shoelace/tsconfig.json | 24 ++
examples/shoelace/vite.config.ts | 5 +
packages/codegen/src/conformance.ts | 31 ++-
packages/codegen/src/css.ts | 75 ++++--
packages/codegen/src/html.ts | 41 ++-
packages/codegen/src/model.ts | 62 ++++-
packages/codegen/src/react.ts | 33 ++-
packages/codegen/test/generate.test.ts | 214 +++++++++++++++-
packages/schema/src/adopt.ts | 9 +-
packages/schema/test/adopt.test.ts | 24 ++
packages/viewer/src/ConnectSection.vue | 8 +
pnpm-lock.yaml | 100 ++++++++
63 files changed, 3039 insertions(+), 60 deletions(-)
create mode 100644 examples/shoelace/.uidx/button.uidx
create mode 100644 examples/shoelace/.uidx/checkbox.uidx
create mode 100644 examples/shoelace/.uidx/config.json
create mode 100644 examples/shoelace/.uidx/input.uidx
create mode 100644 examples/shoelace/.uidx/switch.uidx
create mode 100644 examples/shoelace/.uidx/tokens.uidx
create mode 100644 examples/shoelace/.uidx/uidx.json
create mode 100644 examples/shoelace/README.md
create mode 100644 examples/shoelace/generated/contract/button.json
create mode 100644 examples/shoelace/generated/contract/checkbox.json
create mode 100644 examples/shoelace/generated/contract/input.json
create mode 100644 examples/shoelace/generated/contract/switch.json
create mode 100644 examples/shoelace/generated/html/button.css
create mode 100644 examples/shoelace/generated/html/button.html
create mode 100644 examples/shoelace/generated/html/checkbox.css
create mode 100644 examples/shoelace/generated/html/checkbox.html
create mode 100644 examples/shoelace/generated/html/input.css
create mode 100644 examples/shoelace/generated/html/input.html
create mode 100644 examples/shoelace/generated/html/switch.css
create mode 100644 examples/shoelace/generated/html/switch.html
create mode 100644 examples/shoelace/generated/html/tokens.css
create mode 100644 examples/shoelace/generated/react/Button.tsx
create mode 100644 examples/shoelace/generated/react/Checkbox.tsx
create mode 100644 examples/shoelace/generated/react/Input.tsx
create mode 100644 examples/shoelace/generated/react/Switch.tsx
create mode 100644 examples/shoelace/generated/react/button.css
create mode 100644 examples/shoelace/generated/react/checkbox.css
create mode 100644 examples/shoelace/generated/react/elements.d.ts
create mode 100644 examples/shoelace/generated/react/index.ts
create mode 100644 examples/shoelace/generated/react/input.css
create mode 100644 examples/shoelace/generated/react/models.ts
create mode 100644 examples/shoelace/generated/react/runtime.ts
create mode 100644 examples/shoelace/generated/react/switch.css
create mode 100644 examples/shoelace/generated/react/tokens.css
create mode 100644 examples/shoelace/index.html
create mode 100644 examples/shoelace/package.json
create mode 100644 examples/shoelace/scripts/generate.mjs
create mode 100644 examples/shoelace/src/log.ts
create mode 100644 examples/shoelace/src/main.ts
create mode 100644 examples/shoelace/src/react-demo.tsx
create mode 100644 examples/shoelace/test/generated.test.ts
create mode 100644 examples/shoelace/tsconfig.json
create mode 100644 examples/shoelace/vite.config.ts
diff --git a/examples/design-system/generated/contract/button.json b/examples/design-system/generated/contract/button.json
index 77c9620..8885d24 100644
--- a/examples/design-system/generated/contract/button.json
+++ b/examples/design-system/generated/contract/button.json
@@ -93,11 +93,11 @@
"behavior": [
{
"id": "activation",
- "text": "click, Enter or Space activates; type=\"submit\" calls requestSubmit on the enclosing form, type=\"reset\" resets it, type=\"button\" does nothing to the form."
+ "text": "click, Enter or Space activates; `type=\"submit\"` calls `requestSubmit` on the enclosing form, `type=\"reset\"` resets it, `type=\"button\"` does nothing to the form."
},
{
"id": "disabled",
- "text": "no activation and no events while disabled."
+ "text": "no activation and no events while `disabled`."
},
{
"id": "icon",
diff --git a/examples/design-system/generated/contract/checkbox.json b/examples/design-system/generated/contract/checkbox.json
index 74c20b0..6fabc9c 100644
--- a/examples/design-system/generated/contract/checkbox.json
+++ b/examples/design-system/generated/contract/checkbox.json
@@ -89,19 +89,19 @@
"behavior": [
{
"id": "toggle",
- "text": "click or Space flips checked; a click while indeterminate sets checked and clears indeterminate."
+ "text": "click or Space flips `checked`; a click while `indeterminate` sets `checked` and clears `indeterminate`."
},
{
"id": "change-event",
- "text": "change fires once per user toggle, never when checked is set from code."
+ "text": "`change` fires once per user toggle, never when `checked` is set from code."
},
{
"id": "disabled",
- "text": "no interaction and no events while disabled; still focusable for assistive technology, as native."
+ "text": "no interaction and no events while `disabled`; still focusable for assistive technology, as native."
},
{
"id": "form",
- "text": "submits value while checked and nothing otherwise, matching native ."
+ "text": "submits `value` while checked and nothing otherwise, matching native ` `."
},
{
"id": "label",
diff --git a/examples/design-system/generated/contract/contact-list.json b/examples/design-system/generated/contract/contact-list.json
index 9ed86b4..4aca073 100644
--- a/examples/design-system/generated/contract/contact-list.json
+++ b/examples/design-system/generated/contract/contact-list.json
@@ -78,7 +78,7 @@
},
{
"id": "empty",
- "text": "with no items the empty slot shows and nothing is chosen."
+ "text": "with no items the `empty` slot shows and nothing is chosen."
}
],
"examples": [
diff --git a/examples/design-system/generated/contract/contact-option.json b/examples/design-system/generated/contract/contact-option.json
index fcc367f..f95c6cd 100644
--- a/examples/design-system/generated/contract/contact-option.json
+++ b/examples/design-system/generated/contract/contact-option.json
@@ -125,7 +125,7 @@
},
{
"id": "form",
- "text": "the group submits the chosen option's value under the shared name."
+ "text": "the group submits the chosen option's `value` under the shared `name`."
}
],
"styles": [
diff --git a/examples/design-system/generated/contract/field.json b/examples/design-system/generated/contract/field.json
index 701942c..385d469 100644
--- a/examples/design-system/generated/contract/field.json
+++ b/examples/design-system/generated/contract/field.json
@@ -86,7 +86,7 @@
},
{
"id": "error",
- "text": "error mounts the error message and asks the control to expose aria-invalid; announcing it is the application's policy."
+ "text": "`error` mounts the error message and asks the control to expose `aria-invalid`; announcing it is the application's policy."
},
{
"id": "claim",
diff --git a/examples/design-system/generated/react/Button.tsx b/examples/design-system/generated/react/Button.tsx
index 0a5eb13..be77919 100644
--- a/examples/design-system/generated/react/Button.tsx
+++ b/examples/design-system/generated/react/Button.tsx
@@ -23,7 +23,7 @@ export interface ButtonProps {
function ButtonBase({ label, variant = "primary", size = "medium", disabled, type = "button", className, style }: ButtonProps) {
const ref = useRef(null)
return (
-
+
{label}
diff --git a/examples/design-system/generated/react/Checkbox.tsx b/examples/design-system/generated/react/Checkbox.tsx
index f31b132..e1998af 100644
--- a/examples/design-system/generated/react/Checkbox.tsx
+++ b/examples/design-system/generated/react/Checkbox.tsx
@@ -27,7 +27,7 @@ function CheckboxBase({ checked, indeterminate, disabled, name, value = "on", on
const ref = useRef(null)
useElementEvent(ref, 'change', onChange)
return (
-
+
diff --git a/examples/design-system/generated/react/ContactList.tsx b/examples/design-system/generated/react/ContactList.tsx
index aac7ca8..14c9eeb 100644
--- a/examples/design-system/generated/react/ContactList.tsx
+++ b/examples/design-system/generated/react/ContactList.tsx
@@ -25,7 +25,7 @@ export interface ContactListProps {
function ContactListBase({ items, name, disabled, empty, renderOption, className, style }: ContactListProps) {
const ref = useRef(null)
return (
-
+
{items.map((item, index) => (
{renderOption ? renderOption(item, index) : (
diff --git a/examples/design-system/generated/react/ContactOption.tsx b/examples/design-system/generated/react/ContactOption.tsx
index d881eee..10b925c 100644
--- a/examples/design-system/generated/react/ContactOption.tsx
+++ b/examples/design-system/generated/react/ContactOption.tsx
@@ -28,7 +28,7 @@ function ContactOptionBase({ item, checked, disabled, value, name, onChange, cla
const ref = useRef(null)
useElementEvent(ref, 'change', onChange)
return (
-
+
diff --git a/examples/design-system/generated/react/Field.tsx b/examples/design-system/generated/react/Field.tsx
index 7a05d6b..4cec6cd 100644
--- a/examples/design-system/generated/react/Field.tsx
+++ b/examples/design-system/generated/react/Field.tsx
@@ -23,7 +23,7 @@ export interface FieldProps {
function FieldBase({ label, description, errorMessage, error, control, className, style }: FieldProps) {
const ref = useRef(null)
return (
-
+
{control !== undefined ? {control} : null}
{label}
diff --git a/examples/shoelace/.uidx/button.uidx b/examples/shoelace/.uidx/button.uidx
new file mode 100644
index 0000000..76c61cc
--- /dev/null
+++ b/examples/shoelace/.uidx/button.uidx
@@ -0,0 +1,77 @@
+---
+id: button
+---
+
+The action every screen has. Shoelace's `sl-button` supplies the behaviour —
+focus, keyboard activation, disabled and loading semantics, form submission —
+and this identity supplies the look, drawn on its `base` and `label` parts.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The words on the button, given as its content.
+ How loudly it asks.
+ Padding and type scale.
+ Inert and dimmed.
+
+
+ Fires once per activation, never while disabled.
+
+
+ An icon before the label.
+ The label.
+ An icon after the label.
+
+
+ The button's box: its fill, border and padding.
+ The label's container.
+
+
+
+## Behavior
+
+- activation: click, Enter or Space fires `press` once.
+- disabled: no activation and no events while `disabled`; still announced as a disabled button.
+
+## Examples
+
+
+
+
+
+
+
+
diff --git a/examples/shoelace/.uidx/checkbox.uidx b/examples/shoelace/.uidx/checkbox.uidx
new file mode 100644
index 0000000..bff2ce6
--- /dev/null
+++ b/examples/shoelace/.uidx/checkbox.uidx
@@ -0,0 +1,61 @@
+---
+id: checkbox
+---
+
+An on/off choice with its label. Shoelace's `sl-checkbox` supplies toggling,
+keyboard and form behaviour; this identity draws the box and the check.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The choice, given as its content.
+ Chosen.
+ Not changeable, dimmed.
+
+
+ Fires once per toggle.
+
+
+ The label.
+
+
+ The control and label in a row.
+ The box.
+ The check mark.
+ The label's container.
+
+
+
+
+## Behavior
+
+- toggle: click or Space flips `checked` and fires `change` once.
diff --git a/examples/shoelace/.uidx/config.json b/examples/shoelace/.uidx/config.json
new file mode 100644
index 0000000..53b9bc3
--- /dev/null
+++ b/examples/shoelace/.uidx/config.json
@@ -0,0 +1 @@
+{ "port": 4461 }
diff --git a/examples/shoelace/.uidx/input.uidx b/examples/shoelace/.uidx/input.uidx
new file mode 100644
index 0000000..ab96c01
--- /dev/null
+++ b/examples/shoelace/.uidx/input.uidx
@@ -0,0 +1,65 @@
+---
+id: input
+---
+
+A single-line text field with its label and help. Shoelace's `sl-input`
+supplies typing, validation, form participation and the label's association;
+this identity supplies the look, drawn on its form-control parts.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ What the field asks for.
+ A hint shown while the field is empty.
+ A sentence under the field.
+ What the field holds.
+ Not editable, dimmed.
+
+
+ Fires when an edit is committed.
+
+
+ An icon before the text.
+ An icon after the text.
+
+
+ The label, field and help, stacked.
+ The label.
+ The field's box.
+ The native input.
+ The help sentence.
+
+
+
+
+## Behavior
+
+- label: the label names the field for assistive technology.
+- change: fires `change` when the value is committed, not on every keystroke.
diff --git a/examples/shoelace/.uidx/switch.uidx b/examples/shoelace/.uidx/switch.uidx
new file mode 100644
index 0000000..cbac57c
--- /dev/null
+++ b/examples/shoelace/.uidx/switch.uidx
@@ -0,0 +1,59 @@
+---
+id: switch
+---
+
+A setting that takes effect at once. Shoelace's `sl-switch` supplies
+toggling, keyboard and form behaviour; this identity draws the track and thumb.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The setting, given as its content.
+ On.
+ Not changeable, dimmed.
+
+
+ Fires once per toggle.
+
+
+ The label.
+
+
+ The track and label in a row.
+ The track.
+ The thumb.
+ The label's container.
+
+
+
+
+## Behavior
+
+- toggle: click or Space flips `checked` and fires `change` once.
diff --git a/examples/shoelace/.uidx/tokens.uidx b/examples/shoelace/.uidx/tokens.uidx
new file mode 100644
index 0000000..2c71023
--- /dev/null
+++ b/examples/shoelace/.uidx/tokens.uidx
@@ -0,0 +1,82 @@
+---
+id: tokens
+---
+
+The foundations every component references: a raw palette, semantic colours
+that change with the light and dark modes, and the space, radius and type
+scales. Components alias the semantic tier, never the palette.
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/shoelace/.uidx/uidx.json b/examples/shoelace/.uidx/uidx.json
new file mode 100644
index 0000000..6e00e0d
--- /dev/null
+++ b/examples/shoelace/.uidx/uidx.json
@@ -0,0 +1,48 @@
+{
+ "id": "shoelace-example",
+ "files": [
+ "**/*.uidx"
+ ],
+ "assets": [
+ "assets/**"
+ ],
+ "headless": {
+ "manifest": "../node_modules/@shoelace-style/shoelace/dist/custom-elements.json",
+ "profile": {
+ "coverage": "subset"
+ },
+ "bindings": {
+ "Button": {
+ "events": {
+ "press": "click"
+ }
+ },
+ "Input": {
+ "attributes": {
+ "helpText": "help-text"
+ },
+ "events": {
+ "change": "sl-change"
+ }
+ },
+ "Checkbox": {
+ "events": {
+ "change": "sl-change"
+ }
+ },
+ "Switch": {
+ "events": {
+ "change": "sl-change"
+ }
+ }
+ }
+ },
+ "codegen": {
+ "out": "../generated",
+ "targets": [
+ "html",
+ "react",
+ "contract"
+ ]
+ }
+}
diff --git a/examples/shoelace/README.md b/examples/shoelace/README.md
new file mode 100644
index 0000000..94e8faa
--- /dev/null
+++ b/examples/shoelace/README.md
@@ -0,0 +1,68 @@
+# Example: a design system over Shoelace
+
+The same loop as `examples/design-system`, against a library uidx does not
+ship: [Shoelace](https://shoelace.style)'s web components. Shoelace supplies
+behaviour and accessibility (toggling, keyboard, focus delegation, form
+participation, ARIA); the `.uidx` identities supply the look, reaching inside
+each element's shadow root through the `::part()`s Shoelace exposes. No
+Shoelace theme is loaded: every pixel on the demo page comes from
+`generated/`.
+
+```
+.uidx/ the identities: tokens (light and dark), Button, Input, Checkbox, Switch
+.uidx/uidx.json the connection — Shoelace's manifest, its naming profile, each component's names in it
+generated/html/ one stylesheet (sl-x::part(…) rules) and one markup fragment per component, plus tokens.css
+generated/react/ one typed React component per identity over the same elements
+generated/contract/ the contract of each page as JSON
+```
+
+- `pnpm generate` renders `generated/` from `.uidx/`, checking each contract
+ against `node_modules/@shoelace-style/shoelace/dist/custom-elements.json`.
+- `pnpm check` (and `pnpm test`) fail when `generated/` is out of date.
+- `pnpm dev` opens a page with the HTML fragments beside the React
+ components, an event log, and a Dark mode switch — the generated Switch
+ itself — that flips `data-color` on ``.
+- From the repository root, `pnpm --filter @uidxkit/uidx dev` pointed at this
+ folder opens the identities in the viewer. The **Connect** tab is where the
+ connection in `uidx.json` was made: the manifest, `coverage: subset`, and
+ the bindings (`press → click`, `change → sl-change`, `helpText → help-text`).
+
+## How an identity maps onto a Shoelace element
+
+| In the `.uidx` file | In the generated code |
+| --- | --- |
+| `
` | `` |
+| ` ` | `sl-button::part(base) { … }` |
+| `` | `` (Shoelace declares the slot) |
+| `` | `placeholder="…"`, an attribute — the text lives in the shadow root |
+| `
+
+
+
+
+
+
HTML/CSS target
+
+
+
+
+
+
+
diff --git a/examples/shoelace/package.json b/examples/shoelace/package.json
new file mode 100644
index 0000000..4a8ea73
--- /dev/null
+++ b/examples/shoelace/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@uidx/example-shoelace",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "description": "A design system over Shoelace's web components: uidx identities supply the look through Shoelace's ::part()s, Shoelace supplies behaviour and accessibility.",
+ "scripts": {
+ "generate": "node scripts/generate.mjs",
+ "check": "node scripts/generate.mjs --check",
+ "dev": "vite",
+ "build": "vite build",
+ "test": "vitest run --maxWorkers=2 --testTimeout=20000",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@shoelace-style/shoelace": "2.20.1",
+ "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/shoelace/scripts/generate.mjs b/examples/shoelace/scripts/generate.mjs
new file mode 100644
index 0000000..46b54fe
--- /dev/null
+++ b/examples/shoelace/scripts/generate.mjs
@@ -0,0 +1,66 @@
+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/`, the way the viewer's Get code button
+ * does: the library, its naming profile and each component's names in it
+ * come from `.uidx/uidx.json`, as the Connect tab wrote them. With
+ * `--check`, compares instead of writing, so a test fails when the design
+ * moved and the code did not.
+ */
+const root = fileURLToPath(new URL('..', import.meta.url))
+const check = process.argv.includes('--check')
+const config = JSON.parse(await readFile(resolve(root, '.uidx/uidx.json'), 'utf8'))
+const out = resolve(root, '.uidx', config.codegen.out)
+
+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 headless =
+ typeof config.headless === 'string' ? { manifest: config.headless } : config.headless
+const manifest = JSON.parse(await readFile(resolve(root, '.uidx', headless.manifest), 'utf8'))
+const result = generate({
+ pages,
+ tokens,
+ manifest,
+ library: { profile: headless.profile, components: headless.bindings },
+ targets: config.codegen.targets,
+ react: config.codegen.react,
+})
+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 ${config.codegen.out}`)
+}
diff --git a/examples/shoelace/src/log.ts b/examples/shoelace/src/log.ts
new file mode 100644
index 0000000..675644c
--- /dev/null
+++ b/examples/shoelace/src/log.ts
@@ -0,0 +1,8 @@
+const panel = () => document.getElementById('log')!
+
+/** Appends one line to the on-page event log (newest last). */
+export function log(line: string): void {
+ const out = panel()
+ out.textContent += `${new Date().toLocaleTimeString()} ${line}\n`
+ out.scrollTop = out.scrollHeight
+}
diff --git a/examples/shoelace/src/main.ts b/examples/shoelace/src/main.ts
new file mode 100644
index 0000000..0199fa2
--- /dev/null
+++ b/examples/shoelace/src/main.ts
@@ -0,0 +1,92 @@
+// Shoelace supplies the elements: registering them is all the behaviour there is.
+import '@shoelace-style/shoelace/dist/components/button/button.js'
+import '@shoelace-style/shoelace/dist/components/input/input.js'
+import '@shoelace-style/shoelace/dist/components/checkbox/checkbox.js'
+import '@shoelace-style/shoelace/dist/components/switch/switch.js'
+
+// The uidx identities supply the look: tokens (with the dark mode) and one
+// stylesheet per component, exactly as generated.
+import '../generated/html/tokens.css'
+import '../generated/html/button.css'
+import '../generated/html/input.css'
+import '../generated/html/checkbox.css'
+import '../generated/html/switch.css'
+
+import button from '../generated/html/button.html?raw'
+import input from '../generated/html/input.html?raw'
+import checkbox from '../generated/html/checkbox.html?raw'
+import switchHtml from '../generated/html/switch.html?raw'
+
+import { log } from './log'
+import { mountReact } from './react-demo'
+
+/** A generated fragment as an element, optionally with extra attributes for a variant. */
+function fragment(html: string, attributes: Record = {}, text?: string): Element {
+ const template = document.createElement('template')
+ template.innerHTML = html
+ const element = template.content.querySelector('*')!
+ for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value)
+ if (text !== undefined) element.querySelector('[data-node="text"]')!.textContent = text
+ return element
+}
+
+function section(title: string, children: Element[], layout = 'row'): HTMLElement {
+ const out = document.createElement('section')
+ out.innerHTML = `${title} `
+ const row = document.createElement('div')
+ row.className = layout
+ row.append(...children)
+ out.append(row)
+ return out
+}
+
+const target = document.getElementById('html-target')!
+target.append(
+ section('Button', [
+ fragment(button),
+ fragment(button, { variant: 'neutral' }, 'Cancel'),
+ fragment(button, { variant: 'danger', size: 'small' }, 'Delete'),
+ fragment(button, { size: 'large' }, 'Continue'),
+ fragment(button, { disabled: '' }, 'Disabled'),
+ ]),
+ section(
+ 'Input',
+ [fragment(input), fragment(input, { disabled: '', value: 'locked@example.com' })],
+ 'stack',
+ ),
+ section('Checkbox', [
+ fragment(checkbox),
+ fragment(checkbox, { checked: '' }, 'Already on'),
+ fragment(checkbox, { disabled: '' }, 'Disabled'),
+ ]),
+ section('Switch', [
+ fragment(switchHtml, {}, 'Notifications'),
+ fragment(switchHtml, { checked: '' }, 'Sync'),
+ ]),
+)
+
+// Every event the contracts declare, as the elements dispatch them.
+for (const name of ['click', 'sl-change', 'sl-input']) {
+ target.addEventListener(name, (event) => {
+ const element = (event.target as Element).closest('sl-button, sl-input, sl-checkbox, sl-switch')
+ if (!element || (name === 'click' && element.localName !== 'sl-button')) return
+ const state = element as HTMLElement & { checked?: boolean; value?: string }
+ const detail =
+ 'checked' in state && element.localName !== 'sl-button'
+ ? ` checked=${state.checked}`
+ : state.value !== undefined && element.localName === 'sl-input'
+ ? ` value="${state.value}"`
+ : ''
+ log(`html <${element.localName}> ${name}${detail}`)
+ })
+}
+
+// The theme switch is the generated Switch itself, flipping the token mode.
+const theme = fragment(switchHtml, {}, 'Dark mode') as HTMLElement & { checked: boolean }
+theme.addEventListener('sl-change', () => {
+ document.documentElement.dataset.color = theme.checked ? 'dark' : 'light'
+ log(`theme data-color="${document.documentElement.dataset.color}"`)
+})
+document.getElementById('theme')!.append(theme)
+
+mountReact(document.getElementById('react-target')!)
diff --git a/examples/shoelace/src/react-demo.tsx b/examples/shoelace/src/react-demo.tsx
new file mode 100644
index 0000000..48da84e
--- /dev/null
+++ b/examples/shoelace/src/react-demo.tsx
@@ -0,0 +1,61 @@
+import { StrictMode, useState } from 'react'
+import { createRoot } from 'react-dom/client'
+import { Button, Checkbox, Input, Switch } from '../generated/react'
+import { log } from './log'
+
+/**
+ * The React target: the generated components, used the way an application
+ * would. State lives in React; behaviour in Shoelace's elements underneath.
+ */
+function Demo() {
+ const [count, setCount] = useState(0)
+ const [agreed, setAgreed] = useState(false)
+ return (
+ <>
+
+ Button
+
+ {
+ setCount((n) => n + 1)
+ log('react onPress')
+ }}
+ />
+
+
+
+
+
+
+ Checkbox · Switch
+
+ {
+ setAgreed((on) => !on)
+ log('react onChange')
+ }}
+ />
+ log('react onChange')} />
+
+
+ >
+ )
+}
+
+export function mountReact(element: HTMLElement): void {
+ createRoot(element).render(
+
+
+ ,
+ )
+}
diff --git a/examples/shoelace/test/generated.test.ts b/examples/shoelace/test/generated.test.ts
new file mode 100644
index 0000000..f1f1a4f
--- /dev/null
+++ b/examples/shoelace/test/generated.test.ts
@@ -0,0 +1,66 @@
+import { readdir, readFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { parse } from '@uidx/format'
+import { modelIndex } from '@uidx/schema/design-system'
+import { auditDesignSystem } from '@uidx/schema/design-system-audit'
+import { generate } from '@uidx/codegen'
+
+/**
+ * The same rule as the design-system example (ADR 0017 §4), against a
+ * library uidx does not ship: every identity passes the audit, conforms to
+ * Shoelace's own manifest under the connection in `.uidx/uidx.json`, and
+ * `generated/` is what the identities render to, byte for byte.
+ */
+const root = resolve(import.meta.dirname, '..')
+
+async function load() {
+ const config = JSON.parse(await readFile(resolve(root, '.uidx/uidx.json'), 'utf8'))
+ 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, '.uidx', config.headless.manifest), 'utf8'),
+ )
+ const options = {
+ pages,
+ tokens,
+ manifest,
+ library: { profile: config.headless.profile, components: config.headless.bindings },
+ targets: config.codegen.targets,
+ react: config.codegen.react,
+ }
+ return { options, out: resolve(root, '.uidx', config.codegen.out) }
+}
+
+describe('the Shoelace example', () => {
+ it('passes the design-system audit for every page', async () => {
+ const { options } = await load()
+ const models = modelIndex(options.pages.map((page) => page.doc))
+ for (const { file, doc } of options.pages) {
+ expect(auditDesignSystem(doc, models).map((d) => `${file}: ${d.message}`)).toEqual([])
+ }
+ })
+
+ it("conforms to Shoelace's manifest", async () => {
+ const { options } = await load()
+ const result = generate(options)
+ expect(result.diagnostics.map((d) => `${d.file}: ${d.message}`)).toEqual([])
+ })
+
+ it('has generated/ up to date with .uidx/', async () => {
+ const { options, out } = await load()
+ for (const [path, text] of generate(options).files) {
+ const existing = await readFile(resolve(out, path), 'utf8').catch(() => null)
+ expect(existing, path).toBe(text)
+ }
+ })
+})
diff --git a/examples/shoelace/tsconfig.json b/examples/shoelace/tsconfig.json
new file mode 100644
index 0000000..596f761
--- /dev/null
+++ b/examples/shoelace/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "customConditions": ["development"],
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "types": ["node", "vite/client"]
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "generated/react/**/*.ts",
+ "generated/react/**/*.tsx",
+ "test/**/*.ts"
+ ]
+}
diff --git a/examples/shoelace/vite.config.ts b/examples/shoelace/vite.config.ts
new file mode 100644
index 0000000..f499c1a
--- /dev/null
+++ b/examples/shoelace/vite.config.ts
@@ -0,0 +1,5 @@
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ build: { target: 'esnext' },
+})
diff --git a/packages/codegen/src/conformance.ts b/packages/codegen/src/conformance.ts
index 89c6c83..69a0a03 100644
--- a/packages/codegen/src/conformance.ts
+++ b/packages/codegen/src/conformance.ts
@@ -17,6 +17,25 @@ import {
* from `custom-elements.json`, which the headless build generates from its
* source, so drift on either side fails a build rather than a user.
*/
+/** Events every element dispatches, which a manifest does not list. */
+const NATIVE_EVENTS = new Set([
+ 'click',
+ 'dblclick',
+ 'input',
+ 'change',
+ 'focus',
+ 'blur',
+ 'focusin',
+ 'focusout',
+ 'keydown',
+ 'keyup',
+ 'pointerdown',
+ 'pointerup',
+ 'submit',
+ 'reset',
+ 'toggle',
+])
+
export function checkConformance(model: ComponentModel, manifest: Manifest): Diagnostic[] {
const out: Diagnostic[] = []
if (!model.tag || !model.contract) return out
@@ -32,7 +51,10 @@ export function checkConformance(model: ComponentModel, manifest: Manifest): Dia
// Compared in the library's spelling: a binding may rename an attribute
// or an event, and the contract still has to cover what the element does.
const props = new Set(model.contract.props.map((prop) => attributeName(model, prop.name)))
- for (const attribute of root.attributes ?? []) {
+ // A subset of a general library: what the element has and the design
+ // system leaves out is a choice, not drift.
+ const full = model.profile.coverage === 'full'
+ for (const attribute of full ? (root.attributes ?? []) : []) {
if (!props.has(attribute.name)) {
report(
`${model.tag} exposes the attribute "${attribute.name}", which the contract does not declare as a prop`,
@@ -40,7 +62,7 @@ export function checkConformance(model: ComponentModel, manifest: Manifest): Dia
}
}
const events = new Set(model.contract.events.map((event) => eventName(model, event.name)))
- for (const event of root.events ?? []) {
+ for (const event of full ? (root.events ?? []) : []) {
if (!events.has(event.name)) {
report(
`${model.tag} dispatches "${event.name}", which the contract does not declare as an event`,
@@ -48,7 +70,10 @@ export function checkConformance(model: ComponentModel, manifest: Manifest): Dia
}
}
for (const event of model.contract.events) {
- if (!(root.events ?? []).some((entry) => entry.name === eventName(model, event.name))) {
+ const name = eventName(model, event.name)
+ // Every element dispatches the DOM's own events; manifests list only their own.
+ if (NATIVE_EVENTS.has(name)) continue
+ if (!(root.events ?? []).some((entry) => entry.name === name)) {
report(
`the contract declares the event "${event.name}", which ${model.tag} does not dispatch`,
)
diff --git a/packages/codegen/src/css.ts b/packages/codegen/src/css.ts
index dd757a4..23ebe2d 100644
--- a/packages/codegen/src/css.ts
+++ b/packages/codegen/src/css.ts
@@ -1,5 +1,5 @@
import { aliasTarget, type JsonValue, type UidxDocument } from '@uidx/format'
-import { resolveTokenValues } from '@uidx/schema'
+import { buildTokenIndex, defaultTuple, mergeModes, TokenResolver } from '@uidx/schema'
/**
* UIDX properties as CSS (ADR 0017 §3).
@@ -97,11 +97,13 @@ export function cssPaint(value: JsonValue): string | null {
return null
}
-export type CssKind = 'container' | 'text'
+export type CssKind = 'container' | 'text' | 'vector'
/**
* The declarations for one node's attributes. `kind` decides what a fill is:
- * a frame's fill is its background, a text's fill is its colour.
+ * a frame's fill is its background, a text's fill is its colour. A vector's
+ * paths paint with `currentColor`, so its fill — or, when it is only
+ * outlined, its stroke — is its colour, never a box border.
*/
export function cssDeclarations(
attrs: Record,
@@ -114,10 +116,14 @@ export function cssDeclarations(
for (const [name, value] of Object.entries(attrs)) {
switch (name) {
case 'fills':
- set(kind === 'text' ? 'color' : 'background-color', cssPaint(value))
+ set(kind === 'container' ? 'background-color' : 'color', cssPaint(value))
break
case 'strokes': {
const color = cssPaint(value)
+ if (kind === 'vector') {
+ if (cssPaint(attrs.fills ?? null) === null) set('color', color)
+ break
+ }
if (color) {
const weight = attrs.strokeWeight === undefined ? '1px' : cssLength(attrs.strokeWeight)
set('border', `${weight ?? '1px'} solid ${color}`)
@@ -254,21 +260,52 @@ export function cssRule(selector: string, declarations: Record):
* that use them add the unit (`cssLength`), so one token serves a radius and
* a font size alike.
*/
+function tokenText(value: JsonValue): string | null {
+ const color = cssColor(value)
+ if (color !== null) return color
+ if (typeof value === 'number') return round(value)
+ if (typeof value === 'string') return value
+ if (typeof value === 'boolean') return String(value)
+ return null
+}
+
+function tokenBlock(selector: string, values: Map): string {
+ const lines = [...values]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([address, text]) => ` ${cssVariable(address)}: ${text};`)
+ return lines.length ? `${selector} {\n${lines.join('\n')}\n}\n` : ''
+}
+
+/**
+ * Every token as a custom property: the default modes on `:root`, then one
+ * block per other mode of each collection — `[data-color="dark"]` — holding
+ * only the variables that mode changes. Setting the attribute on any element
+ * (usually ``) switches that subtree, the way a frame's mode selection
+ * does on the canvas.
+ */
export function tokensCss(docs: readonly UidxDocument[]): string {
- const values = resolveTokenValues(docs)
- const lines: string[] = []
- for (const [address, value] of [...values].sort(([a], [b]) => a.localeCompare(b))) {
- const color = cssColor(value)
- const text =
- color ??
- (typeof value === 'number'
- ? round(value)
- : typeof value === 'string'
- ? value
- : typeof value === 'boolean'
- ? String(value)
- : null)
- if (text !== null) lines.push(` ${cssVariable(address)}: ${text};`)
+ const index = buildTokenIndex(docs)
+ const resolver = new TokenResolver(index)
+ const base = defaultTuple(index)
+ const texts = (tuple: typeof base) => {
+ const out = new Map()
+ for (const [address, value] of resolver.resolve(tuple)) {
+ const text = tokenText(value)
+ if (text !== null) out.set(address, text)
+ }
+ return out
+ }
+ const defaults = texts(base)
+ const blocks = [tokenBlock(':root', defaults)]
+ for (const [collection, info] of index.collections) {
+ for (const mode of info.modes.slice(1)) {
+ const changed = new Map()
+ for (const [address, text] of texts(mergeModes(base, { [collection]: mode }, index))) {
+ if (defaults.get(address) !== text) changed.set(address, text)
+ }
+ const name = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, '-')
+ blocks.push(tokenBlock(`[data-${name(collection)}="${mode.replace(/"/g, '')}"]`, changed))
+ }
}
- return lines.length ? `:root {\n${lines.join('\n')}\n}\n` : ''
+ return blocks.filter(Boolean).join('\n')
}
diff --git a/packages/codegen/src/html.ts b/packages/codegen/src/html.ts
index 439e1c4..f907da6 100644
--- a/packages/codegen/src/html.ts
+++ b/packages/codegen/src/html.ts
@@ -37,7 +37,8 @@ export function stateSelector(
): string {
switch (stateKind(state, contract)) {
case 'interaction':
- return state === 'hover' ? ':hover' : state === 'focus' ? ':focus-visible' : ':active'
+ if (state === 'focus') return model?.shadow ? ':focus-within' : ':focus-visible'
+ return state === 'hover' ? ':hover' : ':active'
// The list the consumer passed is empty: the React target sets it.
case 'collection':
return '[data-empty]'
@@ -124,7 +125,7 @@ function attrValues(node: UidxNode): Record {
/** A text's fill is its colour; so is a vector's, whose paths use `currentColor`. */
function kindOf(node: UidxNode): CssKind {
- return node.element === 'Text' || node.element === 'Vector' ? 'text' : 'container'
+ return node.element === 'Text' ? 'text' : node.element === 'Vector' ? 'vector' : 'container'
}
/** The selector for a node inside the component, given the root's selector. */
@@ -252,7 +253,10 @@ function markup(
// 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 []
+ // What a slot inside it receives is still the page's to give: slot content
+ // lives in the light DOM whatever the shadow tree around it looks like.
+ if (info?.kind === 'shadow')
+ return slotsWithin(node, (slot) => markup(model, slot, ctx, samples, depth, index, fills))
// ADR 0017 §2: an element with `repeat` is rendered once per sample of its
// list's model, the n-th row with `{as.field}` bound to the n-th samples.
// A repeating slot renders its content per row with no wrapper, so a
@@ -321,9 +325,13 @@ function markup(
}
case 'Slot': {
const fill = fills.get(node.name)
+ // An empty slot of the library's own is the consumer's to fill; its
+ // name as placeholder text would land inside the element as content.
+ if (!fill && !node.children.length && model.slotted.has(node.name)) return []
const inner =
fill ?? (node.children.length ? children() : [`${pad} ${escapeHtml(node.name)}`])
- return [`${pad}`, ...inner, `${pad} `]
+ const slot = model.slotted.has(node.name) ? ` slot="${node.name}"` : ''
+ return [`${pad}`, ...inner, `${pad} `]
}
case 'Text': {
const [open, close] = (info && partTags(info)) ?? [
@@ -359,7 +367,7 @@ function markup(
return markup(model, model.composes, ctx, samples, depth, index, fills)
if (node === model.node) {
const tag = model.tag ?? 'div'
- const attrs = model.tag ? '' : ` class="${model.stem}"`
+ const attrs = model.tag ? hostAttributes(model) : ` class="${model.stem}"`
return [`${pad}<${tag}${attrs}>`, ...children(), `${pad}${tag}>`]
}
const [open, close] = (info && partTags(info)) ?? [``, '
']
@@ -372,3 +380,26 @@ function markup(
export function emitHtml(model: ComponentModel, ctx: HtmlContext): string {
return `\n${markup(model, model.node, ctx, model.samples, 0).join('\n')}\n`
}
+
+/** The slots below a node, nearest first, each rendered by `render`: what a shadow part passes through. */
+export function slotsWithin(node: UidxNode, render: (slot: UidxNode) => string[]): string[] {
+ return node.children.flatMap((child) =>
+ child.element === 'Slot' ? render(child) : slotsWithin(child, render),
+ )
+}
+
+/**
+ * Sample text the element takes as an attribute, written on it: a library
+ * like Shoelace draws `sl-input`'s label and placeholder in its shadow tree
+ * from attributes, so the design's words would otherwise never reach it.
+ */
+export function hostAttributes(model: ComponentModel): string {
+ return (model.contract?.props ?? [])
+ .flatMap((prop) => {
+ const attribute = attributeName(model, prop.name)
+ return typeof prop.sample === 'string' && model.elementAttributes.has(attribute)
+ ? [` ${attribute}="${escapeHtml(prop.sample)}"`]
+ : []
+ })
+ .join('')
+}
diff --git a/packages/codegen/src/model.ts b/packages/codegen/src/model.ts
index 5e9bb55..f0acf64 100644
--- a/packages/codegen/src/model.ts
+++ b/packages/codegen/src/model.ts
@@ -92,12 +92,21 @@ export interface LibraryProfile {
customStates: 'state' | 'data-attribute' | 'class'
/** Parts: elements of their own (or `cssParts`), or light-DOM `[data-part="x"]`. */
parts: 'element' | 'data-part'
+ /**
+ * How much of the element the contract must cover. `full`: every attribute
+ * and event the element has is declared — a headless library made for this
+ * design system. `subset`: the design system exposes a chosen part of a
+ * general library (Shoelace's button has nineteen attributes), and only
+ * what it declares is checked against the element.
+ */
+ coverage: 'full' | 'subset'
}
export const DEFAULT_PROFILE: LibraryProfile = {
props: 'attribute',
customStates: 'state',
parts: 'element',
+ coverage: 'full',
}
/** The library's names for one component's identity names. Absent entries keep the identity's. */
@@ -126,6 +135,7 @@ export function libraryProfile(library?: LibraryBindings): LibraryProfile {
props: pick('props', ['attribute', 'data-attribute', 'class']),
customStates: pick('customStates', ['state', 'data-attribute', 'class']),
parts: pick('parts', ['element', 'data-part']),
+ coverage: pick('coverage', ['full', 'subset']),
}
}
@@ -148,10 +158,12 @@ export function partKind(rootTag: string, part: string, manifest?: Manifest): Pa
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'
+ if (tagSet.has(`${rootTag}-${part}`)) return 'element'
+ // A part the root declares as its own shadow part wins over a tag that only
+ // happens to end in the same word: Shoelace's sl-button has a `label` part,
+ // and sl-menu-label is another component entirely.
+ if ((tags.get(rootTag)?.cssParts ?? []).some((entry) => entry.name === part)) return 'shadow'
+ return 'element'
}
export function partTag(rootTag: string, part: string, tags?: ReadonlySet): string {
@@ -200,8 +212,11 @@ export interface SlotInfo {
export function vectorPaint(node: UidxNode): string {
const fills = node.attrs.fills?.value
const strokes = node.attrs.strokes?.value
- const filled = Array.isArray(fills) && fills.length > 0
- const stroked = Array.isArray(strokes) && strokes.length > 0
+ // A paint list, or a token alias standing for one.
+ const painted = (value: JsonValue | undefined) =>
+ (Array.isArray(value) && value.length > 0) || (typeof value === 'string' && value !== '')
+ const filled = painted(fills)
+ const stroked = painted(strokes)
if (!stroked || filled) return 'fill="currentColor"'
const weight =
typeof node.attrs.strokeWeight?.value === 'number' ? node.attrs.strokeWeight.value : 1
@@ -265,6 +280,19 @@ export interface ComponentModel {
profile: LibraryProfile
/** The library's names for this component's, from `uidx.json`; empty when unbound. */
binding: ComponentBinding
+ /**
+ * Named slots the library's element declares: content for them carries
+ * `slot="name"`, which a shadow-DOM slot needs to receive it.
+ */
+ slotted: Set
+ /** The attributes the library's element declares, in its spelling. */
+ elementAttributes: Set
+ /**
+ * The element renders a shadow root (its manifest declares CSS parts). Its
+ * host matches `:focus` through focus delegation but never
+ * `:focus-visible`, so a focus state is selected as `:focus-within`.
+ */
+ shadow: boolean
}
/** The prop a bare `{name}` alias names, if the contract declares it. */
@@ -325,9 +353,26 @@ export function componentModel(
// reflect as data attributes and parts as data-part, which a div can carry.
const profile: LibraryProfile =
implemented === undefined
- ? { props: 'data-attribute', customStates: 'data-attribute', parts: 'data-part' }
+ ? {
+ props: 'data-attribute',
+ customStates: 'data-attribute',
+ parts: 'data-part',
+ coverage: 'full',
+ }
: libraryProfile(library)
const tag = implemented === undefined ? undefined : (binding.tag ?? implemented)
+ const declared = tag ? manifestTags(manifest).get(tag) : undefined
+ const slotted = new Set(
+ ((declared as { slots?: { name?: string }[] } | undefined)?.slots ?? [])
+ .map((slot) => slot.name ?? '')
+ .filter((name) => name !== ''),
+ )
+ const elementAttributes = new Set(
+ ((declared as { attributes?: { name?: string }[] } | undefined)?.attributes ?? [])
+ .map((attribute) => attribute.name ?? '')
+ .filter(Boolean),
+ )
+ const shadow = ((declared as { cssParts?: unknown[] } | undefined)?.cssParts?.length ?? 0) > 0
const parts: PartInfo[] = []
const slots: SlotInfo[] = []
const repeats: RepeatInfo[] = []
@@ -392,6 +437,9 @@ export function componentModel(
models: models ?? modelIndex([doc]),
profile,
binding,
+ slotted,
+ elementAttributes,
+ shadow,
parts,
slots,
repeats,
diff --git a/packages/codegen/src/react.ts b/packages/codegen/src/react.ts
index 163fcc0..8965832 100644
--- a/packages/codegen/src/react.ts
+++ b/packages/codegen/src/react.ts
@@ -18,6 +18,7 @@ import {
type RepeatInfo,
vectorPaint,
} from './model.js'
+import { slotsWithin } from './html.js'
/**
* The React target (ADR 0017 §3): one component per identity, wrapping the
@@ -201,12 +202,22 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
const textBound = new Set()
// Which props are rendered as text somewhere: those accept a ReactNode.
- const scanText = (node: UidxNode): void => {
+ // Text the library draws inside a shadow part (outside any slot) is not
+ // ours to render: when the element takes it as an attribute, it stays a
+ // string prop passed through, as Shoelace's sl-input takes its label.
+ const scanText = (node: UidxNode, shadowed: boolean): void => {
+ const part = model.partOf.get(node)
+ const kind =
+ part === undefined ? undefined : model.parts.find((entry) => entry.name === part)?.kind
+ const inShadow = node.element === 'Slot' ? false : shadowed || kind === 'shadow'
const prop = boundProp(model, node.attrs.characters?.value ?? null)
- if (prop && node.element === 'Text') textBound.add(prop.name)
- for (const child of node.children) scanText(child)
+ if (prop && node.element === 'Text') {
+ const attribute = model.elementAttributes.has(attributeName(model, prop.name))
+ if (!(inShadow && attribute)) textBound.add(prop.name)
+ }
+ for (const child of node.children) scanText(child, inShadow)
}
- scanText(model.node)
+ scanText(model.node, false)
const attributeProps: PropSpec[] = []
for (const prop of contract?.props ?? []) {
@@ -291,7 +302,7 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
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 []
+ if (info?.kind === 'shadow') return slotsWithin(node, (slot) => render(slot, depth, scopes))
// ADR 0017 §2: an element with `repeat` maps over its list; the n-th row
// binds `{as.field}` to the item. A repeating slot lets the consumer draw
// the row instead, through its render prop.
@@ -379,9 +390,10 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
case 'Slot': {
const prop = node.name === 'default' ? 'children' : node.name
const fallback = node.children.length ? children() : []
+ const slot = model.slotted.has(node.name) ? ` slot="${node.name}"` : ''
if (fallback.length) {
return [
- `${pad}`,
+ `${pad}`,
`${pad} {${prop} !== undefined ? ${prop} : (`,
...fallback.map((line) => ` ${line}`),
`${pad} )}`,
@@ -389,7 +401,7 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
]
}
return [
- `${pad}{${prop} !== undefined ? {${prop}} : null}`,
+ `${pad}{${prop} !== undefined ? {${prop}} : null}`,
]
}
case 'Text': {
@@ -425,7 +437,12 @@ export function emitReact(model: ComponentModel, ctx: ReactContext): string {
const isBoolean = prop.type === 'boolean'
const name = attributeName(model, prop.name)
const attr = model.profile.props === 'data-attribute' ? `data-${name}` : name
- return `${attr}={${isBoolean ? `${prop.name} || undefined` : prop.name}}`
+ // On a custom element React sets the property, and `undefined` does not
+ // clear a reflected boolean in Lit — `disabled` would stick once on — so
+ // the element gets a real boolean. Elsewhere `undefined` drops the
+ // attribute rather than writing `"false"`.
+ const flag = model.tag && attr === name ? `!!${prop.name}` : `${prop.name} || undefined`
+ return `${attr}={${isBoolean ? flag : prop.name}}`
})
.join(' ')
// The `empty` state (ADR 0017 §4) is the consumer's data, not a prop, so
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
index e61e2d9..f35c870 100644
--- a/packages/codegen/test/generate.test.ts
+++ b/packages/codegen/test/generate.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { CODES, parseOrThrow } from '@uidx/format'
-import { generate, tsType, componentModel, partTag } from '../src/index.js'
+import { generate, tsType, componentModel, partTag, stateSelector } from '../src/index.js'
import { CHECKBOX, CONTACT_ITEM, CONTACT_LIST, FIELD, MANIFEST, TOKENS } from './fixtures.js'
/**
@@ -158,6 +158,34 @@ describe('tokens.css', () => {
`:root {\n --radius-sm: 4;\n --space-sm: 8;\n --surface-accent: rgb(0 128 255);\n --surface-control: rgb(255 255 255);\n}\n`,
)
})
+
+ it('adds a block per extra mode holding only what that mode changes', () => {
+ const moded = parseOrThrow(`---
+id: tokens
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+`)
+ const css = generate({ pages: [], tokens: [moded], targets: ['html'] }).files.get(
+ 'html/tokens.css',
+ )
+ expect(css).toBe(
+ `:root {\n --color-accent: rgb(0 0 255);\n --color-surface: rgb(255 255 255);\n}\n\n[data-color="dark"] {\n --color-surface: rgb(0 0 0);\n}\n`,
+ )
+ })
})
describe('the HTML/CSS target', () => {
@@ -413,7 +441,7 @@ describe('the React target', () => {
expect(tsx).toContain(`onChange?: (detail: { checked: boolean }) => void`)
expect(tsx).toContain(`useElementEvent(ref, 'change', onChange)`)
expect(tsx).toContain(
- ` {
expect(checkbox['x-uidx']).toHaveProperty('accessibility')
})
})
+
+describe('a general web-component library (Shoelace)', () => {
+ const MANIFEST = {
+ modules: [
+ {
+ declarations: [
+ {
+ tagName: 'sl-button',
+ attributes: [{ name: 'variant' }, { name: 'href' }, { name: 'disabled' }],
+ events: [{ name: 'sl-focus' }],
+ slots: [{ name: '' }, { name: 'prefix' }],
+ cssParts: [{ name: 'base' }, { name: 'label' }],
+ },
+ ],
+ },
+ ],
+ }
+ const PAGE = parseOrThrow(`---
+id: button
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ Words.
+ Emphasis.
+
+
+ Activation.
+
+
+ An icon.
+ The label.
+
+
+ The box.
+ The label.
+
+`)
+ const run = (profile: Record) =>
+ generate({
+ pages: [{ file: 'button.uidx', doc: PAGE }],
+ tokens: [],
+ manifest: MANIFEST,
+ library: { profile, components: { Button: { events: { press: 'click' } } } },
+ targets: ['html', 'react'],
+ })
+
+ it('requires the whole element under full coverage, a declared subset under subset', () => {
+ const full = run({}).diagnostics.map((d) => d.message)
+ expect(full.some((m) => m.includes('"href"'))).toBe(true)
+ expect(full.some((m) => m.includes('sl-focus'))).toBe(true)
+ // press is renamed to the DOM's own click, which no manifest lists.
+ expect(full.some((m) => m.includes('"press"'))).toBe(false)
+ expect(run({ coverage: 'subset' }).diagnostics).toEqual([])
+ })
+
+ it('colours an outlined vector with its stroke, as currentColor, never a border', () => {
+ const check = parseOrThrow(`---
+id: tick
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+## Contract
+
+
+ Unused.
+
+`)
+ const files = generate({
+ pages: [{ file: 'tick.uidx', doc: check }],
+ tokens: [],
+ targets: ['html'],
+ }).files
+ const css = files.get('html/tick.css')!
+ expect(css).toContain('color: var(--color-ink);')
+ expect(css).not.toContain('border:')
+ expect(files.get('html/tick.html')).toContain('stroke="currentColor" stroke-width="2"')
+ })
+
+ it('selects focus on a shadow host as :focus-within, which it matches, not :focus-visible', () => {
+ const shadowed = componentModel(PAGE.tree.children[0]!, PAGE, MANIFEST)
+ expect(stateSelector('focus', undefined, shadowed)).toBe(':focus-within')
+ expect(stateSelector('focus')).toBe(':focus-visible')
+ })
+
+ it("passes slot content through the library's shadow parts, with slot names it declares", () => {
+ const files = run({ coverage: 'subset' }).files
+ const html = files.get('html/button.html')!
+ expect(html).toContain('')
+ expect(html).toContain('Save')
+ expect(files.get('html/button.css')).toContain('sl-button::part(base)')
+ // `label` is sl-button's own part, not some other element ending in -label.
+ expect(files.get('html/button.css')).toContain('sl-button::part(label)')
+ const react = files.get('react/Button.tsx')!
+ expect(react).toContain('slot="prefix"')
+ expect(react).toContain('data-slot="default"')
+ })
+
+ it('writes sample text the element takes as an attribute onto it, in HTML and React', () => {
+ const field = parseOrThrow(`---
+id: input
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ Asks.
+ Hint.
+
+`)
+ const output = generate({
+ pages: [{ file: 'input.uidx', doc: field }],
+ tokens: [],
+ manifest: {
+ modules: [
+ {
+ declarations: [
+ {
+ tagName: 'sl-input',
+ attributes: [{ name: 'label' }, { name: 'placeholder' }],
+ cssParts: [
+ { name: 'form-control' },
+ { name: 'form-control-label' },
+ { name: 'base' },
+ { name: 'input' },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ library: { profile: { coverage: 'subset' } },
+ targets: ['html', 'react'],
+ })
+ expect(output.files.get('html/input.html')).toContain(
+ '',
+ )
+ const react = output.files.get('react/Input.tsx')!
+ expect(react).toContain('label?: string')
+ expect(react).toContain('label={label} placeholder={placeholder}')
+ })
+})
diff --git a/packages/schema/src/adopt.ts b/packages/schema/src/adopt.ts
index 43043df..8193e9b 100644
--- a/packages/schema/src/adopt.ts
+++ b/packages/schema/src/adopt.ts
@@ -37,12 +37,17 @@ export function adoptElement(
escape(member.description ?? `${PLACEHOLDER}the ${what} "${member.name}".`)
const anatomy: string[] = []
+ const namedSlots = element.members.slots.filter((slot) => slot.name !== '')
+ // A part and a slot may share a name (Shoelace's sl-input has both a
+ // `prefix` part and a `prefix` slot); layers are siblings, so the part's
+ // layer takes a suffix. Its `part` attribute is what binds it.
+ const slotNames = new Set(namedSlots.map((slot) => slot.name))
for (const part of element.parts) {
+ const layer = slotNames.has(part.name) ? `${part.name}-part` : part.name
anatomy.push(
- ` `,
+ ` `,
)
}
- const namedSlots = element.members.slots.filter((slot) => slot.name !== '')
const defaultSlot = element.members.slots.some((slot) => slot.name === '')
for (const slot of namedSlots) anatomy.push(` `)
if (defaultSlot) {
diff --git a/packages/schema/test/adopt.test.ts b/packages/schema/test/adopt.test.ts
index 76cda7b..31e858e 100644
--- a/packages/schema/test/adopt.test.ts
+++ b/packages/schema/test/adopt.test.ts
@@ -46,4 +46,28 @@ describe('adoptElement', () => {
expect(contract.slots.map((slot) => slot.name)).toEqual(['hint', 'default'])
expect(contract.parts.map((part) => part.name)).toEqual(['thumb'])
})
+
+ it('drafts an element whose part and slot share a name (Shoelace sl-input)', () => {
+ const shared = parseHeadless('custom-elements.json', {
+ modules: [
+ {
+ declarations: [
+ {
+ tagName: 'sl-input',
+ cssParts: [{ name: 'prefix' }, { name: 'base' }],
+ slots: [{ name: 'prefix' }, { name: '' }],
+ },
+ ],
+ },
+ ],
+ })
+ const doc = parseOrThrow(adoptElement(shared.roots[0]!).source)
+ const layers = doc.tree.children[0]!.children
+ expect(layers.map((layer) => [layer.name, layer.attrs.part?.value])).toEqual([
+ ['prefix-part', 'prefix'],
+ ['base', 'base'],
+ ['prefix', undefined],
+ ['default', undefined],
+ ])
+ })
})
diff --git a/packages/viewer/src/ConnectSection.vue b/packages/viewer/src/ConnectSection.vue
index 0e84c8f..e497a9d 100644
--- a/packages/viewer/src/ConnectSection.vue
+++ b/packages/viewer/src/ConnectSection.vue
@@ -150,6 +150,14 @@ const PROFILE: { key: string; label: string; options: [string, string][] }[] = [
['data-part', '[data-part="x"]'],
],
},
+ {
+ key: 'coverage',
+ label: 'Coverage',
+ options: [
+ ['full', 'Every attribute and event'],
+ ['subset', 'A chosen subset (general library)'],
+ ],
+ },
]
function setProfile(key: string, value: string): void {
const profile = { ...(props.config.headless?.profile ?? {}) }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8b6bfa7..03bd4e5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -103,6 +103,49 @@ importers:
specifier: ^5.0.0
version: 5.0.0(@types/node@26.5.1)(jsdom@30.0.1)(vite@8.3.0(@types/node@26.5.1)(esbuild@0.28.1)(yaml@2.9.0))
+ examples/shoelace:
+ dependencies:
+ '@shoelace-style/shoelace':
+ specifier: 2.20.1
+ version: 2.20.1(@floating-ui/utils@0.2.12)(@types/react@19.3.0)
+ react:
+ specifier: ^19.0.0
+ version: 19.3.0
+ react-dom:
+ specifier: ^19.0.0
+ version: 19.3.0(react@19.3.0)
+ devDependencies:
+ '@types/node':
+ specifier: ^26.5.1
+ version: 26.5.1
+ '@types/react':
+ specifier: ^19.0.0
+ version: 19.3.0
+ '@types/react-dom':
+ specifier: ^19.0.0
+ version: 19.3.0(@types/react@19.3.0)
+ '@uidx/codegen':
+ specifier: workspace:*
+ version: link:../../packages/codegen
+ '@uidx/format':
+ specifier: workspace:*
+ version: link:../../packages/format
+ '@uidx/schema':
+ specifier: workspace:*
+ version: link:../../packages/schema
+ '@uidxkit/uidx':
+ specifier: workspace:*
+ version: link:../../packages/cli
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+ vite:
+ specifier: 8.3.0
+ version: 8.3.0(@types/node@26.5.1)(esbuild@0.28.1)(yaml@2.9.0)
+ vitest:
+ specifier: ^5.0.0
+ version: 5.0.0(@types/node@26.5.1)(jsdom@30.0.1)(vite@8.3.0(@types/node@26.5.1)(esbuild@0.28.1)(yaml@2.9.0))
+
packages/agent:
dependencies:
'@ai-sdk/anthropic':
@@ -511,6 +554,10 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
+ '@ctrl/tinycolor@4.2.1':
+ resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==}
+ engines: {node: '>=14'}
+
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -786,6 +833,11 @@ packages:
'@lit/context@1.1.6':
resolution: {integrity: sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==}
+ '@lit/react@1.0.8':
+ resolution: {integrity: sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==}
+ peerDependencies:
+ '@types/react': 17 || 18 || 19
+
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
@@ -1082,6 +1134,16 @@ packages:
cpu: [x64]
os: [win32]
+ '@shoelace-style/animations@1.2.0':
+ resolution: {integrity: sha512-avvo1xxkLbv2dgtabdewBbqcJfV0e0zCwFqkPMnHFGbJbBHorRFfMAHh1NG9ymmXn0jW95ibUVH03E1NYXD6Gw==}
+
+ '@shoelace-style/localize@3.2.3':
+ resolution: {integrity: sha512-pWoZywizMilenLfQcpE9bjaqi7WKiQXJB42cxvmSPwYS76U776wJ7B5QQ0P28yVit1vpHuFZzq1CtfVFv32jaQ==}
+
+ '@shoelace-style/shoelace@2.20.1':
+ resolution: {integrity: sha512-FSghU95jZPGbwr/mybVvk66qRZYpx5FkXL+vLNpy1Vp8UsdwSxXjIHE3fsvMbKWTKi9UFfewHTkc5e7jAqRYoQ==}
+ engines: {node: '>=14.17.0'}
+
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -1521,6 +1583,11 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
+ composed-offset-position@0.0.6:
+ resolution: {integrity: sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==}
+ peerDependencies:
+ '@floating-ui/utils': ^0.2.5
+
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -2587,6 +2654,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ qr-creator@1.0.0:
+ resolution: {integrity: sha512-C0cqfbS1P5hfqN4NhsYsUXePlk9BO+a45bAQ3xLYjBL3bOIFzoVEjs79Fado9u9BPBD3buHi3+vY+C8tHh4qMQ==}
+
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
@@ -3276,6 +3346,8 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
+ '@ctrl/tinycolor@4.2.1': {}
+
'@esbuild/aix-ppc64@0.28.1':
optional: true
@@ -3480,6 +3552,10 @@ snapshots:
dependencies:
'@lit/reactive-element': 2.1.2
+ '@lit/react@1.0.8(@types/react@19.3.0)':
+ dependencies:
+ '@types/react': 19.3.0
+
'@lit/reactive-element@2.1.2':
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
@@ -3722,6 +3798,24 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.4':
optional: true
+ '@shoelace-style/animations@1.2.0': {}
+
+ '@shoelace-style/localize@3.2.3': {}
+
+ '@shoelace-style/shoelace@2.20.1(@floating-ui/utils@0.2.12)(@types/react@19.3.0)':
+ dependencies:
+ '@ctrl/tinycolor': 4.2.1
+ '@floating-ui/dom': 1.8.0
+ '@lit/react': 1.0.8(@types/react@19.3.0)
+ '@shoelace-style/animations': 1.2.0
+ '@shoelace-style/localize': 3.2.3
+ composed-offset-position: 0.0.6(@floating-ui/utils@0.2.12)
+ lit: 3.3.3
+ qr-creator: 1.0.0
+ transitivePeerDependencies:
+ - '@floating-ui/utils'
+ - '@types/react'
+
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.23':
@@ -4218,6 +4312,10 @@ snapshots:
commander@4.1.1: {}
+ composed-offset-position@0.0.6(@floating-ui/utils@0.2.12):
+ dependencies:
+ '@floating-ui/utils': 0.2.12
+
concat-map@0.0.1: {}
confbox@0.1.8: {}
@@ -5424,6 +5522,8 @@ snapshots:
punycode@2.3.1: {}
+ qr-creator@1.0.0: {}
+
qs@6.16.0:
dependencies:
es-define-property: 1.0.1
From e36c76c7eff97632a37f1cf8eea79e3ce9b982f7 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 1 Oct 2026 10:09:51 +0000
Subject: [PATCH 61/74] Fix what building a Switch in the editor,
designer-style, ran into
Built a Shoelace Switch from an empty component using only the canvas and
panels (connect, fill from library, draw, nest, bind parts, design each
state on its variant, Get code), then ran the result on real Shoelace.
Each fix carries a test.
- format: an attribute edit or a re-lowered node now moves the spans of
the regions after the tree, so a contract op right after connecting an
element no longer splices into the middle of a tag (Fill from library
was rejected with UIDX003)
- viewer: a click on a panel number now puts the caret in its edit box;
typed digits used to reach the layer tree and rename the layer
- viewer: Design-tab edits on a component with states land on the
default-state twin, for its layers and for the component itself; they
were silently dropped when the layer was chosen in the Layers panel
- viewer: a token picked for a state variant writes that state's style row
instead of doing nothing
- codegen: an axis that hugs is not given the width or height measured
when it was drawn; per-side stroke weights win over the uniform one
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01W7AN4RSMLMWKACuggBMYEH
---
packages/codegen/src/css.ts | 36 +++++++++++--
packages/codegen/test/generate.test.ts | 49 +++++++++++++++++-
packages/format/src/incremental.ts | 34 ++++++++++++-
packages/format/test/incremental.test.ts | 51 +++++++++++++++++++
packages/viewer/src/CanvasPane.vue | 36 ++++++++++---
packages/viewer/src/CornerField.vue | 4 ++
packages/viewer/src/EffectListField.vue | 2 +
packages/viewer/src/PaddingField.vue | 3 ++
packages/viewer/src/PropertyField.vue | 2 +
packages/viewer/src/SizeField.vue | 2 +
packages/viewer/src/focus-edit.ts | 15 ++++++
packages/viewer/src/variable-binding.ts | 18 +++++--
packages/viewer/test/derived-edits.test.ts | 25 +++++++++
.../viewer/test/number-field-focus.test.ts | 34 +++++++++++++
14 files changed, 295 insertions(+), 16 deletions(-)
create mode 100644 packages/viewer/src/focus-edit.ts
create mode 100644 packages/viewer/test/number-field-focus.test.ts
diff --git a/packages/codegen/src/css.ts b/packages/codegen/src/css.ts
index 23ebe2d..f1e0889 100644
--- a/packages/codegen/src/css.ts
+++ b/packages/codegen/src/css.ts
@@ -99,6 +99,26 @@ export function cssPaint(value: JsonValue): string | null {
export type CssKind = 'container' | 'text' | 'vector'
+const STROKE_SIDES = [
+ 'strokeTopWeight',
+ 'strokeRightWeight',
+ 'strokeBottomWeight',
+ 'strokeLeftWeight',
+] as const
+
+/**
+ * Whether an auto-layout frame sizes `axis` to its content (Figma's Hug). The
+ * primary axis is the layout's direction, the counter axis the other one; a
+ * frame with no sizing mode on an axis but no number either hugs it too.
+ */
+function hugs(attrs: Record, axis: 'width' | 'height'): boolean {
+ const mode = attrs.layoutMode
+ if (mode !== 'HORIZONTAL' && mode !== 'VERTICAL') return false
+ const primary = (mode === 'HORIZONTAL') === (axis === 'width')
+ const sizing = primary ? attrs.primaryAxisSizingMode : attrs.counterAxisSizingMode
+ return sizing === 'AUTO' || (sizing === undefined && attrs[axis] === undefined)
+}
+
/**
* The declarations for one node's attributes. `kind` decides what a fill is:
* a frame's fill is its background, a text's fill is its colour. A vector's
@@ -127,6 +147,13 @@ export function cssDeclarations(
if (color) {
const weight = attrs.strokeWeight === undefined ? '1px' : cssLength(attrs.strokeWeight)
set('border', `${weight ?? '1px'} solid ${color}`)
+ // Weights set per side (the panel writes all four when the weight
+ // is typed) win over the uniform one, as the canvas draws them.
+ const sides = STROKE_SIDES.map((side) =>
+ attrs[side] === undefined ? (weight ?? '1px') : cssLength(attrs[side]!),
+ )
+ if (STROKE_SIDES.some((side) => attrs[side] !== undefined) && sides.every(Boolean))
+ set('border-width', sides.join(' '))
}
break
}
@@ -147,6 +174,10 @@ export function cssDeclarations(
break
case 'width':
case 'height':
+ // An axis that hugs is sized by its content; the number stored beside
+ // it is only what the content measured when it was drawn.
+ if (!hugs(attrs, name)) set(name, cssLength(value))
+ break
case 'minWidth':
case 'maxWidth':
case 'minHeight':
@@ -166,10 +197,7 @@ export function cssDeclarations(
if (value === 'HORIZONTAL' || value === 'VERTICAL') {
// A frame that hugs its width sizes to its content, as the canvas
// draws it; a block-level flex box would stretch to its container.
- const widthSizing =
- value === 'HORIZONTAL' ? attrs.primaryAxisSizingMode : attrs.counterAxisSizingMode
- const hugs = widthSizing === 'AUTO' && attrs.width === undefined
- out.display = hugs ? 'inline-flex' : 'flex'
+ out.display = hugs(attrs, 'width') ? 'inline-flex' : 'flex'
out['flex-direction'] = value === 'HORIZONTAL' ? 'row' : 'column'
}
break
diff --git a/packages/codegen/test/generate.test.ts b/packages/codegen/test/generate.test.ts
index f35c870..d4779c4 100644
--- a/packages/codegen/test/generate.test.ts
+++ b/packages/codegen/test/generate.test.ts
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
import { CODES, parseOrThrow } from '@uidx/format'
-import { generate, tsType, componentModel, partTag, stateSelector } from '../src/index.js'
+import {
+ cssDeclarations,
+ generate,
+ tsType,
+ componentModel,
+ partTag,
+ stateSelector,
+} from '../src/index.js'
import { CHECKBOX, CONTACT_ITEM, CONTACT_LIST, FIELD, MANIFEST, TOKENS } from './fixtures.js'
/**
@@ -919,3 +926,43 @@ id: input
expect(react).toContain('label={label} placeholder={placeholder}')
})
})
+
+describe('what a frame drawn on the canvas becomes in CSS', () => {
+ it('sizes a hugging axis by its content, whatever number was measured beside it', () => {
+ const css = cssDeclarations(
+ {
+ layoutMode: 'HORIZONTAL',
+ primaryAxisSizingMode: 'AUTO',
+ counterAxisSizingMode: 'FIXED',
+ width: 89,
+ height: 20,
+ },
+ 'container',
+ )
+ expect(css.width).toBeUndefined()
+ expect(css.height).toBe('20px')
+ expect(css.display).toBe('inline-flex')
+ // A column hugs its height on the primary axis, its width on the counter.
+ const column = cssDeclarations(
+ { layoutMode: 'VERTICAL', counterAxisSizingMode: 'AUTO', width: 45, height: 17 },
+ 'container',
+ )
+ expect([column.width, column.height]).toEqual([undefined, '17px'])
+ })
+
+ it('draws a stroke with the weights set per side, over the uniform one', () => {
+ const css = cssDeclarations(
+ {
+ strokes: [{ type: 'SOLID', color: '{color#text}' }],
+ strokeWeight: 1,
+ strokeTopWeight: 2,
+ strokeRightWeight: 2,
+ strokeBottomWeight: 2,
+ strokeLeftWeight: 2,
+ },
+ 'container',
+ )
+ expect(css.border).toBe('1px solid var(--color-text)')
+ expect(css['border-width']).toBe('2px 2px 2px 2px')
+ })
+})
diff --git a/packages/format/src/incremental.ts b/packages/format/src/incremental.ts
index 8a0897a..9515e15 100644
--- a/packages/format/src/incremental.ts
+++ b/packages/format/src/incremental.ts
@@ -220,7 +220,38 @@ function takeAttribute(doc: UidxDocument, patch: PlainAttributePatch): UidxDocum
if (a.loc.start >= from) attrs[k] = shiftAttr(a, delta)
}
const tree = moveAfter(doc.tree, from, delta, node.address, attrs)
- return { ...doc, tree, source }
+ return { ...doc, ...movedTail(doc, delta), tree, source }
+}
+
+const RANGE_KEYS = new Set(['loc', 'valueLoc', 'openTagLoc'])
+
+/** Every span inside a spec value, moved by `by`. */
+function movedSpans(value: T, by: number): T {
+ if (Array.isArray(value)) return value.map((item) => movedSpans(item, by)) as T
+ if (!value || typeof value !== 'object') return value
+ const out: Record = {}
+ for (const [key, child] of Object.entries(value)) {
+ const range = child as Range
+ out[key] =
+ RANGE_KEYS.has(key) && typeof range?.start === 'number' && typeof range.end === 'number'
+ ? { start: range.start + by, end: range.end + by }
+ : movedSpans(child, by)
+ }
+ return out as T
+}
+
+/**
+ * The regions after the tree — the contract, behaviour, models, styles — and
+ * their verbatim text sit wholly after any edit inside the tree, so a change
+ * of `by` characters moves every one of their spans. Left stale, the next
+ * contract op splices at the old offsets and writes into the middle of a tag.
+ */
+function movedTail(doc: UidxDocument, by: number): Pick {
+ if (by === 0) return { spec: doc.spec, trailing: doc.trailing }
+ return {
+ spec: doc.spec && movedSpans(doc.spec, by),
+ trailing: doc.trailing && { ...doc.trailing, loc: moveRange(doc.trailing.loc, 0, by) },
+ }
}
/** A range after `from` moves; one spanning it grows at the end. */
@@ -514,6 +545,7 @@ function relower(
const tree = rebuild(0)
const next: UidxDocument = {
...doc,
+ ...movedTail(doc, delta),
tree,
source: nextSource,
sourceHash: fnv1a(nextSource),
diff --git a/packages/format/test/incremental.test.ts b/packages/format/test/incremental.test.ts
index d415673..bb656e9 100644
--- a/packages/format/test/incremental.test.ts
+++ b/packages/format/test/incremental.test.ts
@@ -239,6 +239,57 @@ describe('the attribute fast path (no parse at all)', () => {
})
})
+describe('the regions after the tree', () => {
+ const SPECCED = `---
+id: switch
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+## Contract
+
+
+ The words.
+
+`
+ const spans = (doc: ReturnType) =>
+ JSON.stringify([doc.spec, doc.trailing?.loc])
+
+ // Connecting an element (an attribute on the tree) then filling the contract
+ // from the library: the contract op must splice where the region now is.
+ it('move with an edit inside the tree, so a contract op after it lands in place', () => {
+ const edits: UidxPatch[][] = [
+ [{ op: 'add', address: 'Switch', prop: 'implements', value: 'sl-switch' }],
+ [
+ {
+ op: 'insert-node',
+ parent: 'Switch',
+ index: 0,
+ node: { element: 'Frame', attrs: { name: 'base' } },
+ },
+ ],
+ ]
+ for (const patches of edits) {
+ const first = applyPatchesIncremental(parseOrThrow(SPECCED), patches).doc
+ expect(spans(first)).toBe(spans(parseOrThrow(first.source)))
+ const declare: UidxPatch = {
+ op: 'contract',
+ kind: 'prop',
+ name: 'checked',
+ declaration: { attrs: { type: 'boolean' }, description: 'On.' },
+ }
+ const next = applyPatchesIncremental(first, [declare]).doc
+ expect(next.spec!.contract!.props.map((prop) => prop.name)).toEqual(['label', 'checked'])
+ }
+ })
+})
+
describe('on a large variant document', () => {
const src = largeDocument()
it('an attribute op inside a variant is fast and agrees with the full parse', () => {
diff --git a/packages/viewer/src/CanvasPane.vue b/packages/viewer/src/CanvasPane.vue
index 9c96188..e58a48a 100644
--- a/packages/viewer/src/CanvasPane.vue
+++ b/packages/viewer/src/CanvasPane.vue
@@ -1516,18 +1516,30 @@ function reapplyLocalEdits(): void {
* change; re-selecting the same ids would be harmless but would repaint on
* every click, and the guard says plainly that this is a one-way sync.
*/
+/**
+ * The scene node an address is drawn as: its own, or — for a base layer of a
+ * derived component, which has none (ADR 0016 §4) — its twin under the
+ * default state. The rail and the panel name the base layer; selection and
+ * edits must land on the twin, or a width typed into the panel goes nowhere.
+ */
+function drawnId(address: string): string | undefined {
+ return scene.value?.addresses.sceneIdOf(address) ?? twinId(address)
+}
+
+/** The default-state twin of a base layer of a derived component, if it has one. */
+function twinId(address: string): string | undefined {
+ const twin = props.doc ? defaultVariantAddress(props.doc, address) : null
+ return twin === null ? undefined : scene.value?.addresses.sceneIdOf(twin)
+}
+
function applySelection(addresses: readonly string[]): void {
const graph = scene.value
if (!graph || !ready.value) return
// A base layer of a derived component has no node of its own: the default
// state draws it (ADR 0016 §4), so the rail's address lands on that twin.
- const twin = (address: string): string | undefined => {
- const drawn = props.doc ? defaultVariantAddress(props.doc, address) : null
- return drawn === null ? undefined : graph.addresses.sceneIdOf(drawn)
- }
const ids = addresses
- .map((address) => graph.addresses.sceneIdOf(address) ?? twin(address))
+ .map(drawnId)
.filter((id): id is string => id !== undefined && id !== graph.rootId)
const selected = editor.state.selectedIds
@@ -1954,7 +1966,17 @@ function applyProp(
): void {
if (props.writable === false) return
const graph = scene.value?.graph
- if (!graph || !graph.getNode(address)) return
+ if (!graph) return
+ // The panel names a base layer of a derived component — or the component
+ // itself — when the rail chose it. The canvas draws it as its default-state
+ // twin (the component as that variant's root, not the set around the
+ // variants), and an edit there is what writes the base.
+ const twin = twinId(address)
+ if (twin !== undefined && twin !== address) {
+ if (graph.getNode(twin)) applyProp(twin, prop, value, mode)
+ return
+ }
+ if (!graph.getNode(address)) return
const saved = current ? resolve(current.tree, address) : null
const stroke = saved
? strokeEdit(saved, prop, value, resolveAlias, rootFontSizeOf(current))
@@ -2072,7 +2094,7 @@ function applyProp(
* these overlays, so this only has to say which one and on what.
*/
function applyHover(address: string, prop: string | null): void {
- const id = scene.value?.addresses.sceneIdOf(address)
+ const id = drawnId(address)
const target = prop ? hoverTargetFor(prop) : null
if (!id || !target) {
editor.setAutoLayoutHover(null)
diff --git a/packages/viewer/src/CornerField.vue b/packages/viewer/src/CornerField.vue
index dfaab30..6e0216c 100644
--- a/packages/viewer/src/CornerField.vue
+++ b/packages/viewer/src/CornerField.vue
@@ -1,5 +1,6 @@
-
-
Corner radius
+
+ Corner radius
+
+
+
+ {{ shadowNote(cornerMark.shadowedBy) }}
+
+
+
+ Every text inside {{ instanceName }} · slot text with its own colour keeps it
+
@@ -2441,6 +2863,18 @@ function onDetach(prop: string, value: JsonValue): void {
@commit="onCommit"
@detach="onDetach"
/>
+
+
+ Remove
+
+
+ {{ field.label }}
+
+
-
+
-
+
+
emit('preview', p, v)"
@commit="(p, v) => emit('commit', p, v)"
/>
@@ -629,6 +682,19 @@ const segmented = computed(
.field-caption.bound {
color: var(--bound);
}
+.field-caption.marked {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+/* The component's value rather than the use's — InstancePropsSection's unset look. */
+.number[data-origin='component'] .scrub,
+.bound-value[data-origin='component'] .token-pill,
+.enum-select[data-origin='component'],
+.enum-segmented[data-origin='component'] {
+ color: var(--text-faint);
+ opacity: 0.7;
+}
.value {
min-width: 0;
}
diff --git a/packages/viewer/src/detach.ts b/packages/viewer/src/detach.ts
index 4a6e2fb..04a9bdd 100644
--- a/packages/viewer/src/detach.ts
+++ b/packages/viewer/src/detach.ts
@@ -1,21 +1,36 @@
import {
aliasTarget,
+ hasVariants,
isAlias,
METADATA_ATTRS,
- STATE_AXIS,
+ propertyBinding,
toNodeSpec,
type JsonValue,
+ type UidxAttr,
type UidxDocument,
type UidxNode,
type UidxNodeSpec,
type UidxPatch,
} from '@uidx/format'
-import { deriveVariants, derivesVariants } from '@uidx/schema'
+import {
+ boxTargetOf,
+ deriveVariants,
+ instanceFills,
+ instanceRole,
+ laysOut,
+ layered,
+ placementOf,
+ variantFor,
+ withTextFills,
+ wrappedFrame,
+ type BoxLayer,
+ type BoxTarget,
+} from '@uidx/schema'
import { parentOf } from './layer-moves'
/** Attributes that make an instance an instance, and go when it becomes plain layers. */
-const INSTANCE_ONLY = new Set(['component', 'props', 'overrides'])
+const INSTANCE_ONLY = new Set(['component', 'props', 'overrides', 'textFills'])
/** Attributes that only mean something on a component's own tree. */
const COMPONENT_ONLY = new Set(['implements', 'variants', 'props', 'part', ...METADATA_ATTRS])
@@ -25,6 +40,12 @@ const COMPONENT_ONLY = new Set(['implements', 'variants', 'props', 'part', ...ME
* variant its props select is the one copied, its `{prop}` bindings are
* replaced by the values it showed, its overrides are applied and its slots
* are filled with what the instance put in them.
+ *
+ * What the use styled from outside is baked in where the build drew it (ADR
+ * 0018 §6): its outer box, and the size it fixes, on the node that draws the
+ * box, beneath anything a state row set; its text colour as the fills of each
+ * text it reached. Where it sits stays on the copy's root, and its locked
+ * attributes, which never drew, are left behind.
*/
export function detachInstance(
pages: ReadonlyMap,
@@ -39,7 +60,7 @@ export function detachInstance(
if (!definition || !parent) return null
const values = propValues(definition, instance)
- const root = drawnRoot(definition, values)
+ const { source, root, target } = drawnRoot(definition, instance)
const overrides = overrideMap(instance)
const fills = new Map(
instance.children
@@ -47,34 +68,84 @@ export function detachInstance(
.map((child) => [child.name, child.children] as const),
)
+ /**
+ * The node the use's box lands on (`boxTargetOf`): the copy's root when the
+ * component lays itself out, else the frame or instance it wraps.
+ */
+ const boxNode = target.kind === 'self' ? root : target.node
+ const box = statedBox(instance)
+ const pinned = pinnedSize(instance, boxNode, parent)
+ const own = statedTextFills(instance)
+ const colour = own ?? inheritedTextFills(doc, instance)
+
+ /**
+ * A node of the component as the use draws it (`expandInstance`): the box
+ * laid over the node that draws it, and the colour over every text — each
+ * beneath any value a state row stamped. The instance a composition holds
+ * is handed the box and the use's own colour, as if it had stated them
+ * over what the definition wrote there.
+ */
+ const drawn = (node: UidxNode): UidxNode => {
+ if (node === boxNode && target.kind === 'instance')
+ return inheriting(layered(node, own ? { ...box, textFills: own } : box), colour)
+ if (node === boxNode) return layered(node, box)
+ if (node.element === 'Text') return withTextFills(node, colour?.value)
+ if (node.element === 'Instance') return inheriting(node, colour)
+ return node
+ }
+
+ /**
+ * An override is looked up by its path inside what the instance draws. When
+ * the copy is a variant's frame, the build's path starts at that frame — a
+ * styles table's `root/label` — and the copy's own path does not.
+ */
+ const overridesAt = (path: string): Record => ({
+ ...overrides.get(path),
+ ...(root === source ? {} : overrides.get(path ? `${root.name}/${path}` : root.name)),
+ })
+
const rewrite = (node: UidxNode, path: string): UidxNodeSpec => {
- const own: Record = {}
- for (const [key, attr] of Object.entries(node.attrs)) {
- if (COMPONENT_ONLY.has(key) || key === 'repeat' || key === 'as') continue
- own[key] = substitute(attr.value, values)
+ const shown = drawn(node)
+ const attrs: Record = {}
+ for (const [key, attr] of Object.entries(shown.attrs)) {
+ if (!copied(node, key)) continue
+ // A variant's attributes are its coordinates, which name it and draw nothing.
+ if (node.element === 'Variant' && key in node.attrs) continue
+ attrs[key] = substitute(attr.value, values)
}
- Object.assign(own, overrides.get(path) ?? {})
+ // The size the use fixes sits over the node's own, as the build pins it.
+ if (node === boxNode) Object.assign(attrs, pinned)
+ Object.assign(attrs, overridesAt(path))
if (node.element === 'Slot') {
const filled = fills.get(node.name)
return {
element: 'Frame',
- attrs: own,
+ attrs,
children: (filled ?? node.children).map((child) =>
- filled ? toNodeSpec(child) : rewrite(child, join(path, child.name)),
+ filled ? fillContent(child, colour) : rewrite(child, join(path, child.name)),
),
}
}
+ // An instance inside the component stays one, holding what the component
+ // put in its slots; the colour reaches those through it.
+ if (node.element === 'Instance') {
+ const held = node.children.map((child) => written(child, values))
+ return { element: 'Instance', attrs, ...(held.length ? { children: held } : {}) }
+ }
const children = node.children.map((child) => rewrite(child, join(path, child.name)))
return {
element: node.element === 'Component' || node.element === 'Variant' ? 'Frame' : node.element,
- attrs: own,
+ attrs,
...(children.length ? { children } : {}),
}
}
const spec = rewrite(root, '')
- // The instance's own placement and sizing win over the definition's.
- for (const [key, attr] of Object.entries(instance.attrs))
+ // Where the use sits wins over the definition's, and so do its modes and
+ // its repeat. The rest of what it states has gone where it draws, or
+ // nowhere: its box to the box node, its colour to the texts, its locked
+ // inside dropped (ADR 0018 §1).
+ for (const [key, attr] of Object.entries(placementOf(instance).attrs))
if (!INSTANCE_ONLY.has(key)) spec.attrs[key] = attr.value
spec.attrs.name = instance.name
return {
@@ -133,26 +204,153 @@ function propValues(definition: UidxNode, instance: UidxNode): Map): UidxNode {
- if (!derivesVariants(definition)) return definition
+/**
+ * The tree the instance draws, chosen as the build chooses it (`variantFor`):
+ * `source` is the variant its props select — a stated `state` included — or
+ * the component, and `target` is where its box lands in that. `root` is what
+ * the copy is made from. A variant has no look of its own (ADR 0005 §5), so
+ * when it only wraps one frame, that frame is the copy: the pill a styles
+ * table draws one level down, not the wrapper around it. The box may land
+ * deeper still, through a frame that only wraps another (ADR 0018 §2); that
+ * frame stays in the copy, with what a state row set on it.
+ *
+ * Derived first, so the attributes a state row wrote carry their stamp.
+ */
+function drawnRoot(
+ definition: UidxNode,
+ instance: UidxNode,
+): { source: UidxNode; root: UidxNode; target: BoxTarget } {
const set = deriveVariants(definition)
- const visualOn = (definition.spec?.contract?.props ?? []).find(
- (prop) => prop.visual && prop.type === 'boolean' && values.get(prop.name) === true,
- )
- const wanted = (axis: string): string | undefined => {
- if (axis === STATE_AXIS) return visualOn?.name ?? 'default'
- const value = values.get(axis)
- return typeof value === 'string' ? value : undefined
+ const source = hasVariants(set) ? (variantFor(set, instance) ?? set.children[0] ?? set) : set
+ const target = boxTargetOf(source)
+ const wrapped = wrappedFrame(source)
+ const root = source.element === 'Variant' && wrapped?.element === 'Frame' ? wrapped : source
+ return { source, root, target }
+}
+
+/**
+ * Whether an attribute of the component's tree goes into the copy: not the
+ * ones only a component's own tree means. `props` on an instance inside it is
+ * what that instance shows, though, not a declaration, so it stays.
+ */
+const copied = (node: UidxNode, key: string): boolean =>
+ key === 'props'
+ ? node.element === 'Instance'
+ : !COMPONENT_ONLY.has(key) && key !== 'repeat' && key !== 'as'
+
+/**
+ * The outer box the use states, as written: a token stays an alias, since the
+ * copy sits where the instance did and resolves where it resolved. A `{prop}`
+ * binding is dropped, as the build drops it, so the component's own value
+ * shows (ADR 0018 §5).
+ */
+function statedBox(instance: UidxNode): BoxLayer {
+ const out: BoxLayer = {}
+ for (const [prop, attr] of Object.entries(instance.attrs))
+ if (instanceRole(prop) === 'box' && !binds(attr.value)) out[prop] = attr
+ return out
+}
+
+/** The text colour the use states, unless it is a binding, which draws nothing. */
+function statedTextFills(instance: UidxNode): UidxAttr | undefined {
+ const stated = instance.attrs.textFills
+ return stated && !binds(stated.value) ? stated : undefined
+}
+
+/**
+ * The colour an enclosing instance hands down, when this one sits in what it
+ * put in a slot: the nearest that states one wins, as CSS `color` does (ADR
+ * 0018 §4). An instance holds nothing but its slot fills, so any instance
+ * above this one is such an encloser.
+ */
+function inheritedTextFills(doc: UidxDocument, instance: UidxNode): UidxAttr | undefined {
+ for (let node = parentOf(doc, instance.address); node; node = parentOf(doc, node.address)) {
+ const stated = node.element === 'Instance' ? statedTextFills(node) : undefined
+ if (stated) return stated
}
- const variant =
- set.children.find((candidate) =>
- Object.entries(candidate.attrs).every(([axis, attr]) => {
- const want = wanted(axis)
- return want === undefined || attr.value === want
- }),
- ) ?? set.children[0]
- return variant?.children[0] ?? definition
+ return undefined
+}
+
+/** Whether `value` holds a component-property or item binding, `{label}` or `{item.tone}`, anywhere. */
+function binds(value: JsonValue): boolean {
+ const target = aliasTarget(value)
+ if (target !== null) return propertyBinding(target) !== null
+ if (value === null || typeof value !== 'object') return false
+ return (Array.isArray(value) ? value : Object.values(value)).some(binds)
+}
+
+/** An instance handed the colour as its own, unless it states one: the nearest wins. */
+function inheriting(node: UidxNode, colour: UidxAttr | undefined): UidxNode {
+ if (!colour || node.attrs.textFills !== undefined) return node
+ return { ...node, attrs: { ...node.attrs, textFills: colour } }
+}
+
+/**
+ * The size the use fixes, written on the node its box lands on, so size and
+ * look land together (ADR 0018 §1, §2): each dimension it states and does not
+ * leave its parent to fill (`instanceFills`). A frame that lays out is made
+ * Fixed on that axis, as the build pins it (`pinnedFrame`), or its hugging
+ * would undo the number. An instance takes the size as stated, which already
+ * says Fixed.
+ */
+function pinnedSize(
+ instance: UidxNode,
+ node: UidxNode,
+ parent: UidxNode,
+): Record {
+ const filled = instanceFills(instance, layoutOf(parent))
+ const layout = node.attrs.layoutMode?.value
+ const out: Record = {}
+ for (const dimension of ['width', 'height'] as const) {
+ const stated = instance.attrs[dimension]
+ if (stated === undefined || filled[dimension]) continue
+ out[dimension] = stated.value
+ if (node.element === 'Instance' || (layout !== 'HORIZONTAL' && layout !== 'VERTICAL')) continue
+ const primary = (layout === 'HORIZONTAL') === (dimension === 'width')
+ out[primary ? 'primaryAxisSizingMode' : 'counterAxisSizingMode'] = 'FIXED'
+ }
+ return out
+}
+
+/**
+ * The direction `node` lays out what it holds, which decides whether a child's
+ * stretch fills its width or its height. A component, a variant or a slot that
+ * states no layout is drawn as a hugging column (`laysOut`).
+ */
+function layoutOf(node: UidxNode): 'HORIZONTAL' | 'VERTICAL' | undefined {
+ const mode = node.attrs.layoutMode?.value
+ if (mode === 'HORIZONTAL' || mode === 'VERTICAL') return mode
+ return mode === undefined && laysOut(node) ? 'VERTICAL' : undefined
+}
+
+/**
+ * What the use put in a slot, copied as written: it is the consuming page's
+ * own content. A text that states no fills takes the colour; one that states
+ * its own keeps it, since explicit beats inherited (ADR 0018 §4). An instance
+ * takes the colour as its own unless it states one.
+ */
+function fillContent(node: UidxNode, colour: UidxAttr | undefined): UidxNodeSpec {
+ if (node.element === 'Instance') return toNodeSpec(inheriting(node, colour))
+ if (node.element === 'Text' && node.attrs.fills === undefined)
+ return toNodeSpec(withTextFills(node, colour?.value))
+ const children = node.children.map((child) => fillContent(child, colour))
+ const attrs = Object.fromEntries(
+ Object.entries(node.attrs).map(([key, attr]) => [key, attr.value]),
+ )
+ return { element: node.element, attrs, ...(children.length ? { children } : {}) }
+}
+
+/**
+ * What the component wrote inside an instance it holds — that instance's slot
+ * fills — with the component's `{prop}` bindings replaced. A `` here is
+ * a fill and stays one: an instance holds nothing else.
+ */
+function written(node: UidxNode, values: ReadonlyMap): UidxNodeSpec {
+ const attrs: Record = {}
+ for (const [key, attr] of Object.entries(node.attrs))
+ if (copied(node, key)) attrs[key] = substitute(attr.value, values)
+ const children = node.children.map((child) => written(child, values))
+ return { element: node.element, attrs, ...(children.length ? { children } : {}) }
}
function overrideMap(node: UidxNode): Map> {
diff --git a/packages/viewer/src/editable.ts b/packages/viewer/src/editable.ts
index d0a72d1..51d2463 100644
--- a/packages/viewer/src/editable.ts
+++ b/packages/viewer/src/editable.ts
@@ -8,6 +8,8 @@ import {
} from '@uidx/format'
import {
defaultFor,
+ INSTANCE_CASCADE_PROPS,
+ instanceRole,
isDerivedPosition,
isIdentityProp,
mappingFor,
@@ -19,6 +21,7 @@ import {
SECTION_LABEL,
fieldOrderFor,
sectionOrderFor,
+ type InstanceBase,
type PropGroup,
} from '@uidx/schema'
@@ -78,6 +81,27 @@ export interface EditableProp {
readonlyReason: string | null
/** False for a field synthesized so an empty section can offer its `+` (C8). */
authored: boolean
+ /**
+ * Where `value` comes from: the node's own attribute (`own`); for a row of an
+ * instance's outer box the use has not set, the component it draws, which a
+ * person wrote (`component`, ADR 0018 §7); or the engine's default, where
+ * nothing states one (`engine`). `editableProps` always says. Optional so a
+ * row built by hand still type-checks.
+ */
+ origin?: 'own' | 'component' | 'engine'
+ /**
+ * The state the instance's own props select that sets this property too.
+ * A state sits above the use (ADR 0018 §3), so a value the use states shows
+ * only in the other states. Null when no state does, and for every node
+ * that is not an instance.
+ */
+ shadow?: { state: string } | null
+ /**
+ * True for a row that cannot be edited but can go: an attribute on an
+ * instance that belongs to its component's inside, which nothing draws
+ * (ADR 0018 §1). The panel offers Remove (`resetPatches`).
+ */
+ removable?: boolean
}
export interface PairedField {
@@ -119,9 +143,52 @@ const UNEDITABLE_SHAPES: Record = {
*
* They are editable, through `PropertiesPane`'s structural route rather than
* the scene one, for the same reason they are absent here.
+ *
+ * `textFills` is absent for the same reason: an instance hands it down to the
+ * texts inside instead of setting it on a node (ADR 0018 §4), so it too is a
+ * patch on the instance rather than a scene write.
*/
export function isMapped(name: string): boolean {
- return isIdentityProp(name) || mappingFor(name) !== undefined || PIN_PROPS.includes(name)
+ return (
+ isIdentityProp(name) ||
+ mappingFor(name) !== undefined ||
+ PIN_PROPS.includes(name) ||
+ INSTANCE_CASCADE_PROPS.includes(name)
+ )
+}
+
+/**
+ * Why an instance's attribute draws nothing (ADR 0018 §1): it belongs to the
+ * component's inside — its layout, how its strokes end and join, its text —
+ * which every target ignores on a use. Split as the agent's refusal splits it.
+ */
+function lockedReason(name: string): string {
+ const inside =
+ propUiFor(name)?.group === 'layout' ? 'the component’s own layout' : 'inside the component'
+ return `${inside} — an instance restyles only its outer box, so nothing draws this`
+}
+
+/**
+ * What an unset row of an instance's outer box shows from its component
+ * (ADR 0018 §7), or undefined to leave it to the engine as any unset row is.
+ *
+ * A box row takes the value the component draws where the box lands, for the
+ * combination the use asks for, and only one it states: an engine default
+ * never passes for the component's. Text color takes what the texts the use
+ * would colour draw now, which is a state's colour where a state the use
+ * selects colours them all, as a box row shows a state's value. When they
+ * disagree it has no one value to show, and null stands for "mixed" there,
+ * still the component's. Only a component that draws no text such a colour
+ * could reach leaves the row to the engine.
+ */
+function inheritedFrom(base: InstanceBase, name: string): { value: JsonValue } | undefined {
+ const role = instanceRole(name)
+ if (role === 'box') {
+ const value = base.values[name]
+ return value === undefined ? undefined : { value }
+ }
+ if (role !== 'cascade' || base.text === null) return undefined
+ return { value: base.text === 'mixed' ? null : base.text }
}
/**
@@ -226,7 +293,24 @@ function positionLabel(name: string): string | null {
return null
}
-export function editableProps(node: UidxNode, parent: UidxNode | null = null): EditableProp[] {
+/**
+ * `base` is what a selected instance inherits from its component
+ * (`instanceBase`): the value each unset row of its outer box shows, and the
+ * states that sit above the use (ADR 0018 §7). Without one — any node but an
+ * instance, or an instance whose component is missing — an unset row is the
+ * engine's, as it always was.
+ */
+export function editableProps(
+ node: UidxNode,
+ parent: UidxNode | null = null,
+ base: InstanceBase | null = null,
+): EditableProp[] {
+ const instance = node.element === 'Instance'
+ const inherits = instance ? base : null
+ const shadowOf = (name: string): { state: string } | null => {
+ const state = inherits?.stateWins.get(name)
+ return state === undefined ? null : { state }
+ }
const positionReason = derivedPositionReason(node, parent)
const reasonFor = (name: string): string | null => {
if (name === 'x' || name === 'y') return positionReason
@@ -257,6 +341,30 @@ export function editableProps(node: UidxNode, parent: UidxNode | null = null): E
boundTo: bound,
readonlyReason: 'not in the prop table, so no edit to it can be written',
authored: true,
+ origin: 'own',
+ shadow: null,
+ }
+ }
+
+ // An instance's inside is its component's (ADR 0018 §1), and every
+ // target ignores it on a use, so no control may edit it. The file says
+ // it all the same, so it shows — unsectioned, by its own name, beside
+ // the other attributes nothing reads — and offers to go.
+ if (instance && instanceRole(name) === 'locked') {
+ return {
+ name,
+ label: name,
+ group: null,
+ control: 'readonly',
+ options: null,
+ value: attr.value,
+ raw: attr.raw,
+ boundTo: bound,
+ readonlyReason: lockedReason(name),
+ authored: true,
+ origin: 'own',
+ shadow: null,
+ removable: true,
}
}
@@ -289,6 +397,8 @@ export function editableProps(node: UidxNode, parent: UidxNode | null = null): E
boundTo: bound,
readonlyReason: shapeReason ?? reasonFor(name),
authored: true,
+ origin: 'own',
+ shadow: shadowOf(name),
}
})
@@ -313,6 +423,10 @@ export function editableProps(node: UidxNode, parent: UidxNode | null = null): E
if (authoredNames.has(name)) continue
if (name === 'name' || METADATA_ATTRS.has(name)) continue
if (ui.appliesTo && !ui.appliesTo.includes(element)) continue
+ // `appliesTo` already keeps an instance's inside off it, and
+ // `prop-ui.test.ts` holds the two tables together; said here as well
+ // because this is where the panel decides what it offers.
+ if (instance && instanceRole(name) === 'locked') continue
let looseEdge = false
if (PIN_PROPS.includes(name) || name === 'x' || name === 'y') {
const standing = pinRowStanding(node, parent, name)
@@ -328,6 +442,28 @@ export function editableProps(node: UidxNode, parent: UidxNode | null = null): E
ui.control === 'opaque'
? (UNEDITABLE_SHAPES[name] ?? 'this value shape has no control yet')
: null
+ // An instance's box row shows its component's value, list rows included:
+ // the component draws that paint, so an empty `+` would say it draws
+ // none. A token it binds reads as a binding, as an authored one does.
+ const inherited = inherits ? inheritedFrom(inherits, name) : undefined
+ if (inherited) {
+ const bound = isAlias(inherited.value) ? aliasTarget(inherited.value) : null
+ fields.push({
+ name,
+ label: positionLabel(name) ?? ui.label ?? name,
+ group: ui.group,
+ control: shapeReason ? 'readonly' : bound ? 'number' : (ui.control as ControlKind),
+ options: ui.control === 'enum' ? (ui.options ?? []) : null,
+ value: inherited.value,
+ raw: '',
+ boundTo: bound,
+ readonlyReason: shapeReason ?? reasonFor(name),
+ authored: false,
+ origin: 'component',
+ shadow: shadowOf(name),
+ })
+ continue
+ }
fields.push({
name,
label: positionLabel(name) ?? ui.label ?? name,
@@ -356,6 +492,8 @@ export function editableProps(node: UidxNode, parent: UidxNode | null = null): E
boundTo: null,
readonlyReason: looseEdge ? LOOSE_EDGE_REASON : (shapeReason ?? reasonFor(name)),
authored: false,
+ origin: 'engine',
+ shadow: shadowOf(name),
})
}
return fields
diff --git a/packages/viewer/src/field-icons.ts b/packages/viewer/src/field-icons.ts
index b9b16ee..a581428 100644
--- a/packages/viewer/src/field-icons.ts
+++ b/packages/viewer/src/field-icons.ts
@@ -71,6 +71,8 @@ export const ICON_PATHS = {
'eye-off': 'M1.5 6 C 3 3.5, 9 3.5, 10.5 6 C 9 8.5, 3 8.5, 1.5 6 Z M2.5 9.5 L9.5 2.5',
expand: 'M2 5 V2 H5 M7 2 H10 V5 M10 7 V10 H7 M5 10 H2 V7',
droplet: 'M6 1.5 C 8 4.5, 9.5 6.5, 9.5 8 A 3.5 3.5 0 0 1 2.5 8 C 2.5 6.5, 4 4.5, 6 1.5 Z',
+ // An instance's inside is its component's (ADR 0018 §1): the locked layout line.
+ lock: 'M3 5.5 H9 V10.5 H3 Z M4.3 5.5 V4 A1.7 1.7 0 0 1 7.7 4 V5.5',
// The UI3 pass (spec §5): the popup's chrome and the variable glyphs.
search: 'M5.2 1.8 A 3.4 3.4 0 1 1 5.2 8.6 A 3.4 3.4 0 1 1 5.2 1.8 M7.8 7.8 L10.5 10.5',
close: 'M2.5 2.5 L9.5 9.5 M9.5 2.5 L2.5 9.5',
diff --git a/packages/viewer/src/hover-map.ts b/packages/viewer/src/hover-map.ts
index 6b2fcf7..4662c29 100644
--- a/packages/viewer/src/hover-map.ts
+++ b/packages/viewer/src/hover-map.ts
@@ -1,3 +1,7 @@
+import type { UidxNode } from '@uidx/format'
+import { instanceBase, instanceDefinition, instanceRole } from '@uidx/schema'
+import type { InstanceScope } from './resize-writes'
+
/**
* What the canvas lights up while a control in the panel is hovered (story
* C9). Figma answers a hovered padding field by tinting that band on the
@@ -53,3 +57,23 @@ export function hoverTargetFor(prop: string): HoverTarget | null {
if (NODE_PROPS.has(prop)) return { kind: 'node' }
return null
}
+
+/**
+ * Which node the hovered row lights: `id`, where the selected layer is drawn,
+ * unless the layer is an instance and the row is one of its outer box (ADR
+ * 0018 §2). That box is drawn on the frame its component wraps — through any
+ * frame that only wraps another — or through a composition on the frame of
+ * the instance it holds, and the instance's own node is only the wrapper
+ * around it, with no padding to tint. A component that lays itself out draws
+ * its box on the instance itself, unless all it does is wrap one frame.
+ */
+export function hoverNodeFor(
+ prop: string,
+ id: string,
+ layer: UidxNode | null,
+ scope: InstanceScope,
+): string {
+ if (layer?.element !== 'Instance' || instanceRole(prop) !== 'box') return id
+ const base = instanceBase({ ...layer, address: id }, instanceDefinition(layer, scope), scope)
+ return base?.target.id ?? id
+}
diff --git a/packages/viewer/src/instance-box-edits.ts b/packages/viewer/src/instance-box-edits.ts
new file mode 100644
index 0000000..bf381b8
--- /dev/null
+++ b/packages/viewer/src/instance-box-edits.ts
@@ -0,0 +1,201 @@
+import {
+ resolve,
+ type JsonValue,
+ type UidxDocument,
+ type UidxNode,
+ type UidxPatch,
+} from '@uidx/format'
+import {
+ INSTANCE_BOX_PROPS,
+ INSTANCE_CASCADE_PROPS,
+ instanceDefinition,
+ instancePreview,
+ instanceRole,
+ propUiFor,
+ type InstancePreviewUpdate,
+ type PropGroup,
+} from '@uidx/schema'
+import type { SceneNode } from '@open-pencil/scene-graph'
+import type { InstanceScope } from './resize-writes'
+
+/**
+ * Restyling an instance from outside (ADR 0018 §7): the patch a row of its
+ * outer box writes, what the canvas draws while that row is still a scrub,
+ * and the resets.
+ *
+ * Every edit is a structural patch on the ``, never a scene write.
+ * The node that draws the value is generated and has no address, and the
+ * instance's own node is only the wrapper around it, so a scene write would
+ * either reach nothing or paint the wrapper. The preview is drawn by the
+ * build's own functions (`instancePreview`), so what a scrub shows is what
+ * the patch's echo draws.
+ *
+ * Judgement only, the split `instance-prop-edits.ts` keeps: nothing here
+ * draws or emits.
+ */
+
+/**
+ * What a use may hand back to its component: its outer box, the text colour
+ * it hands down and the size it states. Never where it sits or how it flows,
+ * its props, its slot fills or its modes — those say which use this is and
+ * where, not how it looks.
+ */
+export const OVERRIDE_PROPS: readonly string[] = [
+ ...INSTANCE_BOX_PROPS,
+ ...INSTANCE_CASCADE_PROPS,
+ 'width',
+ 'height',
+]
+
+export interface InstanceBoxEditOptions {
+ /** The scope the instance is written in: the scene build's own resolvers. */
+ scope: InstanceScope
+ /**
+ * The instance's scene id, where that is not its address. A use inside a
+ * slot fill is drawn at the definition's position (ADR 0007 §3), so when
+ * Panel's slot sits in a frame, the `b` written as `p#body/b` is drawn as
+ * `p#frame/body/b` (`SceneResult.addresses`). The preview moves the scene's
+ * nodes, so it is drawn from here; the patch still names the address.
+ */
+ sceneId?: string
+ /** The layout of the node it sits in, as the scene draws it. */
+ parentLayout?: SceneNode['layoutMode']
+ /** The texts its colour reaches: its entry in `SceneResult.textTargets`, keyed by scene id too. */
+ textTargets?: readonly string[]
+ /**
+ * The instance as the scene draws it now, when an earlier step of the same
+ * gesture moved it off the file's version: that step's `next`. Each step
+ * previews from the one before, so a value scrubbed back to the file's is
+ * drawn back, and the props a compound control writes accrue.
+ */
+ drawn?: UidxNode
+}
+
+export interface InstanceBoxEdit {
+ /** An `add`, `set` or `remove` on the instance; none when the file already says it. */
+ patches: UidxPatch[]
+ /**
+ * What the canvas moves to show it, by scene id: the instance's own node,
+ * the node its box lands on and the texts its colour reaches, each only
+ * where something changed. Empty when nothing draws the instance.
+ */
+ preview: InstancePreviewUpdate[]
+ /** The instance as the preview draws it, at its scene id: `drawn` for the gesture's next step. */
+ next: UidxNode
+}
+
+/**
+ * Sets one property of an instance's outer box, or its text colour, to
+ * `value`, or takes it off for undefined so the component's shows again.
+ *
+ * The patch answers to the file: `add` where it states nothing, `set` where it
+ * states something else, `remove` for a reset. The preview answers to the
+ * scene: it names the nodes under `options.sceneId`, and starts from
+ * `options.drawn` when a step before moved it.
+ *
+ * Null when `address` is not an instance, or `prop` is neither the outer box
+ * nor the text colour: where a use sits, its size and its props each have a
+ * route of their own, and the component's inside has none (ADR 0018 §1).
+ */
+export function instanceBoxEdit(
+ doc: UidxDocument,
+ address: string,
+ prop: string,
+ value: JsonValue | undefined,
+ options: InstanceBoxEditOptions,
+): InstanceBoxEdit | null {
+ const instance = resolve(doc.tree, address)
+ if (!instance || instance.element !== 'Instance') return null
+ const role = instanceRole(prop)
+ if (role !== 'box' && role !== 'cascade') return null
+
+ // The build reads an instance's address as its scene id (`instancePreview`).
+ const drawn = { ...(options.drawn ?? instance), address: options.sceneId ?? address }
+ const next = stating(drawn, prop, value)
+ const preview = instancePreview(
+ drawn,
+ next,
+ instanceDefinition(instance, options.scope),
+ options.scope,
+ options.parentLayout,
+ options.textTargets,
+ )
+ return { patches: written(instance, prop, value), preview: preview ?? [], next }
+}
+
+/**
+ * The patches that take each of `props` off `instance`, so its component's
+ * value shows again: one `remove` per prop it states, none for one it does
+ * not. A row's ↺, a section's, and the Remove an attribute of the
+ * component's inside offers (`EditableProp.removable`).
+ */
+export function resetPatches(instance: UidxNode, props: readonly string[]): UidxPatch[] {
+ return props
+ .filter((prop) => instance.attrs[prop] !== undefined)
+ .map((prop): UidxPatch => ({ op: 'remove', address: instance.address, prop }))
+}
+
+/**
+ * What a section's ↺ hands back: the overrides among its rows. Layout's are
+ * the stated size and the padding; Position has none, because where a use
+ * sits is not how it looks.
+ */
+export function sectionResetProps(group: PropGroup): string[] {
+ return OVERRIDE_PROPS.filter((prop) => propUiFor(prop)?.group === group)
+}
+
+/**
+ * Reset all overrides: every one `instance` states, as one list, so the shell
+ * sends one envelope and one undo brings them all back.
+ */
+export function resetAllPatches(instance: UidxNode): UidxPatch[] {
+ return resetPatches(instance, OVERRIDE_PROPS)
+}
+
+/** How many overrides `instance` states, one per attribute: the card's "3 overrides". */
+export function overrideCount(instance: UidxNode): number {
+ return OVERRIDE_PROPS.filter((prop) => instance.attrs[prop] !== undefined).length
+}
+
+/** The patch that leaves the file stating `value` for `prop`, or nothing when it already does. */
+function written(instance: UidxNode, prop: string, value: JsonValue | undefined): UidxPatch[] {
+ const address = instance.address
+ const stated = instance.attrs[prop]
+ if (value === undefined) return stated ? [{ op: 'remove', address, prop }] : []
+ if (!stated) return [{ op: 'add', address, prop, value }]
+ return sameValue(stated.value, value) ? [] : [{ op: 'set', address, prop, value }]
+}
+
+/**
+ * `instance` stating `value` for `prop`, or without it for undefined: the
+ * version a build would read once the patch lands. The span is collapsed onto
+ * the instance's own, as the schema's synthetic attributes are, since no
+ * source text holds it yet.
+ */
+function stating(instance: UidxNode, prop: string, value: JsonValue | undefined): UidxNode {
+ const attrs = { ...instance.attrs }
+ if (value === undefined) {
+ delete attrs[prop]
+ } else {
+ const at = { start: instance.loc.start, end: instance.loc.start }
+ attrs[prop] = {
+ name: prop,
+ value,
+ raw: typeof value === 'string' ? JSON.stringify(value) : `{${JSON.stringify(value)}}`,
+ loc: at,
+ valueLoc: at,
+ }
+ }
+ return { ...instance, attrs }
+}
+
+function sameValue(a: unknown, b: unknown): boolean {
+ if (a === b) return true
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
+ if (Array.isArray(a) !== Array.isArray(b)) return false
+ const keys = Object.keys(a)
+ if (keys.length !== Object.keys(b).length) return false
+ return keys.every((key) =>
+ sameValue((a as Record)[key], (b as Record)[key]),
+ )
+}
diff --git a/packages/viewer/src/override-state.ts b/packages/viewer/src/override-state.ts
new file mode 100644
index 0000000..702cc77
--- /dev/null
+++ b/packages/viewer/src/override-state.ts
@@ -0,0 +1,122 @@
+import { aliasTarget, type JsonValue } from '@uidx/format'
+import { optionLabelFor } from '@uidx/schema'
+import { asPaints, colorToHex, paintColorAlias, type Rgba } from './paint-edit'
+
+/**
+ * The pure half of the instance-override controls (ADR 0018 §7).
+ *
+ * A row on a selected instance shows the component's value until the use
+ * changes it. These are the words and states the controls draw that from; the
+ * controls themselves take them as explicit props, so nothing here knows how
+ * the pane works them out.
+ */
+
+/**
+ * Where an instance row stands against its component:
+ *
+ * - `inherited`: the component's value shows, dimmed, and there is nothing to
+ * reset;
+ * - `set`: the use states the value — a dot and ↺;
+ * - `shadowed`: the use states it, but a state the instance's own props select
+ * sets it too, so the state's value shows now and the use's shows in the
+ * other states (§3).
+ */
+export type OverrideState = 'inherited' | 'set' | 'shadowed'
+
+/** What an override mark says besides its state. */
+export interface OverrideContext {
+ /** The component the use inherits from — "Reset to Button1". */
+ component: string
+ /** The component's own value as the row would show it, or null when it states none. */
+ inherited: string | null
+ /** The state whose row wins the property right now, for `shadowed`. */
+ shadowedBy: string | null
+}
+
+/**
+ * A row's state from where its value comes from and which state, if any, sets
+ * it. A state that sets a value the use never touched hides nothing of the
+ * use's, so that row stays inherited — the section's note covers it.
+ */
+export function overrideState(
+ origin: 'own' | 'component' | 'engine',
+ shadow: { state: string } | null,
+): OverrideState {
+ if (origin !== 'own') return 'inherited'
+ return shadow ? 'shadowed' : 'set'
+}
+
+/** The reset's tooltip: what the row goes back to, and whose value that is. */
+export function resetTitle(component: string, inherited: string | null): string {
+ return inherited === null ? `Reset to ${component}` : `Reset to ${component} — ${inherited}`
+}
+
+/** Why a value the use set does not show right now. */
+export function shadowNote(state: string): string {
+ return `The ‘${state}’ state sets this; your value shows in the other states`
+}
+
+/**
+ * A value as short text for the reset's title: a number as itself, a token by
+ * its address, a paint stack by its first paint.
+ */
+export function inheritedText(value: JsonValue): string | null {
+ if (value === null || value === undefined) return null
+ if (typeof value === 'string') return aliasTarget(value) ?? value
+ if (typeof value !== 'object') return String(value)
+ const paints = asPaints(value)
+ if (!paints) return JSON.stringify(value)
+ const first = paints[0]
+ if (!first) return 'none'
+ if (first.type !== 'SOLID') return first.type.startsWith('GRADIENT') ? 'Gradient' : first.type
+ return paintColorAlias(first) ?? (first.color ? colorToHex(first.color as Rgba) : 'none')
+}
+
+/** The component's layout as `LockedLayoutRow` reads it; unset fields take the engine's defaults. */
+export interface LockedLayout {
+ layoutMode?: JsonValue
+ itemSpacing?: JsonValue
+ primaryAxisAlignItems?: JsonValue
+ counterAxisAlignItems?: JsonValue
+ layoutWrap?: JsonValue
+ clipsContent?: JsonValue
+}
+
+/** CSS's words for the two ends of an axis, which read the same for a row and a column. */
+const ALIGN_WORD: Record = { MIN: 'start', CENTER: 'center', MAX: 'end' }
+
+const alignWord = (prop: string, value: JsonValue | undefined): string => {
+ const option = typeof value === 'string' ? value : 'MIN'
+ return ALIGN_WORD[option] ?? optionLabelFor(prop, option).toLowerCase()
+}
+
+/** A gap as the panel shows it: a token by its name, as its pill does. */
+const gapText = (value: JsonValue | undefined): string => {
+ if (typeof value === 'string') return aliasTarget(value)?.split('#').pop() ?? value
+ return String(value ?? 0)
+}
+
+/**
+ * The one line an instance shows for its component's layout:
+ * "Row · Gap 8 · center/center · No wrap". Wrap is a row's alone, and a
+ * component that lays nothing out has no gap or alignment to speak of.
+ */
+export function lockedLayoutSummary(layout: LockedLayout): string {
+ const mode = typeof layout.layoutMode === 'string' ? layout.layoutMode : 'NONE'
+ const parts = [optionLabelFor('layoutMode', mode)]
+ if (mode !== 'NONE') {
+ parts.push(`Gap ${gapText(layout.itemSpacing)}`)
+ if (mode !== 'GRID') {
+ parts.push(
+ `${alignWord('primaryAxisAlignItems', layout.primaryAxisAlignItems)}/` +
+ alignWord('counterAxisAlignItems', layout.counterAxisAlignItems),
+ )
+ }
+ if (mode === 'HORIZONTAL') {
+ const wrap = typeof layout.layoutWrap === 'string' ? layout.layoutWrap : 'NO_WRAP'
+ parts.push(optionLabelFor('layoutWrap', wrap))
+ }
+ }
+ if (layout.clipsContent === true) parts.push('Clips content')
+ return parts.join(' · ')
+}
diff --git a/packages/viewer/src/wrapped-frame.ts b/packages/viewer/src/wrapped-frame.ts
index c0b5841..0b51bd0 100644
--- a/packages/viewer/src/wrapped-frame.ts
+++ b/packages/viewer/src/wrapped-frame.ts
@@ -1,44 +1,54 @@
-import { instanceDefinition, wrappedFrameUpdate } from '@uidx/schema'
+import { instanceDefinition, instancePreview, tupleAt } from '@uidx/schema'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
-import type { UidxAttr, UidxNode } from '@uidx/format'
+import type { JsonValue, UidxAttr, UidxNode } from '@uidx/format'
import type { InstanceScope } from './resize-writes'
/**
- * The frame inside an instance, drawn live while its size is a preview.
+ * The frame inside an instance, drawn live while its size or its outer box is
+ * a preview.
*
* A component with a styles table draws inside a variant wrapper, on a frame
- * that carries the instance's size (`pinnedFrame` in to-scene). Only a build
- * or the file's echo put the size there, so a resize on page1's Button1 moved
- * the selection box to 266 on every pointer move and left the pill at 160,
- * sliding with the wrapper, until the echo — when it jumped. A width typed
- * into the panel did the same.
+ * that carries the instance's size (`pinnedFrame` in to-scene) and its outer
+ * box (`boxTargetOf`, ADR 0018 §2). Only a build or the file's echo put either
+ * there, so a resize on page1's Button1 moved the selection box to 266 on
+ * every pointer move and left the pill at 160, sliding with the wrapper, until
+ * the echo — when it jumped. A width typed into the panel did the same, and a
+ * fill scrubbed in the panel painted the wrapper instead: a square box behind
+ * the pill.
*
- * The rule for what the frame gets is the echo's own (`wrappedFrameUpdate`),
- * asked with the instance as the gesture would leave the file, so the preview
- * and the echo cannot disagree.
+ * What moves is asked of the echo's own rules (`instancePreview`: the
+ * instance's own node, the frame by `wrappedFrameUpdate`, and the texts its
+ * colour reaches), with the instance as the gesture would leave the file and
+ * its tokens in the modes it is drawn in, so the preview and the echo cannot
+ * disagree.
*/
-/** A size a preview gives an instance, and the fills it lets go of to have it (`releasedFills`). */
+/**
+ * What a preview states on an instance before the file does: a size and the
+ * fills it lets go of to have it (`releasedFills`), and the outer box and text
+ * colour a panel scrub restyles (ADR 0018 §7), each as the file would hold it.
+ */
export interface InstanceFraming {
size: { width?: number; height?: number }
removals: readonly string[]
+ box?: Readonly>
}
/**
* The instance as the file will hold it once `framing` is written: `saved`
- * stating each dimension in the size, without the fills the size lets go of.
- * Nothing reads it but the scene build's helpers, which take `value`.
+ * stating each dimension in the size and each value in the box, without the
+ * fills the size lets go of. Nothing reads it but the scene build's helpers,
+ * which take `value`.
*/
-export function sizedInstance(saved: UidxNode, framing: InstanceFraming): UidxNode {
+export function framedInstance(saved: UidxNode, framing: InstanceFraming): UidxNode {
const attrs: Record = {}
for (const key in saved.attrs) if (!framing.removals.includes(key)) attrs[key] = saved.attrs[key]!
- for (const dimension of ['width', 'height'] as const) {
- const value = framing.size[dimension]
+ for (const [prop, value] of Object.entries({ ...framing.size, ...framing.box })) {
if (value === undefined) continue
- attrs[dimension] = {
- name: dimension,
+ attrs[prop] = {
+ name: prop,
value,
- raw: `{${value}}`,
+ raw: typeof value === 'string' ? JSON.stringify(value) : `{${JSON.stringify(value)}}`,
loc: saved.loc,
valueLoc: saved.loc,
}
@@ -47,38 +57,41 @@ export function sizedInstance(saved: UidxNode, framing: InstanceFraming): UidxNo
}
/**
- * What the frame an instance's component wraps must be given to be drawn for
- * `next` rather than for `drawn` — two versions of the same instance. Null
- * when the component wraps no such frame: one that lays itself out *is* the
- * instance's root, which the preview already sizes.
+ * `scope` as the build reads it at the instance at `address` in `tree`: a
+ * token resolves in the modes in force there, an enclosing frame's and the
+ * instance's own (ADR 0018 §5), the tuple the echo's `update-instance-root`
+ * carries. Bound as the build's `withModes` binds it: a token address from
+ * the tuple's values, anything else from `scope`. Without a token index, or a
+ * tree to read the modes from, every token is at its first mode, as before.
*/
-export function wrappedFramePreview(
- drawn: UidxNode,
- next: UidxNode,
+function scopeAt(
scope: InstanceScope,
- parentLayout?: SceneNode['layoutMode'],
-): { id: string; props: Partial } | null {
- const definition = instanceDefinition(next, scope)
- if (!definition) return null
- return wrappedFrameUpdate(
- next.address,
- { instance: drawn, definition },
- { instance: next, definition },
- scope,
- parentLayout,
- )
+ tree: UidxNode | null | undefined,
+ address: string,
+): InstanceScope {
+ const tokens = scope.tokens
+ if (!tokens || !tree) return scope
+ const values = tokens.resolver.resolve(tupleAt(tree, address, tokens.index))
+ const base = scope.resolveAlias
+ return {
+ ...scope,
+ resolveAlias: (alias) => (alias.includes('#') ? values.get(alias) : base?.(alias)),
+ }
}
export interface WrappedFrame {
/**
- * Draws the frame inside `saved` as the file would once `framing` is
- * written, or as the file is now for null. Called inside the caller's
- * preview scope, before its own write to the instance; the frame is
- * generated, so nothing it is given reaches the file either way.
+ * Draws the instance as the file would hold it once `framing` is written,
+ * or as `saved`, the file, holds it now for null: its own node, the frame
+ * inside it and the texts its colour reaches, wherever one moves. `id` is
+ * where it is drawn — its address, but for a use in a slot fill where the
+ * definition puts it (ADR 0007 §3). Called inside the caller's preview
+ * scope, before its own write to the instance, if any; nothing it moves
+ * reaches the file either way.
*/
- draw(saved: UidxNode, framing: InstanceFraming | null): void
+ draw(saved: UidxNode, framing: InstanceFraming | null, id?: string): void
/**
- * Puts back a frame a preview left drawn away from the file. Runs before a
+ * Puts back what a preview left drawn away from the file. Runs before a
* new document lands, so the diff meets the scene the file drew.
*/
release(): void
@@ -91,19 +104,36 @@ export function createWrappedFrame(context: {
scope: () => InstanceScope
/** How the file states an instance now. */
saved: (address: string) => UidxNode | null
+ /** The file's tree, where the modes in force at an instance are read (`scopeAt`). */
+ tree?: () => UidxNode | null
/** The editor's write, which lays out what it moved. */
update: (id: string, props: Partial) => void
+ /** The texts an instance's colour reaches, by where it is drawn (`SceneResult.textTargets`). */
+ textTargets?: (id: string) => readonly string[] | undefined
}): WrappedFrame {
- /** The instance whose frame is drawn away from the file, and the version it is drawn for. */
+ /** The instance drawn away from the file: its address, and the version drawn, at its scene id. */
let drawnFor: { address: string; instance: UidxNode } | null = null
- const move = (drawn: UidxNode, next: UidxNode): void => {
+ /**
+ * From what is drawn for `drawn` to what is drawn for `next`: one instance,
+ * drawn at `next.address`, written at `address` in the file.
+ */
+ const move = (drawn: UidxNode, next: UidxNode, address: string): void => {
const graph = context.graph()
const node = graph.getNode(next.address)
if (!node) return
const parentLayout = node.parentId ? graph.getNode(node.parentId)?.layoutMode : undefined
- const update = wrappedFramePreview(drawn, next, context.scope(), parentLayout)
- if (update && graph.getNode(update.id)) context.update(update.id, update.props)
+ const scope = scopeAt(context.scope(), context.tree?.(), address)
+ const updates = instancePreview(
+ drawn,
+ next,
+ instanceDefinition(next, scope),
+ scope,
+ parentLayout,
+ context.textTargets?.(next.address),
+ )
+ for (const update of updates ?? [])
+ if (graph.getNode(update.id)) context.update(update.id, update.props)
}
const release = (): void => {
@@ -111,16 +141,18 @@ export function createWrappedFrame(context: {
drawnFor = null
const now = held ? context.saved(held.address) : null
if (held && now?.element === 'Instance') {
- context.graph().runPreviewUpdates(() => move(held.instance, now))
+ const at = { ...now, address: held.instance.address }
+ context.graph().runPreviewUpdates(() => move(held.instance, at, held.address))
}
}
return {
- draw(saved, framing) {
+ draw(saved, framing, id = saved.address) {
// A preview left on another instance goes back before this one moves.
if (drawnFor && drawnFor.address !== saved.address) release()
- const next = framing ? sizedInstance(saved, framing) : saved
- move(drawnFor?.instance ?? saved, next)
+ const now = { ...saved, address: id }
+ const next = framing ? framedInstance(now, framing) : now
+ move(drawnFor?.instance ?? now, next, saved.address)
drawnFor = framing ? { address: saved.address, instance: next } : null
},
release,
diff --git a/packages/viewer/test/canvas-instance-box.test.ts b/packages/viewer/test/canvas-instance-box.test.ts
new file mode 100644
index 0000000..90c3a20
--- /dev/null
+++ b/packages/viewer/test/canvas-instance-box.test.ts
@@ -0,0 +1,760 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { enableAutoUnmount, mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ applyPatches,
+ parseOrThrow,
+ resolve,
+ type JsonValue,
+ type UidxDocument,
+ type UidxNode,
+ type UidxPatch,
+} from '@uidx/format'
+import {
+ applyChanges,
+ buildTokenIndex,
+ diffDocuments,
+ instanceRole,
+ layOutEntity,
+ modelIndex,
+ resolveTokenValues,
+ toSceneGraph,
+ TokenResolver,
+ type SceneResult,
+} from '@uidx/schema'
+import { dumpScene, repo } from '../../schema/test/helpers/workspace-scene'
+import { hoverNodeFor } from '../src/hover-map'
+import { instanceBoxEdit } from '../src/instance-box-edits'
+import PaddingField from '../src/PaddingField.vue'
+import { collapseBurst } from '../src/patch-burst'
+import PropertiesPane from '../src/PropertiesPane.vue'
+import PropertyField from '../src/PropertyField.vue'
+import type { InstanceScope } from '../src/resize-writes'
+import { createWrappedFrame, type InstanceFraming } from '../src/wrapped-frame'
+
+enableAutoUnmount(afterEach)
+
+/**
+ * An instance restyled from the panel, on the canvas (ADR 0018 §7).
+ *
+ * The node that draws a use's outer box is generated: for page1's Button1,
+ * which has a styles table, it is the pill one level down, `button1-1#root`,
+ * and the instance's own node is a transparent wrapper around it. A scrub
+ * written to the instance's node painted that wrapper, a square box behind
+ * the pill, and its commit went through the scene and painted it for good.
+ *
+ * So a scrub is drawn the way a resize is (`createWrappedFrame`): asked of
+ * the build's own functions with the instance as the edit would leave the
+ * file, and put back before another document lands. The commit is a patch
+ * on the ``, never a scene write. These drive what `CanvasPane`
+ * does for such a prop on each pointer move, on release and when a document
+ * lands, and look at the nodes the author is watching — and, last, the same
+ * with the real panel sending them, wired as `App.vue` wires it.
+ */
+const page = (id: string, body: string) =>
+ `---\nid: ${id}\n---\n\n## Visual Contract\n\n\n${body}\n \n`
+
+/**
+ * A collection with a light and a dark mode, which the example's tokens lack:
+ * a box value's token resolves in the modes the instance is drawn in.
+ */
+const TONES = `---\nid: tones\n---\n\n## Visual Contract\n\n
+
+
+
+
+
+
+
+
+
+
+ \n`
+
+/**
+ * The components the uses below place: the design-system example's, named
+ * rather than scanned (a scratch page there must not join), and the schema's
+ * copy of page1's Button1 beside the shapes the examples lack (Chip lays
+ * itself out).
+ */
+const LIBRARY = [
+ ...[
+ 'examples/design-system/.uidx/tokens.uidx',
+ 'examples/design-system/.uidx/button.uidx',
+ 'examples/design-system/.uidx/checkbox.uidx',
+ 'examples/design-system/.uidx/checkbox-field.uidx',
+ 'examples/design-system/.uidx/field.uidx',
+ 'packages/schema/test/fixtures/instance-box/button1.uidx',
+ 'packages/schema/test/fixtures/instance-box/kinds.uidx',
+ ].map((file) => parseOrThrow(readFileSync(join(repo, file), 'utf8'), file)),
+ parseOrThrow(TONES, 'tones.uidx'),
+]
+
+/** Every token at its first mode, as the shell hands them to the panel and the canvas. */
+const LITERALS = resolveTokenValues(LIBRARY)
+
+const BUTTON1_SOURCE = LIBRARY.find((doc) => doc.source.includes('name="Button1"'))!.source
+
+const INSTANCE = ` `
+
+/**
+ * page1, with a use of every other shape beside it: Button, a composition, a
+ * component that lays itself out, and a use in a slot fill, which is drawn
+ * where the definition puts the slot.
+ */
+const PAGE = page(
+ 'page1',
+ ` ${INSTANCE}
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+)
+
+/** Button1 defined beside its use, as page1 could hold it: the echo then updates the scene in place. */
+const LOCAL = BUTTON1_SOURCE.replace('', ` ${INSTANCE}\n`)
+
+/** A use that states its fill already: red. */
+const STATED = page(
+ 'page1',
+ ` `,
+)
+
+/** A use filling a slot its component does not have: in the file, and drawn nowhere. */
+const STRAY = page(
+ 'page1',
+ `
+
+
+
+
+
+
+ `,
+)
+
+/**
+ * A component that draws its look on a frame inside a frame of its own that
+ * only wraps it, with a styles table, as Shoelace's Button draws on `base`:
+ * the box goes through to that frame (ADR 0018 §2).
+ */
+const SHELLED = `${page(
+ 'page1',
+ `
+
+
+
+
+ `,
+)}
+
+
+
+`
+
+/** Button1 hugging its label: a padding change shows as the pill's width. */
+const HUG = page(
+ 'page1',
+ ` `,
+)
+
+const solid = (r: number, g: number, b: number): JsonValue => [
+ { type: 'SOLID', color: { r, g, b, a: 1 } },
+]
+const RED = solid(1, 0, 0)
+const GREEN = solid(0, 0.6, 0)
+const GREY = solid(0.2, 0.2, 0.2)
+const shadow = (radius: number): JsonValue => [
+ {
+ type: 'DROP_SHADOW',
+ color: { r: 0, g: 0, b: 0, a: 0.25 },
+ offset: { x: 0, y: 2 },
+ radius,
+ spread: 0,
+ visible: true,
+ },
+]
+
+/** The colour of a paint list's first paint, rounded to three places. */
+const colour = (value: unknown): Record | undefined => {
+ const color = (Array.isArray(value) ? value[0] : undefined)?.color as
+ Record | undefined
+ if (!color) return undefined
+ return Object.fromEntries(
+ Object.entries(color).map(([key, entry]) => [key, Math.round(entry * 1000) / 1000]),
+ )
+}
+
+function componentsOf(docs: readonly UidxDocument[]): Map {
+ const out = new Map()
+ for (const doc of docs)
+ for (const child of doc.tree.children)
+ if (child.element === 'Component') out.set(child.name, child)
+ return out
+}
+
+/** The editor's `updateNode`: the write, then layout for what it moved. */
+function update(scene: SceneResult, id: string, fields: Record): void {
+ scene.graph.updateNode(id, fields as never)
+ layOutEntity(scene.graph, id.split('#')[0]!, scene.pins)
+}
+
+/**
+ * The page open on the canvas, with the use at `address` selected. `moved`
+ * collects the nodes the wrapped frame wrote, and `held` is the scrub in
+ * flight (`CanvasPane`'s `panelPreview`).
+ */
+function open(source = PAGE, address = 'button1-1') {
+ const library = componentsOf(LIBRARY)
+ const index = buildTokenIndex(LIBRARY)
+ const opened = {
+ doc: parseOrThrow(source, 'page1.uidx'),
+ address,
+ scene: null as unknown as SceneResult,
+ scope: null as unknown as InstanceScope,
+ held: undefined as { address: string; framing: InstanceFraming } | undefined,
+ moved: [] as string[],
+ frame: null as unknown as ReturnType,
+ }
+ // What `CanvasPane` builds and previews with: the shell's components, the
+ // page's own first, and every token with its modes.
+ opened.scope = {
+ resolveAlias: (at) => LITERALS.get(at),
+ resolveComponent: (name) => componentsOf([opened.doc]).get(name) ?? library.get(name),
+ tokens: { resolver: new TokenResolver(index), index },
+ models: modelIndex(LIBRARY),
+ }
+ opened.scene = toSceneGraph(opened.doc, opened.scope)
+ opened.frame = createWrappedFrame({
+ graph: () => opened.scene.graph,
+ scope: () => opened.scope,
+ saved: (at) => resolve(opened.doc.tree, at),
+ tree: () => opened.doc.tree,
+ update: (id, props) => {
+ opened.moved.push(id)
+ update(opened.scene, id, props)
+ },
+ textTargets: (id) => opened.scene.textTargets.get(id),
+ })
+ return opened
+}
+
+type Opened = ReturnType
+
+/** Where a use is drawn (`drawnId`): its address, or for slot content where the definition puts it. */
+const sceneIdOf = (opened: Opened, address = opened.address): string =>
+ opened.scene.addresses.sceneIdOf(address) ?? address
+
+/**
+ * `CanvasPane.restyleInstance`, which `applyProp` hands a row of an
+ * instance's outer box or its text colour, from the panel's `preview` and
+ * `commit` alike: the scrub accrues into the framing the wrapped frame draws,
+ * and a release draws it, lets the hold and the frame go, and sends the patch
+ * — even for a use the canvas does not draw, since the file is what it is for.
+ */
+function restyle(
+ opened: Opened,
+ prop: string,
+ value: JsonValue,
+ mode: 'preview' | 'commit',
+ address = opened.address,
+): UidxPatch[] {
+ const { doc, scene, frame, scope } = opened
+ const instance = resolve(doc.tree, address)!
+ const id = sceneIdOf(opened, address)
+ const drawn = scene.graph.getNode(id) !== undefined
+ if (!drawn && mode === 'preview') return []
+ const held = opened.held?.address === address ? opened.held.framing : undefined
+ const framing: InstanceFraming = {
+ size: {},
+ removals: [],
+ box: { ...held?.box, [prop]: value },
+ }
+ opened.held = mode === 'preview' ? { address, framing } : undefined
+ if (drawn) {
+ scene.graph.runPreviewUpdates(() => frame.draw(instance, framing, id))
+ if (mode === 'commit') frame.settle()
+ }
+ if (mode === 'preview') return []
+ return instanceBoxEdit(doc, address, prop, value, { scope })?.patches ?? []
+}
+
+/**
+ * `CanvasPane.render` for a document that lands: the scrub's frame goes back
+ * first, so the diff meets the scene the file drew; the change is applied in
+ * place, or the page rebuilt when the diff cannot say it; and the scrub still
+ * in flight is drawn again over it, or let go if its node went with the
+ * change (`reapplyLocalEdits`).
+ */
+function land(opened: Opened, next: UidxDocument): void {
+ opened.frame.release()
+ const prev = opened.doc
+ // The shell's components are already the new document's when it lands.
+ opened.doc = next
+ const changes = diffDocuments(prev, next, opened.scope.resolveAlias, opened.scope.tokens)
+ if (changes) applyChanges(opened.scene, changes, opened.scope)
+ else opened.scene = toSceneGraph(next, opened.scope)
+ const held = opened.held
+ if (!held) return
+ const saved = resolve(next.tree, held.address)
+ const id = sceneIdOf(opened, held.address)
+ if (saved && opened.scene.graph.getNode(id))
+ opened.scene.graph.runPreviewUpdates(() => opened.frame.draw(saved, held.framing, id))
+ else opened.held = undefined
+}
+
+const patched = (doc: UidxDocument, patches: readonly UidxPatch[]): UidxDocument =>
+ parseOrThrow(applyPatches(doc.source, [...patches]).source, 'page1.uidx')
+
+/** The page as a fresh build of `doc` draws it. */
+const rebuilt = (opened: Opened, doc: UidxDocument): ReturnType =>
+ dumpScene(toSceneGraph(doc, opened.scope))
+
+const node = (opened: Opened, id: string) => opened.scene.graph.getNode(id)!
+
+describe('scrubbing the outer box of an instance whose component wraps a frame', () => {
+ it('draws the fill on the pill, button1-1#root, and leaves the wrapper alone', () => {
+ const opened = open()
+ const wrapper = { ...node(opened, 'button1-1') }
+ restyle(opened, 'fills', RED, 'preview')
+ expect(opened.moved).toEqual(['button1-1#root'])
+ expect(colour(node(opened, 'button1-1#root').fills)).toEqual({ r: 1, g: 0, b: 0, a: 1 })
+ const after = node(opened, 'button1-1')
+ for (const field of ['fills', 'strokes', 'effects', 'opacity', 'paddingLeft'] as const)
+ expect(after[field], field).toEqual(wrapper[field])
+ })
+
+ it('previews each step from the last, so a value scrubbed back is drawn back', () => {
+ const opened = open()
+ const before = dumpScene(opened.scene)
+ restyle(opened, 'paddingLeft', 40, 'preview')
+ expect(node(opened, 'button1-1#root').paddingLeft).toBe(40)
+ restyle(opened, 'paddingLeft', 12, 'preview')
+ expect(dumpScene(opened.scene)).toEqual(before)
+ })
+
+ it('accrues the sides a compound control scrubs together, and commits each', () => {
+ const opened = open()
+ restyle(opened, 'paddingLeft', 30, 'preview')
+ restyle(opened, 'paddingRight', 30, 'preview')
+ expect(node(opened, 'button1-1#root')).toMatchObject({ paddingLeft: 30, paddingRight: 30 })
+ const patches = [
+ ...restyle(opened, 'paddingLeft', 30, 'commit'),
+ ...restyle(opened, 'paddingRight', 30, 'commit'),
+ ]
+ expect(patches).toEqual([
+ { op: 'add', address: 'button1-1', prop: 'paddingLeft', value: 30 },
+ { op: 'add', address: 'button1-1', prop: 'paddingRight', value: 30 },
+ ])
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, patched(opened.doc, patches)))
+ })
+
+ it('lets a scrub on one instance go when another is scrubbed', () => {
+ const opened = open()
+ const before = dumpScene(opened.scene)
+ const pill = (dump: ReturnType) =>
+ dump.filter((entry) => entry.id.startsWith('button1-1'))
+ restyle(opened, 'fills', RED, 'preview')
+ restyle(opened, 'fills', GREEN, 'preview', 'go')
+ expect(pill(dumpScene(opened.scene))).toEqual(pill(before))
+ expect(colour(node(opened, 'go#root').fills)).toEqual({ r: 0, g: 0.6, b: 0, a: 1 })
+ })
+
+ it('draws nothing for a binding, which the box does not take', () => {
+ const opened = open()
+ const before = dumpScene(opened.scene)
+ restyle(opened, 'fills', '{label}', 'preview')
+ expect(opened.moved).toEqual([])
+ expect(dumpScene(opened.scene)).toEqual(before)
+ })
+
+ it.each([
+ ['button1-1', 'fills', GREEN, RED],
+ ['button1-1', 'fills', GREEN, '{surface#danger}'],
+ ['button1-1', 'paddingLeft', 20, 40],
+ ['button1-1', 'cornerRadius', 8, 4],
+ ['button1-1', 'opacity', 0.8, 0.5],
+ ['button1-1', 'effects', shadow(8), shadow(4)],
+ ['go', 'strokeWeight', 2, 3],
+ ['go', 'strokes', GREEN, GREY],
+ ] as const)(
+ '%s: a %s scrub ends, at release, as a rebuild of the patched file draws it',
+ (address, prop, step, value) => {
+ const opened = open(PAGE, address)
+ restyle(opened, prop, step, 'preview')
+ restyle(opened, prop, value, 'preview')
+ const patches = restyle(opened, prop, value, 'commit')
+ expect(patches).toEqual([{ op: 'add', address, prop, value }])
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, patched(opened.doc, patches)))
+ },
+ )
+
+ it('commits a patch on the and writes nothing to the scene', () => {
+ const opened = open()
+ const written: string[] = []
+ opened.scene.graph.emitter.on('node:updated', (id: string) => written.push(id))
+ restyle(opened, 'fills', GREEN, 'preview')
+ restyle(opened, 'textFills', GREEN, 'preview')
+ const patches = [
+ ...restyle(opened, 'fills', RED, 'commit'),
+ ...restyle(opened, 'textFills', RED, 'commit'),
+ ]
+ expect(patches).toEqual([
+ { op: 'add', address: 'button1-1', prop: 'fills', value: RED },
+ { op: 'add', address: 'button1-1', prop: 'textFills', value: RED },
+ ])
+ // A scene write is what `CanvasPane` turns into patches (`recordSceneWrite`).
+ expect(written).toEqual([])
+ // And nothing is left drawn away from the file for a document to put back.
+ const moved = opened.moved.length
+ opened.frame.release()
+ expect(opened.moved.length).toBe(moved)
+ })
+
+ it('rewrites a value the use states with a set', () => {
+ const opened = open(STATED, 'b')
+ expect(restyle(opened, 'fills', GREEN, 'commit')).toEqual([
+ { op: 'set', address: 'b', prop: 'fills', value: GREEN },
+ ])
+ })
+
+ it('lets the scrub go at release, even when the file already says the value', () => {
+ const opened = open(STATED, 'b')
+ restyle(opened, 'fills', GREEN, 'preview')
+ expect(restyle(opened, 'fills', RED, 'commit')).toEqual([])
+ expect(opened.held).toBeUndefined()
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, opened.doc))
+ })
+
+ it('writes a release on a use the canvas does not draw, and holds nothing for it', () => {
+ // Content for a slot its component does not have is in the file, not on the canvas.
+ const opened = open(STRAY, 'p#nowhere/b')
+ expect(opened.scene.graph.getNode(sceneIdOf(opened))).toBeUndefined()
+ restyle(opened, 'fills', GREEN, 'preview')
+ expect(opened.held).toBeUndefined()
+ expect(restyle(opened, 'fills', RED, 'commit')).toEqual([
+ { op: 'add', address: 'p#nowhere/b', prop: 'fills', value: RED },
+ ])
+ })
+
+ it('agrees with a rebuild when the echo updates the scene in place', () => {
+ const opened = open(LOCAL)
+ restyle(opened, 'fills', GREEN, 'preview')
+ const patches = restyle(opened, 'fills', RED, 'commit')
+ const next = patched(opened.doc, patches)
+ const changes = diffDocuments(opened.doc, next, opened.scope.resolveAlias, opened.scope.tokens)
+ expect(changes?.map((change) => change.kind)).toEqual(['update-instance-root'])
+ land(opened, next)
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, next))
+ })
+
+ it('puts the pill back when another document lands mid-scrub, and draws the scrub over it', () => {
+ const opened = open(LOCAL)
+ const before = node(opened, 'button1-1#root').fills
+ restyle(opened, 'fills', RED, 'preview')
+ restyle(opened, 'paddingLeft', 40, 'preview')
+ // `render` lets the frame go before the diff…
+ opened.frame.release()
+ expect(node(opened, 'button1-1#root').fills).toEqual(before)
+ expect(node(opened, 'button1-1#root').paddingLeft).toBe(12)
+ // …and `reapplyLocalEdits` draws the scrub again over the new document.
+ const moved = parseOrThrow(opened.doc.source.replace('x={328}', 'x={300}'), 'page1.uidx')
+ land(opened, moved)
+ const expected = patched(moved, [
+ { op: 'add', address: 'button1-1', prop: 'fills', value: RED },
+ { op: 'add', address: 'button1-1', prop: 'paddingLeft', value: 40 },
+ ])
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, expected))
+ })
+})
+
+describe('scrubbing an instance’s text colour', () => {
+ it('draws it on exactly the texts the use’s colour reaches', () => {
+ const opened = open(PAGE, 'cf')
+ const targets = opened.scene.textTargets.get('cf')!
+ expect(targets.length).toBeGreaterThan(1)
+ restyle(opened, 'textFills', RED, 'preview')
+ expect(opened.moved).toEqual(targets)
+ for (const id of targets)
+ expect(colour(node(opened, id).fills), id).toEqual({ r: 1, g: 0, b: 0, a: 1 })
+ })
+
+ it('ends, at release, as a rebuild of the patched file draws it', () => {
+ const opened = open(PAGE, 'cf')
+ restyle(opened, 'textFills', GREEN, 'preview')
+ const patches = restyle(opened, 'textFills', '{text#danger}', 'commit')
+ expect(patches).toEqual([
+ { op: 'add', address: 'cf', prop: 'textFills', value: '{text#danger}' },
+ ])
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, patched(opened.doc, patches)))
+ })
+
+ it('puts the texts back when another document lands mid-scrub', () => {
+ const opened = open()
+ const before = dumpScene(opened.scene)
+ restyle(opened, 'textFills', RED, 'preview')
+ expect(colour(node(opened, 'button1-1#root/label').fills)).toEqual({ r: 1, g: 0, b: 0, a: 1 })
+ opened.frame.release()
+ expect(dumpScene(opened.scene)).toEqual(before)
+ })
+})
+
+describe('the other shapes a component takes', () => {
+ it('a composition: draws the box on the frame of the instance it holds', () => {
+ const opened = open(PAGE, 'cf')
+ restyle(opened, 'fills', RED, 'preview')
+ expect(opened.moved).toEqual(['cf#field/root'])
+ const patches = restyle(opened, 'fills', RED, 'commit')
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, patched(opened.doc, patches)))
+ })
+
+ it('a component that lays itself out: draws on the instance itself, still never as a write', () => {
+ const opened = open(PAGE, 'chip')
+ const written: string[] = []
+ opened.scene.graph.emitter.on('node:updated', (id: string) => written.push(id))
+ restyle(opened, 'fills', RED, 'preview')
+ expect(opened.moved).toEqual(['chip'])
+ expect(colour(node(opened, 'chip').fills)).toEqual({ r: 1, g: 0, b: 0, a: 1 })
+ const patches = restyle(opened, 'fills', RED, 'commit')
+ expect(written).toEqual([])
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, patched(opened.doc, patches)))
+ })
+
+ it('a component that draws its look inside a frame that only wraps it: draws on that frame', () => {
+ const opened = open(SHELLED, 's')
+ restyle(opened, 'fills', RED, 'preview')
+ restyle(opened, 'paddingLeft', 30, 'preview')
+ expect([...new Set(opened.moved)]).toEqual(['s#root/base'])
+ expect(colour(node(opened, 's#root/base').fills)).toEqual({ r: 1, g: 0, b: 0, a: 1 })
+ expect(node(opened, 's#root/base').paddingLeft).toBe(30)
+ for (const id of ['s', 's#root']) expect(node(opened, id).fills, id).toEqual([])
+ const patches = [
+ ...restyle(opened, 'fills', RED, 'commit'),
+ ...restyle(opened, 'paddingLeft', 30, 'commit'),
+ ]
+ expect(patches).toEqual([
+ { op: 'add', address: 's', prop: 'fills', value: RED },
+ { op: 'add', address: 's', prop: 'paddingLeft', value: 30 },
+ ])
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, patched(opened.doc, patches)))
+ })
+
+ it('a use inside a slot fill: patched at its address, drawn where the definition puts it', () => {
+ const opened = open(PAGE, 'p#body/b')
+ expect(sceneIdOf(opened)).toBe('p#frame/body/b')
+ restyle(opened, 'fills', RED, 'preview')
+ expect(opened.moved).toEqual(['p#frame/body/b/root'])
+ const patches = restyle(opened, 'fills', RED, 'commit')
+ expect(patches).toEqual([{ op: 'add', address: 'p#body/b', prop: 'fills', value: RED }])
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, patched(opened.doc, patches)))
+ })
+})
+
+describe('hovering a row of an instance’s panel', () => {
+ const at = (opened: Opened, prop: string): string =>
+ hoverNodeFor(prop, sceneIdOf(opened), resolve(opened.doc.tree, opened.address), opened.scope)
+
+ it('lights the pill for a padding or corner row, not the wrapper around it', () => {
+ const opened = open()
+ expect(at(opened, 'paddingLeft')).toBe('button1-1#root')
+ expect(at(opened, 'topLeftRadius')).toBe('button1-1#root')
+ // Where the use sits and how big it is are its own node's.
+ expect(at(opened, 'width')).toBe('button1-1')
+ expect(at(opened, 'x')).toBe('button1-1')
+ })
+
+ it('lights the frame a composition’s instance draws, and the instance of a component that lays itself out', () => {
+ expect(at(open(PAGE, 'cf'), 'paddingLeft')).toBe('cf#field/root')
+ expect(at(open(PAGE, 'chip'), 'paddingLeft')).toBe('chip')
+ expect(at(open(PAGE, 'p#body/b'), 'paddingTop')).toBe('p#frame/body/b/root')
+ expect(at(open(SHELLED, 's'), 'paddingLeft')).toBe('s#root/base')
+ })
+
+ it('lights the node itself for anything that is not an instance', () => {
+ const opened = open(PAGE, 'p')
+ const frame = resolve(opened.doc.tree, 'Panel#frame')!
+ expect(hoverNodeFor('paddingLeft', 'Panel#frame', frame, opened.scope)).toBe('Panel#frame')
+ expect(hoverNodeFor('paddingLeft', 'button1-1', null, opened.scope)).toBe('button1-1')
+ })
+})
+
+describe('a token on the box, scrubbed where the instance is drawn', () => {
+ // ADR 0018 §5: a box value's token resolves in the consuming scope, the
+ // instance's own `modes` included, as the build and the echo resolve it.
+ // A scrub resolved in the first mode drew white, then the echo drew dark.
+ it.each([
+ [
+ 'its own modes',
+ ` `,
+ 'u',
+ ],
+ [
+ 'an enclosing frame’s modes',
+ `
+
+ `,
+ 'f#u',
+ ],
+ ])('draws in %s, as the file will draw it', (_, body, address) => {
+ const opened = open(page('page1', body), address)
+ restyle(opened, 'fills', '{tone#surface}', 'preview')
+ restyle(opened, 'textFills', '{tone#text}', 'preview')
+ const next = patched(opened.doc, [
+ { op: 'add', address, prop: 'fills', value: '{tone#surface}' },
+ { op: 'add', address, prop: 'textFills', value: '{tone#text}' },
+ ])
+ const box = `${address}${address.includes('#') ? '/' : '#'}root`
+ expect(colour(node(opened, box).fills)).toEqual({ r: 0.071, g: 0.082, b: 0.11, a: 1 })
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, next))
+ })
+})
+
+/**
+ * The panel and the canvas wired as `App.vue` wires them: a scrub's `preview`
+ * and its `commit` go to the canvas (`applyProp`, which hands a row of an
+ * instance's outer box to `restyle`), and `patches` go to the file. What the
+ * canvas writes in one tick travels as one envelope, as its burst does.
+ * `send` applies each envelope and lands the file that comes back, on the
+ * canvas and in the panel.
+ */
+function shell(opened: Opened) {
+ const envelopes: UidxPatch[][] = []
+ let burst: UidxPatch[] = []
+ const applyProp = (
+ address: string,
+ prop: string,
+ value: JsonValue,
+ mode: 'preview' | 'commit',
+ ) => {
+ // The only rows scrubbed here, and the ones `applyProp` hands an instance's restyle.
+ expect(['box', 'cascade']).toContain(instanceRole(prop))
+ burst.push(...restyle(opened, prop, value, mode, address))
+ }
+ const components = componentsOf([...LIBRARY, opened.doc])
+ const wrapper = mount(PropertiesPane, {
+ props: {
+ doc: opened.doc,
+ selection: [opened.address],
+ tokens: LITERALS,
+ components,
+ writable: true,
+ onPreview: (address: string, prop: string, value: JsonValue) =>
+ applyProp(address, prop, value, 'preview'),
+ onCommit: (address: string, prop: string, value: JsonValue) =>
+ applyProp(address, prop, value, 'commit'),
+ onPatches: (patches: UidxPatch[]) => envelopes.push(patches),
+ },
+ })
+ /** A document landing, from the file: the canvas draws it, and the panel reads it. */
+ const arrive = async (next: UidxDocument): Promise => {
+ land(opened, next)
+ await wrapper.setProps({ doc: next })
+ }
+ const send = async (): Promise => {
+ if (burst.length) envelopes.push(collapseBurst(burst))
+ burst = []
+ for (const patches of envelopes.splice(0)) await arrive(patched(opened.doc, patches))
+ }
+ return { wrapper, send, arrive }
+}
+
+describe('a scrub released from the panel, then handed back', () => {
+ const stated = (opened: Opened, prop: string) =>
+ resolve(opened.doc.tree, opened.address)!.attrs[prop]
+
+ /** Scrubs both sides of the padding axis in the panel to 34, and lets go. */
+ function scrubPadding(wrapper: ReturnType['wrapper']): void {
+ const padding = wrapper.getComponent(PaddingField)
+ for (const value of [20, 28, 34])
+ padding.vm.$emit('preview', [
+ { prop: 'paddingLeft', value },
+ { prop: 'paddingRight', value },
+ ])
+ padding.vm.$emit('commit', [
+ { prop: 'paddingLeft', value: 34 },
+ { prop: 'paddingRight', value: 34 },
+ ])
+ }
+
+ /** The pill grew with the scrub, and the file and the canvas agree on it. */
+ async function released(opened: Opened, send: () => Promise, own: number): Promise {
+ await send()
+ expect(stated(opened, 'paddingLeft')?.value).toBe(34)
+ expect(opened.held).toBeUndefined()
+ expect(node(opened, 'h#root').width).toBe(own + 44)
+ expect(dumpScene(opened.scene)).toEqual(rebuilt(opened, opened.doc))
+ }
+
+ it('↺ on Padding draws the pill at its own width again', async () => {
+ const opened = open(HUG, 'h')
+ const before = dumpScene(opened.scene)
+ const own = node(opened, 'h#root').width
+ const { wrapper, send } = shell(opened)
+ scrubPadding(wrapper)
+ await released(opened, send, own)
+ await wrapper.get('[data-field="instance-padding"] button.reset').trigger('click')
+ await send()
+ expect(stated(opened, 'paddingLeft')).toBeUndefined()
+ expect(dumpScene(opened.scene)).toEqual(before)
+ })
+
+ it('Reset all draws it so', async () => {
+ const opened = open(HUG, 'h')
+ const before = dumpScene(opened.scene)
+ const own = node(opened, 'h#root').width
+ const { wrapper, send } = shell(opened)
+ scrubPadding(wrapper)
+ await released(opened, send, own)
+ await wrapper.get('[data-field="overrides"] button.reset-all').trigger('click')
+ await send()
+ expect(stated(opened, 'paddingRight')).toBeUndefined()
+ expect(dumpScene(opened.scene)).toEqual(before)
+ })
+
+ it('undo draws it so', async () => {
+ const opened = open(HUG, 'h')
+ const before = dumpScene(opened.scene)
+ const own = node(opened, 'h#root').width
+ const original = opened.doc.source
+ const { wrapper, send, arrive } = shell(opened)
+ scrubPadding(wrapper)
+ await released(opened, send, own)
+ // Undo writes the inverse, and the file comes back as it was.
+ await arrive(parseOrThrow(original, 'page1.uidx'))
+ expect(dumpScene(opened.scene)).toEqual(before)
+ })
+
+ it('↺ on Fill draws the component’s blue again', async () => {
+ const opened = open(HUG, 'h')
+ const before = dumpScene(opened.scene)
+ const { wrapper, send } = shell(opened)
+ const fill = wrapper
+ .findAllComponents(PropertyField)
+ .find((f) => f.props('field').name === 'fills')!
+ fill.vm.$emit('preview', 'fills', GREEN)
+ fill.vm.$emit('preview', 'fills', RED)
+ fill.vm.$emit('commit', 'fills', RED)
+ await send()
+ expect(stated(opened, 'fills')?.value).toEqual(RED)
+ expect(opened.held).toBeUndefined()
+ expect(colour(node(opened, 'h#root').fills)).toEqual({ r: 1, g: 0, b: 0, a: 1 })
+ await wrapper.get('.section[aria-label="Fill"] .section-head button.reset').trigger('click')
+ await send()
+ expect(stated(opened, 'fills')).toBeUndefined()
+ expect(dumpScene(opened.scene)).toEqual(before)
+ })
+})
diff --git a/packages/viewer/test/detach-instance-box.test.ts b/packages/viewer/test/detach-instance-box.test.ts
new file mode 100644
index 0000000..4e7a71f
--- /dev/null
+++ b/packages/viewer/test/detach-instance-box.test.ts
@@ -0,0 +1,646 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import {
+ applyPatches,
+ parse,
+ parseOrThrow,
+ resolve,
+ type JsonValue,
+ type UidxDocument,
+ type UidxNode,
+ type UidxNodeSpec,
+} from '@uidx/format'
+import { toSceneGraph, type SceneResult } from '@uidx/schema'
+
+import {
+ buildPage,
+ dumpScene,
+ repo,
+ type DumpedNode,
+} from '../../schema/test/helpers/workspace-scene'
+import { detachInstance } from '../src/detach'
+
+/**
+ * Detaching an instance restyled from outside (ADR 0018 §6): the copy is
+ * plain layers that look the way the instance did. Its outer box goes onto
+ * the node that drew it, beneath anything a state row set; its text colour
+ * becomes the fills of each text it reached; the size it fixed goes with the
+ * box; and its locked attributes, which never drew, are left behind.
+ */
+
+/** A one-paint solid list, as the file spells it and as it parses. */
+const solid = (r: number, g: number, b: number) => ({
+ source: `[{ type: 'SOLID', color: { r: ${r}, g: ${g}, b: ${b}, a: 1 } }]`,
+ value: [{ type: 'SOLID', color: { r, g, b, a: 1 } }],
+})
+const BLUE = solid(0, 0.3333, 1)
+const LIGHT_BLUE = solid(0.3678, 0.5744, 0.9875)
+const RED = solid(1, 0, 0)
+const GREEN = solid(0, 0.6, 0)
+const GREY = solid(0.6, 0.6, 0.6)
+const DARK = solid(0.2, 0.2, 0.2)
+const WHITE = solid(1, 1, 1)
+
+/**
+ * A copy of the pill button on examples/design-system's page1: a styles
+ * table, so the box a use styles is the derived `root` one level down.
+ */
+const BUTTON1 = `---
+id: button1
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The words it shows.
+
+`
+
+/**
+ * A state row that colours a text and dims the frame, and a frame with a
+ * stroke weight, side weights and corner radii for a use to keep or replace.
+ */
+const TOGGLE = `---
+id: toggle
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The words it shows.
+ Inert and dimmed.
+
+`
+
+/**
+ * One component of each other shape (ADR 0018 §2): Tag only wraps a frame,
+ * Card and Field lay themselves out, and BoxField composes one Field.
+ */
+const KINDS = `---
+id: kinds
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The words it shows.
+
+`
+
+/**
+ * The Shoelace example's shape: the look on a frame inside a frame of the
+ * component's own that only wraps it, so the box goes through to it (ADR
+ * 0018 §2), and a state row that dims the wrapper.
+ */
+const SHELL = `---
+id: shell
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Contract
+
+
+ The words it shows.
+ Inert and dimmed.
+
+`
+
+const HOME = `---
+id: home
+---
+
+## Visual Contract
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+const pages = new Map([
+ ['button1.uidx', parseOrThrow(BUTTON1)],
+ ['toggle.uidx', parseOrThrow(TOGGLE)],
+ ['kinds.uidx', parseOrThrow(KINDS)],
+ ['shell.uidx', parseOrThrow(SHELL)],
+ ['home.uidx', parseOrThrow(HOME)],
+])
+const home = pages.get('home.uidx')!
+
+/** `address` on the home page detached: the page re-parsed, the copy, and what the parser said. */
+function detached(address: string) {
+ const plan = detachInstance(pages, home, address)
+ if (!plan) throw new Error(`nothing to detach at "${address}"`)
+ // `applyPatches` refuses a patch that would leave the file invalid, which
+ // is what a `textFills` written on a frame did (UIDX114).
+ const result = parse(applyPatches(HOME, plan.patches).source)
+ if (!result.doc) throw new Error(result.diagnostics.map((d) => d.message).join('; '))
+ const copy = resolve(result.doc.tree, address)!
+ return { next: result.doc, copy, diagnostics: result.diagnostics, plan }
+}
+
+/** A node's attributes, as values. */
+const valuesOf = (node: UidxNode): Record =>
+ Object.fromEntries(Object.entries(node.attrs).map(([key, attr]) => [key, attr.value]))
+
+/** The attributes of the node at `address`, as values. */
+const at = (doc: UidxDocument, address: string): Record => {
+ const node = resolve(doc.tree, address)
+ if (!node) throw new Error(`no node at "${address}"`)
+ return valuesOf(node)
+}
+
+const components = new Map()
+for (const doc of pages.values())
+ for (const node of doc.tree.children)
+ if (node.element === 'Component') components.set(node.name, node)
+
+const sceneOf = (doc: UidxDocument): SceneResult =>
+ toSceneGraph(doc, { resolveComponent: (name) => components.get(name) })
+
+describe('detaching an instance with an outer box', () => {
+ it('puts the box on the frame that draws it, and where it sits on the copy', () => {
+ const { copy, next, diagnostics } = detached('styled')
+ // Button1's styles table wraps its pill in a variant: the copy is the pill.
+ expect(copy.element).toBe('Frame')
+ expect(valuesOf(copy)).toMatchObject({
+ x: 10,
+ y: 20,
+ layoutMode: 'HORIZONTAL',
+ fills: '{surface#danger}',
+ strokes: DARK.value,
+ strokeWeight: 2,
+ cornerRadius: 4,
+ opacity: 0.8,
+ paddingLeft: 24,
+ paddingRight: 12,
+ paddingTop: 8,
+ paddingBottom: 8,
+ })
+ for (const gone of ['component', 'props', 'textFills']) expect(copy.attrs[gone]).toBeUndefined()
+ expect(at(next, 'styled#label')).toMatchObject({
+ characters: 'Delete',
+ fills: '{text#onAccent}',
+ })
+ expect(diagnostics.map((d) => d.code)).not.toContain('UIDX114')
+ })
+
+ it('leaves the wrapper of a frame-wrapping component unpainted', () => {
+ const { copy, next } = detached('tag')
+ for (const prop of ['fills', 'paddingLeft', 'textFills'])
+ expect(copy.attrs[prop]).toBeUndefined()
+ expect(valuesOf(copy)).toMatchObject({ y: 420 })
+ expect(at(next, 'tag#box')).toMatchObject({
+ fills: RED.value,
+ paddingLeft: 20,
+ paddingRight: 6,
+ cornerRadius: 4,
+ })
+ expect(at(next, 'tag#box/label').fills).toEqual(GREEN.value)
+ })
+
+ it('keeps what a state row set over the value the use states', () => {
+ // The hover row's fill sits above the use's (ADR 0018 §3); at rest the use's would show.
+ const { copy } = detached('hovered')
+ expect(copy.attrs.fills!.value).toEqual(LIGHT_BLUE.value)
+ expect(copy.attrs.state).toBeUndefined()
+
+ const dimmed = detached('disabled')
+ expect(dimmed.copy.attrs.opacity!.value).toBe(0.5)
+ expect(at(dimmed.next, 'disabled#label').fills).toEqual(GREY.value)
+
+ const enabled = detached('enabled')
+ expect(enabled.copy.attrs.opacity!.value).toBe(0.8)
+ expect(at(enabled.next, 'enabled#label').fills).toEqual(RED.value)
+ })
+
+ it('merges the box one attribute at a time', () => {
+ // Strokes alone keep the component's weights.
+ const stroked = detached('stroked').copy
+ expect(valuesOf(stroked)).toMatchObject({
+ strokes: RED.value,
+ strokeWeight: 3,
+ strokeBottomWeight: 2,
+ })
+ // A shorthand replaces its longhands, as in CSS (ADR 0018 §5).
+ const squared = detached('squared').copy
+ expect(valuesOf(squared)).toMatchObject({
+ cornerRadius: 2,
+ strokeWeight: 1,
+ strokes: DARK.value,
+ })
+ for (const longhand of ['topLeftRadius', 'bottomRightRadius', 'strokeBottomWeight'])
+ expect(squared.attrs[longhand]).toBeUndefined()
+ })
+
+ it('leaves behind what an instance may not style', () => {
+ const locked = detached('locked').copy
+ expect(locked.attrs.layoutMode!.value).toBe('HORIZONTAL')
+ for (const gone of ['itemSpacing', 'clipsContent', 'fontSize', 'strokeCap'])
+ expect(locked.attrs[gone]).toBeUndefined()
+
+ // A binding draws nothing on an instance, so the component's own value shows.
+ const { copy, next } = detached('bound')
+ expect(valuesOf(copy)).toMatchObject({ fills: BLUE.value, paddingLeft: 12 })
+ expect(at(next, 'bound#label').fills).toBeUndefined()
+ })
+})
+
+describe('detaching an instance whose box is found through a wrapper', () => {
+ it('copies the wrapper bare and puts the box and size on the frame inside', () => {
+ const { copy, next, diagnostics } = detached('shelled')
+ expect(copy.element).toBe('Frame')
+ expect(valuesOf(copy)).toMatchObject({ x: 10, y: 1000, layoutMode: 'HORIZONTAL' })
+ for (const gone of ['fills', 'paddingLeft', 'textFills', 'component'])
+ expect(copy.attrs[gone], gone).toBeUndefined()
+ expect(at(next, 'shelled#base')).toMatchObject({
+ fills: RED.value,
+ paddingLeft: 30,
+ paddingRight: 12,
+ cornerRadius: 6,
+ width: 199,
+ primaryAxisSizingMode: 'FIXED',
+ })
+ expect(at(next, 'shelled#base/label').fills).toEqual(GREEN.value)
+ expect(diagnostics.map((d) => d.code)).not.toContain('UIDX114')
+ })
+
+ it('keeps what a state row set on the wrapper, and the use’s box inside it', () => {
+ const { copy, next } = detached('dimmed')
+ expect(copy.attrs.opacity!.value).toBe(0.45)
+ expect(at(next, 'dimmed#base')).toMatchObject({ fills: RED.value, opacity: 0.8 })
+ })
+
+ it('draws as the instance did', () => {
+ const before = sceneOf(home).graph
+ const after = sceneOf(detached('shelled').next).graph
+ const base = after.getNode('shelled#base')!
+ const drawn = before.getNode('shelled#root/base')!
+ expect(base.fills).toEqual(drawn.fills)
+ expect([base.width, base.height, base.paddingLeft]).toEqual([
+ drawn.width,
+ drawn.height,
+ drawn.paddingLeft,
+ ])
+ expect(after.getNode('shelled')!.width).toBe(before.getNode('shelled#root')!.width)
+ })
+})
+
+describe('the text colour of a detached instance', () => {
+ it('becomes the fills of every text the component draws', () => {
+ const { next } = detached('card')
+ // An override still wins (ADR 0018 §4), and a vector keeps its colour.
+ expect(at(next, 'card#title').fills).toEqual(GREEN.value)
+ expect(at(next, 'card#footer/hint').fills).toEqual(RED.value)
+ expect(at(next, 'card#mark')).toMatchObject({ fills: BLUE.value, opacity: 0.5 })
+ // An instance inside takes it as its own, unless it states one.
+ expect(at(next, 'card#action').textFills).toEqual(RED.value)
+ expect(at(next, 'card#stop').textFills).toEqual(WHITE.value)
+ })
+
+ it('reaches slot content that states no fills of its own', () => {
+ const { next, diagnostics } = detached('filled')
+ expect(at(next, 'filled#title').fills).toEqual(RED.value)
+ expect(at(next, 'filled#footer/note').fills).toEqual(RED.value)
+ expect(at(next, 'filled#footer/own').fills).toEqual(GREY.value)
+ expect(at(next, 'filled#footer/row/deep').fills).toEqual(RED.value)
+ expect(at(next, 'filled#footer/plain').textFills).toEqual(RED.value)
+ expect(at(next, 'filled#footer/mine').textFills).toEqual(BLUE.value)
+ expect(diagnostics.filter((d) => d.severity === 'error')).toEqual([])
+ })
+
+ it('comes from an enclosing instance when the use states none', () => {
+ // `plain` sits in what `filled` put in Card's footer, so `filled`'s
+ // colour reached Tag's label over the label's own fills.
+ const plain = detached('filled#footer/plain')
+ expect(at(plain.next, 'filled#footer/plain/box/label').fills).toEqual(RED.value)
+ // The nearest colour wins.
+ const mine = detached('filled#footer/mine')
+ expect(at(mine.next, 'filled#footer/mine/box/label').fills).toEqual(BLUE.value)
+ })
+
+ it('yields to an override keyed as the build keys it', () => {
+ // A styles table puts the pill at `root`, so that is where the key points.
+ const { next } = detached('keyed')
+ expect(at(next, 'keyed#label').fills).toEqual(GREEN.value)
+ })
+
+ it('is never written on a frame', () => {
+ for (const address of ['styled', 'tag', 'card', 'filled', 'composed', 'enabled']) {
+ const insert = detached(address).plan.patches.find((patch) => patch.op === 'insert-node')!
+ const carriers: string[] = []
+ const walk = (spec: UidxNodeSpec): void => {
+ if ('textFills' in spec.attrs) carriers.push(spec.element)
+ for (const child of spec.children ?? []) walk(child)
+ }
+ if (insert.op === 'insert-node') walk(insert.node)
+ expect(carriers.filter((element) => element !== 'Instance')).toEqual([])
+ }
+ })
+})
+
+describe('a detached composition', () => {
+ it('hands its box, colour and size to the instance it holds', () => {
+ const { copy, next, diagnostics } = detached('composed')
+ for (const prop of ['fills', 'paddingLeft', 'textFills'])
+ expect(copy.attrs[prop]).toBeUndefined()
+ const field = resolve(next.tree, 'composed#field')!
+ expect(field.element).toBe('Instance')
+ // The use's values beat the ones the definition wrote on `field`.
+ expect(valuesOf(field)).toMatchObject({
+ component: 'Field',
+ props: { label: 'Label' },
+ fills: RED.value,
+ paddingLeft: 16,
+ textFills: BLUE.value,
+ width: 300,
+ })
+ // What the composition puts in Field's slot stays a fill.
+ const control = field.children.find((child) => child.name === 'control')!
+ expect(control.element).toBe('Slot')
+ expect(control.children.map((child) => [child.element, child.name])).toEqual([
+ ['Instance', 'box'],
+ ])
+ expect(diagnostics.filter((d) => d.severity === 'error')).toEqual([])
+ })
+
+ it('keeps what the definition wrote on the held instance when the use states nothing', () => {
+ const { next } = detached('quiet')
+ expect(at(next, 'quiet#field')).toMatchObject({ fills: GREY.value, textFills: GREEN.value })
+ })
+})
+
+describe('the size of a detached instance', () => {
+ const before = sceneOf(home).graph
+ const widthOf = (scene: SceneResult, id: string) => scene.graph.getNode(id)!.width
+
+ it('pins the frame its box lands on, as the build does', () => {
+ const sized = detached('sized')
+ expect(valuesOf(sized.copy)).toMatchObject({
+ width: 199,
+ height: 33,
+ primaryAxisSizingMode: 'FIXED',
+ counterAxisSizingMode: 'FIXED',
+ })
+ const pill = sceneOf(sized.next).graph.getNode('sized')!
+ const drawn = before.getNode('sized#root')!
+ expect([pill.width, pill.height]).toEqual([drawn.width, drawn.height])
+ expect([pill.width, pill.height]).toEqual([199, 33])
+
+ const tag = detached('tag')
+ expect(at(tag.next, 'tag#box')).toMatchObject({ width: 120, primaryAxisSizingMode: 'FIXED' })
+ expect(widthOf(sceneOf(tag.next), 'tag#box')).toBe(before.getNode('tag#box')!.width)
+ })
+
+ it('passes it to the instance a composition holds', () => {
+ const { next } = detached('composed')
+ expect(widthOf(sceneOf(next), 'composed#field')).toBe(before.getNode('composed#field')!.width)
+ expect(before.getNode('composed#field')!.width).toBe(300)
+ })
+
+ it('leaves a dimension the use fills to its parent', () => {
+ const { copy, next } = detached('column#stretched')
+ expect(copy.attrs.primaryAxisSizingMode!.value).toBe('AUTO')
+ expect(widthOf(sceneOf(next), 'column#stretched')).toBe(
+ before.getNode('column#stretched/root')!.width,
+ )
+ })
+})
+
+describe('a rebuild of a detached copy', () => {
+ /**
+ * The scene-regression fixture page (ADR 0018's every shape, restyled) and
+ * the components it places: its own, and the design-system example's
+ * library, file by file, so a scratch page in the example never joins.
+ */
+ const FILES = [
+ 'examples/design-system/.uidx/tokens.uidx',
+ 'examples/design-system/.uidx/button.uidx',
+ 'examples/design-system/.uidx/checkbox.uidx',
+ 'examples/design-system/.uidx/checkbox-field.uidx',
+ 'examples/design-system/.uidx/field.uidx',
+ 'packages/schema/test/fixtures/instance-box/button1.uidx',
+ 'packages/schema/test/fixtures/instance-box/kinds.uidx',
+ 'packages/schema/test/fixtures/instance-box/overrides.uidx',
+ ]
+ const PAGE = FILES.at(-1)!
+ const docs = new Map(
+ FILES.map(
+ (file) => [file, parseOrThrow(readFileSync(join(repo, file), 'utf8'), file)] as const,
+ ),
+ )
+ const page = docs.get(PAGE)!
+ const byId = (nodes: DumpedNode[]) => new Map(nodes.map((node) => [node.id, node]))
+ const built = buildPage(docs, page)
+ const before = byId(dumpScene(built))
+
+ /** What a node draws, as the dump records it: its paint, its box and its words. */
+ const DRAWN = [
+ 'type',
+ 'visible',
+ 'fills',
+ 'strokes',
+ 'borderTopWeight',
+ 'borderRightWeight',
+ 'borderBottomWeight',
+ 'borderLeftWeight',
+ 'independentStrokeWeights',
+ 'dashPattern',
+ 'effects',
+ 'opacity',
+ 'cornerRadius',
+ 'topLeftRadius',
+ 'topRightRadius',
+ 'bottomRightRadius',
+ 'bottomLeftRadius',
+ 'independentCorners',
+ 'cornerSmoothing',
+ 'paddingTop',
+ 'paddingRight',
+ 'paddingBottom',
+ 'paddingLeft',
+ 'characters',
+ 'fontSize',
+ ] as const
+ const drawn = (node: DumpedNode) =>
+ Object.fromEntries(DRAWN.map((key) => [key, node[key as keyof DumpedNode]]))
+
+ /** Every instance the page writes, placed on it or put in another's slot. */
+ const uses: string[] = []
+ const collect = (node: UidxNode): void => {
+ for (const child of node.children) {
+ if (child.element === 'Instance') uses.push(child.address)
+ collect(child)
+ }
+ }
+ collect(page.tree)
+
+ /** Whether scene node `id` is `root` or drawn inside it. */
+ const under = (id: string, root: string): boolean =>
+ id === root || id.startsWith(`${root}${root.includes('#') ? '/' : '#'}`)
+
+ it.each(uses)('draws %s as the instance did', (address) => {
+ const plan = detachInstance(docs, page, address)!
+ const next = parseOrThrow(applyPatches(page.source, plan.patches).source, PAGE)
+ const rebuilt = buildPage(new Map([...docs, [PAGE, next]]), next)
+ const after = byId(dumpScene(rebuilt))
+ // Slot content is drawn where the slot sits in the component, so its
+ // scene id is not its address; the copy keeps the instance's.
+ const name = built.addresses.sceneIdOf(address)!
+ expect(rebuilt.addresses.sceneIdOf(address)).toBe(name)
+
+ // A variant's frame is the copy itself (the frame the build drew one
+ // level down): the instance's own node was a wrapper with no look.
+ const wrapped = before.get(name)!.childIds
+ const frame = wrapped.length === 1 && !after.has(wrapped[0]!) ? wrapped[0] : undefined
+ const copyOf = (id: string): string =>
+ frame === undefined || !under(id, frame)
+ ? id
+ : id === frame
+ ? name
+ : `${name}${name.includes('#') ? '/' : '#'}${id.slice(frame.length + 1)}`
+
+ const drew = [...before.keys()].filter((id) => under(id, name))
+ const mine = drew.filter((id) => !(frame !== undefined && id === name))
+ expect([...after.keys()].filter((id) => under(id, name)).sort()).toEqual(
+ mine.map(copyOf).sort(),
+ )
+ for (const id of mine) {
+ // The instance's own node is the one that stops being an instance.
+ const expected = { ...drawn(before.get(id)!), ...(id === name ? { type: 'FRAME' } : {}) }
+ expect(drawn(after.get(copyOf(id))!), id).toEqual(expected)
+ }
+ if (frame !== undefined) {
+ const wrapper = before.get(name)!
+ expect([wrapper.fills, wrapper.strokes, wrapper.effects, wrapper.opacity]).toEqual([
+ [],
+ [],
+ [],
+ 1,
+ ])
+ }
+ })
+})
diff --git a/packages/viewer/test/editable-instance.test.ts b/packages/viewer/test/editable-instance.test.ts
new file mode 100644
index 0000000..c35514c
--- /dev/null
+++ b/packages/viewer/test/editable-instance.test.ts
@@ -0,0 +1,417 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { parseOrThrow, resolve, type UidxDocument, type UidxNode } from '@uidx/format'
+import { instanceBase, instanceDefinition, type InstanceBase } from '@uidx/schema'
+import { editableProps, isMapped, parentOf, sectionsFor, type EditableProp } from '../src/editable'
+
+/**
+ * The rows the panel offers for a selected instance (ADR 0018 §7): its outer
+ * box and the text colour it hands down, each showing the component's own
+ * value, dimmed, until the use states one — and nothing of the component's
+ * inside, which is the component's business.
+ */
+const page = (id: string, body: string, regions = '') =>
+ `---\nid: ${id}\n---\n\n## Visual Contract\n\n\n${body}\n \n${regions}`
+
+/**
+ * The schema's instance-box fixtures: Button1, a copy of the pill on the
+ * design-system example's page1 (a styles table, so its box is the derived
+ * `root`), and Chip, Tag and Badge, one per other place a box can land.
+ */
+const fixture = (file: string): UidxDocument =>
+ parseOrThrow(
+ readFileSync(join(__dirname, '../../schema/test/fixtures/instance-box', file), 'utf8'),
+ file,
+ )
+
+/** Two texts in two colours, so the colour a use would hand down has no one value to show. */
+const PAIR = page(
+ 'pair',
+ `
+
+
+ `,
+)
+
+/** A pill whose hover row colours its label as well as its frame. */
+const TINTED = page(
+ 'tinted',
+ `
+
+ `,
+ `
+
+
+
+
+## Contract
+
+
+ The words it shows.
+
+`,
+)
+
+/** A dot and no text: nothing a text colour could reach. */
+const SWATCH = page(
+ 'swatch',
+ `
+
+ `,
+)
+
+const LIBRARY = [
+ fixture('button1.uidx'),
+ fixture('kinds.uidx'),
+ parseOrThrow(PAIR, 'pair.uidx'),
+ parseOrThrow(TINTED, 'tinted.uidx'),
+ parseOrThrow(SWATCH, 'swatch.uidx'),
+]
+
+/** Button1's blue, exactly as the fixture writes it. */
+const BLUE = [{ opacity: 1, visible: true, type: 'SOLID', color: { r: 0, g: 0.3333, b: 1, a: 1 } }]
+/** The hover row's lighter blue. */
+const LIGHT_BLUE = [
+ { opacity: 1, visible: true, type: 'SOLID', color: { r: 0.3678, g: 0.5744, b: 0.9875, a: 1 } },
+]
+/** What a text that states no fills draws in: the engine's own black. */
+const ENGINE_TEXT = [
+ { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true },
+]
+const RED = `[{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 } }]`
+
+/** The use named `name` on a page holding `body`, its parent, and what it inherits. */
+function use(body: string, name = 'b') {
+ const doc = parseOrThrow(page('use', body), 'use.uidx')
+ const components = new Map()
+ for (const source of [...LIBRARY, doc])
+ for (const child of source.tree.children)
+ if (child.element === 'Component') components.set(child.name, child)
+ const scope = { resolveComponent: (component: string) => components.get(component) }
+ const instance = resolve(doc.tree, name)!
+ const parent = parentOf(doc.tree, instance.address)
+ const base = instanceBase(instance, instanceDefinition(instance, scope), scope)
+ return { doc, instance, parent, base }
+}
+
+/** The rows `editableProps` gives the use, keyed by prop. */
+function rowsOf(body: string, name = 'b', withBase = true): Map {
+ const { instance, parent, base } = use(body, name)
+ const fields = editableProps(instance, parent, withBase ? base : null)
+ return new Map(fields.map((field) => [field.name, field]))
+}
+
+const BUTTON1 = ` `
+
+describe('an instance offers its outer box and nothing inside it', () => {
+ it('has padding rows and a Text color row', () => {
+ const rows = rowsOf(BUTTON1)
+ for (const prop of ['paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom', 'textFills'])
+ expect(rows.has(prop), prop).toBe(true)
+ expect(rows.get('textFills')).toMatchObject({ group: 'textColor', control: 'paint' })
+ })
+
+ it('has no row for the component’s layout, stroke ends or typography', () => {
+ const rows = rowsOf(BUTTON1)
+ for (const prop of [
+ 'layoutMode',
+ 'itemSpacing',
+ 'counterAxisSpacing',
+ 'primaryAxisAlignItems',
+ 'counterAxisAlignItems',
+ 'counterAxisAlignContent',
+ 'layoutWrap',
+ 'clipsContent',
+ 'itemReverseZIndex',
+ 'strokesIncludedInLayout',
+ 'strokeCap',
+ 'strokeJoin',
+ 'strokeMiterLimit',
+ 'characters',
+ 'fontSize',
+ 'fontFamily',
+ 'fontWeight',
+ 'lineHeight',
+ 'letterSpacing',
+ 'textAlignHorizontal',
+ ])
+ expect(rows.has(prop), prop).toBe(false)
+ })
+
+ it('groups the box into the sections a frame has, with Text color after Fill', () => {
+ const { instance, parent, base } = use(BUTTON1)
+ const sections = sectionsFor(instance, editableProps(instance, parent, base), parent)
+ expect(sections.map((section) => section.group)).toEqual([
+ 'position',
+ 'layout',
+ 'appearance',
+ 'fill',
+ 'textColor',
+ 'stroke',
+ 'effects',
+ ])
+ const layout = sections
+ .find((section) => section.group === 'layout')!
+ .fields.flatMap((row) => [row.field.name, row.pairedWith?.name])
+ expect(layout).toEqual(expect.arrayContaining(['width', 'paddingLeft', 'paddingTop']))
+ expect(layout).not.toContain('layoutMode')
+ expect(layout).not.toContain('itemSpacing')
+ })
+})
+
+describe('an unset row shows the component’s value', () => {
+ it('Fill shows Button1’s paint, from the component', () => {
+ const fills = rowsOf(BUTTON1).get('fills')!
+ expect(fills.value).toEqual(BLUE)
+ expect(fills.origin).toBe('component')
+ expect(fills.authored).toBe(false)
+ expect(fills.control).toBe('paint')
+ expect(fills.boundTo).toBeNull()
+ })
+
+ it('padding and the corner radius are Button1’s too', () => {
+ const rows = rowsOf(BUTTON1)
+ expect(rows.get('paddingLeft')).toMatchObject({ value: 12, origin: 'component' })
+ expect(rows.get('paddingTop')).toMatchObject({ value: 8, origin: 'component' })
+ expect(rows.get('cornerRadius')).toMatchObject({ value: 999, origin: 'component' })
+ })
+
+ it('leaves what the component says nothing about to the engine', () => {
+ const rows = rowsOf(BUTTON1)
+ // A list row keeps its empty `+`: the component draws no stroke.
+ expect(rows.get('strokes')).toMatchObject({ value: null, origin: 'engine' })
+ expect(rows.get('opacity')).toMatchObject({ value: 1, origin: 'engine' })
+ })
+
+ it('reads a token the component binds as a binding', () => {
+ const rows = rowsOf(` `, 'c')
+ const fills = rows.get('fills')!
+ expect(fills).toMatchObject({ value: '{surface#raised}', origin: 'component' })
+ expect(fills.boundTo).toBe('surface#raised')
+ expect(rows.get('cornerRadius')).toMatchObject({ value: 6, origin: 'component' })
+ })
+
+ it('reads the frame a bare component wraps, and the variant the use chose', () => {
+ const tag = rowsOf(` `, 't')
+ expect(tag.get('strokes')).toMatchObject({ value: '{border#default}', origin: 'component' })
+ expect(tag.get('strokes')!.boundTo).toBe('border#default')
+ expect(tag.get('strokeWeight')).toMatchObject({ value: 1, origin: 'component' })
+
+ const badge = rowsOf(` `, 'w')
+ expect(badge.get('fills')).toMatchObject({ value: '{surface#danger}', origin: 'component' })
+ expect(badge.get('topLeftRadius')).toMatchObject({ value: 8, origin: 'component' })
+ })
+})
+
+describe('a component that draws its look inside a frame that only wraps it', () => {
+ /** The Shoelace example's own Button, whose look is on its `base` part (ADR 0018 §2). */
+ const SHOELACE = parseOrThrow(
+ readFileSync(join(__dirname, '../../../examples/shoelace/.uidx/button.uidx'), 'utf8'),
+ 'button.uidx',
+ )
+ const rows = (): Map => {
+ const doc = parseOrThrow(page('use', BUTTON1.replace('Button1', 'Button')), 'use.uidx')
+ const button = SHOELACE.tree.children.find((node) => node.element === 'Component')!
+ const scope = { resolveComponent: (name: string) => (name === 'Button' ? button : undefined) }
+ const instance = resolve(doc.tree, 'b')!
+ const base = instanceBase(instance, instanceDefinition(instance, scope), scope)
+ const fields = editableProps(instance, parentOf(doc.tree, instance.address), base)
+ return new Map(fields.map((field) => [field.name, field]))
+ }
+
+ it('shows the paint, padding and corners of that frame, not the wrapper’s none', () => {
+ const shown = rows()
+ expect(shown.get('fills')).toMatchObject({ value: '{color#accent}', origin: 'component' })
+ expect(shown.get('fills')!.boundTo).toBe('color#accent')
+ expect(shown.get('paddingLeft')).toMatchObject({ value: '{space#lg}', origin: 'component' })
+ expect(shown.get('cornerRadius')).toMatchObject({ value: '{radius#md}', origin: 'component' })
+ })
+})
+
+describe('a stated row is the use’s own', () => {
+ it('shows the use’s value, marked own', () => {
+ const fills = rowsOf(` `).get('fills')!
+ expect(fills.value).toEqual([{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 } }])
+ expect(fills.origin).toBe('own')
+ expect(fills.authored).toBe(true)
+ expect(fills.shadow).toBeNull()
+ })
+
+ it('says which state the use’s props select sets the same property', () => {
+ const rows = rowsOf(
+ ` `,
+ )
+ expect(rows.get('fills')).toMatchObject({ origin: 'own', shadow: { state: 'hover' } })
+ // The hover row sets no corner, so the use's would show.
+ expect(rows.get('cornerRadius')!.shadow).toBeNull()
+ })
+
+ it('an unset row under that state shows the state’s value, and says so', () => {
+ const fills = rowsOf(
+ ` `,
+ ).get('fills')!
+ expect(fills.value).toEqual(LIGHT_BLUE)
+ expect(fills.origin).toBe('component')
+ expect(fills.shadow).toEqual({ state: 'hover' })
+ })
+})
+
+describe('an attribute an instance cannot style', () => {
+ const LOCKED = ` `
+
+ it('shows read-only and unsectioned, offered for removal', () => {
+ const rows = rowsOf(LOCKED)
+ for (const prop of ['layoutMode', 'itemSpacing', 'strokeCap']) {
+ const row = rows.get(prop)!
+ expect(row, prop).toMatchObject({
+ group: null,
+ control: 'readonly',
+ authored: true,
+ origin: 'own',
+ removable: true,
+ })
+ }
+ expect(rows.get('layoutMode')!.value).toBe('VERTICAL')
+ expect(rows.get('layoutMode')!.readonlyReason).toMatch(/the component’s own layout/)
+ expect(rows.get('itemSpacing')!.readonlyReason).toMatch(/the component’s own layout/)
+ expect(rows.get('strokeCap')!.readonlyReason).toMatch(/inside the component/)
+ })
+
+ it('sits in no section', () => {
+ const { instance, parent, base } = use(LOCKED)
+ const named = sectionsFor(instance, editableProps(instance, parent, base), parent).flatMap(
+ (section) => section.fields.flatMap((row) => [row.field.name, row.pairedWith?.name]),
+ )
+ expect(named).not.toContain('layoutMode')
+ expect(named).not.toContain('itemSpacing')
+ expect(named).not.toContain('strokeCap')
+ })
+
+ it('is read-only even without a definition: the role is the attribute’s, not the component’s', () => {
+ const row = rowsOf(
+ ` `,
+ 'b',
+ false,
+ ).get('layoutMode')!
+ expect(row).toMatchObject({ group: null, control: 'readonly', removable: true })
+ })
+})
+
+describe('the Text color row', () => {
+ it('shows what the texts the use reaches draw: the engine’s black for a label with no fills', () => {
+ expect(rowsOf(BUTTON1).get('textFills')).toMatchObject({
+ value: ENGINE_TEXT,
+ origin: 'component',
+ authored: false,
+ })
+ })
+
+ it('reads a token the texts draw in as a binding', () => {
+ const row = rowsOf(` `, 't').get(
+ 'textFills',
+ )!
+ expect(row).toMatchObject({ value: '{text#muted}', origin: 'component' })
+ expect(row.boundTo).toBe('text#muted')
+ })
+
+ it('has no one value when the texts disagree', () => {
+ expect(rowsOf(` `, 'p').get('textFills')).toMatchObject({
+ value: null,
+ origin: 'component',
+ })
+ })
+
+ it('is the use’s own once it states one', () => {
+ const row = rowsOf(` `).get(
+ 'textFills',
+ )!
+ expect(row).toMatchObject({ origin: 'own', authored: true, group: 'textColor' })
+ expect(isMapped('textFills')).toBe(true)
+ })
+
+ it('says when a state the use selects colours the text instead, and shows the state’s colour', () => {
+ const hover = rowsOf(
+ ` `,
+ 't',
+ ).get('textFills')!
+ // The hover row colours the only text, so the row shows what it draws, as
+ // Fill shows the hover row's paint: the component's value, never the
+ // engine's.
+ expect(hover).toMatchObject({
+ value: [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1, a: 1 } }],
+ origin: 'component',
+ authored: false,
+ shadow: { state: 'hover' },
+ })
+ const rest = rowsOf(` `, 't')
+ expect(rest.get('textFills')).toMatchObject({
+ value: ENGINE_TEXT,
+ origin: 'component',
+ shadow: null,
+ })
+ })
+
+ it('leaves to the engine only a component that draws no text', () => {
+ expect(
+ rowsOf(` `, 's').get('textFills'),
+ ).toMatchObject({ value: null, origin: 'engine', shadow: null })
+ })
+})
+
+describe('without a definition', () => {
+ const NOWHERE = ` `
+
+ it('gives the rows it always gave', () => {
+ const { instance, parent } = use(NOWHERE)
+ const without = editableProps(instance, parent)
+ const nulled = editableProps(instance, parent, null)
+ const strip = (fields: EditableProp[]) =>
+ fields.map(({ name, value, control, group, authored, boundTo, readonlyReason }) => ({
+ name,
+ value,
+ control,
+ group,
+ authored,
+ boundTo,
+ readonlyReason,
+ }))
+ expect(strip(nulled)).toEqual(strip(without))
+ const rows = new Map(without.map((field) => [field.name, field]))
+ // An unset list row is its empty `+`, a number the engine's default.
+ expect(rows.get('strokes')).toMatchObject({ value: null, origin: 'engine', shadow: null })
+ expect(rows.get('paddingLeft')).toMatchObject({ value: 0, origin: 'engine' })
+ expect(rows.get('fills')).toMatchObject({ origin: 'own', shadow: null })
+ })
+
+ it('reads no base when the component is missing', () => {
+ expect(use(NOWHERE).base).toBeNull()
+ })
+})
+
+describe('a node that is not an instance', () => {
+ it('marks authored rows own and unset rows the engine’s, with nothing to shadow them', () => {
+ const doc = parseOrThrow(
+ page('frame', ` `),
+ )
+ const frame = resolve(doc.tree, 'f')!
+ const base: InstanceBase = {
+ target: { kind: 'self', id: 'f' },
+ values: { strokes: '{border#default}' },
+ stateWins: new Map([['fills', 'hover']]),
+ text: null,
+ layout: {},
+ }
+ // A base handed in for a frame is ignored: only an instance inherits.
+ for (const fields of [editableProps(frame, doc.tree), editableProps(frame, doc.tree, base)]) {
+ const rows = new Map(fields.map((field) => [field.name, field]))
+ expect(rows.get('fills')).toMatchObject({ origin: 'own', shadow: null })
+ expect(rows.get('strokes')).toMatchObject({ value: null, origin: 'engine', shadow: null })
+ expect(rows.get('layoutMode')).toMatchObject({ group: 'layout', control: 'enum' })
+ expect(rows.get('layoutMode')!.removable).toBeUndefined()
+ }
+ })
+})
diff --git a/packages/viewer/test/instance-box-edits.test.ts b/packages/viewer/test/instance-box-edits.test.ts
new file mode 100644
index 0000000..e0dfb78
--- /dev/null
+++ b/packages/viewer/test/instance-box-edits.test.ts
@@ -0,0 +1,427 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { applyPatches, parseOrThrow, resolve, type JsonValue, type UidxNode } from '@uidx/format'
+import { instanceBase, instanceDefinition, toSceneGraph } from '@uidx/schema'
+import { editableProps, parentOf } from '../src/editable'
+import {
+ instanceBoxEdit,
+ OVERRIDE_PROPS,
+ overrideCount,
+ resetAllPatches,
+ resetPatches,
+ sectionResetProps,
+} from '../src/instance-box-edits'
+import { asPaints, setPaintColor } from '../src/paint-edit'
+
+/**
+ * Restyling an instance from the panel (ADR 0018 §7). Every edit is a patch
+ * on the `` — the node that draws the value is generated and has no
+ * address — and a preview drawn by the build's own functions, so the canvas
+ * shows during a scrub what the patch's echo will draw.
+ */
+const page = (id: string, body: string) =>
+ `---\nid: ${id}\n---\n\n## Visual Contract\n\n\n${body}\n \n`
+
+/** The schema's copy of the design-system example's Button1: its box is the derived `root`. */
+const BUTTON1 = parseOrThrow(
+ readFileSync(join(__dirname, '../../schema/test/fixtures/instance-box/button1.uidx'), 'utf8'),
+ 'button1.uidx',
+)
+
+const solid = (r: number, g: number, b: number, a = 1) => ({
+ type: 'SOLID',
+ color: { r, g, b, a },
+})
+const WHITE = solid(1, 1, 1)
+const SEE_THROUGH_BLUE = solid(0, 0, 1, 0.5)
+const RED = [solid(1, 0, 0)]
+const GREEN = { r: 0, g: 0.6, b: 0, a: 1 }
+
+/**
+ * A component that lays itself out, under a two-paint stack; one holding a
+ * slot; and one holding its slot inside a frame, so what fills it is drawn
+ * under that frame, away from the address the file gives it.
+ */
+const LOCAL = parseOrThrow(
+ page(
+ 'local',
+ `
+
+
+
+
+
+
+
+
+
+ `,
+ ),
+ 'local.uidx',
+)
+
+/** The one token a use below names, as the consuming page resolves it. */
+const TOKENS = new Map([['color#danger', { r: 1, g: 0, b: 0, a: 1 }]])
+
+/** A page holding `body`, with the scope a use on it is written in. */
+function use(body: string) {
+ const doc = parseOrThrow(page('use', body), 'use.uidx')
+ const components = new Map()
+ for (const source of [BUTTON1, LOCAL, doc])
+ for (const child of source.tree.children)
+ if (child.element === 'Component') components.set(child.name, child)
+ const scope = {
+ resolveAlias: (address: string) => TOKENS.get(address),
+ resolveComponent: (name: string) => components.get(name),
+ }
+ return { doc, scope }
+}
+
+/** The colour of a paint list's first paint, rounded to three places. */
+const colour = (value: unknown): Record | undefined => {
+ const color = (Array.isArray(value) ? value[0] : undefined)?.color as
+ Record | undefined
+ if (!color) return undefined
+ return Object.fromEntries(
+ Object.entries(color).map(([key, entry]) => [key, Math.round(entry * 1000) / 1000]),
+ )
+}
+
+const PLAIN = ` `
+
+describe('instanceBoxEdit', () => {
+ it('writes fills as an add on the instance, and previews them on b#root', () => {
+ const { doc, scope } = use(PLAIN)
+ const edit = instanceBoxEdit(doc, 'b', 'fills', RED, { scope })!
+ expect(edit.patches).toEqual([{ op: 'add', address: 'b', prop: 'fills', value: RED }])
+ // The frame the component wraps draws the box; the wrapper around it does not.
+ expect(edit.preview.map((update) => update.id)).toEqual(['b#root'])
+ expect(colour(edit.preview[0]!.props.fills)).toEqual({ r: 1, g: 0, b: 0, a: 1 })
+ })
+
+ it('rewrites a value the use already states with a set', () => {
+ const { doc, scope } = use(
+ ` `,
+ )
+ const edit = instanceBoxEdit(doc, 'b', 'paddingLeft', 40, { scope })!
+ expect(edit.patches).toEqual([{ op: 'set', address: 'b', prop: 'paddingLeft', value: 40 }])
+ expect(edit.preview).toEqual([
+ expect.objectContaining({
+ id: 'b#root',
+ props: expect.objectContaining({ paddingLeft: 40 }),
+ }),
+ ])
+ })
+
+ it('resets with a remove, and previews the component’s value back', () => {
+ const { doc, scope } = use(
+ ` `,
+ )
+ const edit = instanceBoxEdit(doc, 'b', 'fills', undefined, { scope })!
+ expect(edit.patches).toEqual([{ op: 'remove', address: 'b', prop: 'fills' }])
+ expect(edit.preview.map((update) => update.id)).toEqual(['b#root'])
+ expect(colour(edit.preview[0]!.props.fills)).toEqual({ r: 0, g: 0.333, b: 1, a: 1 })
+ expect(edit.next.attrs.fills).toBeUndefined()
+ })
+
+ it('writes nothing for a value the file already holds, or a reset of one it does not', () => {
+ const { doc, scope } = use(
+ ` `,
+ )
+ expect(instanceBoxEdit(doc, 'b', 'paddingLeft', 24, { scope })!.patches).toEqual([])
+ expect(instanceBoxEdit(doc, 'b', 'fills', undefined, { scope })!.patches).toEqual([])
+ })
+
+ it('binds a token in the consuming page’s scope for the preview, and writes it as written', () => {
+ const { doc, scope } = use(PLAIN)
+ const edit = instanceBoxEdit(doc, 'b', 'fills', '{color#danger}', { scope })!
+ expect(edit.patches).toEqual([
+ { op: 'add', address: 'b', prop: 'fills', value: '{color#danger}' },
+ ])
+ expect(colour(edit.preview[0]!.props.fills)).toEqual({ r: 1, g: 0, b: 0, a: 1 })
+ })
+
+ it('copy-on-write: editing one paint of an inherited two-paint stack writes both', () => {
+ const { doc, scope } = use(` `)
+ const instance = resolve(doc.tree, 'd')!
+ const base = instanceBase(instance, instanceDefinition(instance, scope), scope)
+ const fills = editableProps(instance, parentOf(doc.tree, 'd'), base).find(
+ (field) => field.name === 'fills',
+ )!
+ expect(fills.origin).toBe('component')
+ // What the paint stack emits for a recoloured first paint: the whole stack.
+ const next = setPaintColor(asPaints(fills.value)!, 0, GREEN)
+ const edit = instanceBoxEdit(doc, 'd', 'fills', next, { scope })!
+ expect(edit.patches).toEqual([
+ {
+ op: 'add',
+ address: 'd',
+ prop: 'fills',
+ value: [{ ...WHITE, color: GREEN }, SEE_THROUGH_BLUE],
+ },
+ ])
+ // Duo lays itself out, so its box is the instance's own node.
+ expect(edit.preview.map((update) => update.id)).toEqual(['d'])
+ })
+
+ it('previews a text colour on exactly the texts it reaches', () => {
+ const { doc, scope } = use(PLAIN)
+ const targets = toSceneGraph(doc, scope).textTargets.get('b')!
+ expect(targets).toEqual(['b#root/label'])
+ const edit = instanceBoxEdit(doc, 'b', 'textFills', RED, { scope, textTargets: targets })!
+ expect(edit.patches).toEqual([{ op: 'add', address: 'b', prop: 'textFills', value: RED }])
+ expect(edit.preview.map((update) => update.id)).toEqual(targets)
+ expect(colour(edit.preview[0]!.props.fills)).toEqual({ r: 1, g: 0, b: 0, a: 1 })
+ })
+
+ it('previews each step of a scrub from the last, so a value scrubbed back is drawn back', () => {
+ const { doc, scope } = use(PLAIN)
+ const first = instanceBoxEdit(doc, 'b', 'paddingLeft', 40, { scope })!
+ expect(first.preview[0]).toMatchObject({ id: 'b#root', props: { paddingLeft: 40 } })
+ // Back to Button1's own 12: against the file nothing moved, but the scene
+ // still draws 40.
+ expect(instanceBoxEdit(doc, 'b', 'paddingLeft', 12, { scope })!.preview).toEqual([])
+ const back = instanceBoxEdit(doc, 'b', 'paddingLeft', 12, { scope, drawn: first.next })!
+ expect(back.preview[0]).toMatchObject({ id: 'b#root', props: { paddingLeft: 12 } })
+ // The patch is still the file's business: an add, since the file states none.
+ expect(back.patches).toEqual([{ op: 'add', address: 'b', prop: 'paddingLeft', value: 12 }])
+ })
+
+ it('accrues the props a compound control writes in one step', () => {
+ const { doc, scope } = use(PLAIN)
+ const left = instanceBoxEdit(doc, 'b', 'paddingLeft', 30, { scope })!
+ const right = instanceBoxEdit(doc, 'b', 'paddingRight', 30, { scope, drawn: left.next })!
+ expect(right.next.attrs.paddingLeft?.value).toBe(30)
+ expect(right.next.attrs.paddingRight?.value).toBe(30)
+ // Only what this step changed moves: the left side is already drawn.
+ expect(right.preview).toEqual([
+ expect.objectContaining({
+ id: 'b#root',
+ props: expect.not.objectContaining({ paddingLeft: expect.anything() }),
+ }),
+ ])
+ expect(right.patches).toEqual([{ op: 'add', address: 'b', prop: 'paddingRight', value: 30 }])
+ })
+
+ it('is not the route for anything but the outer box and the text colour', () => {
+ const { doc, scope } = use(PLAIN)
+ for (const prop of ['x', 'width', 'visible', 'component', 'props', 'layoutMode', 'fontSize'])
+ expect(instanceBoxEdit(doc, 'b', prop, 1, { scope }), prop).toBeNull()
+ })
+
+ it('is null for an address that is not an instance', () => {
+ const { doc, scope } = use(`${PLAIN}\n `)
+ expect(instanceBoxEdit(doc, 'f', 'fills', RED, { scope })).toBeNull()
+ expect(instanceBoxEdit(doc, 'ghost', 'fills', RED, { scope })).toBeNull()
+ })
+
+ it('still patches without a definition, and previews nothing', () => {
+ const { doc, scope } = use(` `)
+ const edit = instanceBoxEdit(doc, 'b', 'fills', RED, { scope })!
+ expect(edit.patches).toEqual([{ op: 'add', address: 'b', prop: 'fills', value: RED }])
+ expect(edit.preview).toEqual([])
+ })
+
+ /**
+ * The promise the preview makes: the built scene with the preview laid on
+ * it is, where the edit lands, the scene a rebuild of the patched file draws.
+ */
+ it('previews what a rebuild of the patched file draws', () => {
+ const { doc, scope } = use(PLAIN)
+ for (const [prop, value] of [
+ ['fills', RED],
+ ['textFills', RED],
+ ['cornerRadius', 4],
+ ['strokes', [solid(0.2, 0.2, 0.2)]],
+ ] as const) {
+ const live = toSceneGraph(doc, scope)
+ const edit = instanceBoxEdit(doc, 'b', prop, value as JsonValue, {
+ scope,
+ textTargets: live.textTargets.get('b'),
+ })!
+ for (const update of edit.preview) live.graph.updateNode(update.id, update.props)
+ const patched = parseOrThrow(applyPatches(doc.source, edit.patches).source)
+ const rebuilt = toSceneGraph(patched, scope)
+ for (const id of ['b', 'b#root', 'b#root/label']) {
+ const now = live.graph.getNode(id)!
+ const then = rebuilt.graph.getNode(id)!
+ for (const field of ['fills', 'strokes', 'cornerRadius'] as const)
+ expect(now[field], `${prop}: ${id}.${field}`).toEqual(then[field])
+ }
+ }
+ })
+})
+
+/**
+ * A use inside a slot fill is drawn at the definition's position, not at the
+ * address the file gives it (ADR 0007 §3): Panel's slot sits inside its
+ * frame, so the `b` written as `p#body/b` is drawn as `p#frame/body/b`. The
+ * patch names the address; the preview has to name the scene's nodes.
+ */
+describe('instanceBoxEdit on a use inside a slot fill', () => {
+ const SLOTTED = `
+
+
+
+ `
+
+ it('patches the address and previews under the scene id', () => {
+ const { doc, scope } = use(SLOTTED)
+ const live = toSceneGraph(doc, scope)
+ const sceneId = live.addresses.sceneIdOf('p#body/b')!
+ expect(sceneId).toBe('p#frame/body/b')
+ const edit = instanceBoxEdit(doc, 'p#body/b', 'fills', RED, { scope, sceneId })!
+ expect(edit.patches).toEqual([{ op: 'add', address: 'p#body/b', prop: 'fills', value: RED }])
+ expect(edit.preview.map((update) => update.id)).toEqual(['p#frame/body/b/root'])
+ // The next step of the gesture previews from where this one drew.
+ expect(edit.next.address).toBe(sceneId)
+ const next = instanceBoxEdit(doc, 'p#body/b', 'fills', undefined, {
+ scope,
+ sceneId,
+ drawn: edit.next,
+ })!
+ expect(next.preview.map((update) => update.id)).toEqual(['p#frame/body/b/root'])
+ })
+
+ it('previews what a rebuild of the patched file draws', () => {
+ const { doc, scope } = use(SLOTTED)
+ for (const [prop, value] of [
+ ['fills', RED],
+ ['textFills', RED],
+ ['cornerRadius', 4],
+ ] as const) {
+ const live = toSceneGraph(doc, scope)
+ const sceneId = live.addresses.sceneIdOf('p#body/b')!
+ const edit = instanceBoxEdit(doc, 'p#body/b', prop, value as JsonValue, {
+ scope,
+ sceneId,
+ textTargets: live.textTargets.get(sceneId),
+ })!
+ expect(edit.preview.length, prop).toBeGreaterThan(0)
+ for (const update of edit.preview) {
+ expect(live.graph.getNode(update.id), `${prop}: ${update.id}`).toBeDefined()
+ live.graph.updateNode(update.id, update.props)
+ }
+ const rebuilt = toSceneGraph(
+ parseOrThrow(applyPatches(doc.source, edit.patches).source),
+ scope,
+ )
+ for (const id of [sceneId, `${sceneId}/root`, `${sceneId}/root/label`]) {
+ const now = live.graph.getNode(id)!
+ const then = rebuilt.graph.getNode(id)!
+ for (const field of ['fills', 'strokes', 'cornerRadius'] as const)
+ expect(now[field], `${prop}: ${id}.${field}`).toEqual(then[field])
+ }
+ }
+ })
+})
+
+describe('resetting overrides', () => {
+ const STYLED = ` `
+
+ it('resetPatches removes each listed prop the instance states, and nothing it does not', () => {
+ const instance = resolve(use(STYLED).doc.tree, 'b')!
+ expect(resetPatches(instance, ['fills', 'strokes', 'layoutMode'])).toEqual([
+ { op: 'remove', address: 'b', prop: 'fills' },
+ { op: 'remove', address: 'b', prop: 'layoutMode' },
+ ])
+ expect(resetPatches(instance, [])).toEqual([])
+ })
+
+ it('a section resets the overrides its rows show', () => {
+ expect(sectionResetProps('stroke')).toEqual([
+ 'strokes',
+ 'strokeWeight',
+ 'strokeAlign',
+ 'dashPattern',
+ 'strokeTopWeight',
+ 'strokeRightWeight',
+ 'strokeBottomWeight',
+ 'strokeLeftWeight',
+ ])
+ expect(sectionResetProps('fill')).toEqual(['fills'])
+ expect(sectionResetProps('textColor')).toEqual(['textFills'])
+ expect(sectionResetProps('effects')).toEqual(['effects'])
+ expect(sectionResetProps('appearance')).toEqual([
+ 'cornerRadius',
+ 'topLeftRadius',
+ 'topRightRadius',
+ 'bottomRightRadius',
+ 'bottomLeftRadius',
+ 'cornerSmoothing',
+ 'opacity',
+ ])
+ // Layout's are the stated size and the padding: never min/max, never position.
+ expect(sectionResetProps('layout')).toEqual([
+ 'paddingTop',
+ 'paddingRight',
+ 'paddingBottom',
+ 'paddingLeft',
+ 'width',
+ 'height',
+ ])
+ expect(sectionResetProps('position')).toEqual([])
+ expect(sectionResetProps('typography')).toEqual([])
+ })
+
+ it('resetting the stroke section removes every stroke prop the instance states', () => {
+ const { doc } = use(` `)
+ const patches = resetPatches(resolve(doc.tree, 'b')!, sectionResetProps('stroke'))
+ expect(patches.map((patch) => patch.op === 'remove' && patch.prop)).toEqual([
+ 'strokes',
+ 'strokeWeight',
+ 'strokeAlign',
+ 'dashPattern',
+ 'strokeTopWeight',
+ 'strokeRightWeight',
+ 'strokeBottomWeight',
+ 'strokeLeftWeight',
+ ])
+ })
+
+ it('reset all takes the box, the text colour and the stated size, in one list', () => {
+ const instance = resolve(use(STYLED).doc.tree, 'b')!
+ const patches = resetAllPatches(instance)
+ expect(patches).toEqual(
+ ['fills', 'cornerRadius', 'paddingLeft', 'textFills', 'width', 'height'].map((prop) => ({
+ op: 'remove',
+ address: 'b',
+ prop,
+ })),
+ )
+ })
+
+ it('reset all never touches position, flow, props, modes, slot fills or the locked inside', () => {
+ const instance = resolve(use(STYLED).doc.tree, 'b')!
+ const touched = resetAllPatches(instance).map((patch) => patch.op === 'remove' && patch.prop)
+ for (const prop of ['x', 'y', 'props', 'modes', 'layoutPositioning', 'layoutMode', 'component'])
+ expect(touched, prop).not.toContain(prop)
+ for (const prop of touched) expect(OVERRIDE_PROPS).toContain(prop)
+
+ const { doc } =
+ use(`
+
+
+
+ `)
+ expect(resetAllPatches(resolve(doc.tree, 'w')!)).toEqual([
+ { op: 'remove', address: 'w', prop: 'fills' },
+ ])
+ })
+
+ it('counts the overrides a use states', () => {
+ expect(overrideCount(resolve(use(STYLED).doc.tree, 'b')!)).toBe(6)
+ expect(overrideCount(resolve(use(PLAIN).doc.tree, 'b')!)).toBe(0)
+ })
+})
diff --git a/packages/viewer/test/locked-layout-row.test.ts b/packages/viewer/test/locked-layout-row.test.ts
new file mode 100644
index 0000000..0eb3ed1
--- /dev/null
+++ b/packages/viewer/test/locked-layout-row.test.ts
@@ -0,0 +1,86 @@
+import { enableAutoUnmount, mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it } from 'vitest'
+import LockedLayoutRow from '../src/LockedLayoutRow.vue'
+import { lockedLayoutSummary } from '../src/override-state'
+
+/**
+ * An instance's inside is its component's business (ADR 0018 §1): direction,
+ * gap, alignment, wrap and clipping read as one line, with a way to the
+ * component instead of controls that would write nothing.
+ */
+enableAutoUnmount(afterEach)
+
+const BUTTON1 = {
+ layoutMode: 'HORIZONTAL',
+ itemSpacing: 8,
+ primaryAxisAlignItems: 'CENTER',
+ counterAxisAlignItems: 'CENTER',
+ layoutWrap: 'NO_WRAP',
+}
+
+describe('LockedLayoutRow', () => {
+ it('renders the layout as one read-only line with a lock', () => {
+ const wrapper = mount(LockedLayoutRow, { props: { layout: BUTTON1, component: 'Button1' } })
+ expect(wrapper.get('.locked-summary').text()).toBe('Row · Gap 8 · center/center · No wrap')
+ expect(wrapper.get('.locked-from').text()).toBe('— from Button1')
+ expect(wrapper.find('[data-icon="lock"]').exists()).toBe(true)
+ expect(wrapper.find('input, select, textarea').exists()).toBe(false)
+ expect(wrapper.findAll('button')).toHaveLength(1)
+ })
+
+ it('emits openComponent with the component’s name', async () => {
+ const wrapper = mount(LockedLayoutRow, { props: { layout: BUTTON1, component: 'Button1' } })
+ await wrapper.get('button').trigger('click')
+ expect(wrapper.emitted('openComponent')).toEqual([['Button1']])
+ })
+})
+
+describe('lockedLayoutSummary', () => {
+ it('names a column’s ends and leaves out wrap, which only a row has', () => {
+ expect(
+ lockedLayoutSummary({
+ layoutMode: 'VERTICAL',
+ itemSpacing: 4,
+ primaryAxisAlignItems: 'MIN',
+ counterAxisAlignItems: 'STRETCH',
+ }),
+ ).toBe('Column · Gap 4 · start/stretch')
+ })
+
+ it('fills what the component leaves unset with the engine’s defaults', () => {
+ expect(lockedLayoutSummary({ layoutMode: 'HORIZONTAL' })).toBe(
+ 'Row · Gap 0 · start/start · No wrap',
+ )
+ expect(
+ lockedLayoutSummary({
+ layoutMode: 'HORIZONTAL',
+ primaryAxisAlignItems: 'SPACE_BETWEEN',
+ counterAxisAlignItems: 'MAX',
+ layoutWrap: 'WRAP',
+ }),
+ ).toBe('Row · Gap 0 · space between/end · Wrap')
+ })
+
+ it('shows a token-bound gap by its name, the way a token pill does', () => {
+ expect(lockedLayoutSummary({ ...BUTTON1, itemSpacing: '{space#sm}' })).toBe(
+ 'Row · Gap sm · center/center · No wrap',
+ )
+ expect(lockedLayoutSummary({ ...BUTTON1, itemSpacing: '0.5rem' })).toBe(
+ 'Row · Gap 0.5rem · center/center · No wrap',
+ )
+ })
+
+ it('says only Free for a component that lays nothing out', () => {
+ expect(lockedLayoutSummary({ layoutMode: 'NONE', itemSpacing: 8 })).toBe('Free')
+ expect(lockedLayoutSummary({})).toBe('Free')
+ })
+
+ it('adds clipping when the component clips', () => {
+ expect(lockedLayoutSummary({ ...BUTTON1, clipsContent: true })).toBe(
+ 'Row · Gap 8 · center/center · No wrap · Clips content',
+ )
+ expect(lockedLayoutSummary({ layoutMode: 'NONE', clipsContent: true })).toBe(
+ 'Free · Clips content',
+ )
+ })
+})
diff --git a/packages/viewer/test/override-mark.test.ts b/packages/viewer/test/override-mark.test.ts
new file mode 100644
index 0000000..9829d08
--- /dev/null
+++ b/packages/viewer/test/override-mark.test.ts
@@ -0,0 +1,264 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { enableAutoUnmount, mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { JsonValue } from '@uidx/format'
+import OverrideMark from '../src/OverrideMark.vue'
+import PaintStackField from '../src/PaintStackField.vue'
+import PropertyField from '../src/PropertyField.vue'
+import type { EditableProp } from '../src/editable'
+import {
+ inheritedText,
+ overrideState,
+ resetTitle,
+ shadowNote,
+ type OverrideState,
+} from '../src/override-state'
+
+/**
+ * The mark an instance row wears beside its caption (ADR 0018 §7): nothing
+ * while the component's value shows, a dot and ↺ once the use states one, and
+ * a state chip when a state the instance's own props select wins the property.
+ */
+enableAutoUnmount(afterEach)
+
+const mark = (props: Record = {}) =>
+ mount(OverrideMark, {
+ props: {
+ state: 'set' as OverrideState,
+ component: 'Button1',
+ inherited: '12',
+ shadowedBy: null,
+ label: 'Weight',
+ writable: true,
+ ...props,
+ },
+ })
+
+/**
+ * The declarations every rule naming `selector` carries in a component's
+ * `
diff --git a/packages/viewer/src/ConnectSection.vue b/packages/viewer/src/ConnectSection.vue
index 772a750..7778a1d 100644
--- a/packages/viewer/src/ConnectSection.vue
+++ b/packages/viewer/src/ConnectSection.vue
@@ -1,18 +1,43 @@
+
+
-
- {{ error }}
-
+
-
-
-
-
- {{ name }}
- →
- <{{ names.tag || implementsTag }}>
- {{ react.export || name }} from {{ react.from }}
- not connected to code yet
-
-
{{ counts }}
-
-
-
-
- Headless element
- in {{ name }}.uidx
-
-
- The web component or headless element that gives {{ name }} its behaviour and
- accessibility. Generated code renders it.
-
-
- None
-
- {{ implementsTag }} (not in the library)
-
-
- {{ root.tag }}
-
-
-
- {{ element.description }}
-
- {{ partsBound }} of {{ partsDeclared.length }} parts are bound to layers.
- Bind parts
-
-
-
-
-
-
- Names in the library
- uidx.json
-
-
- Only where the library spells something differently from the identity. Empty fields keep
- the identity's name.
-
-
- Tag
-
-
-
- {{ prop.name }}
-
-
-
-
{{ event.name }}
-
+
+
+ :value="implementsTag"
+ :disabled="!writable"
+ aria-label="Implements"
+ @change="chooseElement(($event.target as HTMLSelectElement).value)"
+ >
+
None
+
+
+ {{ COPY.notInLibrary(implementsTag) }}
+
+
+ {{ entry.tag }}
+
+
+
+ {{ libraryFailed ? COPY.libraryUnavailable(implementsTag) : implementsTag }}
+
+
+
+
+
+ {{ COPY.noLibrary }}
+
+ {{ ACTION.chooseLibrary.label }}
+
+
+
+
+
+ {{ COPY.tagMissing(implementsTag) }}
+
+
+
+ {{ moreOpen ? fullDescription : summary }}
+
+
+ {{ moreOpen ? 'Less' : 'More' }}
+
+
+
+ {{ partsLine }}
+
+ Bind parts
+
+
-
-
-
-
-
-
-
Choose the library below to rename against it.
-
+
-
-
+
+
- Select a component, or a layer inside one, to connect it to code.
-
-
- Project
- uidx.json · every component
-
-
- Headless library
-
- {{ config.headless.manifest }}
- · {{ library.roots.length }} elements
-
- No library yet: the custom-elements.json your components implement.
-
-
- From your dependencies…
-
- {{ candidate.package }}
-
-
-
-
- {{ config.headless ? 'Change' : 'Use' }}
-
-
-
-
- How the library spells things
-
- {{ entry.label }}
+
+
+
+
+
+ {{ libraryName }}
+ {{ elementCount }}
+
+ {{ editLibrary ? 'Cancel' : 'Change…' }}
+
+
+
-
- {{ label }}
+ From your dependencies…
+
+ {{ candidate.package }}
-
-
+
+
+ Use library
+
+
+
+
+
+
+
+ {{ entry.label }}
+
+
+ {{ label }}
+
+
+
+
+
-
Output
-
- Folder
-
-
-
-
+
+
+
diff --git a/packages/viewer/src/ContractSection.vue b/packages/viewer/src/ContractSection.vue
index 84828ea..2263d81 100644
--- a/packages/viewer/src/ContractSection.vue
+++ b/packages/viewer/src/ContractSection.vue
@@ -1,5 +1,14 @@
+
+
-
-
-
-
- Select a component to declare what its instances can change — properties, slots and states —
- or a layer inside one to name the part it draws.
-
-
+
+
+
-
- Properties
- of {{ view.component.name }}
-
- Fill from library
-
-
-
-
- The contract already declares everything the element offers.
-
-
-
- {{ group.label }}
-
-
- {{ offer.name }}
-
- {{ offer.library }}
-
- {{ offer.type }}
-
-
-
-
- Cancel
+
+
- Add {{ ticked.size }}
+ Fill from library
-
-
-
- Nothing declared yet. A property is what an instance can change without reaching inside; a
- slot is where it can put its own content. Add one below.
-
+
+
+
+
+ {{ ticked.size }} of {{ offering.length }} selected ·
+ All
+ ·
+ None
+
+
+
+ {{ group.label }}
+
+
+ {{ offer.name }}
+ {{
+ offer.type
+ }}
+
+
+
+
+ Cancel
+
+ Add {{ ticked.size }}
+
+
+
+
+ Nothing new in {{ view.element?.tag }}
+
+ Close
+
+
+
+
+ No properties yet. Add one below{{ view.element ? ', or fill from the library' : '' }}.
+
-
-
-
-
+
+
- {{ prop.name }}
- state
- ?
-
- {{ prop.type }}
-
- ×
-
-
-
-
+
-
-
-
-
+
+
- on {{ event.name }}
- ?
-
- {{ event.detail ?? 'event' }}
-
- ×
-
-
-
-
- Description
-
-
-
- Detail type
- slot {{ slot.name }}
+ ?
+
+ {{
+ slot.accepts ? `accepts ${slot.accepts}` : 'slot'
+ }}
+
-
-
-
-
-
-
-
-
- slot {{ slot.name }}
- ?
-
- {{ slot.accepts ? `accepts ${slot.accepts}` : 'slot' }}
-
- ×
-
-
-
-
+
-
-
-
-
+
+
- {{ state.name }}
- element
-
- state
-
- ×
-
-
-
-
- Description
- {{ state.name }}
+ element
+
+ state
+
-
-
-
-
-
-
- part {{ part.name }}
- ?
-
- part
-
- ×
-
-
-
-
- Description
-
-
-
-
+ ×
+
+
+
+
+ Description
+
+
+
+
+
+
+
+ part {{ part.name }}
+ ?
+
+ part
+
+ ×
+
+
+
+
+ Description
+
+
+
+
-
-
+
+
+
+
+
+
+ {{ state.label }}
+
+
+
-
-
- Styles
- {{ styleRows.length }}
-
-
- What each state and variant changes. Change a look by selecting it on the canvas.
-
+
:aria-expanded="openRow === rowKey(row.keys)"
@click="openRow = openRow === rowKey(row.keys) ? null : rowKey(row.keys)"
>
- {{ row.label }}
+ {{ row.label }}
{{
@@ -1097,18 +1244,25 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>
-
+
-
-
+
-
{{ stray.name }}
+
+
+ {{ stray.name }}
+
- {{ slot.name }}
+ {{ slot.name }}
{{ slot.provided.repeat ? 'Slot, one per item' : 'Slot in tree' }}
- No slot in the tree yet
+ {{ COPY.notDrawn }}
-
+
-
- Accessibility and forms
Role
@change="setComposes(($event.target as HTMLInputElement).value)"
/>
-
+
-
-
- Code binding
- {{
- view.implementsValue ? view.implementsValue : 'optional · for developers'
- }}
-
-
- Implements
- {{ view.component.name }}
-
-
+
+
Element
+
>
None
- {{ option.tag }}{{ option.known ? '' : ' · not in library' }}
+ {{ elementLabel(option) }}
-
- Rendered as {{ boundTag }} — uidx.json binds this component to it.
+
+ {{ COPY.connectLibrary }}
+
+ {{ ACTION.openConnect.label }}
+
-
-
- Attributes: {{ view.element.attributes.join(', ') }}.
-
-
- Events: {{ view.element.events.join(', ') }}.
-
+
+ Library tag {{ boundTag }} · set in
+ uidx.json
+ {{ members.text }}
-
+
Parts
- {{ boundCount }} of {{ view.parts.length }} bound
+ {{
+ COPY.partsBound(boundCount, view.parts.length)
+ }}
Bind by name
-
-
- Choose an element above to see the parts it offers.
-
-
- “{{ view.implementsValue }}” is not in the library, and the contract declares no parts.
-
- “{{ view.implementsValue }}” has no parts to bind.
-
+ {{ COPY.partsPaused }}
+
+ {{ COPY.chooseElement }}
+ {{ COPY.noLibrary }}
+
+
+ {{ COPY.tagMissing(view.implementsValue) }}
+
+ {{ COPY.noParts(view.implementsValue) }}
+
+ Bound from the component's side (Figma declares a property here) — each
+ declared part is a row, and an unbound row is a picker over the layers
+ that could draw it. A bound row names its layer and jumps to it.
+ -->
@@ -1361,8 +1514,13 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>
: 'declared by the contract; the library does not know it')
"
>
- {{ row.name }}
- ?
+ {{ row.name }}
+ ?
value=""
:disabled="!writable || !view.candidates.length"
:aria-label="`Bind ${row.name}`"
+ :title="view.candidates.length ? undefined : 'No unbound layer is left to draw it'"
@change="bind(row.name, ($event.target as HTMLSelectElement).value)"
>
@@ -1415,131 +1574,153 @@ const isState = (prop: { type: string; visual: boolean }): boolean =>
-
- “{{ stray.name }}” is bound to “{{ stray.part }}”, which nothing declares.
-
- Show it
-
-
-
+
+
+
+ {{ COPY.strayPart(stray.name, stray.part) }}
+
+ Show
+
+
+
+
-
-
- Part
- of {{ view.component.name }}
-
-
- {{ view.component.name }} implements no element yet, so there is no part for this layer to
- draw.
-
+
+
+
Select {{ view.component.name }}
-
+
+
{{ COPY.noImplements(view.component.name) }}
-
-
Draws
-
- Nothing — design only
-
+
+ Draws
+
- {{ option.name }}{{ option.kind === 'shadow' ? ' · shadow' : ''
- }}{{ option.takenBy ? ` · bound to ${option.takenBy}` : '' }}
-
-
-
- ↺
-
-
-
-
+ Nothing — design only
+
+ {{ option.name }}{{ option.kind === 'shadow' ? ' · shadow' : ''
+ }}{{ option.takenBy ? ` · bound to ${option.takenBy}` : '' }}
+
+
+
+ ↺
+
+
+
+
+
+ {{ COPY.notAPart(partTag) }}
+
+
+
-
+
+
+
-
- A slot outside a component is a hole nothing declares. Make the frame around it a component
- to give it a contract.
-
-
-
- State
- {{ view.state }}
-
-
- This is {{ view.component.name }} in the {{ view.state }} state, drawn from its styles
- table. Bindings live on the base layer.
-
+
+
+ Bindings live on the base layer.
+
Select {{ view.base.name }}
-
+
-
- Instance
- of {{ view.definition.name }}
-
-
- This document has no component called “{{ view.node.attrs.component?.value }}”.
-
-
- An instance renders its component's contract. Inside a component it also receives what the
- component or an enclosing repeat hands it.
-
-
-
-
+
+
+ This document has no component called “{{ view.node.attrs.component?.value }}”.
+
+
+
+
+
+
+ Select {{ view.definition.name }}
+
+
- {{ row.prop }}
+
+ {{ row.prop }}
+
@change="chooseReceives(row.prop, ($event.target as HTMLSelectElement).value)"
>
- {{ row.from && !row.explicit ? '{' + row.from + '} · inferred' : 'Nothing' }}
+ {{ row.from && !row.explicit ? `${row.from} · inferred` : 'Nothing' }}
- {{ '{' + option + '}' }}
+ {{ option }}
- {{ row.from ? '{' + row.from + '}' : 'nothing in scope' }}
+ {{ row.from ?? 'nothing in scope' }}
-
-
- Select {{ view.definition.name }}
-
- to change what it declares.
+
+ {{ view.definition.name }} has no properties.
-
-
- {{ view.definition.name }} declares no contract props.
-
- Select it
-
- to add some.
-
+
-
-
- Only layers inside a component draw a part. Move this layer into a component, or make one
- from it.
-
-
+
-
-
-
-
- {{ libraryError }}
-
-
Library: {{ library.path }} · {{ library.roots.length }} elements
-
- Code
-
- {{ codegen.running ? 'Generating…' : `Generate → ${codegen.out}` }}
-
-
-
-
- {{ codegen.notice }}
-
+ class="bleed repeat-wrap"
+ >
+
-
-
- Code library
- optional · for developers
-
-
- Connect a component library so the code binding can pick elements and parts from a list.
-
-
- From a dependency
-
- Choose…
- {{ c.package }}
-
-
-
-
- Or a path
-
-
- ✓
-
-
- Written into uidx.json as "headless", relative to it.
-
diff --git a/packages/viewer/src/InspectorEmpty.vue b/packages/viewer/src/InspectorEmpty.vue
new file mode 100644
index 0000000..c617f67
--- /dev/null
+++ b/packages/viewer/src/InspectorEmpty.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
{{ title }}
+
{{ hint }}
+
+ {{ action.label }}
+
+
+ › {{ about.label }}
+ {{ about.text }}
+
+
+
+
+
diff --git a/packages/viewer/src/InspectorSection.vue b/packages/viewer/src/InspectorSection.vue
new file mode 100644
index 0000000..68304f7
--- /dev/null
+++ b/packages/viewer/src/InspectorSection.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+ {{ title }}
+ {{ meta }}
+
+
+
+
+
+ ›
+
+ {{ info }}
+
+
+
+
+ {{ title }}
+ {{ meta }}
+
+
+
+
+
+
+ {{ info }}
+
+
+
+
+
diff --git a/packages/viewer/src/InspectorStatus.vue b/packages/viewer/src/InspectorStatus.vue
new file mode 100644
index 0000000..21413ac
--- /dev/null
+++ b/packages/viewer/src/InspectorStatus.vue
@@ -0,0 +1,368 @@
+
+
+
+
+
+
+
+ {{ top.title }}
+ {{ countText }}
+
+
+ {{ top.actions[0]!.label }}
+
+ ›
+
+
+
+
+
+
+
+
+
+
+
{{ item.title }}
+
{{ item.detail }}
+
{{
+ shortPath(item.path)
+ }}
+
+
+ {{ row.label }}
+
+ {{ row.action.label }}
+
+ {{ row.meta }}
+
+
+
+ +{{ hidden(item) }} more
+
+
+
+
+
+ {{ action.label }}
+
+
+ Details ›
+ {{ item.raw }}
+ Copy
+
+
+
+
+
+
+
+
diff --git a/packages/viewer/src/PropertiesPane.vue b/packages/viewer/src/PropertiesPane.vue
index 29357d6..4cccea2 100644
--- a/packages/viewer/src/PropertiesPane.vue
+++ b/packages/viewer/src/PropertiesPane.vue
@@ -15,17 +15,52 @@ import { LENGTH_FIELD_CONTEXT } from './length-field-context'
import ContractSection from './ContractSection.vue'
import CodeSection from './CodeSection.vue'
import ConnectSection from './ConnectSection.vue'
-import { connection, connectionError, connectionGeneration, saveConnection } from './headless'
+import InspectorEmpty from './InspectorEmpty.vue'
+import InspectorStatus from './InspectorStatus.vue'
import {
- contractIssues,
+ connection,
+ connectionError,
+ connectionErrorKey,
+ connectionGeneration,
+ headlessFailure,
+ loadConnection,
+ refreshHeadless,
+ saveConnection,
+} from './headless'
+import {
+ contractProblems,
contractView,
fieldBindingCandidates,
textBindingCandidates,
} from './contract-edits'
import { describe as describeDerived } from './derived-edits'
import type { CodegenState, HeadlessCandidate, HeadlessLibrary } from './headless'
-
-import { computed, ref, shallowRef, watch } from 'vue'
+import {
+ ABOUT,
+ ACTION,
+ COPY,
+ EMPTY,
+ classifyFailure,
+ configLoadItem,
+ contractProblemItem,
+ fieldErrorText,
+ instanceEmpty,
+ layersSelected,
+ libraryItem,
+ problems,
+ saveItem,
+ sortByTone,
+ writeItem,
+ type EmptyCopy,
+ type Face,
+ type Failure,
+ type MessageAction,
+ type StatusItem,
+ type Tone,
+} from './inspector-messages'
+import { resolveSubject } from './inspector-subject'
+
+import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { vectorEndpoints } from '@open-pencil/core/vector'
import {
PropertySectionContent,
@@ -156,13 +191,13 @@ import type { VariableCandidate } from './variable-binding'
const props = defineProps<{
doc: UidxDocument | null
selection?: string[]
- /** The headless library the Contract tab offers choices from (ADR 0013 §3). */
+ /** The headless library the Contract and Connect tabs offer choices from (ADR 0013 §3). */
headless?: HeadlessLibrary | null
- /** Why the library could not be read, shown in the Contract tab. */
+ /** Why the library could not be read, as the server said it; the tabs' status line words it. */
headlessError?: string
- /** Libraries the project's dependencies ship, offered while none is named. */
+ /** Libraries the project's dependencies ship, offered in Connect while none is named. */
headlessCandidates?: HeadlessCandidate[]
- /** Where generated code goes and how the last run went, for the Contract tab's button. */
+ /** Where generated code goes and how the last run went, for the Code tab's Write code. */
codegen?: CodegenState
/** Token address -> literal, so a bound row can show what it resolves to. */
tokens?: Map
@@ -285,62 +320,297 @@ const emit = defineEmits<{
previewSample: [index: number]
/** 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. */
+ /** Connect › Project chose a headless library; the shell has the server write it. */
chooseLibrary: [path: string]
- /** The Contract tab asked for the code targets to be rendered. */
+ /** The Code tab's Write code: render every component into the output folder. */
generateCode: []
}>()
/**
- * Which face the inspector shows: Design, the scene properties, or Contract,
- * the bindings to the code render (ADR 0013 §3). Two tabs rather than one
- * more section, because they answer different questions about the same layer
- * — what it looks like, and what it is to the headless library — and Figma's
- * Design / Dev split is the precedent authors already know. Pane-local: the
- * choice is about how to look, not what is open.
+ * Which face the inspector shows. Design is the scene properties; Contract,
+ * Connect and Code are what the layer is to code — what its component
+ * declares, how it is wired to a library, and what the generator makes of it
+ * (ADR 0013 §3). Faces rather than more sections, because they answer
+ * different questions about the same layer, and Figma's Design / Dev split is
+ * the precedent authors already know. Pane-local: the choice is about how to
+ * look, not what is open.
*/
-const face = ref<'design' | 'contract' | 'connect' | 'code'>('design')
+const face = ref<'design' | Face>('design')
+
+/** The tab strip, in order. */
+const FACES = [
+ { id: 'design', label: 'Design' },
+ { id: 'contract', label: 'Contract' },
+ { id: 'connect', label: 'Connect' },
+ { id: 'code', label: 'Code' },
+] as const
+const TABS: readonly Face[] = ['contract', 'connect', 'code']
-/** Unbound or stray parts on the selected component, for the tab's badge. */
-const contractIssueCount = computed(() =>
- contractIssues(
- contractView(props.doc, active.value, props.headless ?? null, props.models, props.components),
- ),
+/**
+ * What Contract, Connect and Code are about, answered once for all three: an
+ * instance of PersonRow is PersonRow on every tab, and a layer inside List is
+ * List's (`inspector-subject`).
+ */
+const subject = computed(() =>
+ resolveSubject(props.doc, active.value, props.selection?.length ?? 0, props.components),
)
-function onHover(prop: string | null): void {
- const address = active.value?.address
- if (address) emit('hover', address, prop)
+/** The component the Code tab renders, whichever page declares it. */
+const codeComponent = computed(() =>
+ ['component', 'inside', 'instance'].includes(subject.value.kind) ? subject.value.name : null,
+)
+
+/** The component the Connect tab edits: only one this file declares. */
+const connectComponent = computed(() => subject.value.local)
+
+/**
+ * The file that declares the Code tab's component, so its diagnostics split
+ * into its own and those of the files it uses. This page when it declares
+ * the component; otherwise whichever loaded page does.
+ */
+const codeFile = computed(() => {
+ const { local, definition } = subject.value
+ if (local) return props.file
+ if (!definition) return undefined
+ for (const [file, page] of props.pages ?? [])
+ if (
+ page.tree.children.some(
+ (child) => child.element === 'Component' && child.name === definition.name,
+ )
+ )
+ return file
+ return undefined
+})
+
+/** Moves with every edit and every saved uidx.json change: what the Code tab renders from. */
+const codeStamp = computed(() => `${props.doc?.sourceHash ?? ''}:${connectionGeneration.value}`)
+
+/**
+ * Why the headless library cannot be read, by class: the shell's own record
+ * of the fetch, or else the `headlessError` prop classified here, so a pane
+ * handed only the prop words it alike.
+ */
+const libraryFailure = computed(
+ () =>
+ headlessFailure.value ?? (props.headlessError ? classifyFailure(props.headlessError) : null),
+)
+
+/**
+ * Stray part bindings and bad part values on the selection: what codegen
+ * refuses, so the Contract tab's one warning. Unbound parts are progress, not
+ * problems; and nothing is checked while the library cannot be read.
+ */
+const contractProblemCount = computed(() =>
+ libraryFailure.value
+ ? 0
+ : contractProblems(
+ contractView(
+ props.doc,
+ active.value,
+ props.headless ?? null,
+ props.models,
+ props.components,
+ ),
+ props.headless ?? null,
+ ),
+)
+
+/** Whether each tab's status line is expanded, kept per tab while the pane lives. */
+const statusOpen = reactive>({ contract: false, connect: false, code: false })
+/** An action asked Connect to open Project at a field; `n` makes a repeated ask new. */
+const projectFocus = ref<{ target: 'library' | 'output'; n: number } | null>(null)
+/** An action asked Contract to show the parts. */
+const contractFocus = ref<{ target: 'parts'; n: number } | null>(null)
+/** Moves on the Code tab's Retry, so it renders again. */
+const codeReload = ref(0)
+
+/** What the Code tab last found for a component. */
+interface CodeReport {
+ component: string
+ state: 'live' | 'updating' | 'blocked' | 'failed'
+ items: StatusItem[]
}
+const codeReport = shallowRef(null)
-/** Figma's "on this page" row: computed once for every picker in the panel. */
-const swatches = computed(() => (props.doc ? documentSwatches(props.doc.tree) : []))
+/**
+ * The Code tab's report. A re-render of the same component keeps the last
+ * findings until it lands, so the status line does not blink on every edit.
+ */
+function onCodeStatus(report: CodeReport): void {
+ const last = codeReport.value
+ if (report.state === 'updating' && last?.component === report.component) return
+ codeReport.value = report
+}
+
+/*
+ * The Code tab only reports while it shows. An edit made on another face may
+ * have fixed what it found, so the report is dropped rather than left to mark
+ * the tab with a fault that may be gone.
+ */
+watch(codeStamp, () => {
+ if (face.value !== 'code') codeReport.value = null
+})
-/** The `` an instance names, from the document-wide index (F7). */
/**
- * The component the Code tab shows: the selection itself, the component an
- * instance uses, or the component the selected layer sits inside.
+ * Every tab's status line, from one set of facts so the three tabs cannot
+ * contradict each other: the library on all three, uidx.json on the two that
+ * read it, Contract's stray parts, and Code's own render and last write. A
+ * dead socket is left to the app banner and the lock chip rather than said
+ * three more times.
*/
-const codeComponent = computed(() => {
- const node = active.value
- if (!node || !props.doc) return null
- if (node.element === 'Component') return node.name
- const used = node.attrs.component?.value
- if (node.element === 'Instance' && typeof used === 'string') return used
- const entity = node.address.split('#')[0]!
- const top = props.doc.tree.children.find((child) => child.address === entity)
- return top?.element === 'Component' ? top.name : null
+const tabItems = computed>(() => {
+ const items: Record = { contract: [], connect: [], code: [] }
+ const offline = props.writable === false
+ const library = libraryFailure.value
+ if (library && !(offline && library.code === 'network'))
+ for (const tab of TABS)
+ items[tab].push(libraryItem(library, tab, connection.value.headless?.manifest ?? null))
+ const raw = connectionError.value
+ if (raw && !connectionErrorKey.value) {
+ const load = classifyFailure(raw)
+ const item = configLoadItem(load)
+ // A dead server fails both fetches; say it once.
+ const said = items.connect.some((other) => other.title === item.title)
+ if (!said && !(offline && load.code === 'network')) {
+ items.connect.push(item)
+ items.code.push(item)
+ }
+ }
+ // Connect shows a refused change under its field; the naming profile has none.
+ if (raw && connectionErrorKey.value === 'profile') items.connect.push(saveItem(raw))
+ if (contractProblemCount.value > 0)
+ items.contract.push(contractProblemItem(contractProblemCount.value))
+ const report = codeReport.value
+ if (report && report.component === codeComponent.value) items.code.push(...report.items)
+ const written = writeItem(props.codegen?.result)
+ if (written) items.code.push(written)
+ return items
})
-/** The component the Connect tab edits: one declared in this file, holding the selection. */
-const connectComponent = computed(() => {
- const node = active.value
- if (!node || !props.doc) return null
- const entity = node.address.split('#')[0]!
- const top = props.doc.tree.children.find((child) => child.address === entity)
- return top?.element === 'Component' ? top : null
+/** A tab's 6px dot: its tone, the status title it stands for, and words for a screen reader. */
+interface TabDot {
+ tone: Tone
+ title: string
+ label: string
+}
+
+/**
+ * A dot only for a real problem. Contract marks stray parts, never unbound
+ * ones, which are progress and show under Parts; Connect marks a library or
+ * uidx.json it cannot read; Code marks a render or a write that stopped.
+ */
+const tabDot = computed>(() => {
+ const dot = (tab: Face, tone: Tone, count?: number): TabDot => {
+ const top = sortByTone(tabItems.value[tab])[0]
+ return {
+ tone,
+ title: top?.title ?? '',
+ label: count ? problems(count) : (top?.count ?? problems(tabItems.value[tab].length)),
+ }
+ }
+ const unread = tabItems.value.connect.filter(
+ (item) => item.id === 'library' || item.id === 'config',
+ ).length
+ const report = codeReport.value
+ const rendering =
+ report?.component === codeComponent.value &&
+ (report.state === 'blocked' || report.state === 'failed')
+ const writing =
+ props.codegen?.result?.kind === 'blocked' || props.codegen?.result?.kind === 'failed'
+ return {
+ contract:
+ contractProblemCount.value > 0 ? dot('contract', 'warn', contractProblemCount.value) : null,
+ connect: unread ? dot('connect', 'danger', unread) : null,
+ code: rendering || writing ? dot('code', 'danger') : null,
+ }
})
+/** An empty state: its `data-empty`, its words, and what it offers. */
+interface EmptyState extends EmptyCopy {
+ kind: string
+ action?: MessageAction
+ about?: { label: string; text: string }
+}
+
+/**
+ * What a tab shows instead of its body, or null when it has a subject. The
+ * shell owns these so the three tabs say "nothing selected" and "several
+ * layers" alike, and explains the tab (About) only when nothing is selected.
+ * A layer outside every component is Contract's own view, since it can still
+ * draw a slot; and only Connect cannot use an instance whose component
+ * another page declares.
+ */
+function emptyFor(tab: Face): EmptyState | null {
+ const { kind, name, local, definition } = subject.value
+ if (kind === 'multi')
+ return {
+ kind,
+ title: layersSelected(props.selection?.length ?? 2),
+ hint: EMPTY[tab].multi.hint,
+ }
+ if (kind === 'none') return { kind, ...EMPTY[tab].none, about: ABOUT[tab] }
+ if (kind === 'outside' && tab !== 'contract')
+ return {
+ kind,
+ ...EMPTY[tab].outside,
+ ...(props.canMakeComponent ? { action: ACTION.makeComponent } : {}),
+ }
+ if (kind === 'instance' && !local && name && tab === 'connect') {
+ const { action, ...copy } = instanceEmpty(tab, name)
+ return { kind, ...copy, ...(definition ? { action } : {}) }
+ }
+ return null
+}
+const tabEmpty = computed(() => (face.value === 'design' ? null : emptyFor(face.value)))
+
+/**
+ * Runs what a status line, an empty state or a tab's link asked for. Actions
+ * are data (`inspector-messages`), so one tab can point at another's control
+ * without knowing where it lives.
+ */
+function act(action: MessageAction): void {
+ switch (action.run) {
+ case 'retry-library':
+ void refreshHeadless()
+ break
+ case 'retry-config':
+ void loadConnection()
+ break
+ case 'retry-code':
+ codeReload.value += 1
+ break
+ case 'open-project':
+ face.value = 'connect'
+ projectFocus.value = {
+ target: action.arg === 'output' ? 'output' : 'library',
+ n: Date.now(),
+ }
+ break
+ case 'open-contract':
+ face.value = 'contract'
+ contractFocus.value = { target: 'parts', n: Date.now() }
+ break
+ case 'select':
+ if (action.arg) emit('select', action.arg)
+ break
+ case 'open-component':
+ if (action.arg) emit('openComponent', action.arg)
+ break
+ case 'make-component':
+ emit('makeComponent')
+ break
+ }
+}
+
+function onHover(prop: string | null): void {
+ const address = active.value?.address
+ if (address) emit('hover', address, prop)
+}
+
+/** Figma's "on this page" row: computed once for every picker in the panel. */
+const swatches = computed(() => (props.doc ? documentSwatches(props.doc.tree) : []))
+
+/** The `` an instance names, from the document-wide index (F7). */
function definitionFor(node: UidxNode): UidxNode | undefined {
const named = node.attrs.component?.value
return typeof named === 'string' ? props.components?.get(named) : undefined
@@ -1953,124 +2223,134 @@ function onDetach(prop: string, value: JsonValue): void {
- Design
-
-
- Contract
+ {{ tab.label }}
{{ contractIssueCount }} {{ tabDot[tab.id]!.label }}
-
- Connect
-
-
- Code
-
- Read only
-
- {{ active.element }}
- {{ active.name }}
- {{ chip.value }}
-
+
+ {{ active.element }}
+ {{ active.name }}
+ {{ chip.value }}
+
+ no status
+ {{ option }}
+
+
+ {{
+ COPY.layers(selection!.length)
+ }}
+ {{ COPY.readOnly }}
- no status
- {{ option }}
-
-
-
-
-
- Select a single layer to see what it binds.
-
-
- Reconnect to edit. You can still inspect the bindings.
-
-
-
+
+
+
+
@@ -2082,9 +2362,6 @@ function onDetach(prop: string, value: JsonValue): void {
: 'Select a layer to adjust its size, layout, and appearance.'
}}
-
- Reconnect to edit. You can still inspect properties and export.
-
This state sets
@@ -2998,7 +3275,10 @@ function onDetach(prop: string, value: JsonValue): void {
}
.properties {
- overflow: auto;
+ /* Nothing in the pane may scroll it sideways; a wide value scrolls or
+ ellipsizes inside its own box. */
+ overflow-x: hidden;
+ overflow-y: auto;
min-width: 0;
padding: 0 var(--section-pad);
background: var(--panel);
@@ -3021,22 +3301,32 @@ function onDetach(prop: string, value: JsonValue): void {
border-bottom: 1px solid var(--line);
}
.inspector-title {
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-h2 {
- font-size: 12px;
- font-weight: 600;
- margin: 0;
+ min-width: 0;
}
+/* The lock chip: read-only said once, at the end of the identity row, for
+ every face. Its words are its title and its screen-reader text. */
.read-only-badge {
- color: var(--warn);
- font-size: var(--ui-size-sm);
+ display: inline-flex;
+ flex: none;
+ order: 3;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ margin-left: auto;
+ border-radius: var(--radius-lg);
+ background: var(--raised);
+ color: var(--text-dim);
}
-/* The same toggle the left rail uses for Elements / Tokens / Fonts. */
+/*
+ * The same toggle the left rail uses for Elements / Tokens / Fonts, the
+ * pane's width: four tabs share it, each its label's width plus an equal
+ * share of the rest, and a label ellipsizes before the strip overflows.
+ */
.face-toggle {
display: flex;
+ box-sizing: border-box;
+ width: 100%;
gap: 2px;
padding: 3px;
background: var(--bg);
@@ -3044,10 +3334,13 @@ h2 {
}
.face-toggle button {
display: inline-flex;
+ flex: 1 1 auto;
align-items: center;
- gap: 5px;
+ justify-content: center;
+ gap: 4px;
min-width: 0;
- padding: 4px 10px;
+ padding: 4px 6px;
+ overflow: hidden;
border: 0;
border-radius: 5px;
background: none;
@@ -3055,26 +3348,36 @@ h2 {
font: inherit;
font-size: 11px;
font-weight: 500;
+ /* A 28px strip: the header is 52px tall, 84px with the identity row. */
+ line-height: 14px;
+ white-space: nowrap;
cursor: pointer;
}
+.face-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
.face-toggle button[aria-pressed='true'] {
- background: var(--raised);
+ background: var(--segment-on);
color: var(--text);
box-shadow: var(--shadow-sm);
}
.face-toggle button:hover {
color: var(--text);
}
+/* A tab with a real problem: a dot in the problem's tone, no count. */
.face-toggle .badge {
- min-width: 14px;
- padding: 0 4px;
- border-radius: 999px;
+ flex: none;
+ width: 6px;
+ height: 6px;
+ padding: 0;
+ border-radius: 50%;
background: var(--warn);
- color: var(--bg);
- font-size: 9px;
- font-weight: 600;
- line-height: 14px;
- text-align: center;
+ font-size: 0;
+}
+.face-toggle .badge[data-tone='danger'] {
+ background: var(--danger);
}
.note {
color: var(--text-faint);
@@ -3082,9 +3385,6 @@ h2 {
line-height: 1.5;
margin: 12px 0;
}
-.note.warn {
- color: var(--warn);
-}
.section-link {
position: relative;
display: inline-flex;
@@ -3125,6 +3425,13 @@ h2 {
position: relative;
margin-bottom: 8px;
}
+/* Connect's Project under an empty state: the same full-bleed rule that
+ separates sections, so the empty state reads as one and not as a header. */
+.empty-state + .inspector-tab {
+ margin-inline: calc(-1 * var(--section-pad));
+ padding-inline: var(--section-pad);
+ border-top: 1px solid var(--line);
+}
.section {
/* Full-bleed separators: the rule runs edge to edge of the pane, UI3's
section boundary, so the margins undo the pane's own padding. */
@@ -3143,30 +3450,6 @@ h2 {
font-weight: 600;
color: var(--text);
}
-/* One box for every header icon: 24px hit target, 12px glyph, quiet at
- rest, a raised pill on hover — Figma's header cluster buttons. */
-.cluster-btn {
- display: inline-flex;
- flex: none;
- align-items: center;
- justify-content: center;
- width: var(--row-h);
- height: var(--row-h);
- padding: 0;
- border: 0;
- border-radius: var(--radius);
- background: none;
- color: var(--text-dim);
- cursor: pointer;
-}
-.cluster-btn:hover:not(:disabled) {
- background: var(--raised);
- color: var(--text);
-}
-.cluster-btn:disabled {
- opacity: 0.5;
- cursor: default;
-}
.section-toggle {
display: flex;
align-items: center;
@@ -3323,24 +3606,45 @@ h2 {
.field > :deep(.structured) {
grid-column: 1 / -1;
}
+/* The identity row: one line at any width. What does not fit ellipsizes,
+ the name and chips alike, each keeping its full text in its title. */
.node-head {
display: flex;
- gap: var(--gap);
+ flex-wrap: nowrap;
+ gap: 6px;
align-items: center;
- flex-wrap: wrap;
- padding-top: 12px;
+ box-sizing: border-box;
+ min-width: 0;
+ min-height: 32px;
+ padding-top: 8px;
}
.element {
order: 2;
- color: var(--text-dim);
- font-size: var(--ui-size-sm);
- padding: 2px 6px;
+ flex: none;
+ box-sizing: border-box;
+ max-width: 40%;
+ height: 20px;
+ padding: 0 6px;
+ overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
+ color: var(--text-dim);
+ font-size: var(--ui-size-sm);
+ line-height: 18px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.name {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
color: var(--text);
- font-weight: 700;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.name.multi {
+ color: var(--text-dim);
}
/*
* A maturity chip, coloured by what the status means rather than by a palette
@@ -3348,21 +3652,26 @@ h2 {
* same amber as a warning, deprecated the same red as a rejection.
*/
.meta {
- padding: 0 var(--gap-sm);
+ flex: 0 1 auto;
+ box-sizing: border-box;
+ min-width: 0;
+ max-width: 45%;
+ height: 20px;
+ padding: 0 6px;
+ overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
color: var(--text-dim);
font-size: var(--ui-size-sm);
-}
-.link-button {
- padding: 0;
- font: inherit;
- color: var(--accent);
- background: none;
- border: 0;
- cursor: pointer;
+ line-height: 18px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.status-pick {
+ flex: none;
+ /* As wide as the chosen status, not the longest option: the name needs
+ the room more. Browsers without it keep the native width. */
+ field-sizing: content;
background: none;
font: inherit;
font-size: var(--ui-size-sm);
diff --git a/packages/viewer/src/SlotSettingsSection.vue b/packages/viewer/src/SlotSettingsSection.vue
index 3102fa4..ed00e56 100644
--- a/packages/viewer/src/SlotSettingsSection.vue
+++ b/packages/viewer/src/SlotSettingsSection.vue
@@ -407,9 +407,12 @@ button.chip:hover {
background: none;
color: var(--accent);
font: inherit;
- text-decoration: underline;
cursor: pointer;
}
+/* Underlined under the pointer only, as every other text action in the inspector. */
+.link:hover:not(:disabled) {
+ text-decoration: underline;
+}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
diff --git a/packages/viewer/src/contract-edits.ts b/packages/viewer/src/contract-edits.ts
index 1fec518..0832dda 100644
--- a/packages/viewer/src/contract-edits.ts
+++ b/packages/viewer/src/contract-edits.ts
@@ -651,6 +651,23 @@ export function contractIssues(view: ContractView): number {
return 0
}
+/**
+ * What the tab's warning dot counts: bindings code generation will refuse,
+ * not progress. An unbound part is work not yet done and stays out of it;
+ * a layer bound to a part nothing declares is a fault, and so is a part
+ * layer whose value the implemented element does not offer — `uidx codegen`
+ * stops on both. Without a library, or while the component implements no
+ * element the library knows, there is nothing to check a binding or a part
+ * value against, and code generation checks neither.
+ */
+export function contractProblems(view: ContractView, library: HeadlessLibrary | null): number {
+ if (view.kind === 'component') return view.element ? view.strayParts.length : 0
+ if (view.kind !== 'part' || view.partValue === null || library === null) return 0
+ const element = implementedElement(view.component, library)
+ if (!element) return 0
+ return element.parts.some((part) => part.name === view.partValue) ? 0 : 1
+}
+
/* ------------------------------------------------------------ writes */
function setAttr(node: UidxNode, prop: string, value: string | number | null): UidxPatch[] {
diff --git a/packages/viewer/src/field-icons.ts b/packages/viewer/src/field-icons.ts
index a581428..621f3c9 100644
--- a/packages/viewer/src/field-icons.ts
+++ b/packages/viewer/src/field-icons.ts
@@ -77,6 +77,12 @@ export const ICON_PATHS = {
search: 'M5.2 1.8 A 3.4 3.4 0 1 1 5.2 8.6 A 3.4 3.4 0 1 1 5.2 1.8 M7.8 7.8 L10.5 10.5',
close: 'M2.5 2.5 L9.5 9.5 M9.5 2.5 L2.5 9.5',
plus: 'M6 2 V10 M2 6 H10',
+ // The inspector tabs' status line and section headers: what a section is
+ // for, what went wrong, and the code block's copy confirmation.
+ info: 'M6 1.5 A4.5 4.5 0 1 1 6 10.5 A4.5 4.5 0 1 1 6 1.5 M6 5.5 V8.5 M6 3.6 V3.7',
+ 'alert-circle': 'M6 1.5 A4.5 4.5 0 1 1 6 10.5 A4.5 4.5 0 1 1 6 1.5 M6 3.5 V6.5 M6 8.3 V8.4',
+ copy: 'M4 4 H10 V10 H4 Z M2 8 V2 H8',
+ check: 'M2.5 6.5 L5 9 L9.5 3.5',
trash: 'M2 3 H10 M4.5 3 V1.5 H7.5 V3 M3 3 L3.5 10.5 H8.5 L9 3 M5 5 V8.5 M7 5 V8.5',
variable: 'M2.5 2.5 H9.5 V9.5 H2.5 Z M4.8 4 L4.2 8 M7.8 4 L7.2 8 M3.5 5.2 H8.7 M3.3 6.8 H8.5',
'variables-grid':
diff --git a/packages/viewer/src/headless.ts b/packages/viewer/src/headless.ts
index d384375..304378a 100644
--- a/packages/viewer/src/headless.ts
+++ b/packages/viewer/src/headless.ts
@@ -18,15 +18,27 @@ export {
type HeadlessPart,
} from '@uidx/schema/headless'
import { parseHeadless, type HeadlessCandidate, type HeadlessLibrary } from '@uidx/schema/headless'
+import { classifyFailure, type Failure } from './inspector-messages'
/** `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('')
+/**
+ * The same failure by class, for the inspector tabs' status line: they word
+ * a missing file, an unreadable one and a dead server differently, and the
+ * raw `headlessError` stays for the app bar and the error banner.
+ */
+export const headlessFailure = shallowRef(null)
/** Libraries the project's dependencies ship, while the document names none. */
export const headlessCandidates = shallowRef([])
-function unavailable(response: Response): void {
+/**
+ * Throws when the response is not the uidx server's JSON — an HTML error
+ * page from a proxy, or a dev server without the route — so the caller
+ * reports a missing service instead of a JSON parse error.
+ */
+export function unavailable(response: Response): void {
if (!response.headers.get('content-type')?.includes('application/json')) {
throw new Error('The headless library service is unavailable.')
}
@@ -39,15 +51,48 @@ interface HeadlessPayload {
candidates?: HeadlessCandidate[]
codegen?: { out: string }
error?: string
+ /** The errno (`ENOENT`, `EACCES`…) or `EJSON` behind `error`, from newer servers. */
+ code?: string
+}
+
+/** The server's refusal as an error that keeps its `code`, for `classifyFailure`. */
+function failure(data: { error?: string; code?: string }, fallback: string): Error {
+ return Object.assign(new Error(data.error ?? fallback), { code: data.code })
+}
+
+/** One finding of the code generator, as the server reports it. */
+export interface Diagnostic {
+ file: string
+ line: number
+ column: number
+ message: string
+ severity: string
}
+/**
+ * How the last Write code run ended: files written, stopped by the
+ * generator's own diagnostics, or failed on the way (server, disk).
+ */
+export type WriteResult =
+ | { kind: 'ok'; written: number; out: string; at: number }
+ | { kind: 'blocked'; diagnostics: Diagnostic[] }
+ | { kind: 'failed'; failure: Failure }
+
/** Where `uidx.json` says generated code goes (`codegen.out`), and how the last run went. */
export interface CodegenState {
out: string | null
running: boolean
+ /** The last run as one sentence, for the app bar. */
notice: string
+ /** The last run as data, for the Code tab; null before the first. */
+ result: WriteResult | null
}
-export const codegenState = shallowRef({ out: null, running: false, notice: '' })
+export const codegenState = shallowRef({
+ out: null,
+ running: false,
+ notice: '',
+ result: null,
+})
function adopt(data: HeadlessPayload): void {
headlessLibrary.value =
@@ -62,7 +107,7 @@ function adopt(data: HeadlessPayload): void {
* and the command line never disagree about what the code looks like.
*/
export async function generateCode(): Promise {
- codegenState.value = { ...codegenState.value, running: true, notice: '' }
+ codegenState.value = { ...codegenState.value, running: true, notice: '', result: null }
try {
const response = await fetch('/__uidx/codegen', {
method: 'POST',
@@ -74,26 +119,25 @@ export async function generateCode(): Promise {
const data = (await response.json()) as {
out?: string
written?: string[]
- diagnostics?: {
- file: string
- line: number
- column: number
- message: string
- severity: string
- }[]
+ diagnostics?: Diagnostic[]
error?: string
}
if (!response.ok) throw new Error(data.error ?? 'Could not generate code.')
const errors = (data.diagnostics ?? []).filter((d) => d.severity === 'error')
+ const out = data.out ?? codegenState.value.out
const notice = errors.length
? `Not written: ${errors.map((d) => `${d.file}:${d.line} ${d.message}`).join('; ')}`
- : `Wrote ${data.written?.length ?? 0} files to ${data.out ?? codegenState.value.out}`
- codegenState.value = { ...codegenState.value, running: false, notice }
+ : `Wrote ${data.written?.length ?? 0} files to ${out}`
+ const result: WriteResult = errors.length
+ ? { kind: 'blocked', diagnostics: errors }
+ : { kind: 'ok', written: data.written?.length ?? 0, out: out ?? '', at: Date.now() }
+ codegenState.value = { ...codegenState.value, running: false, notice, result }
} catch (error) {
codegenState.value = {
...codegenState.value,
running: false,
notice: error instanceof Error ? error.message : String(error),
+ result: { kind: 'failed', failure: classifyFailure(error) },
}
}
}
@@ -106,6 +150,7 @@ export async function generateCode(): Promise {
*/
export async function chooseHeadless(path: string): Promise {
headlessError.value = ''
+ headlessFailure.value = null
try {
const response = await fetch('/__uidx/headless', {
method: 'PUT',
@@ -115,12 +160,13 @@ export async function chooseHeadless(path: string): Promise {
})
unavailable(response)
const data = (await response.json()) as HeadlessPayload
- if (!response.ok) throw new Error(data.error ?? 'Could not choose the headless library.')
+ if (!response.ok) throw failure(data, 'Could not choose the headless library.')
adopt(data)
void loadConnection()
connectionGeneration.value += 1
} catch (error) {
headlessError.value = error instanceof Error ? error.message : String(error)
+ headlessFailure.value = classifyFailure(error)
}
}
@@ -133,18 +179,22 @@ export async function chooseHeadless(path: string): Promise {
*/
export async function refreshHeadless(): Promise {
headlessError.value = ''
+ headlessFailure.value = null
try {
const response = await fetch('/__uidx/headless', { signal: AbortSignal.timeout(15_000) })
unavailable(response)
const data = (await response.json()) as HeadlessPayload
- if (!response.ok) throw new Error(data.error ?? 'Could not read the headless library.')
+ if (!response.ok) throw failure(data, 'Could not read the headless library.')
adopt(data)
- void loadConnection()
} catch (error) {
headlessLibrary.value = null
headlessCandidates.value = []
headlessError.value = error instanceof Error ? error.message : String(error)
+ headlessFailure.value = classifyFailure(error)
}
+ // Outside the try: uidx.json still loads when the library it names does
+ // not, so Connect can show the configured path and the output folder.
+ void loadConnection()
}
/**
@@ -189,14 +239,26 @@ export const connection = shallowRef({ headless: null, codegen
/** Moves on every saved change, so what renders code from it refreshes. */
export const connectionGeneration = shallowRef(0)
export const connectionError = shallowRef('')
+/**
+ * Which change `connectionError` is about, when a save failed; null when it
+ * is a load error. The tab shows a save error under its field, and a load
+ * error in the status line.
+ */
+export const connectionErrorKey = shallowRef(null)
export async function loadConnection(): Promise {
+ connectionError.value = ''
+ connectionErrorKey.value = null
try {
const response = await fetch('/__uidx/config', { signal: AbortSignal.timeout(15_000) })
unavailable(response)
const data = (await response.json()) as ConnectionConfig & { error?: string }
if (!response.ok) throw new Error(data.error ?? 'Could not read the code connection.')
connection.value = { headless: data.headless, codegen: data.codegen }
+ // `adopt` sets the output folder from the library route; when that route
+ // failed, uidx.json is the only source left.
+ if (codegenState.value.out === null && data.codegen?.out)
+ codegenState.value = { ...codegenState.value, out: data.codegen.out }
} catch (error) {
connectionError.value = error instanceof Error ? error.message : String(error)
}
@@ -205,6 +267,7 @@ export async function loadConnection(): Promise {
/** Writes one change to `uidx.json`; on success the library and codegen state follow. */
export async function saveConnection(change: ConfigChange): Promise {
connectionError.value = ''
+ connectionErrorKey.value = null
try {
const response = await fetch('/__uidx/config', {
method: 'PATCH',
@@ -221,6 +284,7 @@ export async function saveConnection(change: ConfigChange): Promise {
return true
} catch (error) {
connectionError.value = error instanceof Error ? error.message : String(error)
+ connectionErrorKey.value = change.key
return false
}
}
diff --git a/packages/viewer/src/inspector-controls.css b/packages/viewer/src/inspector-controls.css
index 187b096..a33a28f 100644
--- a/packages/viewer/src/inspector-controls.css
+++ b/packages/viewer/src/inspector-controls.css
@@ -223,11 +223,11 @@
}
/* Component rows share the inspector's gutters and leave room for 32px fields. */
-.properties .editor :is(.component-props, .instance-props, .variants, .contract) {
+.properties .editor :is(.component-props, .instance-props, .variants) {
margin: 0 calc(-1 * var(--section-pad));
padding: 0 var(--section-pad) 12px;
}
-.properties .editor :is(.component-props, .instance-props, .variants, .contract) > .head {
+.properties .editor :is(.component-props, .instance-props, .variants) > .head {
height: 44px;
align-items: center;
}
@@ -293,3 +293,181 @@
height: 14px;
accent-color: var(--accent);
}
+
+/*
+ * The controls every inspector face shares: Design, Contract, Connect and
+ * Code draw their header icons, text links, buttons, path chips and problem
+ * marks from these rules, so one fault or one action looks the same on every
+ * tab. Unscoped (not `.editor`) where the tab shell's status line and empty
+ * state, which sit outside the editor, use them too.
+ */
+
+/* One box for every header icon: 24px hit target, 12px glyph, quiet at
+ rest, a raised pill on hover — Figma's header cluster buttons. */
+.properties .cluster-btn {
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ justify-content: center;
+ width: var(--row-h);
+ height: var(--row-h);
+ padding: 0;
+ border: 0;
+ border-radius: var(--radius);
+ background: none;
+ color: var(--text-dim);
+ cursor: pointer;
+}
+.properties .cluster-btn:hover:not(:disabled) {
+ background: var(--raised);
+ color: var(--text);
+}
+.properties .cluster-btn:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+/* A toggle in a tab's section header (wrap lines, the (i) tip) reads as on. */
+.properties .inspector-tab .cluster-btn:is([aria-pressed='true'], [aria-expanded='true']) {
+ background: var(--raised);
+ color: var(--text);
+}
+
+/* An action that reads as text: accent, underlined only under the pointer. */
+.properties .link-button {
+ padding: 0;
+ font: inherit;
+ color: var(--accent);
+ background: none;
+ border: 0;
+ cursor: pointer;
+}
+.properties .link-button:hover:not(:disabled) {
+ text-decoration: underline;
+}
+
+/* A command: raised, never accent-filled, so a tab's one primary action
+ does not shout over the canvas selection colour. */
+.properties .btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: var(--field-h);
+ padding: 0 12px;
+ background: var(--raised);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ color: var(--text);
+ font: 500 var(--ui-size) / 1 var(--ui-font);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ cursor: pointer;
+}
+.properties .btn:hover:not(:disabled) {
+ border-color: var(--text-faint);
+}
+.properties .btn.block {
+ width: 100%;
+}
+.properties .btn.primary {
+ font-weight: 600;
+}
+.properties .btn.compact {
+ height: 24px;
+ padding: 0 8px;
+ font-size: var(--ui-size-sm);
+}
+
+.properties .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* A file or folder path: monospace, one line, the full path in its title. */
+.properties .path-chip {
+ display: inline-block;
+ max-width: 100%;
+ padding: 0 4px;
+ overflow: hidden;
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--text) 7%, transparent);
+ color: var(--text-dim);
+ font: 11px var(--mono-font);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ vertical-align: bottom;
+}
+
+/* The one problem mark: a 6px dot in the tone's colour. */
+.properties .tone-dot {
+ display: inline-block;
+ flex: none;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--text-faint);
+}
+.properties .tone-dot[data-tone='warn'] {
+ background: var(--warn);
+}
+.properties .tone-dot[data-tone='danger'] {
+ background: var(--danger);
+}
+.properties .tone-dot[data-tone='info'] {
+ background: var(--text-faint);
+}
+
+/* Why a field's value was refused, under the field. */
+.properties .field-error {
+ display: flex;
+ gap: 6px;
+ margin: 4px 0 0;
+ color: var(--text-dim);
+ font-size: var(--ui-size-sm);
+ line-height: 16px;
+}
+.properties .field-error::before {
+ content: '';
+ flex: none;
+ width: 6px;
+ height: 6px;
+ margin-top: 5px;
+ border-radius: 50%;
+ background: var(--danger);
+}
+
+/* An inline finding in a section body: dot, sentence, optional action. */
+.properties .issue {
+ display: grid;
+ grid-template-columns: 6px minmax(0, 1fr) auto;
+ gap: 6px;
+ align-items: start;
+ padding: 4px 0;
+ color: var(--text-dim);
+ font-size: var(--ui-size-sm);
+ line-height: 16px;
+ overflow-wrap: anywhere;
+}
+.properties .issue > .tone-dot {
+ margin-top: 5px;
+}
+.properties .issue > .link-button {
+ white-space: nowrap;
+}
+
+.properties .editor :is(.reset, .stale-name, .layer, .link-button, .btn, .cluster-btn):disabled {
+ opacity: 0.5;
+ cursor: default;
+ text-decoration: none;
+}
+/* Checkboxes sit on the gutter, like every other control's left edge. */
+.properties .inspector-tab input[type='checkbox'] {
+ margin: 0;
+}
diff --git a/packages/viewer/src/inspector-messages.ts b/packages/viewer/src/inspector-messages.ts
new file mode 100644
index 0000000..53c5a79
--- /dev/null
+++ b/packages/viewer/src/inspector-messages.ts
@@ -0,0 +1,668 @@
+import type { ConfigChange, Diagnostic, WriteResult } from './headless'
+
+export type { Diagnostic, WriteResult } from './headless'
+
+/**
+ * Every word the Contract, Connect and Code tabs say about their state: the
+ * status line's titles, details and actions, the empty states, and the (i)
+ * teaching copy behind each section header.
+ *
+ * One module so the three tabs word and colour one fault identically. A
+ * library that cannot be read is "Library file not found" on every tab, with
+ * only the detail and the actions differing by what that tab can do about
+ * it; the raw server text survives, but only behind "Details". The server's
+ * `code` field is preferred when it sends one, and today's raw strings are
+ * matched by pattern otherwise, so an older server still reads well.
+ *
+ * Pure: no Vue, no fetch. The tabs and the shell build items from here and
+ * hand them to `InspectorStatus` and `InspectorEmpty`.
+ */
+
+/** How bad a status item is. Danger stops work; warn is a finding; info is news. */
+export type Tone = 'danger' | 'warn' | 'info'
+
+/** What a status row, an empty state or a hint link asks the shell to do. */
+export interface MessageAction {
+ label: string
+ run:
+ | 'retry-library'
+ | 'retry-config'
+ | 'retry-code'
+ | 'open-project'
+ | 'open-contract'
+ | 'select'
+ | 'open-component'
+ | 'make-component'
+ /** `open-project`: 'library' or 'output'; `select`: an address; `open-component`: a name. */
+ arg?: string
+}
+
+/** One row of a status item's list: a diagnostic, a file, a stray binding. */
+export interface StatusRow {
+ label: string
+ /** The untrimmed text, for the row's title. */
+ full?: string
+ /** A second, faint line: where the row comes from. */
+ meta?: string
+ action?: MessageAction
+}
+
+/** One entry of a tab's status line. */
+export interface StatusItem {
+ /** Stable per kind of fault (`library`, `config`, `code`, `blocked:`…), for keys and tests. */
+ id: string
+ tone: Tone
+ /** At most six words; the bar shows it on one line. */
+ title: string
+ /** One or two short sentences, clamped to two lines. */
+ detail?: string
+ /** A file or folder the fault is about; shown shortened, in full in its title. */
+ path?: string
+ pathTitle?: string
+ /** Replaces the bar's `+N` count, e.g. '3 problems'. */
+ count?: string
+ rows?: StatusRow[]
+ actions?: MessageAction[]
+ /** The server's own words, behind "Details". */
+ raw?: string
+}
+
+/** A transport or server failure, by class. `raw` is what was thrown, verbatim. */
+export interface Failure {
+ code:
+ | 'not-found'
+ | 'no-access'
+ | 'is-folder'
+ | 'invalid-json'
+ | 'unavailable'
+ | 'timeout'
+ | 'network'
+ | 'unknown'
+ raw: string
+}
+
+/** The three tabs this module words. */
+export type Face = 'contract' | 'connect' | 'code'
+
+/* ------------------------------------------------------------ helpers */
+
+const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`
+
+/** `n problem(s)`, as every count in the tabs reads. */
+export const problems = (n: number): string => plural(n, 'problem')
+
+/** The order the status line lists items in, most severe first. */
+export const TONE_RANK: Record = { danger: 0, warn: 1, info: 2 }
+
+/** Items most severe first; equal tones keep their order. */
+export function sortByTone(items: readonly StatusItem[]): StatusItem[] {
+ return items
+ .map((item, index) => ({ item, index }))
+ .sort((a, b) => TONE_RANK[a.item.tone] - TONE_RANK[b.item.tone] || a.index - b.index)
+ .map(({ item }) => item)
+}
+
+const basename = (path: string): string => path.split(/[\\/]/).filter(Boolean).pop() ?? path
+
+/**
+ * A path short enough for one line of the panel: the first named segment
+ * (with any leading `..` or `/`) and as many trailing segments as fit,
+ * joined by an ellipsis — '../vendor/…/custom-elements.json'. The full path
+ * belongs in the element's title.
+ */
+export function shortPath(p: string, max = 34): string {
+ if (p.length <= max) return p
+ const parts = p.split('/')
+ let lead = 0
+ while (
+ lead < parts.length - 1 &&
+ (parts[lead] === '' || parts[lead] === '.' || parts[lead] === '..')
+ )
+ lead += 1
+ if (lead >= parts.length - 1) return p
+ const head = parts.slice(0, lead + 1).join('/')
+ const rest = parts.slice(lead + 1)
+ let tail = [rest.pop()!]
+ while (rest.length > 1 && `${head}/…/${[rest.at(-1), ...tail].join('/')}`.length <= max)
+ tail = [rest.pop()!, ...tail]
+ if (rest.length === 0) return p
+ const short = `${head}/…/${tail.join('/')}`
+ return short.length <= max || tail.length > 1 ? short : `…/${tail.join('/')}`
+}
+
+/**
+ * A library description without its Markdown, on one line: `code`, *em*,
+ * _em_ and [links](url) become their text.
+ */
+export function plainText(text: string): string {
+ return text
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+ .replace(/`([^`]*)`/g, '$1')
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
+ .replace(/(^|[^\w*])\*([^*\n]+)\*(?=[^\w*]|$)/g, '$1$2')
+ .replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/g, '$1$2')
+ .replace(/\s+/g, ' ')
+ .trim()
+}
+
+/** The first sentence of a library description, without its Markdown. */
+export function firstSentence(text: string): string {
+ const plain = plainText(text)
+ const cut = plain.search(/[.!?] /)
+ return cut === -1 ? plain : plain.slice(0, cut + 1)
+}
+
+const sentenceCase = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1)
+
+/** The path an errno message names: `open '/abs/x.json'` → `/abs/x.json`. */
+const pathIn = (raw: string): string | undefined =>
+ /\b(?:open|mkdir|scandir|stat|lstat|rmdir|unlink|rename|copyfile|access|writeFile|readFile)\s+'([^']+)'/.exec(
+ raw,
+ )?.[1]
+
+/* ------------------------------------------------------------ failures */
+
+const SERVER_CODES: Record = {
+ ENOENT: 'not-found',
+ ENOTDIR: 'not-found',
+ EACCES: 'no-access',
+ EPERM: 'no-access',
+ EROFS: 'no-access',
+ EISDIR: 'is-folder',
+ EJSON: 'invalid-json',
+}
+
+/**
+ * Classifies anything a fetch to the uidx server can throw. The `code` a
+ * newer server attaches wins; otherwise the message is matched, so the
+ * classes hold for today's raw strings too.
+ */
+export function classifyFailure(error: unknown): Failure {
+ const raw = error instanceof Error ? error.message : String(error)
+ const sent = (error as { code?: unknown } | null)?.code
+ if (typeof sent === 'string' && SERVER_CODES[sent]) return { code: SERVER_CODES[sent], raw }
+ const name = (error as { name?: unknown } | null)?.name
+ if (name === 'TimeoutError') return { code: 'timeout', raw }
+ if (/service is unavailable/i.test(raw)) return { code: 'unavailable', raw }
+ if (/\bENOENT\b|\bENOTDIR\b/.test(raw)) return { code: 'not-found', raw }
+ if (/\bEACCES\b|\bEPERM\b|\bEROFS\b/.test(raw)) return { code: 'no-access', raw }
+ if (/\bEISDIR\b/.test(raw)) return { code: 'is-folder', raw }
+ if (/timed out|TimeoutError/i.test(raw)) return { code: 'timeout', raw }
+ if (/Failed to fetch|NetworkError|Load failed/i.test(raw)) return { code: 'network', raw }
+ // An HTML error page where JSON was expected: the server, not the file.
+ if (/Unexpected token '?<|
+
+/** 'Open List': shows a component defined on another page. */
+export const openComponent = (name: string): MessageAction => ({
+ label: `Open ${name}`,
+ run: 'open-component',
+ arg: name,
+})
+
+/** 'Select Button': selects a layer by address. */
+export const selectLayer = (name: string, address: string): MessageAction => ({
+ label: `Select ${name}`,
+ run: 'select',
+ arg: address,
+})
+
+/* ------------------------------------------------------------ items */
+
+const LIBRARY_TITLE: Record = {
+ 'not-found': 'Library file not found',
+ 'no-access': "Can't open the library file",
+ 'is-folder': 'Library path is a folder',
+ 'invalid-json': "Library file isn't valid JSON",
+ timeout: "Server didn't respond",
+ unavailable: 'Library service unavailable',
+ network: "Can't reach the uidx server",
+ unknown: "Can't read the library",
+}
+
+const LIBRARY_DETAIL: Record = {
+ connect: 'Element and part checks are paused until it loads.',
+ contract: 'Part checks are paused.',
+ code: "Code can't render without it.",
+}
+
+/**
+ * The headless library could not be read. The title is the same on every
+ * tab; the detail says what this tab cannot do meanwhile, and the actions
+ * are what it can do — choose a library where the chooser lives (Connect),
+ * point there from elsewhere. A file fault names the file as `uidx.json`
+ * configures it, with the absolute path the server tried in its title.
+ */
+export function libraryItem(f: Failure, face: Face, configured: string | null): StatusItem {
+ const aboutFile = !transportDetail(f.code)
+ const tried = pathIn(f.raw)
+ const path = aboutFile ? (configured ?? tried) : undefined
+ return {
+ id: 'library',
+ tone: 'danger',
+ title: LIBRARY_TITLE[f.code],
+ detail: LIBRARY_DETAIL[face],
+ ...(path ? { path, pathTitle: tried ?? path } : {}),
+ actions: aboutFile
+ ? [face === 'connect' ? ACTION.chooseLibrary : ACTION.fixInConnect, ACTION.retryLibrary]
+ : [ACTION.retryLibrary],
+ raw: f.raw,
+ }
+}
+
+/** `uidx.json` could not be read, so Connect and Code have no configuration. */
+export function configLoadItem(f: Failure): StatusItem {
+ const transport = transportDetail(f.code)
+ return {
+ id: 'config',
+ tone: 'danger',
+ title:
+ f.code === 'network'
+ ? "Can't reach the uidx server"
+ : f.code === 'timeout'
+ ? "Server didn't respond"
+ : f.code === 'invalid-json'
+ ? "uidx.json isn't valid JSON"
+ : "Can't read uidx.json",
+ detail: transport ?? 'Connections and the output folder show once it loads.',
+ actions: [ACTION.retryConfig],
+ raw: f.raw,
+ }
+}
+
+/** A change to `uidx.json` the server refused, where no field owns the error. */
+export function saveItem(raw: string): StatusItem {
+ const f = classifyFailure(raw)
+ const detail =
+ transportDetail(f.code) ?? (f.code === 'unknown' ? firstSentence(raw) || undefined : undefined)
+ return {
+ id: 'save',
+ tone: 'danger',
+ title: "Couldn't save the change",
+ ...(detail ? { detail } : {}),
+ ...(detail !== raw ? { raw } : {}),
+ }
+}
+
+/**
+ * The short text under the field a refused change came from. The server's
+ * sentences are written for the CLI; these are the panel's.
+ */
+export function fieldErrorText(key: ConfigChange['key'], raw: string): string {
+ const f = classifyFailure(raw)
+ const transport = transportDetail(f.code)
+ if (transport) return "Couldn't save. The server didn't respond."
+ if (/output|where generated code goes/i.test(raw) && key === 'react')
+ return 'Needs an output folder'
+ if (/module/i.test(raw)) return 'Name the module to import from'
+ if (/Name the folder/i.test(raw)) return 'Enter a folder'
+ if (/library first/i.test(raw)) return 'Choose a library first'
+ const sentence = firstSentence(raw).replace(/\.$/, '')
+ return sentence.length && sentence.length <= 60 ? sentence : "Couldn't save the change"
+}
+
+/**
+ * One diagnostic as a status row: the message without its trailing aside,
+ * sentence-cased, with where it comes from as the meta line. The manifest
+ * miss every new binding hits reads as 'Unknown part “spinner-track”'.
+ */
+export function shortDiagnostic(d: Pick): {
+ label: string
+ full: string
+ meta: string
+} {
+ const part = /part "([^"]+)"(?: \(bound to "[^"]+"\))? has no element in the manifest/.exec(
+ d.message,
+ )
+ const label = part
+ ? `Unknown part “${part[1]}”`
+ : sentenceCase(d.message.replace(/\s*\([^()]*\)\s*$/, '').trim())
+ return {
+ label,
+ full: `${d.file}:${d.line} ${d.message}`,
+ meta: `${basename(d.file)} · line ${d.line}`,
+ }
+}
+
+const isError = (d: Diagnostic): boolean => d.severity === 'error'
+
+const sameFile = (a: string, b: string): boolean =>
+ a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`) || basename(a) === basename(b)
+
+/**
+ * The Code tab's diagnostics: one item for the subject's own file, whose
+ * rows link to its contract, and one 'Blocked by ' item per other file
+ * — a component that uses a broken one cannot render either, and the fix is
+ * over there. Without `ownFile` every problem counts as the subject's own.
+ * Rows are all kept; the status line shows three and folds the rest.
+ */
+export function codeItems(diags: readonly Diagnostic[], ownFile?: string): StatusItem[] {
+ const errors = diags.filter(isError)
+ if (!errors.length) return []
+ const own = ownFile === undefined ? errors : errors.filter((d) => sameFile(d.file, ownFile))
+ const others = new Map()
+ for (const d of errors) {
+ if (own.includes(d)) continue
+ const list = others.get(d.file) ?? []
+ list.push(d)
+ others.set(d.file, list)
+ }
+ const items: StatusItem[] = []
+ if (own.length)
+ items.push({
+ id: 'code',
+ tone: 'danger',
+ title: "Code can't be generated",
+ count: problems(own.length),
+ rows: own.map((d) => ({ ...shortDiagnostic(d), action: ACTION.openContract })),
+ })
+ for (const [file, list] of others)
+ items.push({
+ id: `blocked:${file}`,
+ tone: 'danger',
+ title: `Blocked by ${basename(file)}`,
+ detail: `That file has ${problems(list.length)}.`,
+ rows: list.map((d) => shortDiagnostic(d)),
+ })
+ return items
+}
+
+/** The Code tab's own request failed, or named a component the server refused. */
+export function requestItem(f: Failure | { status: 404 }): StatusItem {
+ if ('status' in f)
+ return {
+ id: 'request',
+ tone: 'danger',
+ title: "Can't render this component",
+ detail: "Its name has characters code can't use.",
+ }
+ const detail =
+ transportDetail(f.code) ?? FILE_DETAIL[f.code] ?? (firstSentence(f.raw) || undefined)
+ const path = pathIn(f.raw)
+ return {
+ id: 'request',
+ tone: 'danger',
+ title: "Couldn't render code",
+ ...(detail ? { detail } : {}),
+ ...(path ? { path, pathTitle: path } : {}),
+ actions: [ACTION.retryCode],
+ ...(detail !== f.raw ? { raw: f.raw } : {}),
+ }
+}
+
+/** A file the server read for a render, by class; the errno text stays behind Details. */
+const FILE_DETAIL: Partial> = {
+ 'not-found': 'A file it reads is missing.',
+ 'no-access': "A file it reads can't be opened.",
+ 'is-folder': 'A file it reads is a folder.',
+ 'invalid-json': "A file it reads isn't valid JSON.",
+}
+
+/**
+ * The last Write code run, when it did not write. Success is the footer's
+ * caption, not a status: it is news, and the status line is for faults.
+ */
+export function writeItem(result: WriteResult | null | undefined): StatusItem | null {
+ if (!result || result.kind === 'ok') return null
+ if (result.kind === 'blocked') {
+ const errors = result.diagnostics.filter(isError)
+ const list = errors.length ? errors : result.diagnostics
+ return {
+ id: 'write',
+ tone: 'danger',
+ title: 'Nothing written',
+ detail: `${problems(list.length)} ${list.length === 1 ? 'blocks' : 'block'} code generation.`,
+ count: problems(list.length),
+ rows: list.map((d) => shortDiagnostic(d)),
+ }
+ }
+ const f = result.failure
+ if (/"codegen"|codegen\.out/.test(f.raw))
+ return {
+ id: 'write',
+ tone: 'danger',
+ title: 'No output folder',
+ detail: 'Set where generated code goes.',
+ actions: [ACTION.setFolder],
+ }
+ const transport = transportDetail(f.code)
+ if (transport)
+ return {
+ id: 'write',
+ tone: 'danger',
+ title: "Couldn't write code",
+ detail: transport,
+ raw: f.raw,
+ }
+ const path = pathIn(f.raw)
+ const detail =
+ f.code === 'no-access'
+ ? 'Permission denied.'
+ : f.code === 'not-found'
+ ? 'A folder on the way is missing.'
+ : f.code === 'is-folder'
+ ? 'A file there is a folder.'
+ : firstSentence(f.raw) || undefined
+ return {
+ id: 'write',
+ tone: 'danger',
+ title: "Can't write to the output folder",
+ ...(detail ? { detail } : {}),
+ ...(path ? { path, pathTitle: path } : {}),
+ actions: [ACTION.changeFolder],
+ ...(detail !== f.raw ? { raw: f.raw } : {}),
+ }
+}
+
+/**
+ * Stray part bindings or a bad part value: the Contract tab's one warning.
+ * Worded like the Code tab's row for the same fault ('Unknown part “x”'),
+ * and short enough to sit beside "Show" in the narrowest panel.
+ */
+export function contractProblemItem(n: number): StatusItem {
+ return {
+ id: 'contract',
+ tone: 'warn',
+ title: plural(n, 'unknown part binding'),
+ actions: [ACTION.showParts],
+ }
+}
+
+/* ------------------------------------------------------------ empty states */
+
+export interface EmptyCopy {
+ title: string
+ hint: string
+}
+
+/**
+ * What each tab says when it has nothing to show. `none` is nothing
+ * selected, `multi` several layers, `outside` a layer in no component.
+ * The multi title counts the layers: see `layersSelected`.
+ */
+export const EMPTY: Record> = {
+ contract: {
+ none: { title: 'No component selected', hint: 'Select a component, or a layer inside one.' },
+ multi: { title: 'Several layers selected', hint: 'Select one to see its contract.' },
+ outside: {
+ title: 'Not in a component',
+ hint: 'Only layers inside a component have a contract.',
+ },
+ },
+ connect: {
+ none: { title: 'No component selected', hint: 'Select a component to connect it to code.' },
+ multi: { title: 'Several layers selected', hint: 'Select one to see its connection.' },
+ outside: {
+ title: 'Not in a component',
+ hint: 'Only components, and the layers inside them, connect to code.',
+ },
+ },
+ code: {
+ none: {
+ title: 'No component selected',
+ hint: 'Select a component or an instance to see its code.',
+ },
+ multi: { title: 'Several layers selected', hint: 'Select one to see its code.' },
+ outside: {
+ title: 'Not in a component',
+ hint: 'Only components and their instances become code.',
+ },
+ },
+}
+
+/** The multi-select title: '2 layers selected'. */
+export const layersSelected = (n: number): string => `${n} layers selected`
+
+/** An instance whose component is defined on another page. */
+export function instanceEmpty(face: Face, name: string): EmptyCopy & { action: MessageAction } {
+ return {
+ title: `Instance of ${name}`,
+ hint:
+ face === 'connect'
+ ? "Connect it where it's defined."
+ : face === 'contract'
+ ? `Its contract is declared on ${name}.`
+ : `Its code is ${name}'s.`,
+ action: openComponent(name),
+ }
+}
+
+/** A `` outside every component (Contract's own empty view). */
+export const SLOT_OUTSIDE: EmptyCopy = {
+ title: "Slot isn't in a component",
+ hint: 'Make its frame a component to declare it.',
+}
+
+/** The Code tab with a component whose chosen targets produce no file. */
+export const noFiles = (component: string): EmptyCopy & { action: MessageAction } => ({
+ title: `No files for ${component}`,
+ hint: 'The chosen targets produce none.',
+ action: ACTION.chooseTargets,
+})
+
+/** The disclosures under the nothing-selected state: what each tab is for. */
+export const ABOUT: Record = {
+ contract: {
+ label: 'About Contract',
+ text: 'The contract is what an instance of a component can change without reaching inside: its properties, events, slots and states. Select a layer inside a component to name the part of the headless element it draws.',
+ },
+ connect: {
+ label: 'About Connect',
+ text: 'Connect ties a component to code: the headless element it implements, the names the library uses, and an existing React component to render with. Project settings name the library and where generated code goes.',
+ },
+ code: {
+ label: 'About Code',
+ text: 'Code is a live preview of the files code generation writes for a component, refreshed as you edit. Write code writes every component into the output folder.',
+ },
+}
+
+/**
+ * The (i) text of every section header in the three tabs. Each says what
+ * the section is for and, where it writes, which file it saves to.
+ */
+export const INFO = {
+ // Contract
+ properties:
+ 'What an instance can change without reaching inside. Fill from library declares what the headless element exposes and the contract lacks.',
+ states:
+ 'Turn a state on to draw it beside the others, then select it on the canvas to change its look. Boolean properties marked visual are states too.',
+ styles: 'What each state and variant changes. Select one on the canvas to change its look.',
+ slots:
+ 'Where an instance can put its own content. A Slot layer in the tree draws each one; one that is not drawn has no place yet.',
+ codeBinding:
+ "The headless element this component implements, and the layer that draws each of its parts. Generated code renders the element. Saved in the component's .uidx file.",
+ // Connect
+ element: (name: string): string =>
+ `The web component that gives ${name || 'the component'} its behaviour and accessibility. Generated code renders it. Saved in ${name || 'the component'}.uidx.`,
+ names:
+ 'Only where the library spells a name differently; empty fields keep the same name. Saved in uidx.json.',
+ react: (name: string): string =>
+ `Render ${name || 'the component'} with a React component your app already ships; generated code maps its props and events onto it. Saved in uidx.json.`,
+ project:
+ 'Settings for every component: the library, how it names things, and where generated code goes. Saved in uidx.json.',
+ // Code
+ code: 'A live preview of what Write code produces. Write code writes every component into the output folder.',
+} as const
+
+/* ------------------------------------------------------------ shared copy */
+
+/**
+ * Sentences two tabs say about the same thing, kept here so they cannot
+ * drift: the Element picker in Contract and Connect, the parts progress, the
+ * Code footer.
+ */
+export const COPY = {
+ /** An Element option the library lacks. */
+ notInLibrary: (tag: string): string => `${tag} · not in library`,
+ /** An Element option while the library cannot be read. */
+ libraryUnavailable: (tag: string): string => `${tag} · library unavailable`,
+ /** The configured tag is not in the library. */
+ tagMissing: (tag: string): string => `${tag} isn't in the library.`,
+ noLibrary: 'No library connected.',
+ /** The lock chip in the identity row, said once for every tab. */
+ readOnly: 'Read only. Reconnect to edit; you can still inspect and export.',
+ /** The identity row's name for a multi-selection. */
+ layers: (n: number): string => `${n} layers`,
+ connectLibrary: 'Connect a component library to pick elements from a list.',
+ partsPaused: 'Not checked while the library is unavailable.',
+ chooseElement: 'Choose an element to see its parts.',
+ noParts: (tag: string): string => `${tag} has no parts.`,
+ partsBound: (bound: number, total: number): string => `${bound} of ${total} bound`,
+ partsProgress: (bound: number, total: number): string => `${bound}/${total} parts bound`,
+ strayPart: (name: string, part: string): string =>
+ `“${name}” is bound to “${part}”, which nothing declares.`,
+ notAPart: (tag: string): string => `Not a part of ${tag}. Code generation stops here.`,
+ noImplements: (name: string): string => `${name} doesn't implement an element yet.`,
+ notDrawn: 'Not drawn',
+ needsOutput: 'Needs an output folder.',
+ footnote: (name: string): string =>
+ `Element and parts save to ${name || 'the component'}.uidx; names, React and project to uidx.json.`,
+ // Code
+ codeBlocked: 'Code appears here once the problem above is fixed.',
+ noOutput: 'No output folder (codegen.out in uidx.json).',
+ writeTo: (out: string): string => `All components → ${shortPath(out)}`,
+ wrote: (n: number): string => `Wrote ${plural(n, 'file')}`,
+ updating: 'Updating…',
+ lastRendered: 'Last rendered',
+} as const
+
+/** Why Write code is disabled, or what it will do: the button's title. */
+export function writeBlockedReason(state: {
+ writable: boolean
+ problems: number
+ files: number
+ out: string | null
+}): string {
+ if (!state.writable) return 'Reconnect to write code'
+ if (state.problems > 0) return `Fix ${problems(state.problems)} to write code`
+ if (!state.files) return 'Nothing to write yet'
+ return `Write every component into ${state.out ?? 'the output folder'}`
+}
diff --git a/packages/viewer/src/inspector-subject.ts b/packages/viewer/src/inspector-subject.ts
new file mode 100644
index 0000000..47ce54e
--- /dev/null
+++ b/packages/viewer/src/inspector-subject.ts
@@ -0,0 +1,90 @@
+import type { UidxDocument, UidxNode } from '@uidx/format'
+
+/**
+ * What the Contract, Connect and Code tabs are about, from one selection.
+ *
+ * The three tabs used to answer this separately and disagreed: an instance
+ * of PersonRow inside List was PersonRow to Code and List to Connect. One
+ * resolver means one answer — an instance is about the component it uses,
+ * any other layer inside a component is about that component — and the tabs
+ * only differ in what they do with it.
+ */
+export interface Subject {
+ /**
+ * - `none`: nothing selected; `multi`: several layers.
+ * - `component`: a `` itself.
+ * - `instance`: an ``, about the component it uses.
+ * - `inside`: a layer inside a top-level component, about that component.
+ * - `outside`: a layer in no component.
+ */
+ kind: 'none' | 'multi' | 'component' | 'inside' | 'instance' | 'outside'
+ /** The component's name, or null for none, multi and outside. */
+ name: string | null
+ /** That component as declared in this document, when it is; what Connect edits. */
+ local: UidxNode | null
+ /** That component wherever it is declared: `local`, or another page's (from `components`). */
+ definition: UidxNode | null
+ /** 'of List' when the subject is not the selected layer itself; null when it is, or there is none. */
+ relation: string | null
+}
+
+const empty = (kind: 'none' | 'multi' | 'outside'): Subject => ({
+ kind,
+ name: null,
+ local: null,
+ definition: null,
+ relation: null,
+})
+
+/** The top-level `` of `doc` called `name`, if it declares one. */
+function topComponent(doc: UidxDocument | null, name: string): UidxNode | null {
+ return (
+ doc?.tree.children.find((child) => child.element === 'Component' && child.name === name) ?? null
+ )
+}
+
+/**
+ * Resolves the selection to the tabs' subject. Rules, in order: several
+ * layers; nothing; a component; an instance naming a component; a layer
+ * inside a top-level component; anything else.
+ */
+export function resolveSubject(
+ doc: UidxDocument | null,
+ active: UidxNode | null,
+ selected: number,
+ /** Component name -> definition across every page, for an instance of another page's. */
+ components?: ReadonlyMap,
+): Subject {
+ if (selected > 1) return empty('multi')
+ if (!active) return empty('none')
+ if (active.element === 'Component')
+ return {
+ kind: 'component',
+ name: active.name,
+ local: active,
+ definition: active,
+ relation: null,
+ }
+ const used = active.attrs.component?.value
+ if (active.element === 'Instance' && typeof used === 'string' && used !== '') {
+ const local = topComponent(doc, used)
+ return {
+ kind: 'instance',
+ name: used,
+ local,
+ definition: local ?? components?.get(used) ?? null,
+ relation: `of ${used}`,
+ }
+ }
+ const entity = active.address.split('#')[0]!
+ const top = doc?.tree.children.find((child) => child.address === entity)
+ if (top?.element === 'Component')
+ return {
+ kind: 'inside',
+ name: top.name,
+ local: top,
+ definition: top,
+ relation: `of ${top.name}`,
+ }
+ return empty('outside')
+}
diff --git a/packages/viewer/src/theme.css b/packages/viewer/src/theme.css
index 82c1ce6..b4b25dd 100644
--- a/packages/viewer/src/theme.css
+++ b/packages/viewer/src/theme.css
@@ -62,6 +62,10 @@
--ok: #14ae5c;
/* A value that comes from a design token rather than a literal. */
--bound: #c08bff;
+ /* Code, paths and file names. */
+ --mono-font: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ /* Pressed segment of a segmented control. */
+ --segment-on: var(--raised);
/* Scrim behind an overlay that must stay readable over the canvas. */
--overlay: rgba(12, 14, 18, 0.9);
@@ -110,6 +114,7 @@
--warn: #b77900;
--ok: #0f9150;
--bound: #8a3ffc;
+ --segment-on: var(--panel);
--overlay: rgba(250, 251, 252, 0.94);
--shadow: 0 4px 16px rgb(16 24 40 / 0.12);
--shadow-sm: 0 1px 2px rgb(16 24 40 / 0.08);
diff --git a/packages/viewer/test/code-section.test.ts b/packages/viewer/test/code-section.test.ts
index 63f7084..0b3a1dc 100644
--- a/packages/viewer/test/code-section.test.ts
+++ b/packages/viewer/test/code-section.test.ts
@@ -1,10 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { flushPromises, mount } from '@vue/test-utils'
+import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import AppBar from '../src/AppBar.vue'
import CodeSection from '../src/CodeSection.vue'
+import { headlessFailure } from '../src/headless'
-afterEach(() => vi.unstubAllGlobals())
+// The tab watches the shared library state; a section left mounted would answer for this one.
+enableAutoUnmount(afterEach)
+afterEach(() => {
+ vi.unstubAllGlobals()
+ localStorage.clear()
+ headlessFailure.value = null
+})
+
+/** The uidx server's JSON, as `unavailable()` expects it. */
+const json = (body: unknown, status = 200): Response =>
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
+
+const codegen = (extra: object = {}) => ({
+ out: '../generated',
+ running: false,
+ notice: '',
+ result: null,
+ ...extra,
+})
+
+/** The manifest miss every new part binding hits. */
+const spinner = {
+ file: 'button.uidx',
+ line: 11,
+ column: 3,
+ message:
+ 'part "spinner-track" has no element in the manifest (looked for hwc-button-spinner-track)',
+ severity: 'error',
+}
/** The Code tab: a component's generated files, live, one tab per target. */
describe('the Code tab', () => {
@@ -15,17 +44,10 @@ describe('the Code tab', () => {
]
it('asks the server for the component and shows React first, with a tab per file', async () => {
- const fetcher = vi
- .fn()
- .mockResolvedValue(new Response(JSON.stringify({ files, diagnostics: [] })))
+ const fetcher = vi.fn().mockResolvedValue(json({ files, diagnostics: [] }))
vi.stubGlobal('fetch', fetcher)
const section = mount(CodeSection, {
- props: {
- component: 'Button',
- stamp: 'a',
- writable: true,
- codegen: { out: 'src/ds', running: false, notice: '' },
- },
+ props: { component: 'Button', stamp: 'a', writable: true, codegen: codegen() },
})
await flushPromises()
expect(fetcher.mock.calls[0]![0]).toBe('/__uidx/code?component=Button')
@@ -38,8 +60,12 @@ describe('the Code tab', () => {
expect(section.find('pre').text()).toContain('export function Button()')
await section.find('[data-file="html/button.css"]').trigger('click')
expect(section.find('.path').text()).toBe('html/button.css')
+ expect(section.find('[data-file="html/button.css"]').attributes('aria-selected')).toBe('true')
+ await section.find('[data-file="html/button.css"]').trigger('keydown', { key: 'ArrowRight' })
+ expect(section.find('.path').text()).toBe('contract/button.json')
await section.find('[data-action="write"]').trigger('click')
expect(section.emitted('generateCode')).toHaveLength(1)
+ expect(section.emitted('status')).toEqual([[{ component: 'Button', state: 'live', items: [] }]])
})
it('says what to select when nothing has a component, and how to configure writing', async () => {
@@ -47,15 +73,193 @@ describe('the Code tab', () => {
expect(
mount(CodeSection, { props: { component: null, stamp: '', writable: true } }).text(),
).toContain('Select a component')
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json({ files, diagnostics: [] })))
+ const section = mount(CodeSection, {
+ props: { component: 'Button', stamp: '', writable: true },
+ })
+ await flushPromises()
+ expect(section.find('.write').text()).toContain('codegen')
+ await section.find('.write .link-button').trigger('click')
+ expect(section.emitted('act')).toEqual([
+ [{ label: 'Set folder', run: 'open-project', arg: 'output' }],
+ ])
+ })
+
+ it('writes code from the footer and says how many files the last run wrote', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json({ files, diagnostics: [] })))
+ const section = mount(CodeSection, {
+ props: {
+ component: 'Button',
+ stamp: '',
+ writable: true,
+ codegen: codegen({
+ result: { kind: 'ok', written: 3, out: '../generated', at: Date.now() },
+ }),
+ },
+ })
+ await flushPromises()
+ const write = section.find('[data-action="write"]')
+ expect(write.text()).toBe('Write code')
+ expect(write.attributes('disabled')).toBeUndefined()
+ expect(write.attributes('title')).toBe('Write every component into ../generated')
+ expect(section.find('.write').text()).toContain('All components → ../generated')
+ expect(section.find('.write').text()).toContain('Wrote 3 files')
+ await section.setProps({ writable: false })
+ expect(section.find('[data-action="write"]').attributes('title')).toBe(
+ 'Reconnect to write code',
+ )
+ })
+
+ it('disables Copy and Write while the generator reports problems, and reports them up', async () => {
vi.stubGlobal(
'fetch',
- vi.fn().mockResolvedValue(new Response(JSON.stringify({ files, diagnostics: [] }))),
+ vi.fn().mockResolvedValue(
+ json({
+ files: [],
+ diagnostics: [
+ spinner,
+ { ...spinner, file: 'checkbox-field.uidx', line: 4 },
+ { ...spinner, line: 2, severity: 'warning' },
+ ],
+ }),
+ ),
)
+ const section = mount(CodeSection, {
+ props: {
+ component: 'Button',
+ stamp: '',
+ writable: true,
+ file: 'button.uidx',
+ codegen: codegen(),
+ },
+ })
+ await flushPromises()
+ expect(section.find('[data-action="copy"]').attributes('disabled')).toBeDefined()
+ const write = section.find('[data-action="write"]')
+ expect(write.attributes('disabled')).toBeDefined()
+ expect(write.attributes('title')).toBe('Fix 2 problems to write code')
+ expect(section.find('pre').exists()).toBe(false)
+ expect(section.find('.hint').text()).toBe('Code appears here once the problem above is fixed.')
+ // The raw diagnostic is the status line's, never the tab body's.
+ expect(section.text()).not.toContain('looked for')
+ const [[report]] = section.emitted('status') as [[{ state: string; items: { id: string }[] }]]
+ expect(report.state).toBe('blocked')
+ expect(report.items.map((item) => item.id)).toEqual(['code', 'blocked:checkbox-field.uidx'])
+ })
+
+ it('disables Copy when there is no code, and offers the targets when none produce a file', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json({ files: [], diagnostics: [] })))
+ const section = mount(CodeSection, {
+ props: { component: 'Button', stamp: '', writable: true, codegen: codegen() },
+ })
+ expect(section.find('[data-action="copy"]').attributes('disabled')).toBeDefined()
+ await flushPromises()
+ expect(section.find('[data-action="copy"]').attributes('disabled')).toBeDefined()
+ expect(section.find('[data-action="write"]').exists()).toBe(false)
+ expect(section.find('[data-empty="no-files"]').text()).toContain('No files for Button')
+ await section.find('[data-empty="no-files"] .link-button').trigger('click')
+ expect(section.emitted('act')).toEqual([
+ [{ label: 'Choose targets', run: 'open-project', arg: 'output' }],
+ ])
+ })
+
+ it('reports a failed request, retries on reload, and drops the failure once code arrives', async () => {
+ const fetcher = vi
+ .fn()
+ .mockResolvedValueOnce(
+ new Response('Bad gateway', {
+ status: 502,
+ headers: { 'content-type': 'text/html' },
+ }),
+ )
+ .mockResolvedValueOnce(json({ files, diagnostics: [] }))
+ vi.stubGlobal('fetch', fetcher)
+ const section = mount(CodeSection, {
+ props: { component: 'Button', stamp: '', writable: true, reload: 0, codegen: codegen() },
+ })
+ await flushPromises()
+ const reports = section.emitted('status') as [
+ { state: string; items: { title: string; detail?: string; actions?: unknown[] }[] },
+ ][]
+ expect(reports[0]![0].state).toBe('failed')
+ expect(reports[0]![0].items[0]).toMatchObject({
+ title: "Couldn't render code",
+ detail: 'The server sent an unexpected response.',
+ actions: [{ label: 'Retry', run: 'retry-code' }],
+ })
+ expect(section.find('[data-action="copy"]').attributes('disabled')).toBeDefined()
+ expect(section.find('.write').exists()).toBe(false)
+ await section.setProps({ reload: 1 })
+ await flushPromises()
+ expect(fetcher).toHaveBeenCalledTimes(2)
+ expect(reports[1]![0]).toEqual({ component: 'Button', state: 'live', items: [] })
+ expect(section.find('.hint').exists()).toBe(false)
+ expect(section.find('[data-action="copy"]').attributes('disabled')).toBeUndefined()
+ })
+
+ it("names a component the server can't render", async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(json({ error: 'Name a component of this document.' }, 404)),
+ )
+ const section = mount(CodeSection, {
+ props: { component: 'Bad$Name', stamp: '', writable: true },
+ })
+ await flushPromises()
+ const [[report]] = section.emitted('status') as [[{ items: { title: string }[] }]]
+ expect(report.items.map((item) => item.title)).toEqual(["Can't render this component"])
+ })
+
+ it('leaves a failure the unreadable library caused to the library, and renders once it loads', async () => {
+ const missing =
+ "ENOENT: no such file or directory, open '/p/vendor/missing/custom-elements.json'"
+ headlessFailure.value = {
+ code: 'not-found',
+ raw: `Could not read the headless library: ${missing}`,
+ }
+ const fetcher = vi
+ .fn()
+ .mockResolvedValueOnce(json({ error: missing }, 400))
+ .mockResolvedValueOnce(json({ files, diagnostics: [] }))
+ vi.stubGlobal('fetch', fetcher)
const section = mount(CodeSection, {
props: { component: 'Button', stamp: '', writable: true },
})
await flushPromises()
- expect(section.find('.write').text()).toContain('codegen')
+ const reports = section.emitted('status') as [{ state: string; items: unknown[] }][]
+ expect(reports[0]![0]).toEqual({ component: 'Button', state: 'failed', items: [] })
+ expect(section.text()).not.toContain('ENOENT')
+ headlessFailure.value = null
+ await flushPromises()
+ expect(fetcher).toHaveBeenCalledTimes(2)
+ expect(reports.at(-1)![0]).toEqual({ component: 'Button', state: 'live', items: [] })
+ expect(section.find('pre').exists()).toBe(true)
+ })
+
+ it('wraps long lines on request, remembers it, and copies the file on show', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json({ files, diagnostics: [] })))
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } })
+ const section = mount(CodeSection, {
+ props: { component: 'Button', stamp: '', writable: true, relation: 'of List' },
+ })
+ await flushPromises()
+ expect(section.find('.section-meta').text()).toBe('of List')
+ expect(section.find('pre').attributes('data-wrap')).toBe('false')
+ await section.find('[data-action="wrap"]').trigger('click')
+ expect(section.find('pre').attributes('data-wrap')).toBe('true')
+ expect(localStorage.getItem('uidx.code.wrap')).toBe('true')
+ const again = mount(CodeSection, { props: { component: 'Button', stamp: '', writable: true } })
+ await flushPromises()
+ expect(again.find('[data-action="wrap"]').attributes('aria-pressed')).toBe('true')
+
+ const copy = section.find('[data-action="copy"]')
+ expect(copy.attributes('aria-label')).toBe('Copy react/Button.tsx')
+ await copy.trigger('click')
+ await flushPromises()
+ expect(writeText).toHaveBeenCalledWith('export function Button() {}\n')
+ expect(section.find('[data-action="copy"] [data-icon="check"]').exists()).toBe(true)
+ expect(section.find('.sr-only').text()).toBe('Copied')
})
})
diff --git a/packages/viewer/test/connect-section.test.ts b/packages/viewer/test/connect-section.test.ts
index 46d2293..6756345 100644
--- a/packages/viewer/test/connect-section.test.ts
+++ b/packages/viewer/test/connect-section.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
import { parseOrThrow, resolve } from '@uidx/format'
import ConnectSection from '../src/ConnectSection.vue'
@@ -58,8 +59,10 @@ const mounted = (config: ConnectionConfig = CONFIG) =>
describe('the Connect tab', () => {
it('says what the component is in code, and how many parts are bound', () => {
const tab = mounted()
- expect(tab.find('.status-line').text()).toContain('Button→')
- expect(tab.find('.status-card').text()).toContain('1/2 parts bound')
+ expect((tab.find('[aria-label="Implements"]').element as HTMLSelectElement).value).toBe(
+ 'x-button',
+ )
+ expect(tab.find('[data-field="parts-progress"]').text()).toContain('1/2 parts bound')
})
it('counts the parts the library offers before the contract names them, as Contract does', () => {
@@ -69,7 +72,7 @@ describe('the Connect tab', () => {
const tab = mount(ConnectSection, {
props: { doc, component, library: shadow, config: CONFIG, writable: true },
})
- expect(tab.find('.status-card').text()).toContain('1/3 parts bound')
+ expect(tab.find('[data-field="parts-progress"]').text()).toContain('1/3 parts bound')
})
it('writes the element into the component file', async () => {
@@ -132,4 +135,172 @@ describe('the Connect tab', () => {
await fresh.find('[aria-label="Library path"]').trigger('keydown', { key: 'Enter' })
expect(fresh.emitted('chooseLibrary')).toEqual([['lib/custom-elements.json']])
})
+
+ it('names where it saves once, in sections whose (i) explains them', () => {
+ const tab = mount(ConnectSection, {
+ props: { doc, component, library, config: CONFIG, writable: true, relation: 'of Button' },
+ })
+ const heads = tab.findAll('.section-head .title').map((title) => title.text())
+ expect(heads).toEqual([
+ 'Headless element',
+ 'Names in the library',
+ 'React component',
+ 'Project',
+ ])
+ expect(tab.find('[data-group="element"] .section-meta').text()).toBe('of Button')
+ expect(tab.find('[data-group="element"] .info-tip').attributes('title')).toContain(
+ 'Saved in Button.uidx.',
+ )
+ expect(tab.find('.footnote').text()).toBe(
+ 'Element and parts save to Button.uidx; names, React and project to uidx.json.',
+ )
+ expect(tab.find('[data-group="project"] .section-meta').text()).toBe(
+ 'custom-elements.json · 2 elements',
+ )
+ })
+
+ it('keeps the element a list and pauses its checks while the library is unreadable', () => {
+ const tab = mount(ConnectSection, {
+ props: { doc, component, library: null, config: CONFIG, writable: true, libraryFailed: true },
+ })
+ const select = tab.find('[aria-label="Implements"]')
+ expect(select.element.tagName).toBe('SELECT')
+ expect(select.findAll('option').map((option) => option.text())).toEqual([
+ 'None',
+ 'x-button · library unavailable',
+ ])
+ expect(tab.find('[data-field="parts-progress"]').exists()).toBe(false)
+ expect(tab.find('[data-group="names"]').text()).not.toContain('No library connected')
+ expect(tab.find('[data-group="project"] .section-meta').text()).toBe('Library unavailable')
+ expect(tab.find('[data-field="library"]').text()).toContain('Unavailable')
+ expect(tab.find('[data-field="library"] .tone-dot[data-tone="warn"]').exists()).toBe(true)
+ })
+
+ it('points to Project from where a library or an output folder is missing', async () => {
+ const tab = mounted({ headless: null, codegen: null })
+ expect(tab.find('[aria-label="Implements"]').element.tagName).toBe('SELECT')
+ const bare = mount(ConnectSection, {
+ props: {
+ doc,
+ component,
+ library: null,
+ config: { headless: null, codegen: null },
+ writable: true,
+ },
+ })
+ expect(bare.find('[aria-label="Implements"]').element.tagName).toBe('INPUT')
+ expect(bare.find('[data-group="project"] .section-meta').text()).toBe('Not set up')
+ await bare.find('[data-group="element"] .link-button').trigger('click')
+ await bare.find('[data-group="react"] .field-help .link-button').trigger('click')
+ expect(bare.emitted('act')).toEqual([
+ [{ label: 'Choose library', run: 'open-project', arg: 'library' }],
+ [{ label: 'Set folder', run: 'open-project', arg: 'output' }],
+ ])
+ })
+
+ it('shows a refused value under its field', () => {
+ const mapped: ConnectionConfig = {
+ ...CONFIG,
+ codegen: { ...CONFIG.codegen!, react: { Button: { from: '' } } },
+ }
+ const react = mount(ConnectSection, {
+ props: {
+ doc,
+ component,
+ library,
+ config: mapped,
+ writable: true,
+ fieldError: { key: 'react', text: 'Name the module to import from' },
+ },
+ })
+ expect(react.find('[data-group="react"] .field-error').text()).toBe(
+ 'Name the module to import from',
+ )
+ expect(react.find('[aria-label="Module"]').attributes('aria-invalid')).toBe('true')
+ const output = mount(ConnectSection, {
+ props: {
+ doc,
+ component,
+ library,
+ config: CONFIG,
+ writable: true,
+ fieldError: { key: 'codegen', text: 'Enter a folder' },
+ },
+ })
+ expect(output.find('[data-group="project"] .field-error').text()).toBe('Enter a folder')
+ expect(output.findAll('.field-error')).toHaveLength(1)
+ })
+
+ it('removes a React mapping from its section head', async () => {
+ const tab = mounted({
+ ...CONFIG,
+ codegen: { ...CONFIG.codegen!, react: { Button: { from: '@acme/ui' } } },
+ })
+ expect(tab.find('[data-group="react"] .section-meta').text()).toBe('Button')
+ await tab.find('[aria-label="Remove mapping"]').trigger('click')
+ expect(tab.emitted('save')).toEqual([[{ key: 'react', component: 'Button', value: null }]])
+ })
+
+ it('changes the library from a closed row, by package name', async () => {
+ const tab = mounted({
+ ...CONFIG,
+ headless: {
+ ...CONFIG.headless!,
+ manifest: '../node_modules/@shoelace-style/shoelace/dist/custom-elements.json',
+ },
+ })
+ const row = tab.find('[data-field="library"]')
+ expect(row.find('.value').text()).toBe('shoelace')
+ expect(tab.find('[aria-label="Library path"]').exists()).toBe(false)
+ await row.find('button').trigger('click')
+ await tab.find('[aria-label="Library path"]').setValue('lib/custom-elements.json')
+ await tab.find('[aria-label="Library path"]').trigger('keydown', { key: 'Enter' })
+ expect(tab.emitted('chooseLibrary')).toEqual([['lib/custom-elements.json']])
+ expect(tab.find('[aria-label="Library path"]').exists()).toBe(false)
+ })
+
+ it('opens Project at the field the status line asks for', async () => {
+ const closed = mounted()
+ expect(closed.find('[data-group="project"]').attributes('open')).toBeUndefined()
+ const tab = mount(ConnectSection, {
+ attachTo: document.body,
+ props: {
+ doc,
+ component,
+ library,
+ config: CONFIG,
+ writable: true,
+ focus: { target: 'output', n: 101 },
+ },
+ })
+ await nextTick()
+ expect(tab.find('[data-group="project"]').attributes('open')).toBeDefined()
+ expect(document.activeElement?.getAttribute('aria-label')).toBe('Output folder')
+ await tab.setProps({ focus: { target: 'library', n: 102 } })
+ await nextTick()
+ expect(document.activeElement?.getAttribute('aria-label')).toBe('Library path')
+ tab.unmount()
+ })
+
+ it('shows the first sentence of the library description, and the rest on request', async () => {
+ const described = parseHeadless('custom-elements.json', {
+ modules: [
+ {
+ declarations: [
+ {
+ tagName: 'x-button',
+ description: 'A *headless* `button`. It renders\nno styles of its own.',
+ },
+ ],
+ },
+ ],
+ })
+ const tab = mount(ConnectSection, {
+ props: { doc, component, library: described, config: CONFIG, writable: true },
+ })
+ const text = () => tab.find('[data-group="element"] .clamp').text()
+ expect(text()).toBe('A headless button.')
+ await tab.find('[data-group="element"] .description .link-button').trigger('click')
+ expect(text()).toBe('A headless button. It renders no styles of its own.')
+ })
})
diff --git a/packages/viewer/test/contract-edits.test.ts b/packages/viewer/test/contract-edits.test.ts
index 425d85c..98f28d4 100644
--- a/packages/viewer/test/contract-edits.test.ts
+++ b/packages/viewer/test/contract-edits.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { mount } from '@vue/test-utils'
+import { flushPromises, mount } from '@vue/test-utils'
import { applyPatches, parseOrThrow, resolve, type UidxPatch } from '@uidx/format'
import { defaultVariantAddress, derivedDocument, modelIndex } from '@uidx/schema'
import ContractSection from '../src/ContractSection.vue'
@@ -7,6 +7,7 @@ import PropertiesPane from '../src/PropertiesPane.vue'
import {
bindPart,
contractIssues,
+ contractProblems,
contractType,
contractView,
scaffoldFromLibrary,
@@ -83,6 +84,9 @@ const CHECKBOX = page(
`,
)
+/** CHECKBOX with a layer bound to a part neither the library nor the contract declares. */
+const STRAY = CHECKBOX.replace('
@@ -196,6 +200,8 @@ describe('what the tab shows', () => {
// 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)
+ // One part still to bind is progress, not a problem: no warning for it.
+ expect(contractProblems(view, LIBRARY)).toBe(0)
})
it('keeps an element the library lacks as a choice, marked', () => {
@@ -834,31 +840,72 @@ describe('the Contract section', () => {
expect(mountFor(bare, 'loose').text()).toContain('Only layers inside a component')
expect(mountFor(LIST, 'List#option/row').text()).toContain('no component called “Row”')
})
-})
-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)
+ it('names a binding nothing declares, and shows its layer', async () => {
+ const section = mountFor(STRAY, 'Checkbox')
+ const issue = section.find('[data-field="code-binding"] .issue')
+ expect(issue.text()).toContain('“dash” is bound to “spinner”, which nothing declares.')
+ await issue.find('button').trigger('click')
+ expect(section.emitted('select')).toEqual([['Checkbox#dash']])
+ })
+
+ it('says when a layer draws a part its element does not offer', () => {
+ expect(mountFor(STRAY, 'Checkbox#dash').find('.issue').text()).toBe(
+ 'Not a part of hwc-checkbox. Code generation stops here.',
+ )
+ })
+
+ it('pauses the part checks while the library cannot be read, and keeps the Element select', () => {
+ // The fault itself is the shell's status line; here only what waits on it.
+ const doc = parseOrThrow(STRAY)
const section = mount(ContractSection, {
props: {
doc,
- node: null,
+ node: resolve(doc.tree, 'Checkbox'),
library: null,
- candidates: [{ package: '@acme/kit', path: 'node_modules/@acme/kit/custom-elements.json' }],
+ libraryError: 'Could not read the headless library: ENOENT',
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')
+ expect(section.text()).toContain('Not checked while the library is unavailable.')
+ expect(section.find('.issue').exists()).toBe(false)
+ expect(section.find('[data-field="choose-library"]').exists()).toBe(false)
+ expect(
+ section.findAll('[data-field="implements"] select option').map((o) => o.text().trim()),
+ ).toEqual(['None', 'hwc-checkbox · library unavailable'])
+ })
+
+ it('opens Code binding when the shell asks to show the parts, once per request', async () => {
+ const fresh = page(
+ 'fresh',
+ ` `,
+ )
+ const section = mountFor(fresh, 'Plain')
+ const folded = section.find('details[data-field="code-binding"]')
+ expect(folded.attributes('open')).toBeUndefined()
+ await section.setProps({ focus: { target: 'parts', n: 42 } })
+ await flushPromises()
+ expect((folded.element as HTMLDetailsElement).open).toBe(true)
+ // Back on the tab later, the same request is not served again.
+ const again = mount(ContractSection, {
+ props: { ...section.props(), focus: { target: 'parts', n: 42 } },
+ })
+ await flushPromises()
+ expect(again.find('details[data-field="code-binding"]').attributes('open')).toBeUndefined()
+ })
+})
+
+describe('choosing a library', () => {
+ it('points to Connect, where the project names its library', async () => {
+ // The chooser is Connect › Project's (connect-section.test.ts covers the path).
+ const doc = parseOrThrow(CHECKBOX)
+ const section = mount(ContractSection, {
+ props: { doc, node: resolve(doc.tree, 'Checkbox'), library: null, writable: true },
+ })
await section.find('[data-field="choose-library"] button').trigger('click')
- expect(section.emitted('chooseLibrary')![1]).toEqual(['../lib/custom-elements.json'])
+ expect(section.emitted('act')).toEqual([
+ [{ label: 'Open Connect', run: 'open-project', arg: 'library' }],
+ ])
})
it('says which tag uidx.json binds a component to', () => {
@@ -1012,28 +1059,6 @@ describe('editing the contract from the tab (ADR 0013 §2)', () => {
})
})
-describe('generating code from the tab', () => {
- it('offers the configured folder and asks the shell to generate', async () => {
- const doc = parseOrThrow(CHECKBOX)
- const section = mount(ContractSection, {
- props: {
- doc,
- node: resolve(doc.tree, 'Checkbox'),
- library: LIBRARY,
- codegen: { out: '../generated', running: false, notice: 'Wrote 3 files to ../generated' },
- writable: true,
- },
- })
- const button = section.find('[data-field="generate"] button')
- expect(button.text()).toBe('Generate → ../generated')
- await button.trigger('click')
- expect(section.emitted('generateCode')).toEqual([[]])
- expect(section.find('[data-field="generate-notice"]').text()).toBe(
- 'Wrote 3 files to ../generated',
- )
- })
-})
-
describe('the inspector tabs', () => {
it('points the Design face at the Contract tab for props, instead of a second editor', async () => {
const pane = mount(PropertiesPane, {
@@ -1061,18 +1086,20 @@ describe('the inspector tabs', () => {
])
})
- it('switches between Design and Contract, and counts parts to bind on the tab', async () => {
+ it('switches between Design and Contract, and marks the tab only for a real problem', async () => {
const doc = parseOrThrow(CHECKBOX)
const pane = mount(PropertiesPane, {
props: { doc, selection: ['Checkbox'], writable: true, headless: LIBRARY },
})
const tabs = pane.findAll('.face-toggle button')
+ // One part still to bind is progress, shown under Parts, not a mark on the tab.
expect(tabs.map((t) => t.text().replace(/\s+/g, ' '))).toEqual([
'Design',
- 'Contract 1',
+ 'Contract',
'Connect',
'Code',
])
+ expect(tabs[1]!.find('.badge').exists()).toBe(false)
expect(tabs[0]!.attributes('aria-pressed')).toBe('true')
expect(pane.find('.contract').exists()).toBe(false)
await tabs[1]!.trigger('click')
@@ -1080,6 +1107,17 @@ describe('the inspector tabs', () => {
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']])
+
+ // A layer bound to a part nothing declares is one: codegen refuses it.
+ const warned = mount(PropertiesPane, {
+ props: {
+ doc: parseOrThrow(STRAY),
+ selection: ['Checkbox'],
+ writable: true,
+ headless: LIBRARY,
+ },
+ })
+ expect(warned.find('.face-toggle .badge[data-tone="warn"]').exists()).toBe(true)
})
})
diff --git a/packages/viewer/test/contract-problems.test.ts b/packages/viewer/test/contract-problems.test.ts
new file mode 100644
index 0000000..c267aa1
--- /dev/null
+++ b/packages/viewer/test/contract-problems.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest'
+import { parseOrThrow, resolve } from '@uidx/format'
+
+import { contractIssues, contractProblems, contractView } from '../src/contract-edits'
+import { parseHeadless, type HeadlessLibrary } from '../src/headless'
+
+const LIBRARY = parseHeadless('vendor/custom-elements.json', {
+ modules: [
+ {
+ declarations: [
+ { tagName: 'hwc-button', attributes: [{ name: 'variant' }] },
+ { tagName: 'hwc-button-label' },
+ { tagName: 'hwc-button-spinner' },
+ ],
+ },
+ ],
+})
+
+const page = (body: string, regions = '') =>
+ `---\nid: button\n---\n\n## Visual Contract\n\n\n${body}\n \n${regions}`
+
+/**
+ * The Contract tab's warning dot counts faults, not progress: an unbound part
+ * is work not done yet, a binding code generation refuses is a problem.
+ */
+describe('contractProblems', () => {
+ const doc = parseOrThrow(
+ page(
+ `
+
+
+
+
+
+
+ `,
+ ),
+ )
+ const view = (address: string, library: HeadlessLibrary | null = LIBRARY) =>
+ contractView(doc, resolve(doc.tree, address), library)
+
+ it('counts stray bindings on a component, not its unbound parts', () => {
+ const button = view('Button')
+ // spinner is unbound (progress); spinner-track is bound to nothing declared (a fault).
+ expect(contractIssues(button)).toBe(2)
+ expect(contractProblems(button, LIBRARY)).toBe(1)
+ })
+
+ it('counts a part value the implemented element does not offer', () => {
+ expect(contractProblems(view('Button#track'), LIBRARY)).toBe(1)
+ expect(contractProblems(view('Button#label'), LIBRARY)).toBe(0)
+ expect(contractProblems(view('Button#icon'), LIBRARY)).toBe(0)
+ })
+
+ it('has nothing to check bindings against without a library or an element', () => {
+ expect(contractProblems(view('Button#track', null), null)).toBe(0)
+ expect(contractProblems(view('Plain#thing'), LIBRARY)).toBe(0)
+ // The same bindings seen from the component: unchecked, so not a fault.
+ expect(contractProblems(view('Button', null), null)).toBe(0)
+ expect(contractProblems(view('Plain'), LIBRARY)).toBe(0)
+ })
+})
diff --git a/packages/viewer/test/inspector-messages.test.ts b/packages/viewer/test/inspector-messages.test.ts
new file mode 100644
index 0000000..5d207eb
--- /dev/null
+++ b/packages/viewer/test/inspector-messages.test.ts
@@ -0,0 +1,303 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ ABOUT,
+ EMPTY,
+ INFO,
+ classifyFailure,
+ codeItems,
+ configLoadItem,
+ contractProblemItem,
+ fieldErrorText,
+ firstSentence,
+ libraryItem,
+ plainText,
+ requestItem,
+ saveItem,
+ shortDiagnostic,
+ shortPath,
+ sortByTone,
+ writeItem,
+ type Diagnostic,
+ type StatusItem,
+} from '../src/inspector-messages'
+
+const ENOENT =
+ "Could not read the headless library: ENOENT: no such file or directory, open '/abs/vendor/hwc/missing/custom-elements.json'"
+
+const diag = (file: string, line: number, message: string, severity = 'error'): Diagnostic => ({
+ file,
+ line,
+ column: 1,
+ message,
+ severity,
+})
+
+/** The tabs' words: one module, so a fault reads the same on every tab. */
+describe('classifyFailure', () => {
+ it('reads the class from the raw server and transport messages', () => {
+ expect(classifyFailure(new Error(ENOENT)).code).toBe('not-found')
+ expect(classifyFailure(new Error("EACCES: permission denied, open '/x.json'")).code).toBe(
+ 'no-access',
+ )
+ expect(classifyFailure(new Error('EISDIR: illegal operation on a directory, read')).code).toBe(
+ 'is-folder',
+ )
+ expect(
+ classifyFailure(
+ new Error(
+ 'Could not read the headless library: Unexpected token } in JSON at position 48213',
+ ),
+ ).code,
+ ).toBe('invalid-json')
+ expect(classifyFailure(new DOMException('signal timed out', 'TimeoutError')).code).toBe(
+ 'timeout',
+ )
+ expect(classifyFailure(new TypeError('Failed to fetch')).code).toBe('network')
+ expect(classifyFailure(new Error('The headless library service is unavailable.')).code).toBe(
+ 'unavailable',
+ )
+ expect(classifyFailure('something else').code).toBe('unknown')
+ })
+
+ it('prefers the code the server sends, and keeps the raw text', () => {
+ const error = Object.assign(new Error('Could not read the headless library: weird'), {
+ code: 'EACCES',
+ })
+ expect(classifyFailure(error)).toEqual({
+ code: 'no-access',
+ raw: 'Could not read the headless library: weird',
+ })
+ expect(classifyFailure(Object.assign(new Error('x'), { code: 'EJSON' })).code).toBe(
+ 'invalid-json',
+ )
+ })
+
+ it('takes an HTML page where JSON was expected for the server, not the file', () => {
+ expect(
+ classifyFailure(new SyntaxError(`Unexpected token '<', " {
+ it('keeps the first named segment and the file, joined by an ellipsis', () => {
+ expect(shortPath('../vendor/hwc/missing/custom-elements.json')).toBe(
+ '../vendor/…/custom-elements.json',
+ )
+ expect(shortPath('src/ds')).toBe('src/ds')
+ expect(shortPath('/home/user/projects/acme/packages/generated/react')).toBe(
+ '/home/…/packages/generated/react',
+ )
+ })
+
+ it('cuts a description at its first sentence, without Markdown', () => {
+ expect(
+ firstSentence(
+ 'Buttons represent *actions* that are `available` to the [user](https://x.dev). They can be styled.',
+ ),
+ ).toBe('Buttons represent actions that are available to the user.')
+ expect(firstSentence('No full stop')).toBe('No full stop')
+ })
+})
+
+describe('the library item', () => {
+ const missing = classifyFailure(new Error(ENOENT))
+
+ it('has the same title on every tab; the detail and actions differ', () => {
+ const items = (['connect', 'contract', 'code'] as const).map((face) =>
+ libraryItem(missing, face, '../vendor/hwc/missing/custom-elements.json'),
+ )
+ expect(new Set(items.map((item) => item.title))).toEqual(new Set(['Library file not found']))
+ expect(items.map((item) => item.tone)).toEqual(['danger', 'danger', 'danger'])
+ expect(items[0]!.detail).toBe('Element and part checks are paused until it loads.')
+ expect(items[1]!.detail).toBe('Part checks are paused.')
+ expect(items[2]!.detail).toBe("Code can't render without it.")
+ expect(items[0]!.actions!.map((a) => a.label)).toEqual(['Choose library', 'Retry'])
+ expect(items[1]!.actions!.map((a) => a.label)).toEqual(['Fix in Connect', 'Retry'])
+ expect(items[2]!.actions).toEqual(items[1]!.actions)
+ // The configured path shows; the absolute one the server tried is its title.
+ expect(items[0]!.path).toBe('../vendor/hwc/missing/custom-elements.json')
+ expect(items[0]!.pathTitle).toBe('/abs/vendor/hwc/missing/custom-elements.json')
+ expect(items[0]!.raw).toBe(ENOENT)
+ })
+
+ it('words each class of failure, and offers only Retry for the server', () => {
+ const title = (raw: string): string =>
+ libraryItem(classifyFailure(new Error(raw)), 'code', null).title
+ expect(title('EACCES: permission denied')).toBe("Can't open the library file")
+ expect(title('EISDIR: illegal operation on a directory')).toBe('Library path is a folder')
+ expect(title('Unexpected end of JSON input')).toBe("Library file isn't valid JSON")
+ expect(title('signal timed out')).toBe("Server didn't respond")
+ expect(title('The headless library service is unavailable.')).toBe(
+ 'Library service unavailable',
+ )
+ expect(title('Failed to fetch')).toBe("Can't reach the uidx server")
+ const down = libraryItem(classifyFailure(new Error('Failed to fetch')), 'connect', 'x.json')
+ expect(down.actions!.map((a) => a.run)).toEqual(['retry-library'])
+ expect(down.path).toBeUndefined()
+ })
+})
+
+describe('uidx.json items', () => {
+ it('words a load failure and a save failure', () => {
+ const load = configLoadItem(classifyFailure(new Error('Failed to fetch')))
+ expect(load.title).toBe("Can't reach the uidx server")
+ expect(load.actions).toEqual([{ label: 'Retry', run: 'retry-config' }])
+ const save = saveItem('That is not a change this panel makes.')
+ expect(save.title).toBe("Couldn't save the change")
+ expect(save.detail).toBe('That is not a change this panel makes.')
+ expect(save.raw).toBeUndefined()
+ })
+
+ it('turns a refused field into a short line under it', () => {
+ expect(fieldErrorText('react', 'Name the module the React component is imported from.')).toBe(
+ 'Name the module to import from',
+ )
+ expect(fieldErrorText('react', 'Say where generated code goes first ("Output" below).')).toBe(
+ 'Needs an output folder',
+ )
+ expect(fieldErrorText('codegen', 'Name the folder generated code goes into.')).toBe(
+ 'Enter a folder',
+ )
+ })
+})
+
+describe('code diagnostics', () => {
+ const SPINNER =
+ 'part "spinner-track" has no element in the manifest (looked for hwc-button-spinner-track)'
+
+ it('names an unknown part, and says where it is', () => {
+ expect(shortDiagnostic(diag('button.uidx', 11, SPINNER))).toEqual({
+ label: 'Unknown part “spinner-track”',
+ full: `button.uidx:11 ${SPINNER}`,
+ meta: 'button.uidx · line 11',
+ })
+ expect(
+ shortDiagnostic(
+ diag(
+ 'button.uidx',
+ 3,
+ 'part "spinner" (bound to "spinner-track") has no element in the manifest (looked for x)',
+ ),
+ ).label,
+ ).toBe('Unknown part “spinner”')
+ expect(shortDiagnostic(diag('a.uidx', 1, 'the contract is odd (see ADR 13)')).label).toBe(
+ 'The contract is odd',
+ )
+ })
+
+ it("splits the subject's own problems from the files that block it", () => {
+ const items = codeItems(
+ [
+ diag('components/button.uidx', 11, SPINNER),
+ diag('components/button.uidx', 12, 'a'),
+ diag('components/button.uidx', 13, 'b'),
+ diag('components/button.uidx', 14, 'c'),
+ diag('components/checkbox-field.uidx', 4, 'd'),
+ diag('components/button.uidx', 20, 'just a warning', 'warning'),
+ ],
+ 'button.uidx',
+ )
+ expect(items.map((item) => [item.id, item.title, item.count])).toEqual([
+ ['code', "Code can't be generated", '4 problems'],
+ ['blocked:components/checkbox-field.uidx', 'Blocked by checkbox-field.uidx', undefined],
+ ])
+ expect(items[0]!.rows).toHaveLength(4)
+ expect(items[0]!.rows![0]!.action).toEqual({ label: 'Open contract', run: 'open-contract' })
+ expect(items[1]!.detail).toBe('That file has 1 problem.')
+ expect(items[1]!.rows![0]!.action).toBeUndefined()
+ expect(codeItems([diag('a.uidx', 1, 'w', 'warning')])).toEqual([])
+ })
+
+ it('words request failures and the refused name', () => {
+ expect(requestItem({ status: 404 })).toMatchObject({
+ title: "Can't render this component",
+ detail: "Its name has characters code can't use.",
+ })
+ const down = requestItem(classifyFailure(new TypeError('Failed to fetch')))
+ expect(down).toMatchObject({
+ title: "Couldn't render code",
+ detail: "The server didn't respond.",
+ })
+ expect(down.actions).toEqual([{ label: 'Retry', run: 'retry-code' }])
+ // A file the render reads: a short sentence, the errno text behind Details.
+ const missing = requestItem(
+ classifyFailure(new Error("ENOENT: no such file or directory, open '/abs/tokens.json'")),
+ )
+ expect(missing).toMatchObject({
+ detail: 'A file it reads is missing.',
+ path: '/abs/tokens.json',
+ })
+ expect(missing.raw).toMatch(/^ENOENT/)
+ })
+})
+
+describe('the write result', () => {
+ it('is quiet on success and names what stopped it otherwise', () => {
+ expect(writeItem(null)).toBeNull()
+ expect(writeItem({ kind: 'ok', written: 3, out: '../generated', at: 0 })).toBeNull()
+ const blocked = writeItem({
+ kind: 'blocked',
+ diagnostics: [diag('a.uidx', 11, 'x'), diag('b.uidx', 42, 'y')],
+ })!
+ expect(blocked).toMatchObject({
+ title: 'Nothing written',
+ detail: '2 problems block code generation.',
+ })
+ expect(blocked.rows!.map((row) => row.meta)).toEqual(['a.uidx · line 11', 'b.uidx · line 42'])
+ const denied = writeItem({
+ kind: 'failed',
+ failure: classifyFailure(
+ new Error("EACCES: permission denied, mkdir '/home/me/app/packages/generated/react'"),
+ ),
+ })!
+ expect(denied).toMatchObject({
+ title: "Can't write to the output folder",
+ detail: 'Permission denied.',
+ path: '/home/me/app/packages/generated/react',
+ actions: [{ label: 'Change folder', run: 'open-project', arg: 'output' }],
+ })
+ expect(denied.raw).toMatch(/EACCES/)
+ })
+})
+
+describe('order and copy', () => {
+ it('sorts danger before warn before info, keeping order within a tone', () => {
+ const item = (id: string, tone: StatusItem['tone']): StatusItem => ({ id, tone, title: id })
+ expect(
+ sortByTone([
+ item('a', 'info'),
+ item('b', 'warn'),
+ item('c', 'danger'),
+ item('d', 'warn'),
+ ]).map((i) => i.id),
+ ).toEqual(['c', 'b', 'd', 'a'])
+ })
+
+ it('words every empty state and (i) in short sentences', () => {
+ for (const face of ['contract', 'connect', 'code'] as const) {
+ expect(EMPTY[face].none.title).toBe('No component selected')
+ expect(ABOUT[face].label).toMatch(/^About /)
+ }
+ expect(EMPTY.code.none.hint).toContain('Select a component')
+ expect(INFO.element('Button')).toContain('Button.uidx')
+ expect(INFO.react('Button')).toContain('uidx.json')
+ })
+
+ it('words the Contract warning like the Code row for the same fault', () => {
+ expect(contractProblemItem(1).title).toBe('1 unknown part binding')
+ expect(contractProblemItem(2)).toMatchObject({
+ title: '2 unknown part bindings',
+ tone: 'warn',
+ actions: [{ label: 'Show', run: 'open-contract' }],
+ })
+ })
+
+ it('strips Markdown from a whole description without cutting it', () => {
+ const text = 'A `button` with a [link](https://x.dev). Second *one* here.'
+ expect(plainText(text)).toBe('A button with a link. Second one here.')
+ expect(firstSentence(text)).toBe('A button with a link.')
+ })
+})
diff --git a/packages/viewer/test/inspector-shared.test.ts b/packages/viewer/test/inspector-shared.test.ts
new file mode 100644
index 0000000..c8e6308
--- /dev/null
+++ b/packages/viewer/test/inspector-shared.test.ts
@@ -0,0 +1,259 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { h } from 'vue'
+
+import InspectorEmpty from '../src/InspectorEmpty.vue'
+import InspectorSection from '../src/InspectorSection.vue'
+import InspectorStatus from '../src/InspectorStatus.vue'
+import type { StatusItem } from '../src/inspector-messages'
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ localStorage.clear()
+})
+
+const item = (
+ id: string,
+ tone: StatusItem['tone'],
+ extra: Partial = {},
+): StatusItem => ({
+ id,
+ tone,
+ title: `${id} title`,
+ ...extra,
+})
+
+/** The status line: the one place Contract, Connect and Code say something is wrong. */
+describe('InspectorStatus', () => {
+ it('renders nothing while there is nothing to say', () => {
+ const status = mount(InspectorStatus, { props: { items: [], open: false } })
+ expect(status.find('.inspector-status').exists()).toBe(false)
+ })
+
+ it('leads with the worst item, its title and how many more there are', () => {
+ const status = mount(InspectorStatus, {
+ props: {
+ items: [item('note', 'info'), item('lib', 'danger'), item('dot', 'warn')],
+ open: false,
+ },
+ })
+ const root = status.find('.inspector-status')
+ expect(root.attributes('data-tone')).toBe('danger')
+ expect(root.attributes('role')).toBe('alert')
+ expect(status.find('.status-title').text()).toBe('lib title')
+ expect(status.find('.status-count').text()).toBe('+2')
+ expect(status.find('[data-icon="alert-circle"]').exists()).toBe(true)
+ expect(status.find('.status-list').exists()).toBe(false)
+ })
+
+ it('prefers the item’s own count, and is a polite status below danger', () => {
+ const status = mount(InspectorStatus, {
+ props: { items: [item('code', 'warn', { count: '3 problems', detail: 'x' })], open: false },
+ })
+ expect(status.find('.status-count').text()).toBe('3 problems')
+ expect(status.find('.inspector-status').attributes('role')).toBe('status')
+ expect(status.find('.tone-dot[data-tone="warn"]').exists()).toBe(true)
+ })
+
+ it('opens to each item, in tone order, with its detail, path, rows and actions', async () => {
+ const status = mount(InspectorStatus, {
+ props: {
+ items: [
+ item('blocked', 'warn', { detail: 'That file has 1 problem.' }),
+ item('library', 'danger', {
+ detail: 'Part checks are paused.',
+ path: '../vendor/hwc/missing/custom-elements.json',
+ pathTitle: '/abs/vendor/hwc/missing/custom-elements.json',
+ rows: ['a', 'b', 'c', 'd', 'e'].map((label) => ({
+ label,
+ meta: `${label}.uidx · line 1`,
+ })),
+ actions: [{ label: 'Retry', run: 'retry-library' }],
+ }),
+ ],
+ open: false,
+ },
+ })
+ const bar = status.find('button.status-bar')
+ expect(bar.attributes('aria-expanded')).toBe('false')
+ await bar.trigger('click')
+ expect(status.emitted('update:open')).toEqual([[true]])
+ await status.setProps({ open: true })
+
+ const rows = status.findAll('.status-row')
+ expect(rows.map((row) => row.attributes('data-status'))).toEqual(['library', 'blocked'])
+ expect(rows[0]!.find('.row-title').text()).toBe('library title')
+ expect(rows[0]!.find('.status-detail').text()).toBe('Part checks are paused.')
+ const chip = rows[0]!.find('.path-chip')
+ expect(chip.text()).toBe('../vendor/…/custom-elements.json')
+ expect(chip.attributes('title')).toBe('/abs/vendor/hwc/missing/custom-elements.json')
+
+ // Three rows, then the rest behind '+N more'.
+ expect(rows[0]!.findAll('.row-label').map((l) => l.text())).toEqual(['a', 'b', 'c'])
+ const more = rows[0]!.findAll('.status-rows .link-button').at(-1)!
+ expect(more.text()).toBe('+2 more')
+ await more.trigger('click')
+ expect(status.findAll('.status-row')[0]!.findAll('.row-label')).toHaveLength(5)
+
+ await rows[0]!.find('.status-actions .link-button').trigger('click')
+ expect(status.emitted('act')).toEqual([[{ label: 'Retry', run: 'retry-library' }]])
+ })
+
+ it('does not repeat the title of a single item, and keeps raw text behind Details', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ vi.stubGlobal('navigator', { clipboard: { writeText } })
+ const status = mount(InspectorStatus, {
+ props: {
+ items: [item('library', 'danger', { detail: 'Gone.', raw: 'ENOENT: no such file' })],
+ open: true,
+ },
+ })
+ expect(status.find('.row-title').exists()).toBe(false)
+ expect(status.find('.status-bar').text()).toContain('library title')
+ const raw = status.find('details.status-raw')
+ expect(raw.find('summary').text()).toContain('Details')
+ expect(raw.find('pre').text()).toBe('ENOENT: no such file')
+ await raw.find('.link-button').trigger('click')
+ expect(writeText).toHaveBeenCalledWith('ENOENT: no such file')
+ })
+
+ it('shows a lone action inline when there is nothing to open', async () => {
+ const status = mount(InspectorStatus, {
+ props: {
+ items: [item('contract', 'warn', { actions: [{ label: 'Show', run: 'open-contract' }] })],
+ open: false,
+ },
+ })
+ expect(status.find('button.status-bar').exists()).toBe(false)
+ expect(status.find('.chevron').exists()).toBe(false)
+ await status.find('.status-bar .link-button').trigger('click')
+ expect(status.emitted('act')).toEqual([[{ label: 'Show', run: 'open-contract' }]])
+ expect(status.emitted('update:open')).toBeUndefined()
+ })
+})
+
+/** The section: the Design tab's full-bleed head, for the other three tabs. */
+describe('InspectorSection', () => {
+ it('renders its title, meta, actions and body, with its hooks on the root', async () => {
+ const section = mount(InspectorSection, {
+ props: {
+ title: 'Properties',
+ meta: '10',
+ metaTitle: 'Ten declarations',
+ info: 'What an instance can change.',
+ field: 'props',
+ group: 'element',
+ label: 'Properties',
+ },
+ slots: {
+ default: 'rows
',
+ actions: 'Fill from library ',
+ },
+ })
+ const root = section.find('section.section')
+ expect(root.attributes('data-field')).toBe('props')
+ expect(root.attributes('data-group')).toBe('element')
+ expect(root.attributes('aria-label')).toBe('Properties')
+ expect(section.find('.head .title').text()).toBe('Properties')
+ expect(section.find('.section-meta').text()).toBe('10')
+ expect(section.find('.section-meta').attributes('title')).toBe('Ten declarations')
+ expect(section.find('.section-actions .fill').exists()).toBe(true)
+ expect(section.find('.section-body .body').text()).toBe('rows')
+ expect(section.find('.chevron').exists()).toBe(false)
+
+ const tip = section.find('button.info-tip')
+ expect(tip.attributes('aria-label')).toBe('About Properties')
+ expect(tip.attributes('title')).toBe('What an instance can change.')
+ expect(tip.find('[data-icon="info"]').exists()).toBe(true)
+ expect(section.find('.section-tip').exists()).toBe(false)
+ await tip.trigger('click')
+ expect(tip.attributes('aria-expanded')).toBe('true')
+ expect(section.find('.section-tip').text()).toBe('What an instance can change.')
+ await tip.trigger('click')
+ expect(section.find('.section-tip').exists()).toBe(false)
+ })
+
+ it('leaves out what it is not given', () => {
+ const section = mount(InspectorSection, { props: { title: 'States' } })
+ expect(section.find('.section-meta').exists()).toBe(false)
+ expect(section.find('.info-tip').exists()).toBe(false)
+ expect(section.find('section').attributes('data-field')).toBeUndefined()
+ })
+
+ it('collapses as a details whose toggle reports the reader’s choice', async () => {
+ const section = mount(InspectorSection, {
+ props: {
+ title: 'Code binding',
+ collapsible: true,
+ field: 'code-binding',
+ meta: 'for developers',
+ },
+ slots: { default: 'element
' },
+ })
+ const details = section.find('details[data-field="code-binding"]')
+ expect(details.exists()).toBe(true)
+ expect((details.element as HTMLDetailsElement).open).toBe(false)
+ expect(details.text()).toContain('for developers')
+ expect(section.find('summary .chevron').exists()).toBe(true)
+ ;(details.element as HTMLDetailsElement).open = true
+ await details.trigger('toggle')
+ expect(section.emitted('toggle')).toEqual([[true]])
+
+ await section.setProps({ open: false })
+ await section.setProps({ open: true })
+ expect((details.element as HTMLDetailsElement).open).toBe(true)
+ })
+
+ it('keeps head buttons from toggling a collapsible section', async () => {
+ const onClick = vi.fn()
+ const section = mount(InspectorSection, {
+ props: { title: 'React component', collapsible: true, info: 'Render it.' },
+ slots: { actions: () => [h('button', { class: 'cluster-btn remove', onClick }, 'x')] },
+ })
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true })
+ section.find('.remove').element.dispatchEvent(event)
+ expect(onClick).toHaveBeenCalled()
+ expect(event.defaultPrevented).toBe(true)
+ await section.find('.info-tip').trigger('click')
+ expect((section.find('details').element as HTMLDetailsElement).open).toBe(false)
+ expect(section.find('.section-tip').exists()).toBe(true)
+ })
+})
+
+/** The empty state: a title, a hint, at most one action, and the tab's About. */
+describe('InspectorEmpty', () => {
+ it('renders its title, hint and action', async () => {
+ const empty = mount(InspectorEmpty, {
+ props: {
+ kind: 'outside',
+ title: 'Not in a component',
+ hint: 'Only layers inside a component have a contract.',
+ action: { label: 'Make component', run: 'make-component' },
+ },
+ })
+ expect(empty.find('.empty-state').attributes('data-empty')).toBe('outside')
+ expect(empty.find('.empty-title').text()).toBe('Not in a component')
+ expect(empty.find('.empty-hint').text()).toBe('Only layers inside a component have a contract.')
+ expect(empty.find('details.about').exists()).toBe(false)
+ await empty.find('.link-button').trigger('click')
+ expect(empty.emitted('act')).toEqual([[{ label: 'Make component', run: 'make-component' }]])
+ })
+
+ it('keeps the About disclosure closed until opened, then remembers it', async () => {
+ const props = {
+ kind: 'none',
+ title: 'No component selected',
+ about: { label: 'About Code', text: 'A live preview.' },
+ }
+ const first = mount(InspectorEmpty, { props })
+ const about = first.find('details.about')
+ expect(about.find('summary').text()).toContain('About Code')
+ expect((about.element as HTMLDetailsElement).open).toBe(false)
+ expect(first.find('.link-button').exists()).toBe(false)
+ ;(about.element as HTMLDetailsElement).open = true
+ await about.trigger('toggle')
+ expect(localStorage.getItem('uidx.inspector.about')).toBe('open')
+ const second = mount(InspectorEmpty, { props })
+ expect((second.find('details.about').element as HTMLDetailsElement).open).toBe(true)
+ })
+})
diff --git a/packages/viewer/test/inspector-subject.test.ts b/packages/viewer/test/inspector-subject.test.ts
new file mode 100644
index 0000000..7cbee74
--- /dev/null
+++ b/packages/viewer/test/inspector-subject.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest'
+import { parseOrThrow, resolve } from '@uidx/format'
+
+import { resolveSubject } from '../src/inspector-subject'
+
+const page = (id: string, body: string) =>
+ `---\nid: ${id}\n---\n\n## Visual Contract\n\n\n${body}\n \n`
+
+/**
+ * One answer to "what are Contract, Connect and Code about": an instance is
+ * about the component it uses, any other layer inside a component about that
+ * component, so the tabs cannot disagree.
+ */
+describe('resolveSubject', () => {
+ const doc = parseOrThrow(
+ page(
+ 'list',
+ `
+
+
+
+
+
+
+
+
+
+ `,
+ ),
+ )
+ const at = (address: string) => resolve(doc.tree, address)!
+
+ it('is nothing, or several layers, before it is a node', () => {
+ expect(resolveSubject(doc, null, 0)).toMatchObject({ kind: 'none', name: null, local: null })
+ expect(resolveSubject(doc, at('List'), 2)).toMatchObject({ kind: 'multi', name: null })
+ })
+
+ it('is a component itself, with no relation', () => {
+ const list = at('List')
+ expect(resolveSubject(doc, list, 1)).toEqual({
+ kind: 'component',
+ name: 'List',
+ local: list,
+ definition: list,
+ relation: null,
+ })
+ })
+
+ it('resolves an instance to its definition, inside a component or on the page', () => {
+ const row = resolveSubject(doc, at('List#body/row'), 1)
+ expect(row).toMatchObject({ kind: 'instance', name: 'PersonRow', relation: 'of PersonRow' })
+ expect(row.local).toBe(at('PersonRow'))
+ const top = resolveSubject(doc, at('list-1'), 1)
+ expect(top).toMatchObject({ kind: 'instance', name: 'List', relation: 'of List' })
+ expect(top.local).toBe(at('List'))
+ })
+
+ it('finds a definition declared on another page, without making it local', () => {
+ const badge = parseOrThrow(page('badge', ` `)).tree
+ .children[0]!
+ const subject = resolveSubject(doc, at('List#body/badge'), 1, new Map([['Badge', badge]]))
+ expect(subject).toMatchObject({ kind: 'instance', name: 'Badge', local: null })
+ expect(subject.definition).toBe(badge)
+ })
+
+ it('resolves an inner layer to its enclosing component', () => {
+ const subject = resolveSubject(doc, at('List#body'), 1)
+ expect(subject).toMatchObject({ kind: 'inside', name: 'List', relation: 'of List' })
+ expect(subject.local).toBe(at('List'))
+ expect(resolveSubject(doc, at('PersonRow#label'), 1).name).toBe('PersonRow')
+ })
+
+ it('is outside for a layer in no component', () => {
+ expect(resolveSubject(doc, at('loose'), 1)).toMatchObject({
+ kind: 'outside',
+ name: null,
+ local: null,
+ relation: null,
+ })
+ })
+})
diff --git a/packages/viewer/test/properties-pane.test.ts b/packages/viewer/test/properties-pane.test.ts
index 50ace1a..a5265c4 100644
--- a/packages/viewer/test/properties-pane.test.ts
+++ b/packages/viewer/test/properties-pane.test.ts
@@ -1,7 +1,12 @@
-import { mount } from '@vue/test-utils'
-import { describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { applyPatches, parseOrThrow, type JsonValue, type UidxPatch } from '@uidx/format'
import PropertiesPane from '../src/PropertiesPane.vue'
+import CodeSection from '../src/CodeSection.vue'
+import ConnectSection from '../src/ConnectSection.vue'
+import ContractSection from '../src/ContractSection.vue'
+import { connection, connectionError, connectionErrorKey, headlessFailure } from '../src/headless'
+import type { CodegenState } from '../src/headless'
const DOC = parseOrThrow(`---
id: fields
@@ -2537,3 +2542,326 @@ describe('pins (H2)', () => {
expect(wrapper.find('[data-field="centerY"]').exists()).toBe(false)
})
})
+
+/**
+ * Contract, Connect and Code share one shell: a tab strip that always fits
+ * and dots only a real problem, one identity row, a status line that words
+ * every fault once, and the same empty states. These pin what the shell
+ * decides, whatever each tab draws below it.
+ */
+describe('the code tabs share one shell', () => {
+ const TEAM = parseOrThrow(`---
+id: team
+---
+
+## Visual Contract
+
+
+
+
+
+`)
+ const LIST = parseOrThrow(`---
+id: list
+---
+
+## Visual Contract
+
+
+
+
+`)
+ const MISSING =
+ "Could not read the headless library: ENOENT: no such file or directory, open '/abs/vendor/hwc/missing/custom-elements.json'"
+ const CODEGEN: CodegenState = { out: '../generated', running: false, notice: '', result: null }
+
+ const mounted: VueWrapper[] = []
+ function shell(props: Record) {
+ // The Code tab renders through the server; an empty, healthy answer keeps it quiet.
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockImplementation(async () => json({ files: [], diagnostics: [] })),
+ )
+ const wrapper = mount(PropertiesPane, {
+ props: { doc: DOC, selection: ['Card'], tokens, writable: true, ...props },
+ })
+ mounted.push(wrapper)
+ return wrapper
+ }
+ const json = (body: unknown): Response =>
+ new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } })
+ const tab = (wrapper: VueWrapper, name: string) => wrapper.get(`[data-tour="tab-${name}"]`)
+ async function open(wrapper: VueWrapper, name: string) {
+ await tab(wrapper, name).trigger('click')
+ await flushPromises()
+ }
+
+ afterEach(() => {
+ for (const wrapper of mounted.splice(0)) wrapper.unmount()
+ vi.unstubAllGlobals()
+ headlessFailure.value = null
+ connectionError.value = ''
+ connectionErrorKey.value = null
+ connection.value = { headless: null, codegen: null }
+ })
+
+ it('draws four tabs that share the strip, each label in its own ellipsizing span', () => {
+ const tabs = shell({}).findAll('.face-toggle button')
+ expect(tabs.map((t) => t.attributes('data-tour'))).toEqual([
+ 'tab-design',
+ 'tab-contract',
+ 'tab-connect',
+ 'tab-code',
+ ])
+ expect(tabs.map((t) => t.get('.face-label').text())).toEqual([
+ 'Design',
+ 'Contract',
+ 'Connect',
+ 'Code',
+ ])
+ expect(tabs.map((t) => t.attributes('aria-pressed'))).toEqual([
+ 'true',
+ 'false',
+ 'false',
+ 'false',
+ ])
+ expect(tabs.some((t) => t.find('.badge').exists())).toBe(false)
+ })
+
+ it('says read-only once, as a lock chip at the end of the identity row', () => {
+ const wrapper = shell({ writable: false })
+ const head = wrapper.get('.node-head')
+ expect(head.get('.name').attributes('title')).toBe('Card')
+ const lock = head.get('.read-only-badge')
+ expect(lock.attributes('title')).toBe(
+ 'Read only. Reconnect to edit; you can still inspect and export.',
+ )
+ expect(lock.find('[data-icon="lock"]').exists()).toBe(true)
+ expect(lock.get('.sr-only').text()).toContain('Reconnect to edit')
+ expect(wrapper.find('.note.warn').exists()).toBe(false)
+ // With nothing selected the row still carries the chip, alone.
+ const idle = shell({ selection: [], writable: false })
+ expect(idle.get('.node-head').text()).toBe(lock.text())
+ expect(idle.find('.node-head .name').exists()).toBe(false)
+ })
+
+ it('names a multi-selection, and asks for one layer the same way on every code tab', async () => {
+ const wrapper = shell({ selection: ['Card#root', 'Card#root/label'] })
+ expect(wrapper.get('.node-head .name.multi').text()).toBe('2 layers')
+ const hints: Record = {
+ contract: 'Select one to see its contract.',
+ connect: 'Select one to see its connection.',
+ code: 'Select one to see its code.',
+ }
+ for (const [name, hint] of Object.entries(hints)) {
+ await open(wrapper, name)
+ const empty = wrapper.get('.empty-state[data-empty="multi"]')
+ expect(empty.get('.empty-title').text()).toBe('2 layers selected')
+ expect(empty.get('.empty-hint').text()).toBe(hint)
+ expect(empty.find('details.about').exists()).toBe(false)
+ // Connect still offers Project, which needs no selection.
+ expect(wrapper.find('.inspector-tab').exists(), name).toBe(name === 'connect')
+ }
+ })
+
+ it('shows nothing selected alike on every code tab, with what the tab is for', async () => {
+ const wrapper = shell({ selection: [] })
+ for (const name of ['contract', 'connect', 'code']) {
+ await open(wrapper, name)
+ const empty = wrapper.get('.empty-state[data-empty="none"]')
+ expect(empty.get('.empty-title').text()).toBe('No component selected')
+ expect(empty.get('details.about summary').text()).toContain(
+ `About ${name[0]!.toUpperCase()}${name.slice(1)}`,
+ )
+ }
+ })
+
+ it('words a library it cannot read once, on every tab, and dots only Connect', async () => {
+ const wrapper = shell({ headlessError: MISSING })
+ const connect = tab(wrapper, 'connect').get('.badge')
+ expect(connect.attributes('data-tone')).toBe('danger')
+ expect(connect.attributes('title')).toBe('Library file not found')
+ expect(tab(wrapper, 'connect').attributes('title')).toBe('Connect: Library file not found')
+ // Unchecked parts are not problems; the Code tab has not rendered yet.
+ expect(tab(wrapper, 'contract').find('.badge').exists()).toBe(false)
+ expect(tab(wrapper, 'code').find('.badge').exists()).toBe(false)
+ for (const name of ['contract', 'connect', 'code']) {
+ await open(wrapper, name)
+ const status = wrapper.get('.inspector-status')
+ expect(status.attributes('data-tone')).toBe('danger')
+ expect(status.get('.status-title').text()).toBe('Library file not found')
+ }
+ })
+
+ it("takes the status line's Fix in Connect to Connect › Project, at the library", async () => {
+ const wrapper = shell({ headlessError: MISSING })
+ await open(wrapper, 'contract')
+ await wrapper.get('button.status-bar').trigger('click')
+ const fix = wrapper
+ .findAll('.status-actions .link-button')
+ .find((b) => b.text() === 'Fix in Connect')!
+ await fix.trigger('click')
+ expect(tab(wrapper, 'connect').attributes('aria-pressed')).toBe('true')
+ expect(wrapper.getComponent(ConnectSection).props('focus')).toMatchObject({
+ target: 'library',
+ })
+ expect(wrapper.getComponent(ConnectSection).props('libraryFailed')).toBe(true)
+ })
+
+ it('retries the library from the status line', async () => {
+ const wrapper = shell({ headlessError: MISSING })
+ await open(wrapper, 'connect')
+ await wrapper.get('button.status-bar').trigger('click')
+ const fetcher = vi.fn().mockImplementation(async () => json({ path: null }))
+ vi.stubGlobal('fetch', fetcher)
+ await wrapper
+ .findAll('.status-actions .link-button')
+ .find((b) => b.text() === 'Retry')!
+ .trigger('click')
+ expect(fetcher.mock.calls[0]![0]).toBe('/__uidx/headless')
+ })
+
+ it('leaves a dead socket to the lock chip rather than saying it on every tab', async () => {
+ const offline = shell({ headlessError: 'Failed to fetch', writable: false })
+ await open(offline, 'connect')
+ expect(offline.find('.inspector-status').exists()).toBe(false)
+ const online = shell({ headlessError: 'Failed to fetch' })
+ await open(online, 'connect')
+ expect(online.get('.status-title').text()).toBe("Can't reach the uidx server")
+ })
+
+ it('puts a uidx.json that will not load on Connect and Code, and a refused profile on Connect', async () => {
+ connectionError.value = 'Unexpected token } in JSON at position 48'
+ const wrapper = shell({})
+ expect(tab(wrapper, 'connect').get('.badge').attributes('title')).toBe(
+ "uidx.json isn't valid JSON",
+ )
+ await open(wrapper, 'code')
+ expect(wrapper.get('.status-title').text()).toBe("uidx.json isn't valid JSON")
+ await open(wrapper, 'contract')
+ expect(wrapper.find('.inspector-status').exists()).toBe(false)
+
+ connectionError.value = 'Profile values must be strings.'
+ connectionErrorKey.value = 'profile'
+ await open(wrapper, 'connect')
+ expect(wrapper.get('.status-title').text()).toBe("Couldn't save the change")
+ expect(tab(wrapper, 'connect').find('.badge').exists()).toBe(false)
+ })
+
+ it('hands a refused field its error to show under the field', async () => {
+ connectionError.value = 'Name the module the React component is imported from.'
+ connectionErrorKey.value = 'react'
+ const wrapper = shell({})
+ await open(wrapper, 'connect')
+ expect(wrapper.getComponent(ConnectSection).props('fieldError')).toEqual({
+ key: 'react',
+ text: 'Name the module to import from',
+ })
+ expect(wrapper.find('.inspector-status').exists()).toBe(false)
+ })
+
+ it('marks the Code tab when its component cannot be generated, and opens the contract from a row', async () => {
+ const wrapper = shell({})
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockImplementation(async () =>
+ json({
+ files: [],
+ diagnostics: [
+ {
+ file: 'card.uidx',
+ line: 11,
+ column: 3,
+ message: 'part "spinner-track" has no element in the manifest',
+ severity: 'error',
+ },
+ ],
+ }),
+ ),
+ )
+ await open(wrapper, 'code')
+ await flushPromises()
+ expect(tab(wrapper, 'code').get('.badge').attributes('data-tone')).toBe('danger')
+ expect(wrapper.get('.status-title').text()).toBe("Code can't be generated")
+ expect(wrapper.get('.status-count').text()).toBe('1 problem')
+ await wrapper.get('button.status-bar').trigger('click')
+ await wrapper.get('.status-rows .link-button').trigger('click')
+ expect(tab(wrapper, 'contract').attributes('aria-pressed')).toBe('true')
+ expect(wrapper.getComponent(ContractSection).props('focus')).toMatchObject({
+ target: 'parts',
+ })
+ // An edit made elsewhere may have fixed it: the mark goes until Code looks again.
+ expect(tab(wrapper, 'code').find('.badge').exists()).toBe(true)
+ await wrapper.setProps({ doc: parseOrThrow(DOC.source.replace('2.1.0', '2.2.0')) })
+ expect(tab(wrapper, 'code').find('.badge').exists()).toBe(false)
+ })
+
+ it('reports a write that failed on the Code tab, with the way to fix it', async () => {
+ const wrapper = shell({
+ selection: [],
+ codegen: {
+ ...CODEGEN,
+ result: {
+ kind: 'failed',
+ failure: {
+ code: 'no-access',
+ raw: "EACCES: permission denied, mkdir '/home/me/generated/react'",
+ },
+ },
+ },
+ })
+ expect(tab(wrapper, 'code').get('.badge').attributes('title')).toBe(
+ "Can't write to the output folder",
+ )
+ await open(wrapper, 'code')
+ await wrapper.get('button.status-bar').trigger('click')
+ await wrapper
+ .findAll('.status-actions .link-button')
+ .find((b) => b.text() === 'Change folder')!
+ .trigger('click')
+ expect(tab(wrapper, 'connect').attributes('aria-pressed')).toBe('true')
+ expect(wrapper.getComponent(ConnectSection).props('focus')).toMatchObject({
+ target: 'output',
+ })
+ })
+
+ it('sends Connect on an instance to the page that defines its component', async () => {
+ const wrapper = shell({
+ doc: TEAM,
+ selection: ['list-1'],
+ components: new Map([['List', LIST.tree.children[0]!]]),
+ })
+ await open(wrapper, 'connect')
+ const empty = wrapper.get('.empty-state[data-empty="instance"]')
+ expect(empty.get('.empty-title').text()).toBe('Instance of List')
+ expect(empty.get('.empty-hint').text()).toBe("Connect it where it's defined.")
+ await empty.get('.link-button').trigger('click')
+ expect(wrapper.emitted('openComponent')).toEqual([['List']])
+ expect(wrapper.getComponent(ConnectSection).props('component')).toBeNull()
+ // Code is about the same component, wherever it is declared.
+ await open(wrapper, 'code')
+ expect(wrapper.getComponent(CodeSection).props('component')).toBe('List')
+ expect(wrapper.getComponent(CodeSection).props('relation')).toBe('of List')
+ })
+
+ it('offers to make a component of a layer outside every component', async () => {
+ const wrapper = shell({ doc: TEAM, selection: ['loose'], canMakeComponent: true })
+ for (const name of ['connect', 'code']) {
+ await open(wrapper, name)
+ const empty = wrapper.get('.empty-state[data-empty="outside"]')
+ expect(empty.get('.empty-title').text()).toBe('Not in a component')
+ await empty.get('.link-button').trigger('click')
+ }
+ expect(wrapper.emitted('makeComponent')).toHaveLength(2)
+ })
+
+ it('reads an inner layer as its component on Connect and Code', async () => {
+ const wrapper = shell({ selection: ['Card#root/label'] })
+ await open(wrapper, 'connect')
+ expect(wrapper.getComponent(ConnectSection).props('component')?.name).toBe('Card')
+ expect(wrapper.getComponent(ConnectSection).props('relation')).toBe('of Card')
+ await open(wrapper, 'code')
+ expect(wrapper.getComponent(CodeSection).props('component')).toBe('Card')
+ expect(wrapper.getComponent(CodeSection).props('relation')).toBe('of Card')
+ })
+})
From ac796cc81d6f5d529af9e8650dd3de74e6dd78de Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 2 Oct 2026 22:55:27 +0000
Subject: [PATCH 74/74] Fix what a review of the new code tabs found An
adversarial review of the shared frame for Contract, Connect and Code (each
finding reproduced by a second reader before it was fixed) turned up these,
among smaller copy and layout nits:
- An (i) on a collapsed section now shows its tip; it used to press and
show nothing, hiding the teaching copy on Names, React and Project.
- A refused library choice no longer marks the working library as
failed on every tab; the error stays under the path field, which
stays open with what was typed.
- Long component names no longer push section actions, chips or the
footnote past the pane, and two tab dots no longer cut every tab
label at the narrowest inspector.
- Open contract, Bind parts and Show select the component before
opening its Code binding. The Code dot follows the code whether or
not the tab is open, and a stale "Wrote N files" no longer sits over
a render that broke.
- Status rows wrap their diagnostic instead of cutting it, and the
title keeps priority over the count.
- Contract groups its declarations by kind, the slot view and an
undeclared slot use the shared row and wording, and a slot fill
inside an instance names the slot it fills.
- Hints use one grey that passes contrast in the light theme, the
Write code footer stays on the pane's bottom edge, and switching tabs
starts at the top.
- properties-pane.test.ts unmounts every pane after each test; leaked
panes re-rendered on each shared-state write and timed tests out.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01DTPs41geuetWYjWSKmsBRu
---
docs/inspector-design.md | 11 +-
packages/viewer/src/CodeSection.vue | 108 ++++--
packages/viewer/src/ConnectSection.vue | 233 ++++++++----
packages/viewer/src/ContractSection.vue | 360 ++++++++++++------
packages/viewer/src/InspectorEmpty.vue | 20 +-
packages/viewer/src/InspectorSection.vue | 150 +++++---
packages/viewer/src/InspectorStatus.vue | 109 +++---
packages/viewer/src/PropertiesPane.vue | 170 ++++++---
packages/viewer/src/SlotSettingsSection.vue | 49 ++-
packages/viewer/src/headless.ts | 50 ++-
packages/viewer/src/inspector-controls.css | 12 +-
packages/viewer/src/inspector-messages.ts | 184 ++++++---
packages/viewer/test/code-section.test.ts | 60 ++-
packages/viewer/test/connect-section.test.ts | 155 +++++++-
packages/viewer/test/contract-edits.test.ts | 169 +++++++-
packages/viewer/test/headless.test.ts | 162 ++++++++
.../viewer/test/inspector-messages.test.ts | 124 +++++-
packages/viewer/test/inspector-shared.test.ts | 128 ++++++-
packages/viewer/test/properties-pane.test.ts | 185 ++++++++-
19 files changed, 1882 insertions(+), 557 deletions(-)
create mode 100644 packages/viewer/test/headless.test.ts
diff --git a/docs/inspector-design.md b/docs/inspector-design.md
index 0abd9bb..63615b8 100644
--- a/docs/inspector-design.md
+++ b/docs/inspector-design.md
@@ -73,12 +73,17 @@ tab and like each other. Every title, hint, (i) text and action is worded in
- **Section header.** A full-bleed section with a rule below, as in Design:
the title, a faint meta (a count, "of Button" for a layer inside it,
"Updating…"), actions on the right, and an (i) that shows the section's
- explanation, and where it saves, on demand. Accessibility and forms, Code
- binding, Names in the library, React component and Project collapse.
+ explanation, and where it saves, on demand, open or closed. The (i) is the
+ last control, so the icons form one column down the tab. Accessibility and
+ forms, Code binding, Names in the library, React component and Project
+ collapse: the title and meta are the toggle, with the chevron before the
+ (i), and the head's other buttons sit beside the toggle, never in it.
- **Status line.** The first row of the tab, one line until opened: the
worst item's title and a count. Opened, each item shows a short detail, the
path it is about, up to three rows (then "+N more"), its actions, and the
- server's own words behind Details, with Copy. One fault reads the same on
+ server's own words behind Details, with Copy. A row gives the problem a line
+ of its own, then the file and line it comes from and its fix; a file that
+ blocks the render offers to open the component it declares. One fault reads the same on
every tab: an unreadable library is "Library file not found" on all three,
and only the detail and actions differ (Choose library on Connect, Fix in
Connect elsewhere). While the server is unreachable, the tabs leave that to
diff --git a/packages/viewer/src/CodeSection.vue b/packages/viewer/src/CodeSection.vue
index d5785c5..9dcb739 100644
--- a/packages/viewer/src/CodeSection.vue
+++ b/packages/viewer/src/CodeSection.vue
@@ -15,9 +15,9 @@ import {
noFiles,
requestItem,
writeBlockedReason,
+ type CodeReport,
type Failure,
type MessageAction,
- type StatusItem,
} from './inspector-messages'
/**
@@ -44,15 +44,10 @@ const props = defineProps<{
file?: string
/** Moves when the reader asks to retry, so the code refetches at once. */
reload?: number
+ /** The component a file declares, so a file that blocks this one can be opened. */
+ declaredIn?: (file: string) => string | undefined
}>()
-/** The tab's state after a render, for the shell's status line and tab dot. */
-interface CodeReport {
- component: string
- state: 'live' | 'updating' | 'blocked' | 'failed'
- items: StatusItem[]
-}
-
const emit = defineEmits<{
generateCode: []
status: [report: CodeReport]
@@ -168,7 +163,7 @@ function report(component: string): void {
component,
state: fault ? 'failed' : problems.value.length ? 'blocked' : 'live',
items: !fault
- ? codeItems(problems.value, props.file)
+ ? codeItems(problems.value, props.file, props.declaredIn)
: headlessFailure.value && !('status' in fault)
? []
: [requestItem(fault)],
@@ -227,6 +222,14 @@ const copied = ref(false)
const copyFailed = ref(false)
let copyTimer: ReturnType | undefined
const copyLabel = computed(() => (shown.value ? `Copy ${shown.value.path}` : 'Copy code'))
+/** A copy the browser refused says so where the pointer is, not only to a screen reader. */
+const copyTitle = computed(() =>
+ copied.value
+ ? 'Copied'
+ : copyFailed.value
+ ? "Couldn't copy. Select the text instead"
+ : copyLabel.value,
+)
async function copy(): Promise {
const file = shown.value
if (!file) return
@@ -279,6 +282,7 @@ const writeTitle = computed(() =>
writeBlockedReason({
writable: props.writable,
problems: problems.value.length,
+ failed: !!failed.value,
files: tabs.value.length,
out: props.codegen?.out ?? null,
}),
@@ -314,11 +318,14 @@ onUnmounted(() => {
class="cluster-btn"
data-action="copy"
:aria-label="copyLabel"
- :title="copied ? 'Copied' : copyLabel"
+ :title="copyTitle"
:disabled="!shown"
@click="copy"
>
-
+
{{
copied ? 'Copied' : copyFailed ? "Couldn't copy" : ''
@@ -353,7 +360,6 @@ onUnmounted(() => {
v-else-if="!tabs.length && !loading"
kind="no-files"
v-bind="noFiles(component)"
- @act="emit('act', $event)"
/>
{
>· {{ lines.length }} {{ lines.length === 1 ? 'line' : 'lines' }}
+
{{ i + 1 }} {{ line }}
-