@motion-proto/live-tokens 0.96.0 → 0.98.2

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.
Files changed (206) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +5 -0
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +4 -3
  3. package/.claude/skills/live-tokens-create-component/references/contract-tests.md +71 -11
  4. package/CHANGELOG.md +309 -0
  5. package/README.md +2 -2
  6. package/bin/check-component.mjs +8 -0
  7. package/bin/check-page.mjs +6 -0
  8. package/bin/cli.mjs +36 -6
  9. package/bin/contractRunner.mjs +142 -17
  10. package/bin/lib/findings.mjs +20 -4
  11. package/bin/rules/a11y.mjs +53 -0
  12. package/bin/set-colors.mjs +1 -0
  13. package/bin/setup-claude.mjs +2 -2
  14. package/dist-plugin/{chunk-A4TBP4MM.js → chunk-SLICH4IH.js} +2 -2
  15. package/dist-plugin/index.cjs +148 -3
  16. package/dist-plugin/index.js +250 -105
  17. package/dist-plugin/setColors/index.cjs +7 -1
  18. package/dist-plugin/setColors/index.d.cts +9 -4
  19. package/dist-plugin/setColors/index.d.ts +9 -4
  20. package/dist-plugin/setColors/index.js +8 -2
  21. package/dist-plugin/setGeometry/index.d.cts +1 -1
  22. package/dist-plugin/setGeometry/index.d.ts +1 -1
  23. package/dist-plugin/setType/index.d.cts +1 -1
  24. package/dist-plugin/setType/index.d.ts +1 -1
  25. package/dist-plugin/{themeTypes-B8_Idrp4.d.cts → themeTypes-BaTQUyac.d.cts} +10 -1
  26. package/dist-plugin/{themeTypes-B8_Idrp4.d.ts → themeTypes-BaTQUyac.d.ts} +10 -1
  27. package/package.json +49 -2
  28. package/src/demo/LabeledSelect.svelte +1 -1
  29. package/src/editor/bootstrap.ts +3 -1
  30. package/src/editor/component-editor/BadgeEditor.svelte +13 -0
  31. package/src/editor/component-editor/ButtonEditor.svelte +33 -2
  32. package/src/editor/component-editor/CalloutEditor.svelte +13 -0
  33. package/src/editor/component-editor/CardEditor.svelte +12 -2
  34. package/src/editor/component-editor/CodeSnippetEditor.svelte +11 -1
  35. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +18 -1
  36. package/src/editor/component-editor/CornerBadgeEditor.svelte +13 -0
  37. package/src/editor/component-editor/DialogEditor.svelte +15 -0
  38. package/src/editor/component-editor/IconButtonEditor.svelte +29 -1
  39. package/src/editor/component-editor/ImageEditor.svelte +20 -9
  40. package/src/editor/component-editor/ImageLightboxEditor.svelte +44 -0
  41. package/src/editor/component-editor/InlineEditActionsEditor.svelte +13 -1
  42. package/src/editor/component-editor/InputEditor.svelte +23 -0
  43. package/src/editor/component-editor/MenuSelectEditor.svelte +27 -1
  44. package/src/editor/component-editor/NotificationEditor.svelte +15 -0
  45. package/src/editor/component-editor/PanelEditor.svelte +1 -1
  46. package/src/editor/component-editor/ProgressBarEditor.svelte +13 -0
  47. package/src/editor/component-editor/RadioButtonEditor.svelte +20 -1
  48. package/src/editor/component-editor/SectionDividerEditor.svelte +41 -13
  49. package/src/editor/component-editor/SegmentedControlEditor.svelte +29 -1
  50. package/src/editor/component-editor/SideNavigationEditor.svelte +40 -1
  51. package/src/editor/component-editor/SlidePagerEditor.svelte +20 -0
  52. package/src/editor/component-editor/SliderEditor.svelte +33 -0
  53. package/src/editor/component-editor/TabBarEditor.svelte +23 -2
  54. package/src/editor/component-editor/TableEditor.svelte +14 -0
  55. package/src/editor/component-editor/ToggleEditor.svelte +17 -0
  56. package/src/editor/component-editor/TooltipEditor.svelte +11 -2
  57. package/src/editor/component-editor/VideoFrameEditor.svelte +8 -0
  58. package/src/editor/component-editor/VideoLightboxEditor.svelte +10 -0
  59. package/src/editor/component-editor/registration.ts +2 -1
  60. package/src/editor/component-editor/registry.ts +69 -27
  61. package/src/editor/component-editor/scaffolding/DemoHeader.svelte +2 -0
  62. package/src/editor/component-editor/scaffolding/StateBlock.svelte +19 -8
  63. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +3 -0
  64. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +70 -10
  65. package/src/editor/component-editor/scaffolding/editorContext.ts +7 -0
  66. package/src/editor/core/a11y/a11yReport.ts +101 -0
  67. package/src/editor/core/a11y/a11yReportTypes.ts +69 -0
  68. package/src/editor/core/a11y/contrastFailures.ts +22 -0
  69. package/src/editor/core/components/contrastPairRegistry.ts +18 -0
  70. package/src/editor/core/palettes/contrast.ts +44 -0
  71. package/src/editor/core/palettes/contrastPairs.ts +79 -0
  72. package/src/editor/core/palettes/resolveColor.ts +77 -0
  73. package/src/editor/core/store/editorPersistence.ts +9 -1
  74. package/src/editor/core/store/editorRenderer.ts +2 -0
  75. package/src/editor/core/store/editorStore.ts +29 -0
  76. package/src/editor/core/store/editorTypes.ts +9 -0
  77. package/src/editor/core/store/editorViewStore.ts +2 -2
  78. package/src/editor/core/store/unsavedEdits.ts +10 -0
  79. package/src/editor/core/themes/buildColors.ts +16 -5
  80. package/src/editor/core/themes/liveStateStream.ts +7 -1
  81. package/src/editor/core/themes/parsers/gradient.ts +5 -2
  82. package/src/editor/core/themes/slices/customColors.ts +226 -0
  83. package/src/editor/core/themes/themeTypes.ts +10 -0
  84. package/src/editor/docs/chapters.ts +1 -0
  85. package/src/editor/docs/content/accessibility.md +362 -0
  86. package/src/editor/docs/content/editing-tokens.md +56 -1
  87. package/src/editor/docs/content.generated.ts +2 -1
  88. package/src/editor/pages/ComponentEditorPage.svelte +10 -1
  89. package/src/editor/pages/EditorShell.svelte +97 -10
  90. package/src/editor/skill-atlas/skillSources.generated.ts +3 -3
  91. package/src/editor/skill-atlas/trees/check-compliance.ts +3 -3
  92. package/src/editor/skill-atlas/trees/create-component.ts +12 -12
  93. package/src/editor/ui/CustomColorReplaceStep.svelte +118 -0
  94. package/src/editor/ui/EditorViewSwitcher.svelte +15 -2
  95. package/src/editor/ui/GradientEditor.svelte +9 -1
  96. package/src/editor/ui/ThemePanel.svelte +4 -4
  97. package/src/editor/ui/UIContrastReadout.svelte +186 -0
  98. package/src/editor/ui/UIInfoPopover.svelte +16 -2
  99. package/src/editor/ui/UIPaletteSelector.svelte +347 -15
  100. package/src/editor/ui/UITokenSelector.svelte +33 -14
  101. package/src/editor/ui/VariablesTab.svelte +3 -0
  102. package/src/editor/ui/a11y/A11yComponentIssues.svelte +103 -0
  103. package/src/editor/ui/a11y/A11yFindingRow.svelte +57 -0
  104. package/src/editor/ui/a11y/A11yIssueRow.svelte +121 -0
  105. package/src/editor/ui/a11y/A11yOverview.svelte +242 -0
  106. package/src/editor/ui/a11y/A11yPanel.svelte +128 -0
  107. package/src/editor/ui/a11y/A11yPropertyMark.svelte +84 -0
  108. package/src/editor/ui/a11y/A11yRailMark.svelte +34 -0
  109. package/src/editor/ui/a11y/AccessibilityTab.svelte +334 -0
  110. package/src/editor/ui/a11y/a11yView.ts +218 -0
  111. package/src/editor/ui/anchorToTrigger.ts +63 -0
  112. package/src/editor/ui/contrastReadout.ts +45 -0
  113. package/src/editor/ui/sections/CustomColorsSection.svelte +254 -0
  114. package/src/live-tokens/data/themes/autumn.json +20 -20
  115. package/src/live-tokens/data/themes/halloween.json +20 -20
  116. package/src/live-tokens/data/themes/midnight-study.json +20 -20
  117. package/src/live-tokens/data/themes/ocean.json +20 -20
  118. package/src/live-tokens/data/themes/royal-velvet.json +20 -20
  119. package/src/live-tokens/data/themes/sketchy.json +20 -20
  120. package/src/live-tokens/data/themes/spring-meadow.json +20 -20
  121. package/src/live-tokens/data/themes/sunset.json +20 -20
  122. package/src/system/components/Badge.svelte +3 -3
  123. package/src/system/components/Button.svelte +9 -3
  124. package/src/system/components/CodeSnippet.svelte +8 -1
  125. package/src/system/components/CornerBadge.svelte +3 -3
  126. package/src/system/components/Dialog.svelte +43 -14
  127. package/src/system/components/IconButton.svelte +1 -1
  128. package/src/system/components/Image.svelte +7 -0
  129. package/src/system/components/ImageLightbox.svelte +26 -26
  130. package/src/system/components/Input.svelte +8 -1
  131. package/src/system/components/MenuSelect.svelte +30 -2
  132. package/src/system/components/Notification.svelte +1 -1
  133. package/src/system/components/ProgressBar.svelte +6 -0
  134. package/src/system/components/RadioButton.svelte +36 -3
  135. package/src/system/components/SegmentedControl.svelte +36 -2
  136. package/src/system/components/SlidePager.svelte +2 -2
  137. package/src/system/components/Slider.svelte +13 -9
  138. package/src/system/components/TabBar.svelte +33 -3
  139. package/src/system/components/Toggle.svelte +2 -2
  140. package/src/system/components/VideoFrame.svelte +1 -1
  141. package/src/system/components/VideoLightbox.svelte +2 -2
  142. package/src/system/internal/focusTrap.ts +35 -0
  143. package/src/testing-js/__aria__/badge.yml +40 -0
  144. package/src/testing-js/__aria__/button.yml +88 -0
  145. package/src/testing-js/__aria__/callout.yml +12 -0
  146. package/src/testing-js/__aria__/card.yml +8 -0
  147. package/src/testing-js/__aria__/codesnippet.yml +21 -0
  148. package/src/testing-js/__aria__/collapsiblesection.yml +23 -0
  149. package/src/testing-js/__aria__/cornerbadge.yml +24 -0
  150. package/src/testing-js/__aria__/dialog.yml +42 -0
  151. package/src/testing-js/__aria__/iconbutton.yml +57 -0
  152. package/src/testing-js/__aria__/image.yml +10 -0
  153. package/src/testing-js/__aria__/imagelightbox.yml +50 -0
  154. package/src/testing-js/__aria__/inlineeditactions.yml +16 -0
  155. package/src/testing-js/__aria__/input.yml +61 -0
  156. package/src/testing-js/__aria__/menuselect.yml +30 -0
  157. package/src/testing-js/__aria__/notification.yml +30 -0
  158. package/src/testing-js/__aria__/panel.yml +2 -0
  159. package/src/testing-js/__aria__/progressbar.yml +2 -0
  160. package/src/testing-js/__aria__/radiobutton.yml +20 -0
  161. package/src/testing-js/__aria__/sectiondivider.yml +9 -0
  162. package/src/testing-js/__aria__/segmentedcontrol.yml +35 -0
  163. package/src/testing-js/__aria__/sidenavigation.yml +304 -0
  164. package/src/testing-js/__aria__/slidepager.yml +72 -0
  165. package/src/testing-js/__aria__/slider.yml +28 -0
  166. package/src/testing-js/__aria__/tabbar.yml +35 -0
  167. package/src/testing-js/__aria__/table.yml +144 -0
  168. package/src/testing-js/__aria__/toggle.yml +12 -0
  169. package/src/testing-js/__aria__/tooltip.yml +2 -0
  170. package/src/testing-js/__aria__/videoframe.yml +6 -0
  171. package/src/testing-js/__aria__/videolightbox.yml +12 -0
  172. package/src/testing-js/{chunk-WIZ6W7UT.js → chunk-3WCLMOM7.js} +2 -2
  173. package/src/testing-js/{chunk-SHBYZUPD.js → chunk-5HNZHIHG.js} +211 -14
  174. package/src/testing-js/chunk-5HNZHIHG.js.map +1 -0
  175. package/src/testing-js/{chunk-NB3NZRBM.js → chunk-7CM5D7PF.js} +5 -1
  176. package/src/testing-js/chunk-7CM5D7PF.js.map +1 -0
  177. package/src/testing-js/chunk-EWSGE27V.js +168 -0
  178. package/src/testing-js/chunk-EWSGE27V.js.map +1 -0
  179. package/src/testing-js/{chunk-GKO27NGN.js → chunk-R54I7YL6.js} +2 -2
  180. package/src/testing-js/{chunk-X6KHEVIG.js → chunk-RWH46IU5.js} +97 -29
  181. package/src/testing-js/chunk-RWH46IU5.js.map +1 -0
  182. package/src/testing-js/chunk-SHLPDNAQ.js +658 -0
  183. package/src/testing-js/chunk-SHLPDNAQ.js.map +1 -0
  184. package/src/testing-js/{chunk-3UKGXCDL.js → chunk-YVVR3GJZ.js} +1 -1
  185. package/src/testing-js/chunk-YVVR3GJZ.js.map +1 -0
  186. package/src/testing-js/component-a11y.contract.js +283 -0
  187. package/src/testing-js/component-a11y.contract.js.map +1 -0
  188. package/src/testing-js/component-alias.contract.js +2 -2
  189. package/src/testing-js/component-behavior.contract.js +2 -2
  190. package/src/testing-js/component-editor.contract.js +6 -5
  191. package/src/testing-js/component-editor.contract.js.map +1 -1
  192. package/src/testing-js/component-render.contract.js +10 -6
  193. package/src/testing-js/component-render.contract.js.map +1 -1
  194. package/src/testing-js/index.d.ts +44 -16
  195. package/src/testing-js/index.js +9 -9
  196. package/src/testing-js/page-compliance.contract.js +163 -18
  197. package/src/testing-js/page-compliance.contract.js.map +1 -1
  198. package/src/testing-js/vitest.js +3 -3
  199. package/template/package.json +9 -0
  200. package/src/editor/ui/keepInViewport.ts +0 -84
  201. package/src/testing-js/chunk-3UKGXCDL.js.map +0 -1
  202. package/src/testing-js/chunk-NB3NZRBM.js.map +0 -1
  203. package/src/testing-js/chunk-SHBYZUPD.js.map +0 -1
  204. package/src/testing-js/chunk-X6KHEVIG.js.map +0 -1
  205. /package/src/testing-js/{chunk-WIZ6W7UT.js.map → chunk-3WCLMOM7.js.map} +0 -0
  206. /package/src/testing-js/{chunk-GKO27NGN.js.map → chunk-R54I7YL6.js.map} +0 -0
