Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .changeset/checkbox-relationship-context-prefix.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ Recipe lints and fixes for checkbox, switch and others; pressed scale includes n
- **relationships**: strip the stray "S2 " prefix from contexts; add checkbox `Control`,
disabled and error contexts; scope selected switch and radio-button records with
`isSelected: true` (the schema allows boolean option values, the renderer maps them).
- **sdk/core, site runtime**: a pressed part's nested children scale with it.
- **visuals**: checkbox `pressTarget` is `control`; `notVisual` lists options with no look.
- **visuals**: pressed parts scale nested children; checkbox `pressTarget`; `notVisual` option list.
- **validate**: SPEC-066 to SPEC-070 warn on uncovered options and states, token selectors a
relationship covers, literals, deprecated tokens and focus ring names; SPEC-071 is info.
`recipe_lint_baseline` holds each recipe at its current warning count.
- **spec**: authoring conventions in `component-visuals.md`; `notVisual` recipe field.
- **switch**: document that `isReadOnly` has no distinct visual (hover and press only).
- **sdk/core**: render snapshot test for every recipe; `SceneCache` speeds matrix renders.
- **relationships**: `scope.options` pick a default or the most specific override (arrays allowed);
badge colors, padding and fonts bind that way, shrinking its recipe by 1,600 lines.
`scope.options.state` adds states to the bound token.
- **switch**: `isReadOnly` has no distinct visual (hover and press only).
- **button, action-button**: colors, press perspective, hold-icon inset bind via relationships.
- **focus-indicator**: the `focus-ring` anatomy, gap token and recipe parts are now
`focus-indicator` (legacy key `focus-ring-gap` kept).
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
},
{
"name": {
"anatomy": "focus-ring",
"anatomy": "focus-indicator",
"property": "gap"
},
"value": "2px"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
{
"name": {
"anatomy": "focus-ring",
"anatomy": "focus-indicator",
"property": "gap"
},
"value": "2px"
Expand Down
6 changes: 4 additions & 2 deletions packages/design-data-spec/schemas/relationship.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
},
"options": {
"type": "object",
"description": "Option-scoped narrowing (e.g. variant, size, style, arbitrary component option keys). Each string value MUST validate against the referenced component's options.<key>.values (SPEC-053); a boolean option (for example isSelected) takes `true` or `false`. `state` is the exception: an ordered array of atomic state ids validated against the component's declared states (SPEC-054).",
"description": "Option-scoped narrowing (e.g. variant, size, style, arbitrary component option keys). Each string value MUST validate against the referenced component's options.<key>.values (SPEC-053); a boolean option (for example isSelected) takes `true` or `false`; an array matches when any listed value matches. `state` is the exception: an ordered array of atomic state ids validated against the component's declared states (SPEC-054).",
"properties": {
"state": {
"type": "array",
Expand All @@ -77,7 +77,9 @@
}
},
"additionalProperties": {
"type": ["string", "boolean"]
"type": ["string", "boolean", "array"],
"items": { "type": ["string", "boolean"] },
"minItems": 1
}
}
},
Expand Down
6 changes: 4 additions & 2 deletions packages/design-data-spec/spec/component-visuals.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ A property value is either one **value source** or an ordered list of **conditio

Selector strings **MAY** contain `{size}` (the request's `size` option value, e.g. `m`) and `{sizeIndex}` (that size's entry in `sizeScale`). A scalar `token` selector value matches an array-valued name field that contains it (e.g. `"state": "disabled"` matches `["disabled"]`).

**NORMATIVE:** A relationship may pin component options in its `scope.options` (a value, or an array of values that match when any listed value matches). For a `relationship` selector the renderer **MUST** keep only the matching relationships whose pinned options all match the request, and **MUST** prefer the ones that pin the most options. A relationship that pins no options is the default: it applies only when no relationship that pins options matches. Keys that are not declared options of the component, and the reserved keys `state`, `scale` and `colorScheme`, are not pinned options. A relationship's `scope.options.state` adds states to those of the token it binds, so a hover color that is a plain palette token can still be selected by state. This lets the component data, not the recipe, say which token applies under which options (for example a badge label is white by default, black for the light variants, and gray for subtle).

