@adobe/design-data-spec 4.14.0 → 4.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,7 +36,7 @@
36
36
  "container": {
37
37
  "kind": "box",
38
38
  "props": {
39
- "minWidth": {
39
+ "rotation": {
40
40
  "value": 0.5
41
41
  }
42
42
  }
@@ -4,7 +4,7 @@
4
4
  {
5
5
  "rule_id": "SPEC-083",
6
6
  "severity": "warning",
7
- "message_pattern": ".*Part 'container' \\(root\\).*binds 'minWidth'.*"
7
+ "message_pattern": ".*Part 'container' \\(root\\).*binds 'rotation'.*"
8
8
  }
9
9
  ]
10
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/design-data-spec",
3
- "version": "4.14.0",
3
+ "version": "4.15.0",
4
4
  "description": "Design Data Specification — prose, JSON Schemas, rule catalog, and conformance fixtures for the design data format",
5
5
  "type": "module",
6
6
  "repository": {
package/rules/rules.yaml CHANGED
@@ -1071,7 +1071,7 @@ rules:
1071
1071
  category: component
1072
1072
  note: >
1073
1073
  The recipe schema accepts props the renderer does not read (`direction`,
1074
- `align`, `minWidth`, and others on a part kind that has no use for them). The renderer
1074
+ `align`, `rotation`, and others on a part kind that has no use for them). The renderer
1075
1075
  ignores them without a diagnostic. The list of read props is the renderer's own table,
1076
1076
  not a copy kept in the rule. A recipe binding an ignored prop looks complete and draws
1077
1077
  differently from what it says.
@@ -1170,3 +1170,24 @@ rules:
1170
1170
  message: "Relationship {scope} (context '{context}') repeats an earlier record with the same {ref_or_value}; remove the duplicate"
1171
1171
  spec_ref: spec/relationship-format.md
1172
1172
  introduced_in: "1.0.0-draft"
1173
+
1174
+ - id: SPEC-089
1175
+ name: visual-nested-component-valid
1176
+ severity: error
1177
+ layer: 2
1178
+ category: component
1179
+ note: >
1180
+ Checks the `component` part kind (spec/component-visuals.md, Nested recipes) against the
1181
+ recipes and components the graph holds; the recipe is looked up through the component
1182
+ alias, as the renderer does. It runs over the loaded graph only, so a platform recipe that
1183
+ nests a component is checked when the platform graph is loaded.
1184
+ assert: >
1185
+ A `component` part MUST name a declared component that has a visual recipe. Each `with`
1186
+ key MUST be an option of that component with a valid value, and a `{option}` binding MUST
1187
+ name an option of the host of the same type. `sizeMap` MUST have an entry for every
1188
+ `sizeScale` key of the host, each a `sizeScale` key of the nested recipe. A recipe MUST NOT
1189
+ nest itself, directly or through other recipes, or nest more than two levels deep. The part
1190
+ MUST NOT be the root and MUST be a child of the root or of a box with a `direction`.
1191
+ message: "Component part '{part}' of '{component}': {reason}"
1192
+ spec_ref: spec/component-visuals.md#nested-recipes
1193
+ introduced_in: "1.0.0-draft"
@@ -140,6 +140,10 @@
140
140
  "type": "object",
141
141
  "required": ["component", "width", "height", "nodes"]
142
142
  },