@@ -2,6 +2,8 @@ import { API_BASE } from '../storage/apiBase';
2
2
  import { refreshProductionTheme } from '../productionPulse';
3
3
  import { hydrateAppliedTheme } from './themeDocumentSync';
4
4
  import type { LiveState } from './themeService';
5
+ import { receiveA11yReport } from '../a11y/a11yReport';
6
+ import { A11Y_EVENT, type A11yReport } from '../a11y/a11yReportTypes';
5
7
 
6
8
  export const LIVE_STATE_EVENT = 'live-state';
7
9
 
@@ -12,7 +14,8 @@ let source: EventSource | null = null;
12
14
  * from outside the page (a CLI verb, a branch switch) arrives as one frame,
13
15
  * and this document hydrates from it the way it does from an Apply. An outside
14
16
  * writer can also save the production theme past its bake (`save-theme` into
15
- * the open, published theme), so production is re-read too.
17
+ * the open, published theme), so production is re-read too. The same stream
18
+ * carries each accessibility run's report on its start and on its end.
16
19
  */
17
20
  export function init(): void {
18
21
  if (source || typeof EventSource === 'undefined') return;
@@ -22,6 +25,9 @@ export function init(): void {
22
25
  hydrateAppliedTheme(state.fileName, state);
23
26
  void refreshProductionTheme();
24
27
  });