**NORMATIVE:** `token` and `relationship` selectors yield a set of candidate tokens. The renderer **MUST** narrow the candidates with the request's mode context (for example `colorScheme`, `scale`) using the [cascade](cascade.md), and **MUST** report a diagnostic if zero or more than one candidate remains.

A conditional entry is a value source plus `when`. A condition has optional `options` (option name → value, or array of values), `states` (state names that must all be active) and `modes` (mode-set name → mode). An array matches when any listed value matches, and counts as one condition.
Expand Down Expand Up @@ -122,15 +124,15 @@ SPEC-066 to SPEC-070 are warnings, so `validate` stays green while recipes are m

These are the conventions the lints above enforce, plus the decisions made while adding the first five recipes. Follow them in a new recipe; a change to an existing recipe that breaks one needs a reason.

1. **Bind through relationships.** A recipe takes a value from the component's relationships by default (SPEC-067). Use a `token` selector only when no relationship covers the value, and a literal only for layout keywords, `transparent`, zero, ratio radii or geometry no token describes (SPEC-068). If the token you need is missing, file the gap; do not hide it behind a literal.
1. **Bind through relationships.** A recipe takes a value from the component's relationships by default (SPEC-067). Use a `token` selector only when no relationship covers the value, and a literal only for layout keywords, `transparent`, zero, ratio radii or geometry no token describes (SPEC-068). If the token you need is missing, file the gap; do not hide it behind a literal. Put option-dependent choices in the relationships, not in the recipe: pin `scope.options` on the relationship records (a default with no options, overrides that pin them) and bind the role once, so a variant added later is a data change.
2. **Bind current tokens.** Do not bind a deprecated token (SPEC-069; SPEC-058 covers relationships). When the only token is deprecated, bind it and record the gap, so the lint keeps showing it.
3. **Selection is an option, not a state.** Model "selected" as the `isSelected` option and scope relationships with `options.isSelected: true`. The renderer treats `isSelected` as the `selected` state and `isDisabled` as `disabled` when choosing between tokens that carry a state.
4. **Cover the component.** Every option and state the component declares either changes the recipe or is listed in `notVisual` (SPEC-066). `notVisual` records a deliberate absence, for example `isReadOnly` (it only suppresses hover and press) or `icon` until a shared icon registry exists.
5. **Press scales the part the design scales.** Set `pressTarget` to the part the design source scales when pressed: the checkbox scales its `control` (box and mark), the switch scales its `handle`. Parts nested inside the target scale with it, and the label does not.
6. **Priorities.** Disabled overrides interaction states with `priority: 10` (11 where a more specific disabled case must win). Error uses `priority: 5`.
7. **Relationship contexts are the design token group label,** with no `S1` or `S2` prefix, and the same label names the same concept in every component (for example `Control (disabled)`).
8. **Name the focus ring part alike** across recipes (SPEC-070).
9. **Prove a change.** After changing a recipe, a relationship or the renderer, render every option, state, size and color scheme combination before and after. Combinations the change should not touch must stay byte-identical.
9. **Prove a change.** `sdk/core/tests/recipe_snapshots.rs` renders every valid option, state, size, color scheme and scale combination of every recipe and compares a hash per option set with `tests/fixtures/recipe-snapshots.json`. It also fails on any render diagnostic. After a recipe, relationship, token or renderer change, review the option sets it reports with `design-data render`, then regenerate with `UPDATE_RECIPE_SNAPSHOTS=1`. A new recipe is covered automatically.

New recipes **MUST** be free of SPEC-066 to SPEC-070 warnings. Existing recipes are held at their baseline until they are migrated.

