@radicool/throughline 0.23.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 +8 -4
- package/adapters/cursor/.cursor/rules/component-builder.mdc +8 -4
- package/adapters/generic/skills/component-builder/SKILL.md +8 -4
- package/package.json +1 -1
- package/references/figma-checks.md +357 -28
- package/references/figma-component-standards.md +8 -0
- package/references/proof-bundle.md +1 -0
- package/scripts/README.md +4 -4
- package/scripts/build-figma-checks.mjs +25 -12
- package/scripts/lib/contrast.mjs +5 -2
- package/scripts/lib/figma-checks-read.figma.js +59 -5
- package/scripts/lib/figma-checks.mjs +160 -15
- package/scripts/lib/fixtures/figma-checks/avatar-not-in-table.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ios-swift-skipped.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-missing-default.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-mui-ring.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-no-focus-variant.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-plain-frames.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ring-child-not-absolute.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ring-hidden.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-ring-unbound-colour.json +4 -3
- package/scripts/lib/fixtures/figma-checks/button-shadcn-filled.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-sparse.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-spread-zero.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-transparent-ring-child.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-vanilla-inside.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-vanilla-outside.json +2 -1
- package/scripts/lib/fixtures/figma-checks/button-wrong-combination.json +2 -1
- package/scripts/lib/fixtures/figma-checks/checkbox-checked-axis.real.json +2 -1
- package/scripts/lib/fixtures/figma-checks/checkbox-checked-synonym.json +2 -1
- package/scripts/lib/fixtures/figma-checks/checkbox-missing-selected.json +2 -1
- package/scripts/lib/fixtures/figma-checks/indicator-failed.json +106 -0
- package/scripts/lib/fixtures/figma-checks/input-control-layer-missing.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-control-layer-ring-on-wrapper.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-control-layer.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-mui-weight-unbound.json +2 -1
- package/scripts/lib/fixtures/figma-checks/input-mui.json +2 -1
- 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 +2 -1
- 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
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ Everything above was created during a single working session and synced directly
|
|
|
73
73
|
|
|
74
74
|
| | |
|
|
75
75
|
|---|---|
|
|
76
|
-
| **A supported agent** | Required — [Claude Code](https://docs.claude.com/en/docs/claude-code) (native plugin), or **Cursor**, **Codex**, or any **AGENTS.md**-aware agent via `npx @radicool/throughline@0.
|
|
76
|
+
| **A supported agent** | Required — [Claude Code](https://docs.claude.com/en/docs/claude-code) (native plugin), or **Cursor**, **Codex**, or any **AGENTS.md**-aware agent via `npx @radicool/throughline@0.24.0 init`. |
|
|
77
77
|
| **Figma** | Required, **desktop app** (the browser version causes connection errors). **Professional plan or higher recommended** — multi-mode variables (Light/Dark, brand themes) need it. |
|
|
78
78
|
| **Figma access token** | Optional — only for the few Figma calls the Desktop Bridge can't make, like reading a published library. Without it, you work through the bridge alone. The setup skill walks you through it; your token stays yours and is never shared in chat. |
|
|
79
79
|
| **GitHub** (or similar) | Optional — only when you're ready to graduate to a real remote repo with PRs and CI. |
|
|
@@ -114,9 +114,9 @@ Update anytime with `/plugin marketplace update throughline-marketplace`.
|
|
|
114
114
|
Run the installer in your project — it stamps in the skills, the reference docs, the scripts, and the Figma MCP config for your tool:
|
|
115
115
|
|
|
116
116
|
```
|
|
117
|
-
npx @radicool/throughline@0.
|
|
118
|
-
npx @radicool/throughline@0.
|
|
119
|
-
npx @radicool/throughline@0.
|
|
117
|
+
npx @radicool/throughline@0.24.0 init --target=cursor # → .cursor/rules + .cursor/mcp.json
|
|
118
|
+
npx @radicool/throughline@0.24.0 init --target=codex # → prompts/ + AGENTS.md index + codex-mcp.toml
|
|
119
|
+
npx @radicool/throughline@0.24.0 init --target=generic # → skills/ + AGENTS.md index
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
It's safe to re-run (it merges `AGENTS.md` and `.cursor/mcp.json` non-destructively) and stages everything the skills read into `.throughline/`. Then open the `figma-environment-setup` skill/rule/prompt for your tool to begin. For Codex, add the printed `codex-mcp.toml` block to your Codex config to enable Figma access.
|
|
@@ -212,7 +212,7 @@ Future improvements and planned capabilities. Have a request? **[Open an issue](
|
|
|
212
212
|
- **Deeper Code Connect coverage** — richer Figma-to-code mappings as more plans support it.
|
|
213
213
|
- **Expanded component starters** — a larger foundational kit out of the box.
|
|
214
214
|
- **Richer status & auditing** — more from `/design-system-status`, including drift detection between Figma and code.
|
|
215
|
-
- **Built-in accessibility checks** — the color half is shipped. Eight semantic text-on-surface pairs are checked against WCAG AA's 4.5:1 when tokens are created in Figma, and derived again from the token source when they sync to code — in every mode, so a system that clears in light and fails in dark fails. A failing pair stops the build rather than warning. Components are checked too: on web frameworks, a Figma build stops when a focus state has no visible ring, and in JSX code the adherence gate is built to fail an icon-only button with no accessible name and a status badge that relies on color alone — proven by unit tests. The one real app it has run on had almost nothing for it to check, so it hasn't caught a bug in the wild yet.
|
|
215
|
+
- **Built-in accessibility checks** — the color half is shipped. Eight semantic text-on-surface pairs are checked against WCAG AA's 4.5:1 when tokens are created in Figma, and derived again from the token source when they sync to code — in every mode, so a system that clears in light and fails in dark fails. A failing pair stops the build rather than warning. Components are checked too: on web frameworks, a Figma build stops when a focus state has no visible ring, and in JSX code the adherence gate is built to fail an icon-only button with no accessible name and a status badge that relies on color alone — proven by unit tests. A Figma build also stops when a focus ring falls below WCAG's 3:1 against a surface the architect names for it, in any mode. The one real app it has run on had almost nothing for it to check, so it hasn't caught a bug in the wild yet.
|
|
216
216
|
- **Token fan-out to more platforms** — the DTCG token source is platform-neutral, and web targets are in daily use. Every target is **validated per build, not assumed**: `tokens:validate-output` checks generated output against its source, native Swift and Kotlin as well as web CSS, because the stock transforms have been measured emitting wrong-but-compiling values. iOS/Swift is a curated adapter whose Style Dictionary configuration ships as tested code, and Android/Kotlin uses the same configuration through the Tier 2 protocol. JavaScript theme output (MUI, a Tailwind v3 config) isn't checked yet.
|
|
217
217
|
- **Native component code generation** — producing a SwiftUI view or a Compose composable the way Storybook components are produced for React. This does not exist yet; it is a separate, larger effort than token fan-out, and the two were previously described as one roadmap item.
|
|
218
218
|
|
|
@@ -177,8 +177,12 @@ deterministic naming):
|
|
|
177
177
|
way the executor does: `'spec'` mode before the first write and `'built'` mode
|
|
178
178
|
in the self-verify loop, with `EXPECT` built from your own matrix. You are the
|
|
179
179
|
architect here, so you name `controlLayer` yourself for a wrapped control (an
|
|
180
|
-
Input with a label or helper text)
|
|
181
|
-
|
|
180
|
+
Input with a label or helper text), and `surfaces` yourself: the colour-variable
|
|
181
|
+
names of the page surfaces the component sits on. A `fail` on the
|
|
182
|
+
`focus-indicator` check stops the build, as it would for the executor. A `fail`
|
|
183
|
+
on `focus-contrast` stops it too, naming its detail. A ring below 3:1 is fixed by
|
|
184
|
+
a token value, so don't change a variable or swap the ring to another one to make
|
|
185
|
+
it pass; a surface the file doesn't have is a wrong name in `surfaces`.
|
|
182
186
|
|
|
183
187
|
Checkpoint after each component: show all variants, confirm before the next.
|
|
184
188
|
|
|
@@ -366,8 +370,8 @@ any component built with the toggle + manual-swap fallback is listed in
|
|
|
366
370
|
component-builder --subject <Name> --entry <tmp>.json`. Build `<tmp>.json` with
|
|
367
371
|
`at` (an ISO timestamp you supply), `changed` (what was built), the attested checks `structural-read-back`,
|
|
368
372
|
`state-baseline` and `figma-name` from what the `figma-executor` returned (or
|
|
369
|
-
what your inline read-back found), `focus-indicator`
|
|
370
|
-
passed (not when it was skipped),
|
|
373
|
+
what your inline read-back found), `focus-indicator` and `focus-contrast`
|
|
374
|
+
each when that check ran and passed (not when it was skipped),
|
|
371
375
|
`review` when a reviewer ran, `screenshot` when one was captured, and
|
|
372
376
|
`advancedBecause`. The entry shape is
|
|
373
377
|
`.throughline/references/proof-bundle.md` — don't restate it here. Write
|
|
@@ -181,8 +181,12 @@ deterministic naming):
|
|
|
181
181
|
way the executor does: `'spec'` mode before the first write and `'built'` mode
|
|
182
182
|
in the self-verify loop, with `EXPECT` built from your own matrix. You are the
|
|
183
183
|
architect here, so you name `controlLayer` yourself for a wrapped control (an
|
|
184
|
-
Input with a label or helper text)
|
|
185
|
-
|
|
184
|
+
Input with a label or helper text), and `surfaces` yourself: the colour-variable
|
|
185
|
+
names of the page surfaces the component sits on. A `fail` on the
|
|
186
|
+
`focus-indicator` check stops the build, as it would for the executor. A `fail`
|
|
187
|
+
on `focus-contrast` stops it too, naming its detail. A ring below 3:1 is fixed by
|
|
188
|
+
a token value, so don't change a variable or swap the ring to another one to make
|
|
189
|
+
it pass; a surface the file doesn't have is a wrong name in `surfaces`.
|
|
186
190
|
|
|
187
191
|
Checkpoint after each component: show all variants, confirm before the next.
|
|
188
192
|
|
|
@@ -370,8 +374,8 @@ any component built with the toggle + manual-swap fallback is listed in
|
|
|
370
374
|
component-builder --subject <Name> --entry <tmp>.json`. Build `<tmp>.json` with
|
|
371
375
|
`at` (an ISO timestamp you supply), `changed` (what was built), the attested checks `structural-read-back`,
|
|
372
376
|
`state-baseline` and `figma-name` from what the `figma-executor` returned (or
|
|
373
|
-
what your inline read-back found), `focus-indicator`
|
|
374
|
-
passed (not when it was skipped),
|
|
377
|
+
what your inline read-back found), `focus-indicator` and `focus-contrast`
|
|
378
|
+
each when that check ran and passed (not when it was skipped),
|
|
375
379
|
`review` when a reviewer ran, `screenshot` when one was captured, and
|
|
376
380
|
`advancedBecause`. The entry shape is
|
|
377
381
|
`.throughline/references/proof-bundle.md` — don't restate it here. Write
|
|
@@ -177,8 +177,12 @@ deterministic naming):
|
|
|
177
177
|
way the executor does: `'spec'` mode before the first write and `'built'` mode
|
|
178
178
|
in the self-verify loop, with `EXPECT` built from your own matrix. You are the
|
|
179
179
|
architect here, so you name `controlLayer` yourself for a wrapped control (an
|
|
180
|
-
Input with a label or helper text)
|
|
181
|
-
|
|
180
|
+
Input with a label or helper text), and `surfaces` yourself: the colour-variable
|
|
181
|
+
names of the page surfaces the component sits on. A `fail` on the
|
|
182
|
+
`focus-indicator` check stops the build, as it would for the executor. A `fail`
|
|
183
|
+
on `focus-contrast` stops it too, naming its detail. A ring below 3:1 is fixed by
|
|
184
|
+
a token value, so don't change a variable or swap the ring to another one to make
|
|
185
|
+
it pass; a surface the file doesn't have is a wrong name in `surfaces`.
|
|
182
186
|
|
|
183
187
|
Checkpoint after each component: show all variants, confirm before the next.
|
|
184
188
|
|
|
@@ -366,8 +370,8 @@ any component built with the toggle + manual-swap fallback is listed in
|
|
|
366
370
|
component-builder --subject <Name> --entry <tmp>.json`. Build `<tmp>.json` with
|
|
367
371
|
`at` (an ISO timestamp you supply), `changed` (what was built), the attested checks `structural-read-back`,
|
|
368
372
|
`state-baseline` and `figma-name` from what the `figma-executor` returned (or
|
|
369
|
-
what your inline read-back found), `focus-indicator`
|
|
370
|
-
passed (not when it was skipped),
|
|
373
|
+
what your inline read-back found), `focus-indicator` and `focus-contrast`
|
|
374
|
+
each when that check ran and passed (not when it was skipped),
|
|
371
375
|
`review` when a reviewer ran, `screenshot` when one was captured, and
|
|
372
376
|
`advancedBecause`. The entry shape is
|
|
373
377
|
`.throughline/references/proof-bundle.md` — don't restate it here. Write
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@radicool/throughline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Build a complete design system end to end — author in Figma, sync tokens to code, generate Storybook. Usable from Claude Code, Cursor, Codex, or any AGENTS.md agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,30 +1,39 @@
|
|
|
1
1
|
# Figma stop-condition checks (GENERATED)
|
|
2
2
|
|
|
3
3
|
> **GENERATED FILE — do not edit by hand.** Sources: `scripts/lib/component-states.mjs`
|
|
4
|
-
> (the state table), `scripts/lib/
|
|
5
|
-
>
|
|
4
|
+
> (the state table), `scripts/lib/contrast.mjs` (the WCAG contrast maths),
|
|
5
|
+
> `scripts/lib/figma-checks.mjs` (the pure checker, unit-tested in Node) +
|
|
6
|
+
> `scripts/lib/figma-checks-read.figma.js` (the Figma reader). Regenerate with
|
|
6
7
|
> `node scripts/build-figma-checks.mjs`; CI gates freshness with `--check`.
|
|
7
8
|
|
|
8
9
|
The canonical `figma_execute` snippet for the checks that can stop a component
|
|
9
10
|
build: the COMPONENT_SET assertion against the full variant matrix, the state
|
|
10
|
-
matrix,
|
|
11
|
-
|
|
11
|
+
matrix, the focus ring, and the focus ring's contrast against the surfaces it
|
|
12
|
+
sits on (WCAG 1.4.11, 3:1, in every mode). The script is the rule; do not re-write
|
|
13
|
+
these checks by hand.
|
|
12
14
|
|
|
13
15
|
## How to call it
|
|
14
16
|
|
|
15
17
|
1. Paste the snippet below into `figma_execute`, with the two slots filled above it:
|
|
16
|
-
- `const EXPECT = { name, matrix, uiFramework, controlLayer }` — `name` is the
|
|
18
|
+
- `const EXPECT = { name, matrix, uiFramework, controlLayer, surfaces }` — `name` is the
|
|
17
19
|
component's real name; `matrix` is `{ axis: [values] }` for every axis of the
|
|
18
20
|
set; `uiFramework` is `project.uiFramework` from `design-system.json`;
|
|
19
21
|
`controlLayer` is the layer that is the actual control in a wrapped control,
|
|
20
|
-
copied from the architect's spec when it gives one (omit it otherwise)
|
|
22
|
+
copied from the architect's spec when it gives one (omit it otherwise);
|
|
23
|
+
`surfaces` is the list of colour-variable names of the page surfaces the
|
|
24
|
+
component sits on, copied from the architect's spec. Without it the
|
|
25
|
+
focus-contrast check is skipped. Surfaces are looked up among the file's
|
|
26
|
+
local variables only.
|
|
21
27
|
- `const MODE = 'spec'` or `'built'`.
|
|
22
28
|
2. End the script with `return await runFigmaChecks(EXPECT, MODE);` and pass
|
|
23
29
|
`timeout: 30000`.
|
|
24
30
|
3. Read the result, `{ pass, checks: [{ name, result, detail }] }`. `result` is
|
|
25
31
|
`pass`, `fail` or `skip`; `pass` ignores skips. On `fail`, fix what each failing
|
|
26
32
|
check's `detail` names and run it again, within the ~3-pass budget; after that,
|
|
27
|
-
`BLOCKED` naming the failing checks' `detail`.
|
|
33
|
+
`BLOCKED` naming the failing checks' `detail`. A `focus-contrast` fail is
|
|
34
|
+
the exception: return `BLOCKED` at once, naming its detail. A ring below 3:1 is
|
|
35
|
+
fixed by a token value, which no rebuild changes, and a missing surface is a
|
|
36
|
+
wrong name in the spec. Never edit a variable to make it pass.
|
|
28
37
|
4. `'spec'` reads no node and runs before the first write. `'built'` reads the
|
|
29
38
|
COMPONENT_SET inside the frame named `WIP: <name>` on the current page and runs
|
|
30
39
|
in the self-verify loop. It throws only if that frame is missing; a frame with
|
|
@@ -118,29 +127,159 @@ function missingStates(record) {
|
|
|
118
127
|
return baseline.filter((state) => !recordedKeys.has(normalizeName(state)));
|
|
119
128
|
}
|
|
120
129
|
|
|
130
|
+
// The WCAG contrast maths and the semantic colour pair table behind the
|
|
131
|
+
// `color-contrast` rule (`docs/specs/2026-09-12-token-contrast-validation.md`,
|
|
132
|
+
// step 1). Source of truth for the prose contract is
|
|
133
|
+
// `skills/token-builder/SKILL.md:250-265`.
|
|
134
|
+
//
|
|
135
|
+
// MAINTENANCE: a change to the semantic role set must change this table.
|
|
136
|
+
|
|
137
|
+
const AA_NORMAL_TEXT = 4.5;
|
|
138
|
+
// WCAG 1.4.11 non-text contrast, used by the Figma `focus-contrast` check.
|
|
139
|
+
const AA_NON_TEXT = 3;
|
|
140
|
+
|
|
141
|
+
// `text/disabled` is excluded: WCAG 1.4.3 exempts inactive user-interface
|
|
142
|
+
// components from the contrast minimum, and a disabled role is deliberately
|
|
143
|
+
// low-contrast — gating it would fail correct systems.
|
|
144
|
+
//
|
|
145
|
+
// `bg/subtle` and `bg/muted` are deliberately not paired in v1 (Open
|
|
146
|
+
// questions) — a system is free to use those roles for non-text surfaces, so
|
|
147
|
+
// pairing them risks the first false failures in the table.
|
|
148
|
+
//
|
|
149
|
+
// This table is text-on-surface only. Focus-ring contrast, the 3:1 non-text case
|
|
150
|
+
// (WCAG 1.4.11), is checked in the Figma checks (`figma-checks.mjs`,
|
|
151
|
+
// `focus-contrast`), where the build knows which ring sits on which surface.
|
|
152
|
+
//
|
|
153
|
+
// One threshold is applied to every pair because a colour role does not say
|
|
154
|
+
// what text size it is used at — `text/primary` is body copy as well as
|
|
155
|
+
// headings, so 4.5:1 is the binding requirement for it anyway.
|
|
156
|
+
const CONTRAST_PAIRS = [
|
|
157
|
+
{
|
|
158
|
+
fg: 'color.text.primary',
|
|
159
|
+
bg: 'color.bg.default',
|
|
160
|
+
threshold: AA_NORMAL_TEXT,
|
|
161
|
+
why: 'the default reading pair; if this fails, body copy fails everywhere at once',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
fg: 'color.text.secondary',
|
|
165
|
+
bg: 'color.bg.default',
|
|
166
|
+
threshold: AA_NORMAL_TEXT,
|
|
167
|
+
why: 'secondary text is still text, and still has to clear AA on the same surface primary does',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
fg: 'color.text.link',
|
|
171
|
+
bg: 'color.bg.default',
|
|
172
|
+
threshold: AA_NORMAL_TEXT,
|
|
173
|
+
why: 'a link that fails contrast is a link nobody can find, regardless of colour meaning',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
fg: 'color.text.onEmphasis',
|
|
177
|
+
bg: 'color.bg.emphasis',
|
|
178
|
+
threshold: AA_NORMAL_TEXT,
|
|
179
|
+
why: 'the role exists because it sits on the emphasis fill; that is the whole reason it is not `text/inverse`',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
fg: 'color.text.inverse',
|
|
183
|
+
bg: 'color.bg.inverse',
|
|
184
|
+
threshold: AA_NORMAL_TEXT,
|
|
185
|
+
why: 'the inverse pair flips with the theme, and both sides have to keep clearing together',
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
fg: 'color.status.success.text',
|
|
189
|
+
bg: 'color.status.success.bg',
|
|
190
|
+
threshold: AA_NORMAL_TEXT,
|
|
191
|
+
why: 'a status text role exists to sit on that status\'s own background, not any other surface',
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
fg: 'color.status.warning.text',
|
|
195
|
+
bg: 'color.status.warning.bg',
|
|
196
|
+
threshold: AA_NORMAL_TEXT,
|
|
197
|
+
why: 'a status text role exists to sit on that status\'s own background, not any other surface',
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
fg: 'color.status.danger.text',
|
|
201
|
+
bg: 'color.status.danger.bg',
|
|
202
|
+
threshold: AA_NORMAL_TEXT,
|
|
203
|
+
why: 'a status text role exists to sit on that status\'s own background, not any other surface',
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
// WCAG 2.x sRGB relative luminance over 0-255 channels.
|
|
208
|
+
function relativeLuminance({ r, g, b }) {
|
|
209
|
+
const linearize = (channel) => {
|
|
210
|
+
const c = channel / 255;
|
|
211
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
212
|
+
};
|
|
213
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Takes two `#rrggbb` strings (no alpha; the caller composites first) and
|
|
217
|
+
// returns the raw ratio, unrounded.
|
|
218
|
+
function contrastRatio(hexA, hexB) {
|
|
219
|
+
const lumA = relativeLuminance(parseHex(hexA));
|
|
220
|
+
const lumB = relativeLuminance(parseHex(hexB));
|
|
221
|
+
const lighter = Math.max(lumA, lumB);
|
|
222
|
+
const darker = Math.min(lumA, lumB);
|
|
223
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// `#rrggbb` or `#rrggbbaa` → `{ r, g, b, a }` with `a` in 0-1. `null` for
|
|
227
|
+
// anything else.
|
|
228
|
+
function parseHex(hex) {
|
|
229
|
+
if (typeof hex !== 'string') return null;
|
|
230
|
+
const match = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.exec(hex);
|
|
231
|
+
if (!match) return null;
|
|
232
|
+
const digits = match[1];
|
|
233
|
+
const r = parseInt(digits.slice(0, 2), 16);
|
|
234
|
+
const g = parseInt(digits.slice(2, 4), 16);
|
|
235
|
+
const b = parseInt(digits.slice(4, 6), 16);
|
|
236
|
+
const a = digits.length === 8 ? parseInt(digits.slice(6, 8), 16) / 255 : 1;
|
|
237
|
+
return { r, g, b, a };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The `#rrggbb` of `fg` drawn over `bg` with src-over (`out = fg*a + bg*(1-a)`).
|
|
241
|
+
function composite(fgHex, bgHex) {
|
|
242
|
+
const fg = parseHex(fgHex);
|
|
243
|
+
if (fg.a === 0) return bgHex;
|
|
244
|
+
if (fg.a === 1) return fgHex.slice(0, 7);
|
|
245
|
+
const bg = parseHex(bgHex);
|
|
246
|
+
const mix = (fgChannel, bgChannel) => Math.round(fgChannel * fg.a + bgChannel * (1 - fg.a));
|
|
247
|
+
const toHex = (value) => value.toString(16).padStart(2, '0');
|
|
248
|
+
return `#${toHex(mix(fg.r, bg.r))}${toHex(mix(fg.g, bg.g))}${toHex(mix(fg.b, bg.b))}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
121
251
|
// The Figma stop conditions as a pure checker: the COMPONENT_SET assertion against
|
|
122
|
-
// the full variant matrix, the state matrix, and the focus ring
|
|
252
|
+
// the full variant matrix, the state matrix, the focus ring, and the focus ring's
|
|
253
|
+
// contrast against the surfaces it sits on. ZERO imports,
|
|
123
254
|
// `export const`/`export function` only. This module is inlined, with
|
|
124
|
-
// component-states.mjs above it, into the generated Figma script
|
|
255
|
+
// component-states.mjs and contrast.mjs above it, into the generated Figma script
|
|
125
256
|
// (references/figma-checks.md) by build-figma-checks.mjs, so it must run in both
|
|
126
257
|
// Node and the Figma plugin sandbox.
|
|
127
258
|
//
|
|
128
259
|
// The state table is not imported. `withTable({ resolveArchetype, BASELINE_STATES,
|
|
129
|
-
// STATE_SYNONYMS, normalizeName
|
|
130
|
-
//
|
|
260
|
+
// STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT })`
|
|
261
|
+
// takes it, the fold and the contrast maths from the caller and returns
|
|
262
|
+
// { checkSpec, checkBuilt }. In the generated script the caller passes the
|
|
131
263
|
// inlined component-states.mjs names; in tests it passes the module's own exports.
|
|
132
264
|
//
|
|
133
265
|
// Spec: docs/specs/2026-10-07-figma-checks-as-scripts.md
|
|
134
266
|
// Rules: references/figma-component-standards.md (State handling; audit items 8, 9)
|
|
135
267
|
//
|
|
136
|
-
//
|
|
268
|
+
// Spec (focus contrast): docs/specs/2026-10-13-focus-ring-contrast.md
|
|
269
|
+
//
|
|
270
|
+
// EXPECT: { name, matrix: { axis: [values] }, uiFramework, controlLayer?, surfaces? }
|
|
271
|
+
// surfaces: colour-variable names of the page surfaces the component sits on.
|
|
137
272
|
//
|
|
138
273
|
// SNAPSHOT (what the reader returns for the COMPONENT_SET in `WIP: <name>`):
|
|
139
274
|
// {
|
|
140
275
|
// type, // the set's node type, 'COMPONENT_SET'
|
|
141
276
|
// variantGroupProperties: { Axis: { values: [...] }, ... }, // only the keys are read
|
|
142
|
-
// children: [ VARIANT, ... ]
|
|
277
|
+
// children: [ VARIANT, ... ],
|
|
278
|
+
// colors: { [variableName]: { modes: { [modeName]: '#rrggbb' | '#rrggbbaa' | null }, defaultMode } },
|
|
279
|
+
// missingSurfaces: [name, ...] // EXPECT.surfaces names with no local colour variable
|
|
143
280
|
// }
|
|
281
|
+
// `colors` holds every colour variable read on focus variants and their
|
|
282
|
+
// controlLayer, plus each EXPECT.surfaces name that was found.
|
|
144
283
|
// VARIANT (every child): { type, variantProperties: { Axis: 'Value' } }
|
|
145
284
|
// A VARIANT whose state value folds to `focus` also carries FOCUS NODE fields
|
|
146
285
|
// (below), plus `controlLayer` when EXPECT names one.
|
|
@@ -156,6 +295,8 @@ function missingStates(record) {
|
|
|
156
295
|
// Variable names are resolved by the reader and only for focus variants and their controlLayer.
|
|
157
296
|
//
|
|
158
297
|
// RESULT: { pass, checks: [{ name, result: 'pass' | 'fail' | 'skip', detail }] }
|
|
298
|
+
// built mode returns four checks: structural-read-back, state-baseline,
|
|
299
|
+
// focus-indicator, focus-contrast.
|
|
159
300
|
// `pass` ignores skips. A failed assertion is data; nothing here throws.
|
|
160
301
|
|
|
161
302
|
// The in-scope `uiFramework` values and the focus recipe each one uses. An absent or
|
|
@@ -181,11 +322,17 @@ function frameworkRecipe(uiFramework) {
|
|
|
181
322
|
: null;
|
|
182
323
|
}
|
|
183
324
|
|
|
184
|
-
// A focus variable is any variable whose name has
|
|
185
|
-
//
|
|
186
|
-
//
|
|
325
|
+
// A focus variable is any variable whose name has `focus` as a whole word: in a
|
|
326
|
+
// `/` segment of its own, or inside one split on `-`, `_`, spaces or a camelCase
|
|
327
|
+
// boundary. `border/focus`, `color/focus/ring`, `color/focusRing` and
|
|
328
|
+
// `interaction/border-primary-focus` count; `color/bg/focused` and a raw colour
|
|
329
|
+
// (null) do not.
|
|
187
330
|
function isFocusVariable(name) {
|
|
188
|
-
|
|
331
|
+
if (typeof name !== 'string') return false;
|
|
332
|
+
const words = name
|
|
333
|
+
.split('/')
|
|
334
|
+
.flatMap((seg) => seg.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[\s_-]+/));
|
|
335
|
+
return words.some((word) => word.toLowerCase() === 'focus');
|
|
189
336
|
}
|
|
190
337
|
|
|
191
338
|
// A choice control may carry selection on its own axis instead of as a `selected`
|
|
@@ -222,7 +369,34 @@ function lacksRing(target, recipe, isInput) {
|
|
|
222
369
|
];
|
|
223
370
|
}
|
|
224
371
|
|
|
225
|
-
|
|
372
|
+
// The focus-variable names that make up the ring `lacksRing` accepted, for the
|
|
373
|
+
// contrast check. Same recipes, same tests.
|
|
374
|
+
function ringVariables(target, recipe, isInput) {
|
|
375
|
+
const children = target.children ?? [];
|
|
376
|
+
const focusStrokes = (node) => (node.strokeVariables ?? []).filter(isFocusVariable);
|
|
377
|
+
if (recipe === 'mui' && isInput) return focusStrokes(target);
|
|
378
|
+
if (recipe === 'outline') {
|
|
379
|
+
return [target, ...children].filter((n) => n.strokeAlign === 'OUTSIDE').flatMap(focusStrokes);
|
|
380
|
+
}
|
|
381
|
+
const shadows = (target.effects ?? [])
|
|
382
|
+
.filter((e) => e.type === 'DROP_SHADOW' && e.visible !== false && e.spread > 0 && isFocusVariable(e.colorVariable))
|
|
383
|
+
.map((e) => e.colorVariable);
|
|
384
|
+
const ringChildren = children
|
|
385
|
+
.filter((c) => c.layoutPositioning === 'ABSOLUTE' && c.strokeAlign === 'OUTSIDE')
|
|
386
|
+
.flatMap(focusStrokes);
|
|
387
|
+
return [...shadows, ...ringChildren];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function withTable({
|
|
391
|
+
resolveArchetype,
|
|
392
|
+
BASELINE_STATES,
|
|
393
|
+
STATE_SYNONYMS,
|
|
394
|
+
normalizeName,
|
|
395
|
+
contrastRatio,
|
|
396
|
+
composite,
|
|
397
|
+
parseHex,
|
|
398
|
+
AA_NON_TEXT,
|
|
399
|
+
}) {
|
|
226
400
|
const fold = normalizeName;
|
|
227
401
|
const foldState = (value) => {
|
|
228
402
|
const name = fold(value);
|
|
@@ -242,6 +416,10 @@ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normaliz
|
|
|
242
416
|
.map(([k, v]) => `${k}=${v}`)
|
|
243
417
|
.join(', ');
|
|
244
418
|
|
|
419
|
+
const isFocusVariant = (child) =>
|
|
420
|
+
child.type === 'COMPONENT' &&
|
|
421
|
+
Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
|
|
422
|
+
|
|
245
423
|
function crossProduct(matrix) {
|
|
246
424
|
let combos = [{}];
|
|
247
425
|
for (const [axis, values] of Object.entries(matrix ?? {})) {
|
|
@@ -319,9 +497,6 @@ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normaliz
|
|
|
319
497
|
if (recipe === null) {
|
|
320
498
|
return { name, result: 'skip', detail: `uiFramework "${expect.uiFramework}" is out of scope for the focus check` };
|
|
321
499
|
}
|
|
322
|
-
const isFocusVariant = (child) =>
|
|
323
|
-
child.type === 'COMPONENT' &&
|
|
324
|
-
Object.entries(child.variantProperties ?? {}).some(([k, v]) => fold(k) === 'state' && fold(v) === 'focus');
|
|
325
500
|
const variants = (snapshot.children ?? []).filter(isFocusVariant);
|
|
326
501
|
if (variants.length === 0) {
|
|
327
502
|
return { name, result: 'fail', detail: 'no COMPONENT variant has a state of focus, so no ring can be read; the state check stops on this' };
|
|
@@ -344,6 +519,100 @@ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normaliz
|
|
|
344
519
|
: { name, result: 'pass', detail: `all ${variants.length} focus variants carry a ${recipe}-recipe focus indicator` };
|
|
345
520
|
}
|
|
346
521
|
|
|
522
|
+
// WCAG 1.4.11: each ring colour against each named surface, in every mode either
|
|
523
|
+
// side has. Runs only on a ring focusIndicator accepted.
|
|
524
|
+
function focusContrast(snapshot, expect, indicator) {
|
|
525
|
+
const name = 'focus-contrast';
|
|
526
|
+
if (indicator.result !== 'pass') {
|
|
527
|
+
return { name, result: 'skip', detail: 'focus-indicator did not pass, so there is no ring to measure' };
|
|
528
|
+
}
|
|
529
|
+
const surfaces = expect.surfaces ?? [];
|
|
530
|
+
if (surfaces.length === 0) {
|
|
531
|
+
return {
|
|
532
|
+
name,
|
|
533
|
+
result: 'skip',
|
|
534
|
+
detail: 'no surfaces named; the architect names the colour variables of the surfaces the component sits on',
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const missing = snapshot.missingSurfaces ?? [];
|
|
538
|
+
if (missing.length) {
|
|
539
|
+
return { name, result: 'fail', detail: `no local colour variable named: ${missing.join(', ')}` };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const recipe = frameworkRecipe(expect.uiFramework);
|
|
543
|
+
const isInput = resolveArchetype({ name: expect.name }) === 'input';
|
|
544
|
+
const hasLayer = expect.controlLayer !== undefined && expect.controlLayer !== null;
|
|
545
|
+
const rings = new Set();
|
|
546
|
+
for (const variant of (snapshot.children ?? []).filter(isFocusVariant)) {
|
|
547
|
+
const target = hasLayer ? variant.controlLayer : variant;
|
|
548
|
+
if (target) for (const ring of ringVariables(target, recipe, isInput)) rings.add(ring);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const colors = snapshot.colors ?? {};
|
|
552
|
+
// A mode's value: the same-named mode after the fold, else the default.
|
|
553
|
+
const valueIn = (entry, foldedMode) => {
|
|
554
|
+
const modes = entry?.modes ?? {};
|
|
555
|
+
const hit = Object.keys(modes).find((m) => fold(m) === foldedMode);
|
|
556
|
+
if (hit !== undefined) return modes[hit];
|
|
557
|
+
return entry?.defaultMode !== undefined ? modes[entry.defaultMode] ?? null : null;
|
|
558
|
+
};
|
|
559
|
+
const skipped = { unresolved: 0, translucentSurface: 0 };
|
|
560
|
+
const failures = [];
|
|
561
|
+
const modesSeen = new Set();
|
|
562
|
+
let pairs = 0;
|
|
563
|
+
for (const ring of [...rings].sort()) {
|
|
564
|
+
for (const surface of [...surfaces].sort()) {
|
|
565
|
+
const ringEntry = colors[ring];
|
|
566
|
+
const surfaceEntry = colors[surface];
|
|
567
|
+
// Modes come from the side(s) with more than one: a single-mode collection
|
|
568
|
+
// reads the same value in every mode, so its own mode name adds nothing.
|
|
569
|
+
// When both are single-mode, the ring's one mode is the only comparison.
|
|
570
|
+
const entries = [ringEntry, surfaceEntry];
|
|
571
|
+
const multi = entries.filter((e) => Object.keys(e?.modes ?? {}).length > 1);
|
|
572
|
+
const single = Object.keys(ringEntry?.modes ?? {}).length ? [ringEntry] : [surfaceEntry];
|
|
573
|
+
const modeNames = new Map();
|
|
574
|
+
for (const entry of multi.length ? multi : single) {
|
|
575
|
+
for (const m of Object.keys(entry?.modes ?? {})) if (!modeNames.has(fold(m))) modeNames.set(fold(m), m);
|
|
576
|
+
}
|
|
577
|
+
if (modeNames.size === 0) {
|
|
578
|
+
skipped.unresolved += 1;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
for (const [foldedMode, mode] of [...modeNames].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
|
|
582
|
+
const ringHex = valueIn(ringEntry, foldedMode);
|
|
583
|
+
const surfaceHex = valueIn(surfaceEntry, foldedMode);
|
|
584
|
+
const surfaceParsed = parseHex(surfaceHex);
|
|
585
|
+
if (parseHex(ringHex) === null || surfaceParsed === null) {
|
|
586
|
+
skipped.unresolved += 1;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (surfaceParsed.a < 1) {
|
|
590
|
+
skipped.translucentSurface += 1;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const surfaceOpaque = surfaceHex.slice(0, 7);
|
|
594
|
+
const ratio = contrastRatio(composite(ringHex, surfaceOpaque), surfaceOpaque);
|
|
595
|
+
pairs += 1;
|
|
596
|
+
modesSeen.add(foldedMode);
|
|
597
|
+
if (ratio < AA_NON_TEXT) {
|
|
598
|
+
const shown = (Math.floor(ratio * 100) / 100).toFixed(2);
|
|
599
|
+
failures.push(`${ring} on ${surface} in ${mode}: ${shown}:1, needs ${AA_NON_TEXT}:1`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const skipNote = [
|
|
605
|
+
skipped.unresolved && `${skipped.unresolved} skipped as unresolved or not a colour`,
|
|
606
|
+
skipped.translucentSurface && `${skipped.translucentSurface} skipped for a translucent surface`,
|
|
607
|
+
].filter(Boolean);
|
|
608
|
+
if (failures.length) return { name, result: 'fail', detail: failures.join('; ') };
|
|
609
|
+
if (pairs === 0) {
|
|
610
|
+
return { name, result: 'skip', detail: `no ring/surface pair could be compared: ${skipNote.join(', ') || 'no ring colour found'}` };
|
|
611
|
+
}
|
|
612
|
+
const passed = `${pairs} ring/surface pair(s) clear ${AA_NON_TEXT}:1 across ${modesSeen.size} mode(s)`;
|
|
613
|
+
return { name, result: 'pass', detail: skipNote.length ? `${passed}; ${skipNote.join(', ')}` : passed };
|
|
614
|
+
}
|
|
615
|
+
|
|
347
616
|
// Before anything is written: the matrix the spec asks for against the state table.
|
|
348
617
|
function checkSpec(expect) {
|
|
349
618
|
const entries = Object.entries(expect.matrix ?? {}).flatMap(([axis, values]) => values.map((v) => [axis, v]));
|
|
@@ -353,32 +622,77 @@ function withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normaliz
|
|
|
353
622
|
// After the build: the node as read.
|
|
354
623
|
function checkBuilt(snapshot, expect) {
|
|
355
624
|
const entries = (snapshot.children ?? []).flatMap((c) => Object.entries(c.variantProperties ?? {}));
|
|
356
|
-
|
|
625
|
+
const indicator = focusIndicator(snapshot, expect);
|
|
626
|
+
return result([
|
|
627
|
+
structural(snapshot, expect),
|
|
628
|
+
stateBaseline(expect, entries),
|
|
629
|
+
indicator,
|
|
630
|
+
focusContrast(snapshot, expect, indicator),
|
|
631
|
+
]);
|
|
357
632
|
}
|
|
358
633
|
|
|
359
634
|
return { checkSpec, checkBuilt };
|
|
360
635
|
}
|
|
361
636
|
|
|
362
|
-
const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName });
|
|
637
|
+
const { checkSpec, checkBuilt } = withTable({ resolveArchetype, BASELINE_STATES, STATE_SYNONYMS, normalizeName, contrastRatio, composite, parseHex, AA_NON_TEXT });
|
|
363
638
|
|
|
364
639
|
// Figma plugin-API reader for the stop-condition checks. This file is NOT a
|
|
365
640
|
// Node module — build-figma-checks.mjs concatenates it after the inlined
|
|
366
|
-
// component-states.mjs and figma-checks.mjs into
|
|
641
|
+
// component-states.mjs, contrast.mjs and figma-checks.mjs into
|
|
642
|
+
// references/figma-checks.md, and
|
|
367
643
|
// the result runs inside figma_execute (dynamic-page mode). Constraints honored
|
|
368
644
|
// here (see references/figma-scripting.md): async getters only, variable names
|
|
369
|
-
// resolved with getVariableByIdAsync and only for
|
|
370
|
-
//
|
|
371
|
-
//
|
|
645
|
+
// and per-mode colour values resolved with getVariableByIdAsync and only for
|
|
646
|
+
// focus variants and their controlLayer, plus the EXPECT.surfaces names, which
|
|
647
|
+
// are looked up among the file's LOCAL colour variables only (a surface from a
|
|
648
|
+
// published library reads as missing). Untested in Node, like the doc-card
|
|
649
|
+
// renderer; the pure checker it feeds is unit-tested.
|
|
372
650
|
//
|
|
373
651
|
// In-scope globals when assembled: normalizeName (inlined
|
|
374
652
|
// component-states.mjs), checkSpec, checkBuilt (bound from withTable) and the
|
|
375
653
|
// caller-filled slots EXPECT and MODE (passed to runFigmaChecks). The snapshot
|
|
376
654
|
// shape is documented at the top of scripts/lib/figma-checks.mjs.
|
|
377
655
|
|
|
656
|
+
// Colour variables read on focus nodes, by name, for the contrast check.
|
|
657
|
+
const readVariables = new Map();
|
|
658
|
+
|
|
378
659
|
async function variableName(alias) {
|
|
379
660
|
if (!alias || !alias.id) return null;
|
|
380
661
|
const variable = await figma.variables.getVariableByIdAsync(alias.id);
|
|
381
|
-
|
|
662
|
+
if (!variable) return null;
|
|
663
|
+
if (variable.resolvedType === 'COLOR') readVariables.set(variable.name, variable);
|
|
664
|
+
return variable.name;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function colorHex(value) {
|
|
668
|
+
if (!value || typeof value !== 'object' || !('r' in value)) return null;
|
|
669
|
+
const byte = (x) => Math.round(x * 255).toString(16).padStart(2, '0');
|
|
670
|
+
const alpha = value.a === undefined ? 1 : value.a;
|
|
671
|
+
return `#${byte(value.r)}${byte(value.g)}${byte(value.b)}${alpha < 1 ? byte(alpha) : ''}`;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// The value of `variable` in the mode named `modeName` (after the fold), else
|
|
675
|
+
// its collection's default mode, following aliases up to 10 hops.
|
|
676
|
+
async function resolveColor(variable, modeName, hops) {
|
|
677
|
+
if (!variable || hops > 10) return null;
|
|
678
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
679
|
+
if (!collection) return null;
|
|
680
|
+
const mode = collection.modes.find((m) => normalizeName(m.name) === normalizeName(modeName));
|
|
681
|
+
const value = variable.valuesByMode[mode ? mode.modeId : collection.defaultModeId];
|
|
682
|
+
if (value && value.type === 'VARIABLE_ALIAS') {
|
|
683
|
+
return resolveColor(await figma.variables.getVariableByIdAsync(value.id), modeName, hops + 1);
|
|
684
|
+
}
|
|
685
|
+
return colorHex(value);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// { modes: { [modeName]: hex | null }, defaultMode } over the variable's own collection.
|
|
689
|
+
async function variableColors(variable) {
|
|
690
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
691
|
+
if (!collection) return { modes: {}, defaultMode: undefined };
|
|
692
|
+
const modes = {};
|
|
693
|
+
for (const mode of collection.modes) modes[mode.name] = await resolveColor(variable, mode.name, 0);
|
|
694
|
+
const fallback = collection.modes.find((m) => m.modeId === collection.defaultModeId);
|
|
695
|
+
return { modes, defaultMode: fallback ? fallback.name : undefined };
|
|
382
696
|
}
|
|
383
697
|
|
|
384
698
|
// The fields the focus check reads from a variant, a control layer or a direct child.
|
|
@@ -439,6 +753,7 @@ function groupProperties(set) {
|
|
|
439
753
|
}
|
|
440
754
|
|
|
441
755
|
async function readComponentSet(expect) {
|
|
756
|
+
readVariables.clear();
|
|
442
757
|
await figma.currentPage.loadAsync();
|
|
443
758
|
const frameName = `WIP: ${expect.name}`;
|
|
444
759
|
const frame = figma.currentPage.findOne((n) => n.name === frameName);
|
|
@@ -469,10 +784,24 @@ async function readComponentSet(expect) {
|
|
|
469
784
|
}
|
|
470
785
|
children.push(read);
|
|
471
786
|
}
|
|
787
|
+
const colors = {};
|
|
788
|
+
for (const [name, variable] of readVariables) colors[name] = await variableColors(variable);
|
|
789
|
+
const missingSurfaces = [];
|
|
790
|
+
const surfaces = expect.surfaces || [];
|
|
791
|
+
if (surfaces.length) {
|
|
792
|
+
const local = await figma.variables.getLocalVariablesAsync('COLOR');
|
|
793
|
+
for (const name of surfaces) {
|
|
794
|
+
const variable = local.find((v) => v.name === name);
|
|
795
|
+
if (variable) colors[name] = await variableColors(variable);
|
|
796
|
+
else missingSurfaces.push(name);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
472
799
|
return {
|
|
473
800
|
type: set.type,
|
|
474
801
|
variantGroupProperties: groupProperties(set),
|
|
475
802
|
children,
|
|
803
|
+
colors,
|
|
804
|
+
missingSurfaces,
|
|
476
805
|
};
|
|
477
806
|
}
|
|
478
807
|
|