143
+ "svg": {
144
+ "description": "The scene drawn as SVG (`scene_to_svg`, as `design-data render`). Written only with `design-data spec-sheet --svg`; views fall back to drawing nothing when it is absent.",
145
+ "type": "string"
146
+ },
143
147
  "highlight": {
144
148
  "description": "Recipe part and prop pairs bound to the block's relationship context.",
145
149
  "type": "array",
@@ -119,12 +119,64 @@
119
119
  "part": {
120
120
  "type": "object",
121
121
  "required": ["kind"],
122
- "if": { "properties": { "kind": { "const": "icon" } } },
123
- "then": { "required": ["icon"] },
124
- "else": { "not": { "required": ["icon"] } },
122
+ "allOf": [
123
+ {
124
+ "if": { "properties": { "kind": { "const": "icon" } } },
125
+ "then": { "required": ["icon"] },
126
+ "else": { "not": { "required": ["icon"] } }
127
+ },
128
+ {
129
+ "if": { "properties": { "kind": { "const": "component" } } },
130
+ "then": {
131
+ "required": ["component", "sizeMap"],
132
+ "not": {
133
+ "anyOf": [{ "required": ["props"] }, { "required": ["children"] }]
134
+ }
135
+ },
136
+ "else": {
137
+ "not": {
138
+ "anyOf": [
139
+ { "required": ["component"] },
140
+ { "required": ["sizeMap"] },
141
+ { "required": ["with"] }
142
+ ]
143
+ }
144
+ }
145
+ }
146
+ ],
125
147
  "properties": {
126
148
  "kind": {
127
- "enum": ["box", "text", "icon", "ring", "progress"]
149
+ "enum": ["box", "text", "icon", "ring", "progress", "component"]
150
+ },
151
+ "component": {
152
+ "type": "string",
153
+ "pattern": "^[a-z][a-z0-9-]*$",
154
+ "description": "For a component part: the component whose recipe is embedded (spec/component-visuals.md, Nested recipes). SPEC-089 checks it."
155
+ },
156
+ "sizeMap": {
157
+ "type": "object",
158
+ "description": "For a component part: maps each size of the host's sizeScale to a size of the nested recipe's sizeScale.",
159
+ "additionalProperties": { "type": "string" }
160
+ },
161
+ "with": {
162
+ "type": "object",
163
+ "description": "For a component part: options of the nested component, each a literal or {\"option\": name} (read from the host's request). Not `options`: that key means a condition elsewhere in a recipe.",
164
+ "additionalProperties": {
165
+ "oneOf": [
166
+ { "type": ["string", "boolean", "number"] },
167
+ {
168
+ "type": "object",
169
+ "required": ["option"],
170
+ "properties": {
171
+ "option": {
172
+ "type": "string",
173
+ "pattern": "^[a-zA-Z][a-zA-Z0-9]*$"
174
+ }
175
+ },
176
+ "additionalProperties": false
177
+ }
178
+ ]
179
+ }
128
180
  },
129
181
  "children": {
130
182
  "type": "array",
@@ -182,8 +234,10 @@
182
234
  "paddingBlockStart",
183
235
  "paddingBlockEnd",
184
236
  "height",
237
+ "minHeight",
185
238
  "minWidth",
186
239
  "width",
240
+ "grow",
187
241
  "cornerRadius",
188
242
  "cornerRadiusStartStart",
189
243
  "cornerRadiusStartEnd",
@@ -199,6 +253,7 @@
199
253
  "fontSize",
200
254
  "fontWeight",
201
255
  "lineHeight",
256
+ "overflow",
202
257
  "gapToParent",
203
258
  "rotation",
204
259
  "pressDifference",
@@ -47,36 +47,50 @@ Status: **draft / RFC**. The schema is [`component-visual.schema.json`](../schem
47
47
 
48
48
  ### Part
49
49
 
50
- | Field | Type | Description |
51
- | ------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
52
- | `kind` | enum | `box`, `text`, `icon`, `ring`. `ring` is an outline drawn outside the parent box (focus indicator). |
53
- | `children` | array of strings | Ordered child part names. Child order is layout order. Children **MUST** be declared parts. |
54
- | `visibleWhen` | condition | The part is drawn only when the condition holds. Absent means always drawn. |
55
- | `placement` | object | For a child that is positioned rather than flowed: `anchor` (`top-start`, `top-end`, `bottom-start`, `bottom-end`, `center`) and optional `inset` value. `start`/`end` are logical and flip under RTL. |
56
- | `around` | string | For a `ring`: the part it outlines. Defaults to the root. |
57
- | `icon` | object | For an `icon` part (REQUIRED there, not allowed on other kinds): the icon to draw, `{"set": "ui" \| "workflow", "name": "<id>"}`, plus optional `content` and `optional` flags. See [Icons](#icons). |
58
- | `pressTarget` | string | For the root: the part the pressed scale applies to (for example a switch handle). Parts nested inside it scale with it (a checkbox `box` scales with its mark). Defaults to the whole scene. |
59
- | `props` | object | Map of **visual property** → value source or conditional list. |
50
+ | Field | Type | Description |
51
+ | ------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
52
+ | `kind` | enum | `box`, `text`, `icon`, `ring`, `progress`, `component`. `ring` is an outline drawn outside the parent box (focus indicator). `component` embeds another component's recipe (see [Nested recipes](#nested-recipes)). |
53
+ | `component` | string | For a `component` part (REQUIRED there, not allowed on other kinds): the component whose recipe is drawn. SPEC-089. |
54
+ | `sizeMap` | object | For a `component` part (REQUIRED there): maps each size of the recipe's `sizeScale` to a size of the nested recipe's `sizeScale`. |
55
+ | `with` | object | For a `component` part: options of the nested component, each a literal or `{"option": "<host option>"}`. |
56
+ | `children` | array of strings | Ordered child part names. Child order is layout order. Children **MUST** be declared parts. |
57
+ | `visibleWhen` | condition | The part is drawn only when the condition holds. Absent means always drawn. |
58
+ | `placement` | object | For a child that is positioned rather than flowed: `anchor` (`top-start`, `top-end`, `bottom-start`, `bottom-end`, `center`) and optional `inset` value. `start`/`end` are logical and flip under RTL. |
59
+ | `around` | string | For a `ring`: the part it outlines. Defaults to the root. |
60
+ | `icon` | object | For an `icon` part (REQUIRED there, not allowed on other kinds): the icon to draw, `{"set": "ui" \| "workflow", "name": "<id>"}`, plus optional `content` and `optional` flags. See [Icons](#icons). |
61
+ | `pressTarget` | string | For the root: the part the pressed scale applies to (for example a switch handle). Parts nested inside it scale with it (a checkbox `box` scales with its mark). Defaults to the whole scene. |
62
+ | `props` | object | Map of **visual property** → value source or conditional list. |
60
63
 
61
64
  ### Visual properties
62
65
 
63
- | Property | Applies to | Meaning |
64
- | ---------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------ |
65
- | `direction`, `align`, `justify` | box | Root layout: `direction` `row` (default) or `column`; `align` and `justify` `start`, `center` (default) or `end`. |
66
- | `gap` | box | Space between flowed children. |
67
- | `paddingInlineStart`, `paddingInlineEnd`, `paddingBlockStart`, `paddingBlockEnd` | box | Inner padding. |
68
- | `height`, `minWidth`, `width` | box, icon | Dimensions. Box width defaults to content. |
69
- | `cornerRadius` | box, ring | Corner radius. |
70
- | `cornerRadiusStartStart`, `cornerRadiusStartEnd`, `cornerRadiusEndStart`, `cornerRadiusEndEnd` | box | Per-corner radius (block-inline). Each falls back to `cornerRadius`. Renderers draw left-to-right, so start-start is top-left. |
71
- | `fill`, `stroke`, `strokeWidth`, `opacity` | box, ring | Paint. |
72
- | `color` | text, icon | Content color. |
73
- | `trackColor` | progress | Color of the progress track behind the arc. |
74
- | `fontFamily`, `fontSize`, `fontWeight`, `lineHeight` | text | Text style. `lineHeight` sets the line box height. |
75
- | `gapToParent` | ring | Distance between the ring and the parent box. |
76
- | `rotation` | icon | Clockwise rotation in degrees about the icon's center (a `chevron` is 90 when open). |
66
+ | Property | Applies to | Meaning |
67
+ | ---------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
68
+ | `direction`, `align`, `justify` | box | Root layout: `direction` `row` (default) or `column`; `align` and `justify` `start`, `center` (default) or `end`. |
69
+ | `gap` | box | Space between flowed children. |
70
+ | `paddingInlineStart`, `paddingInlineEnd`, `paddingBlockStart`, `paddingBlockEnd` | box | Inner padding. |
71
+ | `height`, `minWidth`, `minHeight`, `width` | box, icon | Dimensions. Box width defaults to content. The root also reads `width` and `minWidth` (see **Root width**) and `minHeight` (see **Min height**); `minWidth` and `minHeight` are root only. |
72
+ | `grow` | box, text | Fill the rest of a row: `true` (one share) or a positive number (that many shares). See **Grow**. |
73
+ | `cornerRadius` | box, ring | Corner radius. |
74
+ | `cornerRadiusStartStart`, `cornerRadiusStartEnd`, `cornerRadiusEndStart`, `cornerRadiusEndEnd` | box | Per-corner radius (block-inline). Each falls back to `cornerRadius`. Renderers draw left-to-right, so start-start is top-left. |
75
+ | `fill`, `stroke`, `strokeWidth`, `opacity` | box, ring | Paint. |
76
+ | `color` | text, icon | Content color. |
77
+ | `trackColor` | progress | Color of the progress track behind the arc. |
78
+ | `fontFamily`, `fontSize`, `fontWeight`, `lineHeight` | text | Text style. `lineHeight` sets the line box height. |
79
+ | `overflow` | text | `wrap` or `truncate`. Absent: one line that hugs. See **Text wrapping**. |
80
+ | `gapToParent` | ring | Distance between the ring and the parent box. |
81
+ | `rotation` | icon | Clockwise rotation in degrees about the icon's center (a `chevron` is 90 when open). |
77
82
 
78
83
  **Text height.** A `text` part is as high as its line box: a `lineHeight` ending in `px` is that height, a unitless `lineHeight` multiplies `fontSize`, and with no `lineHeight` it is 1.3 times `fontSize`. A bound `fontFamily` leads the SVG font stack, so a family the viewer lacks falls back to the default stack.
79
84
 
85
+ **Text wrapping.** A `text` part that is a direct child of the root and binds `overflow` (inside a row box it is ignored) breaks or truncates its label to fit the width the root leaves it. The prop is named after React Spectrum's `overflowMode` (`wrap` | `truncate`) and, unlike a `wrap` flag, leaves room for other overflow behaviours. With no `overflow`, or with no fixed width (a request `width` or a root `width`), the text hugs on one line and never breaks, as before. The available width is the root's outer width (`max(minWidth, width)`) minus the inline padding, the `gap`s and the width of every other flowed part; a column root gives the text the whole inner width. Several parts with `overflow` share what is left equally. A text part has no width of its own to cap, so the root width is the only constraint: a recipe that needs a narrower label sets a narrower root or a `width` on a box around it.
86
+
87
+ * `wrap`: greedy line breaking. A line may end after a space or after a hyphen; a hyphen is always a break opportunity, also inside tokens such as `--flag` or `A-1`; trailing spaces do not count toward a line's width. When the padding, gaps and other parts leave no width, the available width is 0: every word takes its own line, and the scene reports the first overflowing word and the generic width diagnostic. Each candidate line is measured with the text table (R2). **NORMATIVE:** A word wider than the available width **MUST NOT** be split: it takes a line of its own and overflows the root, and the scene's `diagnostics` **MUST** say so. Hyphenation, bidirectional and CJK line breaking are not defined.
88
+ * `truncate`: one line. When the label is wider than the available width a renderer cuts it and appends `...`, keeping the longest prefix that fits (just `...` when none does).
89
+
90
+ **NORMATIVE:** A wrapped label is **one scene text node per line**: the same `part`, the same `x`, `y` increasing by the line height, `w` the line's own width and `h` one line height. A part with one line is a single node, so output that does not wrap is unchanged and consumers (SVG, overlays, spec-sheet measurements, the Figma plugin) need no special case. A part that sits beside a wrapped label and is centred (an icon) centres on the first line, like React Spectrum's `CenterBaseline`. Wrapping depends on the table (R2): a CLI render and a site agree on the lines only when they read the same table.
91
+
92
+ **Min height.** A root with `minHeight` and no `height` is `max(minHeight, tallest flowed child + block padding)` high. A row root that binds no block padding pads each side by `(minHeight - one line) / 2`, where one line is the tallest flowed child with every label on a single line. So a one-line label gives exactly `minHeight`, and a label that wraps keeps that padding and grows the container by a line height per extra line. A `height` is a fixed height: it wins over `minHeight` (unless larger) and a wrapped label overflows it. A token bound to `minHeight` is reported (`data-tokens`, measurements) as `height`.
93
+
80
94
  **Opacity.** Renderers read `opacity` only on the root part. It dims the whole component as one group (SVG `<g opacity>`), so overlapping parts do not show through each other, the way iOS dims a disabled control. On any other part `opacity` is ignored (SPEC-083).
81
95
 
82
96
  **Height.** A root `box` with no `height` is 32 high, unless the recipe binds `paddingBlockStart` or `paddingBlockEnd`: then its height is the tallest flowed child plus the block padding, and the children sit between the paddings. An explicit `height` always wins and the block padding then only offsets the children.
@@ -85,10 +99,39 @@ Status: **draft / RFC**. The schema is [`component-visual.schema.json`](../schem
85
99
 
86
100
  **Row boxes.** A flowed `box` that binds `direction: "row"` lays its `children` out in a row instead of insetting them: `icon`, `text` and `box` children in order, `gap` apart, between its `paddingInlineStart` and `paddingInlineEnd`, centered across (`align` moves them). Without a `width` it is as wide as its children plus padding; without a `height` it is as tall as the tallest child plus its block padding. A `text` child may bind its own padding props, which add to its cell. Only `row` is supported.
87
101
 
88
- **Column stretch.** In a column root, a flowed `box` with no `width` and no row content fills the column's inner width (a divider or a content region). A flowed `box` with no `height` and no children is as tall as its block padding.
102
+ **Root width.** The root's outer width is `max(minWidth, width)`, where `width` is the request's `width` if it has one, else the recipe's root `width`, else the content width. With neither a `width` nor a `minWidth` the root hugs its content, as before. Padding stays inside the outer width, so the content area is the outer width minus the inline padding. A request `width` overrides the recipe's `width` but never goes below `minWidth`. **NORMATIVE:** When a fixed width (from the recipe or the request) is smaller than the content, the renderer **MUST NOT** clip or shrink the content: it overflows the root, and the scene's `diagnostics` **MUST** say so. Text with `overflow` fits the width instead (see **Text wrapping**); a word that cannot break still overflows.
103
+
104
+ **Grow.** In a row (the root, or a row box), parts with `grow` split the space left after the other parts, the `gap`s and the padding, in proportion to their shares. A growing part starts from its content width and gains its share, so with no free space (a hugging root, or content as wide as the row) nothing changes, and a row that is too narrow never shrinks a part. A growing `box` is wider by its share; a growing `text` keeps its own width and starts at the start of its grown space (the text does not move, wrap or truncate). `grow` has no effect in a column or on an `icon`.
105
+
106
+ **Extra width.** Parts without `grow` do not stretch: in a row root wider than its content they stay grouped and placed by `justify` (centered by default), like a hugging root's content.
107
+
108
+ **Column stretch.** In a column root, a flowed `box` with no `width` (a divider, a content region, or a row box such as an accordion header) fills the column's inner width. A flowed `box` with no `height` and no children is as tall as its block padding.
89
109
 
90
110
  **Nested boxes.** A flowed `box` child paints itself (`fill`, `stroke`, `strokeWidth`, `cornerRadius`) and may list its own `children`, which are boxes laid out inside it. A nested child is centered across the parent. Along the inline axis its `justify` prop (`start`, `center`, `end`) places it, with an inset equal to the cross-axis gap. A nested child with no `width` or `height` fills the parent. A `cornerRadius` of `0.5` with `relativeTo: "height"` draws a pill, or a circle for a square box. The switch uses this for its track and handle. A nested `icon` is centered on both axes; the checkbox uses this for its checkmark and dash.
91
111
 
112
+ ### Nested recipes
113
+
114
+ A part of kind `component` draws another component's recipe inside this one, for example an icon-only action button and a switch in an accordion item's header:
115
+
116
+ ```json
117
+ "action": {
118
+ "kind": "component",
119
+ "component": "action-button",
120
+ "sizeMap": { "s": "s", "m": "m", "l": "l", "xl": "xl" },
121
+ "with": { "hideLabel": true, "isDisabled": { "option": "isDisabled" } }
122
+ }
123
+ ```
124
+
125
+ * **Options.** `with` sets options of the nested component. A value is a literal, or `{"option": name}`, which reads the host's option of that name from the render request (its default when unset) and is skipped when the host has no value for it. The key is `with`, not `options`: `options` means a condition everywhere else in a recipe. A nested option that is not listed keeps the nested component's default. The nested part gets no `label`, no `icon` override and no `width`; those request fields, and the request's interaction states, apply to the host only. `textMetrics` and the mode (`colorScheme`, `scale`) are the host's, so the nested text is measured with the same table.
126
+ * **Size.** `sizeMap` maps each host size to a nested size, because the scales differ (an accordion has `s`, `m`, `l`, `xl`; an action button also has `xs`). It is resolved when the host variant is compiled.
127
+ * **Binding.** The nested recipe binds its own relationships and tokens. The host binds only what is the host's: the `gap` of the row that holds the nested parts, the host's padding, and so on. A literal in `with` needs a reason (a fixed design choice such as icon-only), like any literal.
128
+ * **Layout.** A `component` part is a flowed child of the root, a child of a **row box**, or a child of a box inside a row box that binds `direction: "row"` (a group). Its size is the nested root's solved size; `gap`, `align` and `justify` of the box that holds it apply as for a `box` child. It has no `props` (SPEC-083) and no `grow`. A row box that holds `component` parts and shows none of them (`visibleWhen`) is not laid out at all, so an optional slot leaves no gap. A `component` part anywhere else is not drawn and the scene reports it.
129
+ * **Nodes.** The nested recipe is solved on its own and its nodes are appended to the scene, translated into place. **NORMATIVE:** the nested root's node keeps the host part's id (`action`); every other node is `<id>.<part>` (`action.icon`). Recipe part ids never contain a dot. So a consumer that looks nodes up by anatomy part name (the explorer's Anatomy view, spec-sheet markers, overlays) sees the nested component as one part, and a consumer that draws every node (SVG, the Figma plugin) draws it whole. Measurements, press and opacity of the nested scene are not carried; its diagnostics are, prefixed with the part id.
130
+ * **States.** The host's `hover`, `down` and `keyboard-focus` do not reach the nested part: it renders in its default state. An option-driven state works through the nested component's own options (`isDisabled`, `isSelected`) when `with` binds them.
131
+ * **Limits.** A recipe **MUST NOT** nest itself, directly or through other recipes (cycle), and nesting is at most two levels deep (host, nested, nested in nested). SPEC-089 checks both; the compiler refuses to compile either.
132
+ * **Compiled shape.** A compiled `component` part carries `nested` (the nested variant as `CompiledVariant::to_value` writes it: `root`, `optionDefaults`, `parts`, `drawings`) and `nestedSize`. Every other part of a compiled recipe is unchanged, so a reader that does not know the kind sees an unknown part and ignores it. A backend with one variant document (the slim WASM `Engine`) needs nothing else.
133
+ * **Platforms.** The nested recipe is looked up through the active platform's component aliases, as the renderer does everywhere: under `--platform`, a nested `button` draws `button-ios` if the platform aliases it. The `sizeMap` must then map into the platform recipe's `sizeScale`, or the compile fails. SPEC-089 checks the graph it is given.
134
+
92
135
  ### Icons
93
136
 
94
137
  An `icon` part names the icon it draws from the dataset's `icons/` catalog ([`icon-set.schema.json`](../schemas/icon-set.schema.json)):
@@ -165,7 +208,7 @@ Some relationship contexts describe a value the layout already implies. The vert
165
208
  | `edge` | `top` (default) or `bottom`: the container edge the spacing starts from. |
166
209
  | `from` | The formula. Only `(height - <part>.lineHeight) / 2` and `(height - <part>.height) / 2` exist; `height` is the container's solved height. |
167
210
 
168
- **NORMATIVE:** A renderer **MUST NOT** read a `derived` entry; it changes no scene. `<part>.lineHeight` is the part's solved height, which is its line height for a single-line label; tooling **MUST** solve with a label that does not wrap. Tooling reads a `derived` entry for two things. A spec sheet shows the context with a highlight on `part`, a marker and a measurement of the formula's value over the solved scene, labelled as derived from height. SPEC-087 compares, for every size and scale, the token the context's relationship gives with the formula's value over the solved scene. The comparison is a warning: a context whose relationship resolves no token is reported once, and a disagreement means the token data or the recipe's layout is wrong, never that the recipe binds the token. A control that is centred on the first label line (a checkbox or switch control) declares the label's formula, since the shared token is the space above the label line.
211
+ **NORMATIVE:** A renderer **MUST NOT** read a `derived` entry; it changes no scene. `<part>.lineHeight` is one line's height of the part, so the formula holds when the label wraps: the padding stays that value and the container grows (see **Min height**); tooling **MUST** solve with a label that does not wrap. Tooling reads a `derived` entry for two things. A spec sheet shows the context with a highlight on `part`, a marker and a measurement of the formula's value over the solved scene, labelled as derived from height. SPEC-087 compares, for every size and scale, the token the context's relationship gives with the formula's value over the solved scene. The comparison is a warning: a context whose relationship resolves no token is reported once, and a disagreement means the token data or the recipe's layout is wrong, never that the recipe binds the token. A control that is centred on the first label line (a checkbox or switch control) declares the label's formula, since the shared token is the space above the label line.
169
212
 
170
213
  ### Interim: relationship `context` strings
171
214
 
@@ -175,26 +218,27 @@ Relationship `context` is free text today (for example `Background (selected, em
175
218
 
176
219
  The recipe schema checks one file's structure (Layer 1). A JSON Schema cannot read other files, so cross-file checks against component and relationship data are Layer 2 rules. They run only when the recipe catalog is loaded (`design-data validate --visuals-path`) and are skipped when no component catalog is loaded.
177
220
 
178
- | Rule | Checks |
179
- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
180
- | SPEC-060 | `component` is declared; `parts` keys are anatomy parts; `root` is a top-level anatomy part; `children` appear in the anatomy part's `contains`; `around` and `pressTarget` resolve |
181
- | SPEC-061 | `when` and `visibleWhen` options exist with allowed values and types; states exist on the component; a `{<option>}` placeholder in a selector names a declared option that lists its values |
182
- | SPEC-062 | `relationship` selectors resolve to a record scoped to the component; `token` selectors match a token (`{size}` and `{sizeIndex}` expand over `sizeScale`, and `{<option>}` over the option's values, first) |
183
- | SPEC-063 | `sizeScale` keys equal the component's `size` option values (one entry when it has none) |
184
- | SPEC-066 | Warning. Every option and state the component declares drives the recipe or is listed in `notVisual`; `notVisual` names are declared and unused |
185
- | SPEC-067 | Warning. A `token` selector does not bind a token that one of the component's relationships already binds |
186
- | SPEC-068 | Warning. A literal does not supply a color or a non-zero size (layout keywords, `transparent`, zero and ratio radii are fine) |
187
- | SPEC-069 | Warning. A `token` or `relationship` selector does not resolve only to deprecated tokens (extends SPEC-058 to recipes) |
188
- | SPEC-070 | Warning. Recipes name the focus ring part (`kind: "ring"`) alike |
189
- | SPEC-071 | Info. Counts relationship contexts of the component that the recipe never binds |
190
- | SPEC-075 | Error. A part's `visibleWhen.states` agrees with the `presentIn` of its anatomy part (info when either lists more than one state; see [Anatomy format](anatomy-format.md#presentin)) |
191
- | SPEC-076 | Warning. Runs only with a platform manifest (`validate --platform <id> --visuals-path`). Every binding that resolves on the foundation also resolves once the manifest's cascade is applied |
192
- | SPEC-079 | Warning. A platform-owned recipe renders over the request grid with no scene diagnostic. Planned to become an error in a later minor release |
193
- | SPEC-080 | Info. A platform recipe replaces the foundation recipe of the same component name |
194
- | SPEC-083 | Warning. A part binds only props the renderer reads for it (the root, its `kind`, or a `placement`); the schema accepts more, and the renderer ignores the rest |
195
- | SPEC-085 | Error. An `icon` part's `set` and `name` exist in the `icons/` catalog. Runs when the catalog is loaded (`--icons-path`, or the probed `packages/design-data/icons`) |
196
- | SPEC-086 | Warning. Over the request grid, every `ui` icon has a drawing at the scale its size binding gives (see [Icons](#icons)), so the renderer does not fall back to the nearest size |
197
- | SPEC-087 | Warning. For every size and scale, the token a `derived` context gives equals the value of its formula over the solved scene (see [Derived contexts](#derived-contexts)) |
221
+ | Rule | Checks |
222
+ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
223
+ | SPEC-060 | `component` is declared; `parts` keys are anatomy parts; `root` is a top-level anatomy part; `children` appear in the anatomy part's `contains`; `around` and `pressTarget` resolve |
224
+ | SPEC-061 | `when` and `visibleWhen` options exist with allowed values and types; states exist on the component; a `{<option>}` placeholder in a selector names a declared option that lists its values |
225
+ | SPEC-062 | `relationship` selectors resolve to a record scoped to the component; `token` selectors match a token (`{size}` and `{sizeIndex}` expand over `sizeScale`, and `{<option>}` over the option's values, first) |
226
+ | SPEC-063 | `sizeScale` keys equal the component's `size` option values (one entry when it has none) |
227
+ | SPEC-066 | Warning. Every option and state the component declares drives the recipe or is listed in `notVisual`; `notVisual` names are declared and unused |
228
+ | SPEC-067 | Warning. A `token` selector does not bind a token that one of the component's relationships already binds |
229
+ | SPEC-068 | Warning. A literal does not supply a color or a non-zero size (layout keywords, `transparent`, zero and ratio radii are fine) |
230
+ | SPEC-069 | Warning. A `token` or `relationship` selector does not resolve only to deprecated tokens (extends SPEC-058 to recipes) |
231
+ | SPEC-070 | Warning. Recipes name the focus ring part (`kind: "ring"`) alike |
232
+ | SPEC-071 | Info. Counts relationship contexts of the component that the recipe never binds |
233
+ | SPEC-075 | Error. A part's `visibleWhen.states` agrees with the `presentIn` of its anatomy part (info when either lists more than one state; see [Anatomy format](anatomy-format.md#presentin)) |
234
+ | SPEC-076 | Warning. Runs only with a platform manifest (`validate --platform <id> --visuals-path`). Every binding that resolves on the foundation also resolves once the manifest's cascade is applied |
235
+ | SPEC-079 | Warning. A platform-owned recipe renders over the request grid with no scene diagnostic. Planned to become an error in a later minor release |
236
+ | SPEC-080 | Info. A platform recipe replaces the foundation recipe of the same component name |
237
+ | SPEC-083 | Warning. A part binds only props the renderer reads for it (the root, its `kind`, or a `placement`); the schema accepts more, and the renderer ignores the rest |
238
+ | SPEC-085 | Error. An `icon` part's `set` and `name` exist in the `icons/` catalog. Runs when the catalog is loaded (`--icons-path`, or the probed `packages/design-data/icons`) |
239
+ | SPEC-086 | Warning. Over the request grid, every `ui` icon has a drawing at the scale its size binding gives (see [Icons](#icons)), so the renderer does not fall back to the nearest size |
240
+ | SPEC-087 | Warning. For every size and scale, the token a `derived` context gives equals the value of its formula over the solved scene (see [Derived contexts](#derived-contexts)) |
241
+ | SPEC-089 | Error. A `component` part names a declared component with a recipe; its `with` options and `sizeMap` are valid; no cycle, at most two levels; it sits in the root's flow or a row box (see [Nested recipes](#nested-recipes)) |
198
242
 
199
243
  SPEC-066 to SPEC-070 are warnings, so `validate` stays green while recipes are migrated. A ratchet keeps the debt from growing: `sdk/core/tests/recipe_lint_baseline.rs` compares each (rule, recipe) warning count with `sdk/core/tests/fixtures/recipe-lint-baseline.json`. A new warning fails the test, an improvement must lower the baseline, and a recipe that is not in the baseline must have none.
200
244
 
@@ -225,7 +269,7 @@ New recipes **MUST** be free of SPEC-066 to SPEC-070 warnings. Existing recipes
225
269
 
226
270
  ## Render request and scene graph
227
271
 
228
- A render request is tool input, not dataset content: `{component, options, states, modes, label, icon, overlays}`. `label` replaces the label text and `icon` the content icon (see [Icons](#icons)). A **matrix** is a list of requests plus a layout (rows × columns by option or state).
272
+ A render request is tool input, not dataset content: `{component, options, states, modes, label, icon, width, overlays}`. `label` replaces the label text, `icon` the content icon (see [Icons](#icons)) and `width` (px, optional) is the demo width of the root (see **Root width**); empty means the recipe's own width. A **matrix** is a list of requests plus a layout (rows × columns by option or state).
229
273
 
230
274
  The scene graph is the contract between resolution and output:
231
275
 
@@ -237,7 +281,9 @@ The scene graph is the contract between resolution and output:
237
281
 
238
282
  ### Compiled recipes
239
283
 
240
- A build step **MAY** pre-resolve every `token` and `relationship` binding per `size|colorScheme|scale` into a ranked list of candidates (`{value, token, states}`), so a renderer needs no token graph. Only state-dependent selection remains at render time: take the first candidate whose `states` all apply, else the first. Backends that consume compiled recipes (for example the dependency-free JS runtime emitted by `design-data site`) **MUST** produce the same scene as the Rust reference renderer; the reference renderer is the source of truth and parity is tested.
284
+ A build step **MAY** pre-resolve every `token` and `relationship` binding per `size|colorScheme|scale` into a ranked list of candidates (`{value, token, states}`), so a renderer needs no token graph. Only state-dependent selection remains at render time: take the first candidate whose `states` all apply, else the first. The layout solver that consumes compiled recipes is written once, in Rust, and every surface runs that code: the CLI natively, and the browser (`design-data site`) as a slim WASM build. A backend **MUST NOT** reimplement layout or text measurement; it draws the scene the solver returns.
285
+
286
+ A compiled recipe **MUST NOT** depend on the request's `icon`: the request icon is applied when the scene is solved. For this a compiled content icon part (`icon.content: true`) carries `iconChoices`, an object mapping `set:name` to the key in the recipe's `drawings` of that icon's drawing at the part's scale (an icon with no usable drawing is absent), so a backend with one precompiled variant and no icon catalog can honour the request icon with the rules in [Icons](#icons). `iconChoices` is absent when the build had no icon catalog. Backends that predate `iconChoices` ignore it, and the existing `drawing`, `drawingMiss` and `drawings` fields are unchanged.
241
287
 
242
288
  ## Backend and tooling decisions (research outcomes)
243
289
 
@@ -245,9 +291,9 @@ These record the research behind this draft and are not normative.
245
291
 
246
292
  **R1 — Playground (Domino) fit: reference only for now.** Domino models component *instances* (`componentType`, props, `items`/`itemsOrder`, slots). It has no geometry, paint, layout or token-binding concepts, no JSON Schema for the node envelope, and no document-format versioning. Its `0react-spectrum/s2` schemas are generated from React Spectrum, and prop names (`isQuiet`, `isDisabled`, `staticColor`) mostly match ours. The repository is private (`"private": true`; MIT metadata but no LICENSE file), so a public spec must not depend on its schemas. Its Figma plugin maps Figma → Domino, the opposite of our Figma output. Decision: keep recipes independent. A render request can later be converted to or from a Domino instance by an adapter, because option names align. Revisit when Domino publishes a versioned envelope schema.
247
293
 
248
- **R2 — Text measurement: approximate metrics with Source Sans 3.** Adobe Clean is proprietary and cannot be redistributed in this repository. Source Sans 3 (SIL OFL) is the open fallback. Renderers ship a width table for the fallback font and emit SVG `<text>` with the stack `adobe-clean-spectrum-vf, Adobe Clean, 'Source Sans 3', sans-serif`, so viewers with Adobe Clean available see it. A family name that is not a valid CSS identifier sequence (`Source Sans 3`: a word starting with a digit) **MUST** be quoted, because one unquoted invalid name makes the browser drop the whole `font-family` list and render serif; a family bound by a recipe is emitted quoted and ahead of this stack. Overlay positions use the measured width and may differ slightly from the viewer's font. SVG output maps font-weight names from tokens (`medium`, `bold`, and so on) to numeric weights. Exact shaping (rustybuzz or similar) is deferred until a licensed-font story exists. Outlining text to paths is **not** used for SVG, since it would require the proprietary font. **RECOMMENDED:** a page that embeds the SVG inline loads Adobe Clean through its own web font (for example the Typekit kit the docs site uses, family `adobe-clean-spectrum-vf`). Standalone `.svg` files, SVG used as an `<img>` or CSS background, and rasterized PNG output cannot load web fonts. They use the installed font or fall back to Source Sans 3, so exports **MAY** look slightly different from the live view. Outlining text at export time with a locally licensed font is a possible later option.
294
+ **R2 — Text measurement: approximate metrics with Source Sans 3.** Adobe Clean is proprietary and cannot be redistributed in this repository. Source Sans 3 (SIL OFL) is the open fallback. Renderers ship a width table for the fallback font and emit SVG `<text>` with the stack `adobe-clean-spectrum-vf, Adobe Clean, 'Source Sans 3', sans-serif`, so viewers with Adobe Clean available see it. A family name that is not a valid CSS identifier sequence (`Source Sans 3`: a word starting with a digit) **MUST** be quoted, because one unquoted invalid name makes the browser drop the whole `font-family` list and render serif; a family bound by a recipe is emitted quoted and ahead of this stack. Overlay positions use the measured width and may differ slightly from the viewer's font. Line breaks (see **Text wrapping**) come from the same table, so two renderers agree on the lines only when they read the same table; the CLI and a site that ship the same table agree. SVG output maps font-weight names from tokens (`medium`, `bold`, and so on) to numeric weights. Exact shaping (rustybuzz or similar) is deferred until a licensed-font story exists. Outlining text to paths is **not** used for SVG, since it would require the proprietary font. **RECOMMENDED:** a page that embeds the SVG inline loads Adobe Clean through its own web font (for example the Typekit kit the docs site uses, family `adobe-clean-spectrum-vf`). Standalone `.svg` files, SVG used as an `<img>` or CSS background, and rasterized PNG output cannot load web fonts. They use the installed font or fall back to Source Sans 3, so exports **MAY** look slightly different from the live view. Outlining text at export time with a locally licensed font is a possible later option.
249
295
 
250
- **R3 — Layout: minimal flex first.** The pilot box model needs row direction, centering, gap, padding and one absolutely placed child. A purpose-built layout of that size avoids a dependency and keeps WASM small. Revisit `taffy` when text-field (wrapping, stacked label and help text) is piloted.
296
+ **R3 — Layout: minimal flex first.** The pilot box model needs row direction, centering, gap, padding and one absolutely placed child. A purpose-built layout of that size avoids a dependency and keeps WASM small. It exists once, in the Rust solver; browsers load it as WASM rather than a second implementation. Wrapping a label is a greedy breaker in the solver (see **Text wrapping**), and a nested recipe is the solver run on a second recipe (see [Nested recipes](#nested-recipes)); neither is a layout engine. Revisit `taffy` when text-field (stacked label and help text) is piloted.
251
297
 
252
298
  **R4 — Icons.** Icon geometry is SVG path data in the dataset's `icons/` catalog, imported from React Spectrum S2 (UI icons at every size, a few workflow icons). Recipes reference it by set and name ([Icons](#icons)); they hold no path data.
253
299