Expand Down
18 changes: 9 additions & 9 deletions packages/design-data-spec/spec/relationship-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ Each CTR is one of two subtypes, mirroring the token `oneOf` split (see [token-f

## Scope fields

| Field | Required | Type | Description |
| ----------------- | -------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scope.component` | yes | string | Component name (kebab-case). **MUST** match the `name` of a declared component in the dataset (rule SPEC-051, mirrors SPEC-018). |
| `scope.part` | no | string | Anatomy part name. **MUST** match a declared anatomy part on the referenced component when present (rule SPEC-052, mirrors SPEC-020). |
| `scope.property` | yes | string | The stylistic attribute this relationship describes (e.g. `color`, `width`, `padding`). |
| `scope.options` | no | object | Option-scoped narrowing. Each `options.<key>` value **MUST** validate against the referenced component's `options.<key>.values` (rule SPEC-053 — the headline CTR rule, stricter than the token-side SPEC-040 warning). A boolean option (for example `isSelected`) takes `true` or `false`; use it, not `state`, for conditions the component models as an option. `options.state` is the exception: an ordered array of atomic state ids, validated against the component's declared states (rule SPEC-054, mirrors SPEC-022). |
| `context` | no | string | Human-readable label for how this CTR is used (mirrors `tokenBindings[].context`; maps to a Figma Token Group label). Purely descriptive — not validated. |

Layer 1 (`relationship.schema.json`) only enforces shape: `scope.options` is an object, `scope.options.state` (when present) is an array of strings, and other option values are strings or, for boolean options, `true`/`false`. It does **not** enforce which option values are valid — that is a Layer 2 (Rust, `sdk/core`) relational rule, because it requires cross-referencing the component catalog.
| Field | Required | Type | Description |
| ----------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scope.component` | yes | string | Component name (kebab-case). **MUST** match the `name` of a declared component in the dataset (rule SPEC-051, mirrors SPEC-018). |
| `scope.part` | no | string | Anatomy part name. **MUST** match a declared anatomy part on the referenced component when present (rule SPEC-052, mirrors SPEC-020). |
| `scope.property` | yes | string | The stylistic attribute this relationship describes (e.g. `color`, `width`, `padding`). |
| `scope.options` | no | object | Option-scoped narrowing. Each `options.<key>` value **MUST** validate against the referenced component's `options.<key>.values` (rule SPEC-053 — the headline CTR rule, stricter than the token-side SPEC-040 warning). A boolean option (for example `isSelected`) takes `true` or `false`; use it, not `state`, for conditions the component models as an option. An array value matches when any listed value matches. A relationship with no pinned options is the default for its `(property, context)`; a recipe uses the one that pins the most options matching the request. `scope.options.state` adds states to those of the token the relationship binds. `options.state` is the exception: an ordered array of atomic state ids, validated against the component's declared states (rule SPEC-054, mirrors SPEC-022). |
| `context` | no | string | Human-readable label for how this CTR is used (mirrors `tokenBindings[].context`; maps to a Figma Token Group label). Purely descriptive — not validated. |

Layer 1 (`relationship.schema.json`) only enforces shape: `scope.options` is an object, `scope.options.state` (when present) is an array of strings, and other option values are strings, booleans (for boolean options) or arrays of either. It does **not** enforce which option values are valid — that is a Layer 2 (Rust, `sdk/core`) relational rule, because it requires cross-referencing the component catalog.

## Value-owning vs. relationship-only

Expand Down
2 changes: 1 addition & 1 deletion packages/design-data/components/action-button.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@
"description": "Corner triangle that signals a popover menu opens on hold."
},
{
"name": "focus-ring",
"name": "focus-indicator",
"description": "Outline drawn outside the container for keyboard focus."
}
],
Expand Down
2 changes: 1 addition & 1 deletion packages/design-data/components/button.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@
},
{ "name": "label", "description": "Button text.", "required": true },
{
"name": "focus-ring",
"name": "focus-indicator",
"description": "Outline drawn outside the container for keyboard focus."
}
],
Expand Down
Loading
Loading