@radicool/throughline 0.22.0 → 0.24.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.
- package/README.md +5 -5
- package/adapters/codex/prompts/component-builder.md +12 -4
- package/adapters/cursor/.cursor/rules/component-builder.mdc +12 -4
- package/adapters/generic/skills/component-builder/SKILL.md +12 -4
- package/package.json +1 -1
- package/references/figma-checks.md +813 -0
- package/references/figma-component-standards.md +16 -38
- package/references/figma-scripting.md +6 -6
- package/references/proof-bundle.md +1 -0
- package/references/state-baseline.md +5 -3
- package/scripts/README.md +5 -2
- package/scripts/build-figma-checks.mjs +111 -0
- package/scripts/build-state-baseline.mjs +5 -3
- package/scripts/install.mjs +9 -4
- package/scripts/lib/component-states.mjs +4 -1
- package/scripts/lib/contrast.mjs +5 -2
- package/scripts/lib/figma-checks-read.figma.js +174 -0
- package/scripts/lib/figma-checks.mjs +385 -0
- package/scripts/lib/fixtures/figma-checks/avatar-not-in-table.json +44 -0
- package/scripts/lib/fixtures/figma-checks/button-ios-swift-skipped.json +86 -0
- package/scripts/lib/fixtures/figma-checks/button-missing-default.json +72 -0
- package/scripts/lib/fixtures/figma-checks/button-mui-ring.json +93 -0
- package/scripts/lib/fixtures/figma-checks/button-no-focus-variant.json +72 -0
- package/scripts/lib/fixtures/figma-checks/button-plain-frames.json +93 -0
- package/scripts/lib/fixtures/figma-checks/button-ring-child-not-absolute.json +95 -0
- package/scripts/lib/fixtures/figma-checks/button-ring-hidden.json +93 -0
- package/scripts/lib/fixtures/figma-checks/button-ring-unbound-colour.json +99 -0
- package/scripts/lib/fixtures/figma-checks/button-shadcn-filled.json +93 -0
- package/scripts/lib/fixtures/figma-checks/button-sparse.json +135 -0
- package/scripts/lib/fixtures/figma-checks/button-spread-zero.json +93 -0
- package/scripts/lib/fixtures/figma-checks/button-transparent-ring-child.json +95 -0
- package/scripts/lib/fixtures/figma-checks/button-vanilla-inside.json +88 -0
- package/scripts/lib/fixtures/figma-checks/button-vanilla-outside.json +88 -0
- package/scripts/lib/fixtures/figma-checks/button-wrong-combination.json +93 -0
- package/scripts/lib/fixtures/figma-checks/checkbox-checked-axis.real.json +246 -0
- package/scripts/lib/fixtures/figma-checks/checkbox-checked-synonym.json +88 -0
- package/scripts/lib/fixtures/figma-checks/checkbox-missing-selected.json +80 -0
- package/scripts/lib/fixtures/figma-checks/indicator-failed.json +106 -0
- package/scripts/lib/fixtures/figma-checks/input-control-layer-missing.json +67 -0
- package/scripts/lib/fixtures/figma-checks/input-control-layer-ring-on-wrapper.json +80 -0
- package/scripts/lib/fixtures/figma-checks/input-control-layer.json +80 -0
- package/scripts/lib/fixtures/figma-checks/input-mui-weight-unbound.json +67 -0
- package/scripts/lib/fixtures/figma-checks/input-mui.json +67 -0
- package/scripts/lib/fixtures/figma-checks/ring-single-mode.json +112 -0
- package/scripts/lib/fixtures/figma-checks/ring-translucent.json +111 -0
- package/scripts/lib/fixtures/figma-checks/sample-button.real.json +1498 -0
- package/scripts/lib/fixtures/figma-checks/sample-ring-on-muted-raw.json +113 -0
- package/scripts/lib/fixtures/figma-checks/sample-ring-passes.json +169 -0
- package/scripts/lib/fixtures/figma-checks/surface-missing.json +108 -0
- package/scripts/lib/fixtures/figma-checks/surface-single-mode.json +112 -0
- package/scripts/lib/fixtures/figma-checks/surface-translucent.json +112 -0
- package/scripts/lib/fixtures/figma-checks/zygarden-ring-light-fails.json +113 -0
|
@@ -557,7 +557,9 @@ For each generated artboard / doc card / icon grid, read the nodes back (via
|
|
|
557
557
|
8. **States complete** — each component set's `state` axis includes every relevant
|
|
558
558
|
state for that component (every state the table in `references/state-baseline.md`
|
|
559
559
|
owes it, plus default, plus the applicable loading/success/error), with variants (incl. each
|
|
560
|
-
size) as rows and states as columns. A set shipping only `default` is a fail.
|
|
560
|
+
size) as rows and states as columns. A set shipping only `default` is a fail. The
|
|
561
|
+
programmatic half (the state matrix and the `COMPONENT_SET` structure) is the
|
|
562
|
+
script in `references/figma-checks.md`; the script is the rule.
|
|
561
563
|
9. **Focus state matches the library idiom** — the focus state is built as
|
|
562
564
|
`project.uiFramework`'s real pattern (see "State handling"), not a house-style
|
|
563
565
|
stroke, and with no padded wrapper inflating the component. Shadow-based recipes: a
|
|
@@ -570,52 +572,28 @@ For each generated artboard / doc card / icon grid, read the nodes back (via
|
|
|
570
572
|
wrapper, an inside-aligned stroke, or a transparent variant with no ring at all must
|
|
571
573
|
be retrofitted to the current recipe (see "State handling").
|
|
572
574
|
|
|
573
|
-
**The `focus-indicator` check** is the programmatic half of this item
|
|
574
|
-
|
|
575
|
+
**The `focus-indicator` check** is the programmatic half of this item, run by
|
|
576
|
+
the script in `references/figma-checks.md`; the script is the rule for which
|
|
577
|
+
frameworks and components it covers, how it finds the focus variants and the
|
|
578
|
+
control, what counts as a focus variable, and what each recipe must carry. What
|
|
579
|
+
a miss does depends on who designed the ring:
|
|
575
580
|
- **`figma-executor` returns `BLOCKED`** on a miss, naming the variant. It
|
|
576
581
|
builds from an architect spec, and adding a ring the spec didn't give is a
|
|
577
582
|
design decision it doesn't make.
|
|
578
583
|
- **Building inline**, you designed the ring yourself, so a miss is a build
|
|
579
584
|
error: fix and re-audit like any other item. If the ring is still missing
|
|
580
585
|
when the pass budget below runs out, **stop the build** rather than hand off.
|
|
581
|
-
- **Scope.** It runs only for a component whose name folds (lowercase,
|
|
582
|
-
punctuation stripped) to a name in the table in `references/state-baseline.md`
|
|
583
|
-
(not its exempt list), the same
|
|
584
|
-
exact-name match as the state check and the same list the CLI's
|
|
585
|
-
`state-incomplete` reads. It also
|
|
586
|
-
needs `project.uiFramework` to be exactly `shadcn`, `tailwind`, `mui`,
|
|
587
|
-
`vanilla-css` or `vanilla`, or absent or `null`. Anything else (`ios-swift`,
|
|
588
|
-
a tier-2 library, any other value) is skipped, and the skip is said.
|
|
589
|
-
- **What it reads.** Every child `COMPONENT` of the set whose
|
|
590
|
-
`variantProperties` has a key folding to `state` with a value folding to
|
|
591
|
-
exactly `focus`. `Focused` or `focus-visible` isn't read. The ring is looked
|
|
592
|
-
for on that variant, or, where the variant wraps the control in other content
|
|
593
|
-
(an Input with a label or helper text), on the child frame that is the
|
|
594
|
-
control, the node the recipe above puts the ring on. Reading **zero** focus
|
|
595
|
-
variants on an in-scope component is not a pass: it means the `state` axis
|
|
596
|
-
has no `focus` value, which the state check stops on.
|
|
597
|
-
- **A focus variable** is any variable whose name has a `focus` segment:
|
|
598
|
-
`border/focus`, `color/border/focus`, `color/focus/ring` and
|
|
599
|
-
`color/focus/ringOnFill` all count. Systems name their ring role differently,
|
|
600
|
-
and a collection prefix is common. A raw colour, or a variable with no `focus`
|
|
601
|
-
segment, doesn't count.
|
|
602
|
-
- **Ring recipes** (`shadcn`, `tailwind`, absent or `null`; `mui` on anything
|
|
603
|
-
but an input). The control has either a `DROP_SHADOW` effect with
|
|
604
|
-
`visible !== false`, `spread > 0` and `boundVariables.color` on a focus
|
|
605
|
-
variable, or a direct child with `layoutPositioning = "ABSOLUTE"` and
|
|
606
|
-
`strokeAlign = "OUTSIDE"` whose stroke is bound to a focus variable.
|
|
607
|
-
`spread > 0` matters: binding an effect's colour can silently zero its spread
|
|
608
|
-
(see `figma-scripting.md`), which leaves a ring that is bound and invisible.
|
|
609
|
-
Only the ring is asserted. The recipe's border recolour is styling, and a
|
|
610
|
-
control whose border keeps its normal colour beside a correct ring passes.
|
|
611
|
-
- **`mui` inputs.** The control's own stroke is bound to a focus variable, and its
|
|
612
|
-
weight to a variable (the recipe's `width/focus`).
|
|
613
|
-
- **`vanilla-css` / `vanilla`.** The control or a direct child carries a stroke
|
|
614
|
-
bound to a focus variable with `strokeAlign = "OUTSIDE"`. The `offset/focus` gap
|
|
615
|
-
isn't asserted.
|
|
616
586
|
- **The result.** On a miss, stop and name the variant and what it lacks. On a
|
|
617
587
|
pass, `component-builder` records `focus-indicator` as an attested check. A
|
|
618
588
|
skip records nothing.
|
|
589
|
+
|
|
590
|
+
**The `focus-contrast` check** is the same script's measure of whether the ring
|
|
591
|
+
can be seen: each ring colour against each surface the architect names in
|
|
592
|
+
`surfaces`, at WCAG 1.4.11's 3:1, in every mode. The script is the rule for
|
|
593
|
+
which pairs and modes it compares and when it skips. A fail is a token problem,
|
|
594
|
+
not a build error: `figma-executor` returns `BLOCKED` at once, and inline you
|
|
595
|
+
stop the build. Neither changes a variable or swaps the ring to make it pass.
|
|
596
|
+
On a pass, `component-builder` records `focus-contrast` as an attested check.
|
|
619
597
|
10. **Visual** — the screenshot (from the validation loop) shows no overlaps,
|
|
620
598
|
misalignment, lopsided hug/fill sizing, or clipped strokes/focus rings.
|
|
621
599
|
11. **Usage band rendered by the builder** — the card's `Usage` frame was
|
|
@@ -330,12 +330,12 @@ uses **build-verify-then-replace**:
|
|
|
330
330
|
2. **Verify the working frame green before touching anything real** — a
|
|
331
331
|
screenshot alone is **insufficient**: 10 tone-colored frames render
|
|
332
332
|
identically to 10 real variants, so a fast model can build plain frames and
|
|
333
|
-
self-report success. The gate is a **programmatic read-back** (`figma_execute`)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
333
|
+
self-report success. The gate is a **programmatic read-back** (`figma_execute`):
|
|
334
|
+
for a component set, run the checks in `references/figma-checks.md` (the script
|
|
335
|
+
is the rule for the `COMPONENT_SET` structure, the state matrix and the focus
|
|
336
|
+
ring), plus a spot-check of ≥2 variants showing fills/strokes/radius bound to
|
|
337
|
+
variables (not raw values) with the expected `clipsContent`. A
|
|
338
|
+
`figma_capture_screenshot` is a **secondary**
|
|
339
339
|
check (create → read-back → screenshot → iterate, max ~3), never the sole one.
|
|
340
340
|
3. **Only then finalize:** remove/replace any existing same-named component,
|
|
341
341
|
rename the working frame's component set to the real `<ComponentName>`, **and
|
|
@@ -207,6 +207,7 @@ keyed by component?"
|
|
|
207
207
|
| `state-baseline` | attested | `component-builder` |
|
|
208
208
|
| `figma-name` | attested | `component-builder` |
|
|
209
209
|
| `focus-indicator` | attested | `component-builder` |
|
|
210
|
+
| `focus-contrast` | attested | `component-builder` |
|
|
210
211
|
| `review` | attested | the dispatching skill |
|
|
211
212
|
| `build` | attested | `storybook-chromatic-builder` |
|
|
212
213
|
| `orphan-token` | derived | not recorded: re-derived on every `verify:check` run |
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
Which interaction states a component owes. This is the one list both checks read:
|
|
5
5
|
`verify:check` (`state-incomplete`) reads it from `scripts/lib/component-states.mjs`,
|
|
6
|
-
and `figma-
|
|
7
|
-
this table. CI fails when
|
|
6
|
+
and the Figma checks script (`references/figma-checks.md`) inlines the same module,
|
|
7
|
+
so this table is the human-readable view of both. CI fails when it is stale.
|
|
8
8
|
|
|
9
9
|
A component name matches a row when it folds (lowercase, punctuation and spaces
|
|
10
10
|
stripped) to exactly one of that row's names. `Text Field` folds to `textfield`.
|
|
@@ -17,7 +17,9 @@ matches nothing owes no states.
|
|
|
17
17
|
| input | `input`, `textfield`, `textinput` | `hover`, `focus`, `disabled` |
|
|
18
18
|
| choice | `checkbox`, `radio`, `toggle`, `switch`, `chip` | `hover`, `focus`, `active`, `selected`, `disabled` |
|
|
19
19
|
|
|
20
|
-
Every state in the table must be a value on the component's `state` axis
|
|
20
|
+
Every state in the table must be a value on the component's `state` axis, with one
|
|
21
|
+
exception in Figma: a choice control may carry `selected` on its own axis named
|
|
22
|
+
`selected`, `checked` or `on`. On the
|
|
21
23
|
Figma side the matrix also carries `default` as the resting state; the CLI does not
|
|
22
24
|
count it. `checked` counts as `selected`, and `on` counts as `selected`, since that is what shadcn and Radix call it. Conditional states (`loading`, `error`, `success`) are not in the table:
|
|
23
25
|
whether one applies is a design judgment.
|
package/scripts/README.md
CHANGED
|
@@ -23,12 +23,15 @@ tested here; copied verbatim by `token-crosswalk-builder` into the user's
|
|
|
23
23
|
| `docs-lint.mjs` | Copy lint for .doc.json records — warnings only, always exits 0 on a parseable record; the mechanical subset of `references/doc-writing-standard.md`. | `docs:lint` |
|
|
24
24
|
| `lib/doc-record.mjs` | Canonical record load + `canonicalFingerprint` (sha256 over the record minus `provenance`). The fingerprint every surface is stamped with. | copied alongside docs-check.mjs |
|
|
25
25
|
| `lib/doc-card-render.figma.js` | Figma renderer template for the doc card's `Usage` band and header. Inlined into `references/doc-card-builder.md`; never executed as a module. | plugin-internal (not installed) |
|
|
26
|
+
| `lib/figma-checks.mjs` | Pure checker for the Figma stop conditions: the COMPONENT_SET assertion against the full matrix, the state matrix, the focus ring, and the focus ring's 3:1 contrast against the surfaces it's named to sit on, plus the focus-framework rules. Inlined into `references/figma-checks.md`; unit-tested against `lib/fixtures/figma-checks/`. | plugin-internal (not installed) |
|
|
27
|
+
| `lib/figma-checks-read.figma.js` | Figma reader that turns the live COMPONENT_SET into the checker's snapshot JSON. Inlined into `references/figma-checks.md`; never executed as a module. | plugin-internal (not installed) |
|
|
26
28
|
| `lib/doc-card-plan.mjs` | Pure layout planner for the doc card's `Usage` band + `DOC_CARD_RENDERER_VERSION` (single source of the layout version). Inlined into `references/doc-card-builder.md`; imported by `docs-check.mjs`. | copied alongside docs-check.mjs; also inlined into the generated builder |
|
|
27
29
|
| `verify-check.mjs` | The verification proof bundle: records each stage's entry, and re-derives what it can rather than trusting the record. Fails on an orphaned token, a component documented without its archetype's baseline interaction states, a name that drifts between the manifest, its record and its code surface, or a recorded result the rerun contradicts. | `verify:check` |
|
|
28
30
|
| `lib/proof.mjs` | The proof store's vocabulary and mechanics: the stage list, entry validation, latest-wins merge, and the fingerprint the manifest pointer carries. | copied alongside `verify-check.mjs` |
|
|
29
31
|
| `lib/component-states.mjs` | The archetype table and resolver behind `state-incomplete` — which baseline interaction states each kind of component owes, and when to abstain. `validate-adherence.mjs` also uses it to find Badges. | copied alongside `verify-check.mjs` and `validate-adherence.mjs` |
|
|
30
|
-
| `lib/contrast.mjs` | The WCAG contrast maths and the semantic colour pairs behind `color-contrast` — which text role must clear which surface, in every mode, and where the rule deliberately abstains. | copied alongside `verify-check.mjs
|
|
32
|
+
| `lib/contrast.mjs` | The WCAG contrast maths and the semantic colour pairs behind `color-contrast` — which text role must clear which surface, in every mode, and where the rule deliberately abstains. Also inlined into `references/figma-checks.md` for the focus-ring contrast check. | copied alongside `verify-check.mjs`; also inlined into the generated Figma checks |
|
|
31
33
|
| `build-doc-card-builder.mjs` | Generate `references/doc-card-builder.md` from the planner + the Figma renderer template (`lib/doc-card-render.figma.js`). `--check` gates CI. | plugin-internal (not installed) |
|
|
34
|
+
| `build-figma-checks.mjs` | Generate `references/figma-checks.md`, the `figma_execute` snippet for the Figma stop conditions, by inlining `lib/component-states.mjs`, `lib/contrast.mjs` and `lib/figma-checks.mjs` above the reader (`lib/figma-checks-read.figma.js`). `--check` gates CI. | plugin-internal (not installed) |
|
|
32
35
|
| `build-state-baseline.mjs` | Generate `references/state-baseline.md`, the table of which components owe which interaction states, from `lib/component-states.mjs`. The Figma executor reads the table; `verify:check` reads the module. `--check` gates CI. | plugin-internal (not installed) |
|
|
33
36
|
| `build-native-adapter-config.mjs` | Generate `references/native-adapter-config.md` by slicing `lib/sd-native.mjs` on its `@doc-section` markers and interleaving each fragment under its prose. Fails when module code falls outside every section, so the doc cannot silently ship incomplete. `--check` gates CI. | plugin-internal (not installed) |
|
|
34
37
|
|
|
@@ -246,7 +249,7 @@ the committed `adapters/<target>/` tree plus the runtime payload
|
|
|
246
249
|
(`references/` + `scripts/`, minus `scripts/adapters/`) into `.throughline/`,
|
|
247
250
|
rewriting `${CLAUDE_PLUGIN_ROOT}` → `.throughline`:
|
|
248
251
|
|
|
249
|
-
npx @radicool/throughline@0.
|
|
252
|
+
npx @radicool/throughline@0.24.0 init --target=cursor|codex|generic
|
|
250
253
|
|
|
251
254
|
See `scripts/install.mjs` (pure core + CLI + `install.test.mjs`).
|
|
252
255
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Generates references/figma-checks.md — the canonical figma_execute snippet
|
|
2
|
+
// that runs the Figma stop conditions (COMPONENT_SET assertion, state matrix,
|
|
3
|
+
// focus ring, focus-ring contrast) — by inlining the state table
|
|
4
|
+
// (lib/component-states.mjs), the contrast maths (lib/contrast.mjs) and the
|
|
5
|
+
// pure checker (lib/figma-checks.mjs) above the Figma reader
|
|
6
|
+
// (lib/figma-checks-read.figma.js). Mirrors build-doc-card-builder.mjs: run
|
|
7
|
+
// bare to write, run with --check to gate CI. Zero dependencies.
|
|
8
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
const STATES = join(REPO_ROOT, 'scripts', 'lib', 'component-states.mjs');
|
|
14
|
+
const CONTRAST = join(REPO_ROOT, 'scripts', 'lib', 'contrast.mjs');
|
|
15
|
+
const CHECKER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks.mjs');
|
|
16
|
+
const READER = join(REPO_ROOT, 'scripts', 'lib', 'figma-checks-read.figma.js');
|
|
17
|
+
const OUT = join(REPO_ROOT, 'references', 'figma-checks.md');
|
|
18
|
+
|
|
19
|
+
const BIND = 'const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });';
|
|
20
|
+
|
|
21
|
+
const HEADER = [
|
|
22
|
+
'# Figma stop-condition checks (GENERATED)',
|
|
23
|
+
'',
|
|
24
|
+
'> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/component-states.mjs`',
|
|
25
|
+
'> (the state table), `scripts/lib/contrast.mjs` (the WCAG contrast maths),',
|
|
26
|
+
'> `scripts/lib/figma-checks.mjs` (the pure checker, unit-tested in Node) +',
|
|
27
|
+
'> `scripts/lib/figma-checks-read.figma.js` (the Figma reader). Regenerate with',
|
|
28
|
+
'> `node scripts/build-figma-checks.mjs`; CI gates freshness with `--check`.',
|
|
29
|
+
'',
|
|
30
|
+
'The canonical `figma_execute` snippet for the checks that can stop a component',
|
|
31
|
+
'build: the COMPONENT_SET assertion against the full variant matrix, the state',
|
|
32
|
+
'matrix, the focus ring, and the focus ring\'s contrast against the surfaces it',
|
|
33
|
+
'sits on (WCAG 1.4.11, 3:1, in every mode). The script is the rule; do not re-write',
|
|
34
|
+
'these checks by hand.',
|
|
35
|
+
'',
|
|
36
|
+
'## How to call it',
|
|
37
|
+
'',
|
|
38
|
+
'1. Paste the snippet below into `figma_execute`, with the two slots filled above it:',
|
|
39
|
+
' - `const EXPECT = { name, matrix, uiFramework, controlLayer, surfaces }` — `name` is the',
|
|
40
|
+
' component\'s real name; `matrix` is `{ axis: [values] }` for every axis of the',
|
|
41
|
+
' set; `uiFramework` is `project.uiFramework` from `design-system.json`;',
|
|
42
|
+
' `controlLayer` is the layer that is the actual control in a wrapped control,',
|
|
43
|
+
' copied from the architect\'s spec when it gives one (omit it otherwise);',
|
|
44
|
+
' `surfaces` is the list of colour-variable names of the page surfaces the',
|
|
45
|
+
' component sits on, copied from the architect\'s spec. Without it the',
|
|
46
|
+
' focus-contrast check is skipped. Surfaces are looked up among the file\'s',
|
|
47
|
+
' local variables only.',
|
|
48
|
+
' - `const MODE = \'spec\'` or `\'built\'`.',
|
|
49
|
+
'2. End the script with `return await runFigmaChecks(EXPECT, MODE);` and pass',
|
|
50
|
+
' `timeout: 30000`.',
|
|
51
|
+
'3. Read the result, `{ pass, checks: [{ name, result, detail }] }`. `result` is',
|
|
52
|
+
' `pass`, `fail` or `skip`; `pass` ignores skips. On `fail`, fix what each failing',
|
|
53
|
+
' check\'s `detail` names and run it again, within the ~3-pass budget; after that,',
|
|
54
|
+
' `BLOCKED` naming the failing checks\' `detail`. A `focus-contrast` fail is',
|
|
55
|
+
' the exception: return `BLOCKED` at once, naming its detail. A ring below 3:1 is',
|
|
56
|
+
' fixed by a token value, which no rebuild changes, and a missing surface is a',
|
|
57
|
+
' wrong name in the spec. Never edit a variable to make it pass.',
|
|
58
|
+
'4. `\'spec\'` reads no node and runs before the first write. `\'built\'` reads the',
|
|
59
|
+
' COMPONENT_SET inside the frame named `WIP: <name>` on the current page and runs',
|
|
60
|
+
' in the self-verify loop. It throws only if that frame is missing; a frame with',
|
|
61
|
+
' no set, or a set Figma reports as conflicting, fails the structural check.',
|
|
62
|
+
'',
|
|
63
|
+
'```js',
|
|
64
|
+
'const EXPECT = { name: \'Chip\', matrix: { state: [\'default\', \'hover\'] }, uiFramework: \'shadcn\' };',
|
|
65
|
+
'const MODE = \'spec\';',
|
|
66
|
+
'// … the generated snippet …',
|
|
67
|
+
'return await runFigmaChecks(EXPECT, MODE);',
|
|
68
|
+
'```',
|
|
69
|
+
'',
|
|
70
|
+
'## The snippet',
|
|
71
|
+
'',
|
|
72
|
+
'```js',
|
|
73
|
+
].join('\n');
|
|
74
|
+
|
|
75
|
+
const FOOTER = ['```', ''].join('\n');
|
|
76
|
+
|
|
77
|
+
const stripExports = (src) =>
|
|
78
|
+
src.replace(/^export const /gm, 'const ').replace(/^export function /gm, 'function ');
|
|
79
|
+
|
|
80
|
+
export function buildFigmaChecks({ statesSource, contrastSource, checkerSource, readerSource }) {
|
|
81
|
+
const states = stripExports(statesSource);
|
|
82
|
+
const contrast = stripExports(contrastSource);
|
|
83
|
+
const checker = stripExports(checkerSource);
|
|
84
|
+
for (const [name, src] of [['component-states.mjs', states], ['contrast.mjs', contrast], ['figma-checks.mjs', checker], ['figma-checks-read.figma.js', readerSource]]) {
|
|
85
|
+
if (/^\s*(import|export)\b/m.test(src)) {
|
|
86
|
+
throw new Error(`${name} must stay import-free (only top-level \`export const\`/\`export function\` allowed) — it is inlined into the Figma snippet where no module system exists`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return `${HEADER}\n${states}\n${contrast}\n${checker}\n${BIND}\n\n${readerSource}${FOOTER}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
93
|
+
const result = buildFigmaChecks({
|
|
94
|
+
statesSource: readFileSync(STATES, 'utf8'),
|
|
95
|
+
contrastSource: readFileSync(CONTRAST, 'utf8'),
|
|
96
|
+
checkerSource: readFileSync(CHECKER, 'utf8'),
|
|
97
|
+
readerSource: readFileSync(READER, 'utf8'),
|
|
98
|
+
});
|
|
99
|
+
if (process.argv.includes('--check')) {
|
|
100
|
+
let onDisk = null;
|
|
101
|
+
try { onDisk = readFileSync(OUT, 'utf8'); } catch { /* missing counts as drift */ }
|
|
102
|
+
if (onDisk !== result) {
|
|
103
|
+
console.error('✗ references/figma-checks.md out of date; run: node scripts/build-figma-checks.mjs');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
console.log('✓ figma checks in sync');
|
|
107
|
+
} else {
|
|
108
|
+
writeFileSync(OUT, result);
|
|
109
|
+
console.log('✓ wrote references/figma-checks.md');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -25,8 +25,8 @@ export function render(states = BASELINE_STATES, synonyms = NAME_SYNONYMS, arche
|
|
|
25
25
|
|
|
26
26
|
Which interaction states a component owes. This is the one list both checks read:
|
|
27
27
|
\`verify:check\` (\`state-incomplete\`) reads it from \`scripts/lib/component-states.mjs\`,
|
|
28
|
-
and \`figma-
|
|
29
|
-
this table. CI fails when
|
|
28
|
+
and the Figma checks script (\`references/figma-checks.md\`) inlines the same module,
|
|
29
|
+
so this table is the human-readable view of both. CI fails when it is stale.
|
|
30
30
|
|
|
31
31
|
A component name matches a row when it folds (lowercase, punctuation and spaces
|
|
32
32
|
stripped) to exactly one of that row's names. \`Text Field\` folds to \`textfield\`.
|
|
@@ -37,7 +37,9 @@ matches nothing owes no states.
|
|
|
37
37
|
|---|---|---|
|
|
38
38
|
${rows.join('\n')}
|
|
39
39
|
|
|
40
|
-
Every state in the table must be a value on the component's \`state\` axis
|
|
40
|
+
Every state in the table must be a value on the component's \`state\` axis, with one
|
|
41
|
+
exception in Figma: a choice control may carry \`selected\` on its own axis named
|
|
42
|
+
\`selected\`, \`checked\` or \`on\`. On the
|
|
41
43
|
Figma side the matrix also carries \`default\` as the resting state; the CLI does not
|
|
42
44
|
count it. ${aliases}, since that is what shadcn and Radix call it. Conditional states (\`loading\`, \`error\`, \`success\`) are not in the table:
|
|
43
45
|
whether one applies is a design judgment.
|
package/scripts/install.mjs
CHANGED
|
@@ -99,18 +99,23 @@ function stagePayload(srcRoot, destRoot, skip) {
|
|
|
99
99
|
return written;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// Scripts that never run from a consuming repo: the installer itself, the
|
|
103
|
-
// reference-doc generators, and the
|
|
104
|
-
// renderer
|
|
102
|
+
// Scripts that never run from a consuming repo: the installer itself, the
|
|
103
|
+
// reference-doc generators, and the Figma sources they inline (the doc-card
|
|
104
|
+
// renderer and the Figma checks reach Figma pre-inlined inside
|
|
105
|
+
// references/doc-card-builder.md and references/figma-checks.md). Test fixtures
|
|
106
|
+
// under lib/fixtures/ are dropped too.
|
|
105
107
|
const PLUGIN_INTERNAL = new Set([
|
|
106
108
|
'install.mjs',
|
|
107
109
|
'build-doc-card-builder.mjs',
|
|
108
110
|
'build-native-adapter-config.mjs',
|
|
109
111
|
'lib/doc-card-render.figma.js',
|
|
112
|
+
'build-figma-checks.mjs',
|
|
113
|
+
'lib/figma-checks-read.figma.js',
|
|
114
|
+
'lib/figma-checks.mjs',
|
|
110
115
|
]);
|
|
111
116
|
|
|
112
117
|
export const skipScript = (relPosix) =>
|
|
113
|
-
relPosix.startsWith('adapters/') || relPosix.endsWith('.test.mjs') || PLUGIN_INTERNAL.has(relPosix);
|
|
118
|
+
relPosix.startsWith('adapters/') || relPosix.startsWith('lib/fixtures/') || relPosix.endsWith('.test.mjs') || PLUGIN_INTERNAL.has(relPosix);
|
|
114
119
|
|
|
115
120
|
export function install({ target, dir, pkgRoot = PKG_ROOT }) {
|
|
116
121
|
if (!TARGETS.includes(target)) {
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
// renders it into `references/state-baseline.md`, which the Figma executor reads;
|
|
15
15
|
// CI fails when that file is stale. MAINTENANCE: change this table, then run it.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Defined here rather than imported from source-scan.mjs so this module has no
|
|
18
|
+
// imports and can be inlined into the Figma checks script. A test keeps the two
|
|
19
|
+
// copies in step.
|
|
20
|
+
const normalizeName = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
18
21
|
|
|
19
22
|
export const ARCHETYPES = ['button', 'input', 'choice', 'card', 'modal', 'badge', 'other'];
|
|
20
23
|
|
package/scripts/lib/contrast.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// MAINTENANCE: a change to the semantic role set must change this table.
|
|
7
7
|
|
|
8
8
|
export const AA_NORMAL_TEXT = 4.5;
|
|
9
|
+
// WCAG 1.4.11 non-text contrast, used by the Figma `focus-contrast` check.
|
|
10
|
+
export const AA_NON_TEXT = 3;
|
|
9
11
|
|
|
10
12
|
// `text/disabled` is excluded: WCAG 1.4.3 exempts inactive user-interface
|
|
11
13
|
// components from the contrast minimum, and a disabled role is deliberately
|
|
@@ -15,8 +17,9 @@ export const AA_NORMAL_TEXT = 4.5;
|
|
|
15
17
|
// questions) — a system is free to use those roles for non-text surfaces, so
|
|
16
18
|
// pairing them risks the first false failures in the table.
|
|
17
19
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
+
// This table is text-on-surface only. Focus-ring contrast, the 3:1 non-text case
|
|
21
|
+
// (WCAG 1.4.11), is checked in the Figma checks (`figma-checks.mjs`,
|
|
22
|
+
// `focus-contrast`), where the build knows which ring sits on which surface.
|
|
20
23
|
//
|
|
21
24
|
// One threshold is applied to every pair because a colour role does not say
|
|
22
25
|
// what text size it is used at — `text/primary` is body copy as well as
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Figma plugin-API reader for the stop-condition checks. This file is NOT a
|
|
2
|
+
// Node module — build-figma-checks.mjs concatenates it after the inlined
|
|
3
|
+
// component-states.mjs, contrast.mjs and figma-checks.mjs into
|
|
4
|
+
// references/figma-checks.md, and
|
|
5
|
+
// the result runs inside figma_execute (dynamic-page mode). Constraints honored
|
|
6
|
+
// here (see references/figma-scripting.md): async getters only, variable names
|
|
7
|
+
// and per-mode colour values resolved with getVariableByIdAsync and only for
|
|
8
|
+
// focus variants and their controlLayer, plus the EXPECT.surfaces names, which
|
|
9
|
+
// are looked up among the file's LOCAL colour variables only (a surface from a
|
|
10
|
+
// published library reads as missing). Untested in Node, like the doc-card
|
|
11
|
+
// renderer; the pure checker it feeds is unit-tested.
|
|
12
|
+
//
|
|
13
|
+
// In-scope globals when assembled: normalizeName (inlined
|
|
14
|
+
// component-states.mjs), checkSpec, checkBuilt (bound from withTable) and the
|
|
15
|
+
// caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
|
|
16
|
+
// shape is documented at the top of scripts/lib/figma-checks.mjs.
|
|
17
|
+
|
|
18
|
+
// Colour variables read on focus nodes, by name, for the contrast check.
|
|
19
|
+
const readVariables = new Map();
|
|
20
|
+
|
|
21
|
+
async function variableName(alias) {
|
|
22
|
+
if (!alias || !alias.id) return null;
|
|
23
|
+
const variable = await figma.variables.getVariableByIdAsync(alias.id);
|
|
24
|
+
if (!variable) return null;
|
|
25
|
+
if (variable.resolvedType === 'COLOR') readVariables.set(variable.name, variable);
|
|
26
|
+
return variable.name;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function colorHex(value) {
|
|
30
|
+
if (!value || typeof value !== 'object' || !('r' in value)) return null;
|
|
31
|
+
const byte = (x) => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
32
|
+
const alpha = value.a === undefined ? 1 : value.a;
|
|
33
|
+
return `#${byte(value.r)}${byte(value.g)}${byte(value.b)}${alpha < 1 ? byte(alpha) : ''}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The value of `variable` in the mode named `modeName` (after the fold), else
|
|
37
|
+
// its collection's default mode, following aliases up to 10 hops.
|
|
38
|
+
async function resolveColor(variable, modeName, hops) {
|
|
39
|
+
if (!variable || hops > 10) return null;
|
|
40
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
41
|
+
if (!collection) return null;
|
|
42
|
+
const mode = collection.modes.find((m) => normalizeName(m.name) === normalizeName(modeName));
|
|
43
|
+
const value = variable.valuesByMode[mode ? mode.modeId : collection.defaultModeId];
|
|
44
|
+
if (value && value.type === 'VARIABLE_ALIAS') {
|
|
45
|
+
return resolveColor(await figma.variables.getVariableByIdAsync(value.id), modeName, hops + 1);
|
|
46
|
+
}
|
|
47
|
+
return colorHex(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// { modes: { [modeName]: hex | null }, defaultMode } over the variable's own collection.
|
|
51
|
+
async function variableColors(variable) {
|
|
52
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
53
|
+
if (!collection) return { modes: {}, defaultMode: undefined };
|
|
54
|
+
const modes = {};
|
|
55
|
+
for (const mode of collection.modes) modes[mode.name] = await resolveColor(variable, mode.name, 0);
|
|
56
|
+
const fallback = collection.modes.find((m) => m.modeId === collection.defaultModeId);
|
|
57
|
+
return { modes, defaultMode: fallback ? fallback.name : undefined };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The fields the focus check reads from a variant, a control layer or a direct child.
|
|
61
|
+
async function readFocusNode(node, { withEffects }) {
|
|
62
|
+
const strokes = (node.boundVariables && node.boundVariables.strokes) || [];
|
|
63
|
+
const strokeVariables = [];
|
|
64
|
+
for (const alias of strokes) {
|
|
65
|
+
const name = await variableName(alias);
|
|
66
|
+
if (name !== null) strokeVariables.push(name);
|
|
67
|
+
}
|
|
68
|
+
const bound = node.boundVariables || {};
|
|
69
|
+
const out = {
|
|
70
|
+
strokeAlign: node.strokeAlign,
|
|
71
|
+
strokeVariables,
|
|
72
|
+
strokeWeightBound: Boolean(
|
|
73
|
+
bound.strokeWeight || bound.strokeTopWeight || bound.strokeRightWeight || bound.strokeBottomWeight || bound.strokeLeftWeight,
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
if (withEffects) {
|
|
77
|
+
out.effects = [];
|
|
78
|
+
for (const effect of node.effects || []) {
|
|
79
|
+
const colorAlias = effect.boundVariables && effect.boundVariables.color;
|
|
80
|
+
out.effects.push({
|
|
81
|
+
type: effect.type,
|
|
82
|
+
visible: effect.visible,
|
|
83
|
+
spread: effect.spread,
|
|
84
|
+
colorVariable: await variableName(colorAlias),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
out.layoutPositioning = node.layoutPositioning;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function readFocusTree(node) {
|
|
94
|
+
const focus = await readFocusNode(node, { withEffects: true });
|
|
95
|
+
focus.children = [];
|
|
96
|
+
for (const child of node.children || []) {
|
|
97
|
+
focus.children.push(await readFocusNode(child, { withEffects: false }));
|
|
98
|
+
}
|
|
99
|
+
return focus;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The getter throws when Figma flags the set as conflicting (duplicate variant
|
|
103
|
+
// combinations). Fall back to the axes the children declare, so the structural
|
|
104
|
+
// check names the duplicates instead of the read throwing.
|
|
105
|
+
function groupProperties(set) {
|
|
106
|
+
try {
|
|
107
|
+
return set.variantGroupProperties;
|
|
108
|
+
} catch {
|
|
109
|
+
const axes = {};
|
|
110
|
+
for (const child of set.children) {
|
|
111
|
+
for (const axis of Object.keys(child.variantProperties || {})) axes[axis] = { values: [] };
|
|
112
|
+
}
|
|
113
|
+
return axes;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readComponentSet(expect) {
|
|
118
|
+
readVariables.clear();
|
|
119
|
+
await figma.currentPage.loadAsync();
|
|
120
|
+
const frameName = `WIP: ${expect.name}`;
|
|
121
|
+
const frame = figma.currentPage.findOne((n) => n.name === frameName);
|
|
122
|
+
if (!frame) throw new Error(`no node named "${frameName}" on the current page`);
|
|
123
|
+
const set = frame.type === 'COMPONENT_SET' ? frame : (frame.children || []).find((c) => c.type === 'COMPONENT_SET');
|
|
124
|
+
// A frame holding plain FRAMEs where the set should be is a failed assertion,
|
|
125
|
+
// not a missing target: report what is there so the structural check names it.
|
|
126
|
+
if (!set) {
|
|
127
|
+
const first = (frame.children || [])[0];
|
|
128
|
+
return { type: first ? first.type : 'NONE', variantGroupProperties: {}, children: [] };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const isFocusState = (props) =>
|
|
132
|
+
Object.entries(props || {}).some(([k, v]) => {
|
|
133
|
+
const state = normalizeName(v);
|
|
134
|
+
return normalizeName(k) === 'state' && state === 'focus';
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const children = [];
|
|
138
|
+
for (const child of set.children) {
|
|
139
|
+
const read = { type: child.type, variantProperties: child.variantProperties };
|
|
140
|
+
if (child.type === 'COMPONENT' && isFocusState(child.variantProperties)) {
|
|
141
|
+
Object.assign(read, await readFocusTree(child));
|
|
142
|
+
if (expect.controlLayer !== undefined && expect.controlLayer !== null) {
|
|
143
|
+
const layer = child.findOne((n) => n.name === expect.controlLayer);
|
|
144
|
+
read.controlLayer = layer ? await readFocusTree(layer) : null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
children.push(read);
|
|
148
|
+
}
|
|
149
|
+
const colors = {};
|
|
150
|
+
for (const [name, variable] of readVariables) colors[name] = await variableColors(variable);
|
|
151
|
+
const missingSurfaces = [];
|
|
152
|
+
const surfaces = expect.surfaces || [];
|
|
153
|
+
if (surfaces.length) {
|
|
154
|
+
const local = await figma.variables.getLocalVariablesAsync('COLOR');
|
|
155
|
+
for (const name of surfaces) {
|
|
156
|
+
const variable = local.find((v) => v.name === name);
|
|
157
|
+
if (variable) colors[name] = await variableColors(variable);
|
|
158
|
+
else missingSurfaces.push(name);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
type: set.type,
|
|
163
|
+
variantGroupProperties: groupProperties(set),
|
|
164
|
+
children,
|
|
165
|
+
colors,
|
|
166
|
+
missingSurfaces,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function runFigmaChecks(EXPECT, MODE) {
|
|
171
|
+
if (MODE === 'spec') return checkSpec(EXPECT);
|
|
172
|
+
if (MODE === 'built') return checkBuilt(await readComponentSet(EXPECT), EXPECT);
|
|
173
|
+
throw new Error(`MODE must be 'spec' or 'built', got ${JSON.stringify(MODE)}`);
|
|
174
|
+
}
|