28
+ source.addEventListener(A11Y_EVENT, (event) => {
29
+ receiveA11yReport(JSON.parse((event as MessageEvent<string>).data) as A11yReport);
30
+ });
25
31
  }
26
32
 
27
33
  export function __resetForTests(): void {
@@ -181,7 +181,10 @@ const VAR_RE = /^var\((--[a-z0-9-]+)\)$/i;
181
181
 
182
182
  /** Split on top-level separators only. Stop colours nest commas inside
183
183
  * `color-mix(…)`, so a plain `split(',')` shreds them. */
184
- function splitTopLevel(s: string): string[] {
184
+ export function splitTopLevel(
185
+ s: string,
186
+ isSeparator: (c: string) => boolean = (c) => c === ',',
187
+ ): string[] {
185
188
  const parts: string[] = [];
186
189
  let depth = 0;
187
190
  let start = 0;
@@ -189,7 +192,7 @@ function splitTopLevel(s: string): string[] {
189
192
  const c = s[i];
190
193
  if (c === '(') depth++;
191
194
  else if (c === ')') depth--;
192
- else if (c === ',' && depth === 0) {
195
+ else if (depth === 0 && isSeparator(c)) {
193
196
  parts.push(s.slice(start, i));
194
197
  start = i + 1;
195
198
  }
@@ -0,0 +1,226 @@
1
+ import type { CssVarRef, CustomColorToken, EditorState } from '../../store/editorTypes';
2
+ import type { CustomColorDiskToken } from '../themeTypes';
3
+ import type { GradientStop } from '../parsers/gradient';
4
+ import { get } from 'svelte/store';
5
+ import { mutate, store } from '../../store/editorCore';
6
+ import { oklchToCss, type Oklch } from '../../palettes/oklch';
7
+ import { PALETTE_SPECS, PALETTE_STEPS, palettesToValues } from '../../palettes/paletteDerivation';
8
+ import { WASH_FAMILIES, type WashFamily } from './washes';
9
+
10
+ const CUSTOM_COLOR_SLOT_RE = /^--color-custom-\d+$/;
11
+
12
+ export function isCustomColorSlot(variable: string): boolean {
13
+ return CUSTOM_COLOR_SLOT_RE.test(variable);
14
+ }
15
+
16
+ function isCustomColorToken(t: unknown): t is CustomColorToken {
17
+ if (!t || typeof t !== 'object') return false;
18
+ const { variable, color } = t as Partial<CustomColorToken>;
19
+ return typeof variable === 'string' && isCustomColorSlot(variable)
20
+ && !!color && typeof color === 'object'
21
+ && [color.l, color.c, color.h].every((n) => typeof n === 'number' && Number.isFinite(n));
22
+ }
23
+
24
+ export function isCustomColorList(tokens: unknown): tokens is CustomColorToken[] {
25
+ return Array.isArray(tokens) && tokens.every(isCustomColorToken);
26
+ }
27
+
28
+ /** The lowest free slot, so a removed number is reused before the list grows. */
29
+ export function nextCustomColorSlot(tokens: readonly CustomColorToken[]): string {
30
+ const used = new Set(tokens.map((t) => t.variable));
31
+ for (let n = 1; ; n++) {
32
+ const name = `--color-custom-${n}`;
33
+ if (!used.has(name)) return name;
34
+ }
35
+ }
36
+
37
+ export function customColorsToVars(c: EditorState['customColors']): Record<string, string> {
38
+ const out: Record<string, string> = {};
39
+ for (const t of c.tokens) out[t.variable] = oklchToCss(t.color.l, t.color.c, t.color.h);
40
+ return out;
41
+ }
42
+
43
+ /** The rendered `--color-custom-N` strings in `cssVariables` are a projection
44
+ * for production CSS; the structured field is the basis, so the loader sheds
45
+ * them from the bag. A file without the field keeps its bag as it is. */
46
+ export function loadCustomColorsFromFile(
47
+ next: EditorState,
48
+ customColors: CustomColorDiskToken[] | undefined,
49
+ rawVars: Record<string, string>,
50
+ ): void {
51
+ for (const t of customColors ?? []) delete rawVars[t.variable];
52
+ if (isCustomColorList(customColors)) {
53
+ next.customColors.tokens = structuredClone(customColors);
54
+ }
55
+ }
56
+
57
+ function findCustomColor(s: EditorState, variable: string): CustomColorToken | undefined {
58
+ return s.customColors.tokens.find((t) => t.variable === variable);
59
+ }
60
+
61
+ export function addCustomColor(color: Oklch): string {
62
+ let variable = '';
63
+ mutate('add custom colour', (s) => {
64
+ variable = nextCustomColorSlot(s.customColors.tokens);
65
+ s.customColors.tokens.push({ variable, color: { ...color } });
66
+ });
67
+ return variable;
68
+ }
69
+
70
+ export function setCustomColor(variable: string, color: Oklch): void {
71
+ mutate(`set custom colour ${variable}`, (s) => {
72
+ const t = findCustomColor(s, variable);
73
+ if (t) t.color = { ...color };
74
+ });
75
+ }
76
+
77
+ /** One slot that names a custom colour. An alias counts once however many of
78
+ * its inline gradient stops name the colour; so does a library gradient. */
79
+ export type CustomColorUsage =
80
+ | { kind: 'alias'; component: string; property: string }
81
+ | { kind: 'gradient'; gradient: string }
82
+ | { kind: 'wash'; family: WashFamily };
83
+
84
+ function escapeRegExp(s: string): string {
85
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
86
+ }
87
+
88
+ /** `var(--color-custom-1)` inside a literal, never `var(--color-custom-10)`. */
89
+ function varRefPattern(variable: string): RegExp {
90
+ return new RegExp(`var\\(${escapeRegExp(variable)}\\)`, 'g');
91
+ }
92
+
93
+ function stopsName(stops: readonly GradientStop[], variable: string): boolean {
94
+ return stops.some((st) => st.color === variable);
95
+ }
96
+
97
+ /** A literal names a custom colour when the picker materialized a library
98
+ * gradient with a per-slot angle: the stop list is inlined as `var()` refs. */
99
+ function refNames(ref: CssVarRef, variable: string): boolean {
100
+ switch (ref.kind) {
101
+ case 'token':
102
+ return ref.name === variable;
103
+ case 'literal':
104
+ return varRefPattern(variable).test(ref.value);
105
+ case 'gradient':
106
+ return stopsName(ref.value.stops, variable);
107
+ }
108
+ }
109
+
110
+ export function customColorUsages(state: EditorState, variable: string): CustomColorUsage[] {
111
+ const out: CustomColorUsage[] = [];
112
+ for (const [component, slice] of Object.entries(state.components)) {
113
+ for (const [property, ref] of Object.entries(slice.aliases)) {
114
+ if (refNames(ref, variable)) out.push({ kind: 'alias', component, property });
115
+ }
116
+ }
117
+ for (const g of state.gradients.tokens) {
118
+ if (stopsName(g.stops, variable)) out.push({ kind: 'gradient', gradient: g.variable });
119
+ }
120
+ for (const family of WASH_FAMILIES) {
121
+ if (state.washes[family].color === variable) out.push({ kind: 'wash', family });
122
+ }
123
+ return out;
124
+ }
125
+
126
+ function joinAnd(items: readonly string[]): string {
127
+ if (items.length <= 1) return items[0] ?? '';
128
+ return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
129
+ }
130
+
131
+ /** The replace step's usage line: "Used by 4 properties in Button and Card." */
132
+ export function describeCustomColorUsages(
133
+ usages: readonly CustomColorUsage[],
134
+ componentLabel: (component: string) => string,
135
+ ): string {
136
+ const aliases = usages.filter((u) => u.kind === 'alias');
137
+ const parts: string[] = [];
138
+ if (aliases.length > 0) {
139
+ const components = [...new Set(aliases.map((u) => u.component))].map(componentLabel);
140
+ const noun = aliases.length === 1 ? 'property' : 'properties';
141
+ parts.push(`${aliases.length} ${noun} in ${joinAnd(components)}`);
142
+ }
143
+ for (const u of usages) {
144
+ if (u.kind === 'gradient') parts.push(`Gradient ${u.gradient.replace(/^--gradient-/, '')}`);
145
+ else if (u.kind === 'wash') parts.push(`the ${u.family} colour`);
146
+ }
147
+ return `Used by ${joinAnd(parts)}.`;
148
+ }
149
+
150
+ const WHITE: Oklch = { l: 1, c: 0, h: 0 };
151
+ const BLACK: Oklch = { l: 0, c: 0, h: 0 };
152
+
153
+ function oklabDistance(a: Oklch, b: Oklch): number {
154
+ const ar = (a.h * Math.PI) / 180;
155
+ const br = (b.h * Math.PI) / 180;
156
+ const dl = a.l - b.l;
157
+ const da = a.c * Math.cos(ar) - b.c * Math.cos(br);
158
+ const db = a.c * Math.sin(ar) - b.c * Math.sin(br);
159
+ return Math.hypot(dl, da, db);
160
+ }
161
+
162
+ /** The token a replace step preselects: nearest by OKLab distance among the
163
+ * palette steps, White, Black and the custom colours other than `exclude`. */
164
+ export function nearestColorToken(color: Oklch, state: EditorState, exclude?: string): string {
165
+ const candidates: [string, Oklch][] = [];
166
+ const values = palettesToValues(state.palettes);
167
+ for (const spec of PALETTE_SPECS) {
168
+ for (const step of PALETTE_STEPS) {
169
+ const name = `--color-${spec.cssNamespace}-${step.label}`;
170
+ const v = values[name];
171
+ if (v?.kind === 'color') candidates.push([name, v]);
172
+ }
173
+ }
174
+ candidates.push(['--color-white', WHITE], ['--color-black', BLACK]);
175
+ for (const t of state.customColors.tokens) {
176
+ if (t.variable !== exclude) candidates.push([t.variable, t.color]);
177
+ }
178
+
179
+ let best = candidates[0][0];
180
+ let bestDistance = Infinity;
181
+ for (const [name, value] of candidates) {
182
+ const d = oklabDistance(color, value);
183
+ if (d < bestDistance) {
184
+ best = name;
185
+ bestDistance = d;
186
+ }
187
+ }
188
+ return best;
189
+ }
190
+
191
+ /** Removes a custom colour that no slot names. Returns false, changing
192
+ * nothing, while any slot still names it; `replaceAndRemoveCustomColor`
193
+ * serves that case. */
194
+ export function removeCustomColor(variable: string): boolean {
195
+ const current = get(store);
196
+ if (!findCustomColor(current, variable) || customColorUsages(current, variable).length > 0) return false;
197
+ mutate(`remove custom colour ${variable}`, (s) => {
198
+ s.customColors.tokens = s.customColors.tokens.filter((t) => t.variable !== variable);
199
+ });
200
+ return true;
201
+ }
202
+
203
+ function replaceInStops(stops: GradientStop[], variable: string, replacement: string): void {
204
+ for (const st of stops) if (st.color === variable) st.color = replacement;
205
+ }
206
+
207
+ /** Repoints every slot that names `variable` at `replacement`, keeping each
208
+ * slot's opacity, then removes the custom colour. One undo step. */
209
+ export function replaceAndRemoveCustomColor(variable: string, replacement: string): void {
210
+ if (replacement === variable || !findCustomColor(get(store), variable)) return;
211
+ const pattern = varRefPattern(variable);
212
+ mutate(`replace and remove custom colour ${variable}`, (s) => {
213
+ for (const slice of Object.values(s.components)) {
214
+ for (const ref of Object.values(slice.aliases)) {
215
+ if (ref.kind === 'token' && ref.name === variable) ref.name = replacement;
216
+ else if (ref.kind === 'literal') ref.value = ref.value.replace(pattern, `var(${replacement})`);
217
+ else if (ref.kind === 'gradient') replaceInStops(ref.value.stops, variable, replacement);
218
+ }
219
+ }
220
+ for (const g of s.gradients.tokens) replaceInStops(g.stops, variable, replacement);
221
+ for (const family of WASH_FAMILIES) {
222
+ if (s.washes[family].color === variable) s.washes[family].color = replacement;
223
+ }
224
+ s.customColors.tokens = s.customColors.tokens.filter((t) => t.variable !== variable);
225
+ });
226
+ }
@@ -148,6 +148,13 @@ export interface GradientDiskToken {
148
148
  stops: { position: number; color: string; opacity?: number }[];
149
149
  }
150
150
 
151
+ /** On-disk shape of one `--color-custom-N` token: a theme-owned colour that
152
+ * holds its own OKLCH value, one value for light and dark alike. */
153
+ export interface CustomColorDiskToken {
154
+ variable: string;
155
+ color: Oklch;
156
+ }
157
+
151
158
  /**
152
159
  * Where a live component config resolved from: the unsaved `_working`
153
160
  * buffer, or the open theme's embedded copy. Every theme carries every
@@ -177,6 +184,9 @@ export interface ColorsAndType {
177
184
  /** Absent on files saved before gradients round-tripped; the loader keeps
178
185
  * the stock defaults then, which match what those files rendered. */
179
186
  gradients?: GradientDiskToken[];
187
+ /** Absent when the theme holds no custom colour; the loader reads that as
188
+ * an empty list. */
189
+ customColors?: CustomColorDiskToken[];
180
190
  /** Four fixed harmony axes, each owning a hue with an optional bound family. */
181
191
  harmonyAxes?: HarmonyAxis[];
182
192
  /**
@@ -18,6 +18,7 @@ export const chapters: Chapter[] = [
18
18
  { id: 'where-themes-live', title: 'Where themes live' },
19
19
  { id: 'light-and-dark', title: 'Light and dark' },
20
20
  { id: 'creating-components', title: 'Creating components' },
21
+ { id: 'accessibility', title: 'Accessibility' },
21
22
  { id: 'production-css', title: 'Production CSS' },
22
23
  ];
23
24
 
@@ -0,0 +1,362 @@
1
+ # Accessibility
2
+
3
+ `check-component --tests` and `check-page --tests` test the shipped
4
+ components, your own components, and your pages against WCAG 2 A and AA. Every
5
+ accessibility finding reports at `warn`: the run prints it and leaves the exit
6
+ status to the other rules. `--strict` turns each one into an error that fails
7
+ the run, as it does every other warning.
8
+
9
+ ```bash
10
+ npx live-tokens check-component statcard --tests
11
+ npx live-tokens check-page src/pages/Home.svelte --tests --strict
12
+ ```
13
+
14
+ ## Suites and checks
15
+
16
+ **Components.** The component suite opens every variant and state the
17
+ component's contract names and scans the preview:
18
+
19
+ - **axe-core** checks names, roles, ARIA, contrast, target size, and
20
+ structure.
21
+ - **Siteimprove Alfa**, rule SIA-R65, checks that each focusable element
22
+ looks different when it takes focus.
23
+ - **Focus indicator contrast** measures each focus ring against the backdrop
24
+ around it, at 3:1 under WCAG 2.2 SC 1.4.11.
25
+ - **Control boundary contrast** measures each part the contract lists in
26
+ `boundaries` against the backdrop behind it, at 3:1 under WCAG 2.2
27
+ SC 1.4.11.
28
+ - **Text spacing** applies the WCAG 2.2 SC 1.4.12 override inside the
29
+ component and reports text that clips under it.
30
+ - **Reduced motion** reports motion longer than 200 ms that still runs under
31
+ `prefers-reduced-motion: reduce`.
32
+ - **Playwright** reads each view's accessibility tree and compares it with
33
+ the tree recorded for that view.
34
+ - **Keyboard cases** in the contract press Enter, Space, the arrow keys, and
35
+ Escape, and read what each key did.
36
+
37
+ A project's run scans the open theme. The library's own test run also repeats
38
+ axe's contrast rule, and both contrast checks, under each of the eight preset
39
+ themes.
40
+
41
+ **Pages.** `check-page --tests` runs three tests on each page at each
42
+ viewport:
43
+
44
+ - `page-a11y` runs axe over the page, leaving out the router's chrome and
45
+ every component the component suite scans. axe's contrast rule stays off here, because
46
+ `page-contrast` already reports text contrast at `error`.
47
+ - `page-text-spacing` applies the WCAG 2.2 SC 1.4.12 text spacing override
48
+ (line height 1.5, paragraph spacing 2em, letter spacing 0.12em, word
49
+ spacing 0.16em) and reports content that overflows under it.
50
+ - `page-reduced-motion` loads the page under `prefers-reduced-motion: reduce`
51
+ and reports animations that keep running.
52
+
53
+ ## Rules
54
+
55
+ | Rule | Checker | Reports |
56
+ |------|---------|---------|
57
+ | `a11y-component` | `check-component` | An axe failure inside a component's preview, a focus ring or control boundary below 3:1, or a changed accessibility tree. |
58
+ | `a11y-focus-visible` | `check-component` | An element that looks the same focused as at rest (SIA-R65). |
59
+ | `a11y-keyboard` | `check-component` | A keyboard case whose key failed to activate, move, or close. |
60
+ | `a11y-page` | `check-page` | An axe failure in the page's own markup. |
61
+ | `a11y-text-spacing` | `check-component`, `check-page` | Content that overflows under the text spacing override. |
62
+ | `a11y-reduced-motion` | `check-component`, `check-page` | Motion that still runs under reduced motion. |
63
+ | `a11y-review` | both | A check the engine could not decide, for you to judge by eye. |
64
+
65
+ Each finding's message names the engine's own rule, the element, the view it
66
+ failed in, and a help page that states the requirement and its repair. The
67
+ rules take the same severity settings as every other rule. `--off=a11y-review`
68
+ silences one for a single run, `--error=a11y-page` raises one, and
69
+ `live-tokens.config.json` records either for good:
70
+
71
+ ```json
72
+ { "checks": { "rules": { "a11y-review": "off", "a11y-page": "error" } } }
73
+ ```
74
+
75
+ A scan that crashes reports `tests-setup`.
76
+
77
+ ## Focus indicator contrast
78
+
79
+ The suite forces `:focus` and `:focus-visible` on each focusable element in
80
+ each view and looks for the style focus changes. It searches the element, its
81
+ descendants, and its siblings with their descendants, so a ring painted on a
82
+ sibling, such as a slider thumb's cap, counts. The indicator is the outline
83
+ when focus changes one, else the first outer box shadow, else a border side.
84
+
85
+ The backdrop is every box behind the ring's outer edge, composited in order
86
+ over white. A ring below 3:1 against it reports under `a11y-component` with
87
+ engine rule `focus-indicator-contrast`, such as "Focus outline on span.cap
88
+ 2.14:1 against the backdrop, needs 3:1". Ratios floor to two places, because
89
+ WCAG does not round. Chromium's own `outline-style: auto` ring passes: it pairs
90
+ a dark band with a light one. An element with no indicator at all is
91
+ SIA-R65's finding and adds none here.
92
+
93
+ ## Control boundary contrast
94
+
95
+ A control whose edge shows where to click or what it holds needs that edge
96
+ at 3:1 against the backdrop behind it. So does a selected or current item
97
+ whose surface or edge alone shows that state. The contract lists these parts
98
+ in `boundaries`. Each part passes when its border, on any side, or its
99
+ outline, 1px or wider, or its surface reaches 3:1. A failure names both ratios, such as "Track border
100
+ 1.45:1 and surface 1.3:1 against the backdrop, needs 3:1", and reports under
101
+ `a11y-component` with engine rule `control-boundary-contrast`. A part whose
102
+ form controls are all disabled is skipped, because SC 1.4.11 exempts inactive
103
+ controls.
104
+
105
+ The shipped controls that draw their own edge declare it: Input's field,
106
+ RadioButton's circle, Slider's and Toggle's track and thumb, and
107
+ SegmentedControl's bar. The selected states that show by surface and edge
108
+ alone declare their part too: TabBar's selected tab, SegmentedControl's
109
+ selected option, and SideNavigation's selected title, section, item and
110
+ footer. Each measures against the bar or panel behind it, the surface the
111
+ unselected items show. MenuSelect's selected option carries a check mark, so
112
+ it declares none.
113
+
114
+ ## Text spacing and motion per component
115
+
116
+ The text spacing check measures every element of the component that holds
117
+ text: its root part, its portaled parts, and their descendants. It applies
118
+ the override inside the preview alone and reports each element the override
119
+ pushes more than 1px past its box, on an axis it does not scroll. An element
120
+ already cut at rest adds nothing. The help names the measure: "Under the text
121
+ spacing override, `<span>` holds 24px of content in 18px of height, and its
122
+ overflow-y is hidden".
123
+
124
+ The reduced motion check reads the declared animations and transitions of
125
+ the same elements and their `::before` and `::after`, along with every
126
+ script animation running on them. Motion that lasts past 200 ms, its delay
127
+ included, is recorded. The check then emulates
128
+ `prefers-reduced-motion: reduce` in the same view and reports each motion
129
+ still that long, such as "A transition of left lasts 500 ms under
130
+ prefers-reduced-motion: reduce". A view with no long motion costs no second
131
+ read. The help page is WCAG technique C39, which serves WCAG 2.2 SC 2.3.3
132
+ Animation from Interactions.
133
+
134
+ ## Property tracing
135
+
136
+ A finding that a property can fix names that property. The suite reads the
137
+ CSS rules that match the failing element through the Chrome DevTools
138
+ protocol, keeps the declarations the finding depends on, such as the outline
139
+ and border of a focus ring or the height and padding of a clipped label, and
140
+ follows each `var()` to the component's semantic properties and intrinsics.
141
+ A component's private custom properties lead through to the property behind
142
+ them. Theme and editor variables drop out.
143
+
144
+ `--json` output carries the result in each finding's `details.variables`,
145
+ sorted, and leaves the field out when the trace finds nothing. An excerpt:
146
+
147
+ ```json
148
+ {
149
+ "rule": "a11y-component",
150
+ "component": "slider",
151
+ "details": {
152
+ "engineRule": "focus-indicator-contrast",
153
+ "target": "span.cap",
154
+ "variables": ["--slider-range-thumb-border", "--slider-range-thumb-border-width"]
155
+ }
156
+ }
157
+ ```
158
+
159
+ axe's contrast and target size rules, SIA-R65, both contrast checks, text
160
+ spacing, and reduced motion trace. The ARIA rules, the keyboard cases, and
161
+ the accessibility tree mark the component alone.
162
+
163
+ ## Review findings
164
+
165
+ `a11y-review` collects the checks an engine left undecided. axe reads contrast
166
+ from the colour behind the text, so it returns no verdict for text over a
167
+ gradient, an image, or a backdrop, or under a pseudo-element that covers it.
168
+ The focus indicator and control boundary checks do the same for a ring or an
169
+ edge over a gradient or an image. Judge each element by eye against the help
170
+ page the finding names.
171
+
172
+ Two sources account for most of them. Button's shimmer pseudo-element and its
173
+ tinted hover surfaces leave every Button view undecided, so a Button run lists
174
+ many `a11y-review` warnings. A theme that paints gradient surfaces does the
175
+ same for every component that sits on one.
176
+
177
+ ## Accessibility trees
178
+
179
+ The package records each shipped component's accessibility tree, one block
180
+ per view, in `src/testing/contracts/__aria__/<id>.yml`. A run that reads a
181
+ different tree reports `a11y-component` with engine rule `aria-snapshot` and
182
+ the lines that changed.
183
+
184
+ Inside the live-tokens repository, after a deliberate markup change,
185
+ `--update-aria` records the tree anew, and `LIVE_TOKENS_UPDATE_ARIA=1 npm run
186
+ test:e2e:contract` rewrites every file:
187
+
188
+ ```bash
189
+ node bin/cli.mjs check-component button --tests --update-aria
190
+ ```
191
+
192
+ In a project the trees sit under `node_modules`, where the suite reads them
193
+ and never writes. A project's own components therefore have no recorded tree
194
+ yet.
195
+
196
+ ## Keyboard cases
197
+
198
+ A component contract declares its keyboard cases among its interaction
199
+ cases, each marked `rule: 'a11y-keyboard'`: Enter or Space activates, an
200
+ arrow key moves where the component's WAI-ARIA pattern moves, and Escape
201
+ closes an overlay. A failed keyboard case reports `a11y-keyboard` at `warn`.
202
+ A case without the mark fails as `contract-interaction`, an error. The
203
+ `live-tokens-create-component` skill writes these cases for a new component,
204
+ and its `references/contract-tests.md` describes their shape.
205
+
206
+ An editor that adds placeholder content to its preview, such as a demo label
207
+ or a hover target, marks it `data-preview-demo`, and the scans skip it.
208
+
209
+ ## Boundaries in the contract
210
+
211
+ `boundaries` is an optional contract field,
212
+ `{ part: string; label: string; when?: string }[]`, for the parts that draw a
213
+ control's visible edge or show its selected state. `part` is a key of the
214
+ contract's `parts`, and `label` names the part in a finding. `when` is a
215
+ selector the part must also match to be measured, for a part that carries its
216
+ selected state as a class in some views only.
217
+
218
+ ```ts
219
+ boundaries: [
220
+ { part: 'track', label: 'Track' },
221
+ { part: 'thumb', label: 'Thumb' },
222
+ ],
223
+ ```
224
+
225
+ List a part whose border or surface is the only thing that shows the control
226
+ is there, a thumb whose position shows the value, or a selected item whose
227
+ surface or edge is the only thing that shows it is selected. Leave out a part whose
228
+ text or icon already shows it. A contract without the field runs no boundary
229
+ check.
230
+
231
+ ## Accessibility view
232
+
233
+ The editor's Accessibility view runs the component checks from the browser.
234
+ **Run test** checks every component in the open theme as saved, in about
235
+ three and a half minutes. With edits pending, it shows "Save the changes you
236
+ want to have checked." and starts nothing. Save the theme first, so the run
237
+ and the editor read the same values.
238
+
239
+ Every finding counts, results to check by eye included. An overview opens
240
+ the report. It shows the total, the count in each repair group below, and a
241
+ table of every component with a finding: its count per group and a bar of
242
+ its share. A component's name in the table scrolls to its section.
243
+
244
+ Each component's section heads its count with **Open component**, which opens
245
+ the component's page at its accessibility panel. The section sorts its rows
246
+ by repair:
247
+
248
+ - **Fix with a property** holds each colour pair below its WCAG floor, as the
249
+ editor's contrast checker measures it, such as "Brand text color 3.87:1,
250
+ needs 4.5:1", and every focus ring and control boundary below 3:1.
251
+ - **Fix in the component's code** holds every other finding: axe failures,
252
+ focus styles that SIA-R65 finds unchanged, failed keyboard cases, text that
253
+ clips under the spacing override, and motion that ignores reduced motion.
254
+ - **Check by eye** holds the `a11y-review` results.
255
+
256
+ Findings that share an engine rule, a help text, and their traced properties
257
+ merge into one row. The row names its element, or its element count, and the
258
+ views it failed in, up to four. An issue's text is amber, and a result to
259
+ check by eye is grey. The row's links sit beside its text:
260
+
261
+ - Each property the row traces to links with a "→" to its row on the
262
+ component's page, which scrolls to the row and highlights it. A row that
263
+ traces to no property links to the component's page instead.
264
+ - The link on the right opens the page the finding cites and names its kind:
265
+ "Rule" for an axe or Alfa rule, "Success criterion" with its number for a
266
+ WCAG criterion, "Technique" for a WCAG technique, and "Pattern" for a
267
+ WAI-ARIA pattern.
268
+
269
+ A property's label leads with the words from its variable name that the
270
+ editor's label leaves out, so `--input-focused-border` reads "Focused border
271
+ color" where the editor's row reads "Border color". The contrast rows read
272
+ the same labels.
273
+
274
+ The overview and the per-component counts include the results to check by
275
+ eye. A component's section ends in a "N results to check by eye" button that
276
+ opens one closed list of those results, merged per component. axe's contrast
277
+ violations stay out of the report, because the contrast checker measures the
278
+ same pairs.
279
+
280
+ When Playwright, axe, or Alfa is missing, the report names its install
281
+ command and Run test stays disabled. Install the package and press **Check
282
+ again**. The view runs its tests through the dev server, so it exists under
283
+ the dev server alone, with the rest of the editor.
284
+
285
+ The view runs `check-component --tests --a11y --no-fix --json --open-theme`.
286
+ `--a11y` runs the accessibility suite and the keyboard cases alone, and
287
+ `--open-theme` tests the theme the editor has open. A run without it tests
288
+ the `default` theme. Both work from the command line too.
289
+
290
+ ## Marks in the editor
291
+
292
+ The last finished run marks the rest of the editor:
293
+
294
+ - The component list leads the name of each component with any finding
295
+ with a warning triangle. A condensed list shows the triangle on the
296
+ component's icon.
297
+ - That component's page opens with a panel under its title that lists its
298
+ findings in all three groups, check by eye included.
299
+ - Each "→" in the panel opens the tab holding the property and highlights
300
+ its row.
301
+ - Each property row a finding traces to carries a mark beside its label: a
302
+ warning triangle in the highlight colour, or a grey "?" when every finding on the row is a
303
+ result to check by eye. On a colour or size row the mark sits between the
304
+ label and the value. On a toggle row it follows the label.
305
+
306
+ Click a row's mark to open its findings: the count, then each merged row
307
+ with its element, its views, its help page, and the properties it traces to. The
308
+ editor's own contrast failures add no row mark, because the contrast readout
309
+ on the colour row already shows them.
310
+
311
+ The marks and the panel keep the run's time and change when the next run
312
+ finishes. The contrast readout on each colour row stays live and measures
313
+ every edit as you make it.
314
+
315
+ ## Installing the engines
316
+
317
+ The engines ship as optional peer dependencies. A project made with `create`
318
+ has them. Any other project installs them beside `@playwright/test`:
319
+
320
+ ```bash
321
+ npm install -D axe-core @axe-core/playwright \
322
+ @siteimprove/alfa-act@^0.119.0 @siteimprove/alfa-device@^0.119.0 \
323
+ @siteimprove/alfa-dom@^0.119.0 @siteimprove/alfa-http@^0.119.0 \
324
+ @siteimprove/alfa-rules@^0.119.0 @siteimprove/alfa-url@^0.119.0 \
325
+ @siteimprove/alfa-web@^0.119.0
326
+ ```
327
+
328
+ The Alfa packages share one minor version. axe-core needs 4.11.1 or later to
329
+ read OKLCH colours, which every shipped theme uses. A test whose engine is
330
+ missing reports as inapplicable, and its coverage names the package. A
331
+ missing engine leaves the exit status to the other rules, under `--strict`
332
+ too.
333
+
334
+ ## Limits
335
+
336
+ - The page's text spacing test measures widths, so it reports horizontal
337
+ overflow alone. The component check measures both axes.
338
+ - The page's reduced motion test reports animations still running 200 ms
339
+ after the page loads. A short entrance animation finishes first and goes
340
+ unseen.
341
+ - The component's reduced motion check reads the preference within each
342
+ view. A script that reads it only when the component mounts goes unseen.
343
+ - The page scan leaves contrast to `page-contrast` and focus visibility to
344
+ the component suite.
345
+ - SIA-R65 reads the focused element, its descendants, and its ancestors, so
346
+ it reports a focus ring painted on a sibling as missing. The focus
347
+ indicator check measures that ring; confirm it by eye.
348
+ - A view that forces the focused look at rest, such as Input's focused
349
+ state, leaves real focus nothing to change, so SIA-R65 reports it there.
350
+ The same element passes in the views that leave it at rest.
351
+ - A finding traces to the component's own semantic properties and
352
+ intrinsics. A ring or colour that a theme token paints directly, such as
353
+ SegmentedControl's `--border-neutral-strong` focus ring, traces to
354
+ nothing. A finding on a component nested
355
+ inside another, such as a Button inside a Dialog, marks the host and names
356
+ none of the nested component's properties.
357
+ - A literal value in the component's code, such as a transition's duration,
358
+ has no property to name. The finding traces to whatever property governs
359
+ the same declaration, or to none.
360
+ - A project's own components get no recorded accessibility tree.
361
+ - Automated checks find part of what WCAG asks. Reading order, the meaning of
362
+ labels and alt text, and screen reader behaviour still need a person.