@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
@@ -0,0 +1,101 @@
1
+ import { derived, writable, type Readable } from 'svelte/store';
2
+ import { API_BASE } from '../storage/apiBase';
3
+ import type { A11yContrastFailure, A11yFinding, A11yFinishedReport, A11yReport } from './a11yReportTypes';
4
+
5
+ /** One component's share of the last finished run, sorted by its fix (decision 6). */
6
+ export interface ComponentA11yIssues {
7
+ /** Fix with a property: the editor's own contrast failures. */
8
+ contrast: A11yContrastFailure[];
9
+ /** Fix with a property: the run's findings whose engine rule measures a property's value. */
10
+ property: A11yFinding[];
11
+ /** Fix in the component's code: every other `a11y-*` finding but `a11y-review`. */
12
+ code: A11yFinding[];
13
+ /** Check by eye. */
14
+ review: A11yFinding[];
15
+ }
16
+
17
+ /** Engine rules whose failure a property's value fixes. */
18
+ export const PROPERTY_FIX_RULES: ReadonlySet<string> = new Set(['focus-indicator-contrast', 'control-boundary-contrast']);
19
+
20
+ const latest = writable<A11yReport | null>(null);
21
+ const lastFinished = writable<A11yFinishedReport | null>(null);
22
+ let heard = false;
23
+
24
+ /** The dev server's latest word: a run going, finished or failed. */
25
+ export const a11yReport: Readable<A11yReport | null> = { subscribe: latest.subscribe };
26
+
27
+ /** The last run that finished, which the markers follow while a new run goes (decision 7). */
28
+ export const a11yFinishedReport: Readable<A11yFinishedReport | null> = { subscribe: lastFinished.subscribe };
29
+
30
+ export const a11yIssues: Readable<Record<string, ComponentA11yIssues>> = derived(lastFinished, ($report) => {
31
+ const out: Record<string, ComponentA11yIssues> = {};
32
+ if (!$report) return out;
33
+ const entry = (id: string) => (out[id] ??= { contrast: [], property: [], code: [], review: [] });
34
+ for (const failure of $report.contrast) entry(failure.component).contrast.push(failure);
35
+ for (const finding of $report.findings) {
36
+ if (!finding.component || !finding.rule.startsWith('a11y-')) continue;
37
+ const issues = entry(finding.component);
38
+ const group = finding.rule === 'a11y-review'
39
+ ? issues.review
40
+ : PROPERTY_FIX_RULES.has(finding.details?.engineRule ?? '') ? issues.property : issues.code;
41
+ group.push(finding);
42
+ }
43
+ return out;
44
+ });
45
+
46
+ /** The run's findings under each component property they trace to (decision 7). The editor's contrast failures stay out. */
47
+ export const a11yPropertyFindings: Readable<Map<string, A11yFinding[]>> = derived(lastFinished, ($report) => {
48
+ const out = new Map<string, A11yFinding[]>();
49
+ for (const finding of $report?.findings ?? []) {
50
+ if (!finding.component || !finding.rule.startsWith('a11y-')) continue;
51
+ for (const variable of finding.details?.variables ?? []) {
52
+ const list = out.get(variable);
53
+ if (list) list.push(finding);
54
+ else out.set(variable, [finding]);
55
+ }
56
+ }
57
+ return out;
58
+ });
59
+
60
+ export function receiveA11yReport(report: A11yReport): void {
61
+ heard = true;
62
+ latest.set(report);
63
+ if (report.state === 'finished') lastFinished.set(report);
64
+ }
65
+
66
+ export async function init(): Promise<void> {
67
+ const res = await fetch(`${API_BASE}/a11y/report`);
68
+ if (!res.ok) return;
69
+ const { report } = (await res.json()) as { report: A11yReport | null };
70
+ // An event that landed while the read was in flight is the newer word.
71
+ if (report && !heard) receiveA11yReport(report);
72
+ }
73
+
74
+ /**
75
+ * Starts a run with the contrast failures the editor measured. Answers 409
76
+ * `A11Y_RUN_IN_PROGRESS` while a run goes, and 409 `LEGACY_LAYOUT` on a tree
77
+ * `live-tokens migrate` has not healed; both arrive as the thrown error's `code`.
78
+ */
79
+ export async function startA11yRun(contrast: A11yContrastFailure[]): Promise<A11yReport> {
80
+ const res = await fetch(`${API_BASE}/a11y/run`, {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify({ contrast }),
84
+ });
85
+ if (!res.ok) {
86
+ const body = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
87
+ const err = new Error(body.error || 'The accessibility run did not start') as Error & { status?: number; code?: string };
88
+ err.status = res.status;
89
+ if (body.code) err.code = body.code;
90
+ throw err;
91
+ }
92
+ const report = (await res.json()) as A11yReport;
93
+ receiveA11yReport(report);
94
+ return report;
95
+ }
96
+
97
+ export function __resetForTests(): void {
98
+ heard = false;
99
+ latest.set(null);
100
+ lastFinished.set(null);
101
+ }
@@ -0,0 +1,69 @@
1
+ import type { ContrastRole } from '../palettes/contrast';
2
+
3
+ /** The SSE event the dev server sends on a run's start and on its end. */
4
+ export const A11Y_EVENT = 'a11y';
5
+
6
+ /** One failing pair the editor's contrast checker measured when the run started (decision 4). */
7
+ export interface A11yContrastFailure {
8
+ component: string;
9
+ /** The foreground property, the row a fix changes. */
10
+ variable: string;
11
+ /** Component layers behind the foreground, top first, as the pair declares them. */
12
+ backdrop: string[];
13
+ role: ContrastRole;
14
+ ratio: number;
15
+ floor: number;
16
+ context?: string;
17
+ }
18
+
19
+ /** A finding as `check-component --tests --a11y --json` prints it. */
20
+ export interface A11yFinding {
21
+ rule: string;
22
+ file: string;
23
+ line: number;
24
+ message: string;
25
+ /** Absent on a run-level finding such as `tests-not-installed`. */
26
+ component?: string;
27
+ details?: {
28
+ engineRule: string;
29
+ impact?: string | null;
30
+ target: string;
31
+ view?: string;
32
+ html: string;
33
+ help: string;
34
+ helpUrl: string;
35
+ /** The component properties the result traced to, sorted. Absent when the trace found none. */
36
+ variables?: string[];
37
+ };
38
+ context?: Record<string, unknown>;
39
+ severity: 'error' | 'warn';
40
+ guidance?: string;
41
+ repair: string;
42
+ }
43
+
44
+ interface A11yReportBase {
45
+ startedAt: string;
46
+ contrast: A11yContrastFailure[];
47
+ }
48
+
49
+ export interface A11yRunningReport extends A11yReportBase {
50
+ state: 'running';
51
+ }
52
+
53
+ /** Holds every CLI finding except axe's `color-contrast`, which `contrast` covers. */
54
+ export interface A11yFinishedReport extends A11yReportBase {
55
+ state: 'finished';
56
+ finishedAt: string;
57
+ /** Absent when the run stopped before it copied the data tree, as a missing tool stops it. */
58
+ theme?: string;
59
+ findings: A11yFinding[];
60
+ }
61
+
62
+ /** The CLI crashed or printed no report. */
63
+ export interface A11yFailedReport extends A11yReportBase {
64
+ state: 'failed';
65
+ finishedAt: string;
66
+ error: string;
67
+ }
68
+
69
+ export type A11yReport = A11yRunningReport | A11yFinishedReport | A11yFailedReport;
@@ -0,0 +1,22 @@
1
+ import { measurePair, type ContrastPair, type Resolver } from '../palettes/contrastPairs';
2
+ import type { A11yContrastFailure } from './a11yReportTypes';
3
+
4
+ /** Each pair below its floor. `measurePair` passes an inactive pair, so a disabled state never fails. */
5
+ export function contrastFailures(
6
+ pairs: { component: string; pair: ContrastPair }[],
7
+ resolver: Resolver,
8
+ ): A11yContrastFailure[] {
9
+ return pairs.flatMap(({ component, pair }) => {
10
+ const measure = measurePair(pair, resolver);
11
+ if (measure.passes) return [];
12
+ return [{
13
+ component,
14
+ variable: pair.foreground,
15
+ backdrop: pair.backdrop,
16
+ role: pair.role,
17
+ ratio: measure.ratio,
18
+ floor: measure.floor,
19
+ ...(pair.context ? { context: pair.context } : {}),
20
+ }];
21
+ });
22
+ }
@@ -0,0 +1,18 @@
1
+ import type { ContrastPair } from '../palettes/contrastPairs';
2
+
3
+ // The palette selector reads pairs from here. Importing `registry.ts` instead
4
+ // would close a cycle, because every editor imports the palette selector.
5
+ const pairsByComponent = new Map<string, ContrastPair[]>();
6
+
7
+ /** Replaces the component's pairs, so a custom entry that overrides a built-in drops the built-in's pairs. */
8
+ export function registerContrastPairs(id: string, pairs: ContrastPair[]): void {
9
+ pairsByComponent.set(id, pairs);
10
+ }
11
+
12
+ export function getContrastPairs(id: string, variable: string): ContrastPair[] {
13
+ return (pairsByComponent.get(id) ?? []).filter((pair) => pair.foreground === variable);
14
+ }
15
+
16
+ export function allContrastPairs(): { component: string; pair: ContrastPair }[] {
17
+ return [...pairsByComponent].flatMap(([component, pairs]) => pairs.map((pair) => ({ component, pair })));
18
+ }
@@ -10,6 +10,50 @@ import { hexToOklch, oklchToHex, gamutClamp } from './oklch';
10
10
  export const AA_BODY = 4.5;
11
11
  export const AA_LARGE = 3.0;
12
12
 
13
+ /** WCAG's large-text sizes: the AA floor drops to 3:1 at either. */
14
+ export const LARGE_TEXT_PX = 24;
15
+ export const LARGE_BOLD_PX = 18.66;
16
+ export const BOLD = 700;
17
+
18
+ export function isLargeText(px: number, weight: number): boolean {
19
+ return px >= LARGE_TEXT_PX || (px >= LARGE_BOLD_PX && weight >= BOLD);
20
+ }
21
+
22
+ export type ContrastRole = 'text' | 'non-text';
23
+
24
+ export interface ContrastRule {
25
+ criterion: string;
26
+ name: string;
27
+ level: 'AA';
28
+ floor: number;
29
+ /** Text only: the floor once `isLargeText` holds. */
30
+ largeFloor?: number;
31
+ url: string;
32
+ }
33
+
34
+ export const CONTRAST_RULES: Record<ContrastRole, ContrastRule> = {
35
+ text: {
36
+ criterion: '1.4.3',
37
+ name: 'Contrast (Minimum)',
38
+ level: 'AA',
39
+ floor: AA_BODY,
40
+ largeFloor: AA_LARGE,
41
+ url: 'https://www.w3.org/TR/WCAG22/#contrast-minimum',
42
+ },
43
+ 'non-text': {
44
+ criterion: '1.4.11',
45
+ name: 'Non-text Contrast',
46
+ level: 'AA',
47
+ floor: 3,
48
+ url: 'https://www.w3.org/TR/WCAG22/#non-text-contrast',
49
+ },
50
+ };
51
+
52
+ export function floorFor(role: ContrastRole, large: boolean): number {
53
+ const rule = CONTRAST_RULES[role];
54
+ return large && rule.largeFloor !== undefined ? rule.largeFloor : rule.floor;
55
+ }
56
+
13
57
  function linearizeChannel(v8: number): number {
14
58
  const s = v8 / 255;
15
59
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
@@ -0,0 +1,79 @@
1
+ import { contrastRatio, floorFor, isLargeText, type ContrastRole } from './contrast';
2
+
3
+ export const PAGE_BG = '--page-bg';
4
+
5
+ export interface ContrastPair {
6
+ foreground: string;
7
+ /** Component layers behind the foreground, top first. `--page-bg` always sits under the last one. */
8
+ backdrop: string[];
9
+ role: ContrastRole;
10
+ /** Text only. Sets the large-text floor. */
11
+ fontSize?: string;
12
+ /** Text only. Normal weight when absent. */
13
+ fontWeight?: string;
14
+ /** Disabled state: WCAG exempts it, so the pair always passes. */
15
+ inactive?: boolean;
16
+ /** Names the context when one foreground has several pairs ("inline", "zoomed"). */
17
+ context?: string;
18
+ }
19
+
20
+ /** sRGB channels from 0 to 255 and alpha from 0 to 1, as a canvas pixel reads. */
21
+ export type Rgba = [r: number, g: number, b: number, a: number];
22
+
23
+ export function over(front: Rgba, back: Rgba): Rgba {
24
+ const alpha = front[3] + back[3] * (1 - front[3]);
25
+ if (alpha === 0) return [0, 0, 0, 0];
26
+ const mix = (f: number, b: number) => (f * front[3] + b * back[3] * (1 - front[3])) / alpha;
27
+ return [mix(front[0], back[0]), mix(front[1], back[1]), mix(front[2], back[2]), alpha];
28
+ }
29
+
30
+ export interface Resolver {
31
+ /** One color for a solid value, one per stop for a gradient. */
32
+ color(name: string): Rgba[];
33
+ fontPx(name: string): number;
34
+ fontWeight(name: string): number;
35
+ }
36
+
37
+ export interface PairMeasure {
38
+ pair: ContrastPair;
39
+ ratio: number;
40
+ floor: number;
41
+ large: boolean;
42
+ passes: boolean;
43
+ }
44
+
45
+ const NORMAL_WEIGHT = 400;
46
+
47
+ function hex([r, g, b]: Rgba): string {
48
+ return `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, '0')).join('')}`;
49
+ }
50
+
51
+ export function measurePair(pair: ContrastPair, resolver: Resolver): PairMeasure {
52
+ let behind = resolver.color(PAGE_BG);
53
+ for (const layer of [...pair.backdrop].reverse()) {
54
+ const below = behind;
55
+ behind = resolver.color(layer).flatMap((stop) => below.map((back) => over(stop, back)));
56
+ }
57
+ const fronts = resolver.color(pair.foreground);
58
+ let ratio = Infinity;
59
+ for (const back of behind) {
60
+ for (const front of fronts) {
61
+ ratio = Math.min(ratio, contrastRatio(hex(over(front, back)), hex(back)));
62
+ }
63
+ }
64
+ const large = pair.role === 'text'
65
+ && pair.fontSize !== undefined
66
+ && isLargeText(
67
+ resolver.fontPx(pair.fontSize),
68
+ pair.fontWeight ? resolver.fontWeight(pair.fontWeight) : NORMAL_WEIGHT,
69
+ );
70
+ const floor = floorFor(pair.role, large);
71
+ return { pair, ratio, floor, large, passes: pair.inactive === true || ratio >= floor };
72
+ }
73
+
74
+ /** An inactive pair applies no floor, so it ranks only when every pair is inactive. */
75
+ export function closestToFailing(measures: PairMeasure[]): PairMeasure {
76
+ const active = measures.filter((m) => !m.pair.inactive);
77
+ const pool = active.length > 0 ? active : measures;
78
+ return pool.reduce((worst, m) => (m.ratio / m.floor < worst.ratio / worst.floor ? m : worst));
79
+ }
@@ -0,0 +1,77 @@
1
+ import { splitTopLevel } from '../themes/parsers/gradient';
2
+ import type { Resolver, Rgba } from './contrastPairs';
3
+
4
+ const GRADIENT = /^(?:repeating-)?(?:linear|radial|conic)-gradient\((.*)\)$/is;
5
+ const DIRECTION = /^(?:to|at|from|in|circle|ellipse|closest-side|closest-corner|farthest-side|farthest-corner)$/i;
6
+ const POSITION = /^(?:[+-]?(?:\d+\.?\d*|\.\d+)(?:[a-z]+|%)?|(?:calc|min|max|clamp)\(.*\))$/is;
7
+
8
+ const isSpace = (c: string) => /\s/.test(c);
9
+
10
+ /** The stop colors of a gradient function, or null for any other value. */
11
+ export function gradientStops(value: string): string[] | null {
12
+ const match = value.trim().match(GRADIENT);
13
+ if (!match) return null;
14
+ const stops: string[] = [];
15
+ splitTopLevel(match[1]).forEach((arg, i) => {
16
+ const tokens = splitTopLevel(arg, isSpace).filter(Boolean);
17
+ if (i === 0 && (DIRECTION.test(tokens[0]) || POSITION.test(tokens[0]))) return;
18
+ const color = tokens.filter((token) => !POSITION.test(token)).join(' ');
19
+ if (color) stops.push(color);
20
+ });
21
+ return stops;
22
+ }
23
+
24
+ export function createDomResolver(doc: Document = document): Resolver {
25
+ const view = doc.defaultView!;
26
+ // The canvas answers with the pixel the screen shows, gamut-clamped, for any
27
+ // color syntax the probe computes.
28
+ const canvas = doc.createElement('canvas');
29
+ canvas.width = 1;
30
+ canvas.height = 1;
31
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
32
+ ctx.globalCompositeOperation = 'copy';
33
+
34
+ const paint = (css: string): Rgba => {
35
+ ctx.fillStyle = 'rgba(0, 0, 0, 0)';
36
+ ctx.fillStyle = css;
37
+ ctx.fillRect(0, 0, 1, 1);
38
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
39
+ return [r, g, b, a / 255];
40
+ };
41
+
42
+ const withProbe = <T>(read: (probe: HTMLElement, style: CSSStyleDeclaration) => T): T => {
43
+ // A value `color` rejects makes the probe inherit, so the host's
44
+ // transparent reads as a layer that paints nothing.
45
+ const host = doc.createElement('div');
46
+ host.style.display = 'none';
47
+ host.style.color = 'transparent';
48
+ const probe = doc.createElement('div');
49
+ host.appendChild(probe);
50
+ doc.body.appendChild(host);
51
+ try {
52
+ return read(probe, view.getComputedStyle(probe));
53
+ } finally {
54
+ host.remove();
55
+ }
56
+ };
57
+
58
+ return {
59
+ color: (name) => withProbe((probe, style) => {
60
+ const stops = gradientStops(style.getPropertyValue(name)) ?? [`var(${name})`];
61
+ return stops.map((stop) => {
62
+ // An invalid assignment keeps the previous stop's color.
63
+ probe.style.color = '';
64
+ probe.style.color = stop;
65
+ return paint(style.color);
66
+ });
67
+ }),
68
+ fontPx: (name) => withProbe((probe, style) => {
69
+ probe.style.fontSize = `var(${name})`;
70
+ return parseFloat(style.fontSize);
71
+ }),
72
+ fontWeight: (name) => withProbe((probe, style) => {
73
+ probe.style.fontWeight = `var(${name})`;
74
+ return parseFloat(style.fontWeight);
75
+ }),
76
+ };
77
+ }
@@ -19,6 +19,7 @@ import { storageKey } from './editorConfig';
19
19
  import { store } from './editorCore';
20
20
  import { quietGet, quietSet } from '../storage/storage';
21
21
  import { isGradientSlot, makeDefaultGradients } from '../themes/slices/gradients';
22
+ import { isCustomColorList } from '../themes/slices/customColors';
22
23
  import { seedShadowsFromDom } from '../themes/slices/shadows';
23
24
  import { makeDefaultWashScale, type WashFamily } from '../themes/slices/washes';
24
25
  import { normalizeColumnsState } from '../themes/slices/columns';
@@ -73,6 +74,13 @@ function migrateGradients(state: EditorState): EditorState {
73
74
  return { ...state, gradients: { tokens: makeDefaultGradients() } };
74
75
  }
75
76
 
77
+ // Persisted state from before custom colours gets the empty default from the
78
+ // shallow merge; anything else that is not a well-formed list loads as empty.
79
+ function migrateCustomColors(state: EditorState): EditorState {
80
+ if (isCustomColorList(state.customColors?.tokens)) return state;
81
+ return { ...state, customColors: { tokens: [] } };
82
+ }
83
+
76
84
  // `hydrate` shallow-merges the persisted `components` bag over the default, so
77
85
  // a slice serialized by an older build can lack fields added since (e.g.
78
86
  // `config`, added with the two-field alias/config split). `componentsToVars`
@@ -205,7 +213,7 @@ export function hydrate(): void {
205
213
  store.set(
206
214
  normalizeHarmonyAxes(
207
215
  normalizeBaseAnchors(
208
- normalizePaletteBasis(normalizePaletteLabels(normalizeComponents(normalizeColumns(normalizeWashes(migrateGradients(merged)))))),
216
+ normalizePaletteBasis(normalizePaletteLabels(normalizeComponents(normalizeColumns(normalizeWashes(migrateCustomColors(migrateGradients(merged))))))),
209
217
  ),
210
218
  ),
211
219
  );
@@ -21,6 +21,7 @@ import {
21
21
  washesToVars,
22
22
  shadowsToVars,
23
23
  gradientsToVars,
24
+ customColorsToVars,
24
25
  componentsToVars,
25
26
  } from './editorStore';
26
27
 
@@ -34,6 +35,7 @@ export function deriveCssVars(state: EditorState): Record<string, string> {
34
35
  if (state.gradients.tokens.length > 0) {
35
36
  Object.assign(out, gradientsToVars(state.gradients));
36
37
  }
38
+ Object.assign(out, customColorsToVars(state.customColors));
37
39
  Object.assign(out, palettesToVars(state.palettes));
38
40
  Object.assign(out, componentsToVars(state.components));
39
41
  Object.assign(out, resolveFontStackValues(state.fonts.stacks, state.fonts.sources));
@@ -74,6 +74,10 @@ import {
74
74
  loadGradientsFromFile,
75
75
  makeDefaultGradients,
76
76
  } from '../themes/slices/gradients';
77
+ import {
78
+ customColorsToVars,
79
+ loadCustomColorsFromFile,
80
+ } from '../themes/slices/customColors';
77
81
  import {
78
82
  componentBaseline,
79
83
  getComponentOwnedVarNames,
@@ -102,6 +106,7 @@ function emptyState(): EditorState {
102
106
  columns: defaultColumns(),
103
107
  components: {},
104
108
  gradients: { tokens: makeDefaultGradients() },
109
+ customColors: { tokens: [] },
105
110
  harmonyAxes: defaultHarmonyAxes(),
106
111
  cssVars: {},
107
112
  };
@@ -179,6 +184,20 @@ export {
179
184
  removeGradientToken,
180
185
  } from '../themes/slices/gradients';
181
186
 
187
+ export {
188
+ customColorsToVars,
189
+ nextCustomColorSlot,
190
+ isCustomColorSlot,
191
+ addCustomColor,
192
+ setCustomColor,
193
+ removeCustomColor,
194
+ customColorUsages,
195
+ describeCustomColorUsages,
196
+ nearestColorToken,
197
+ replaceAndRemoveCustomColor,
198
+ } from '../themes/slices/customColors';
199
+ export type { CustomColorUsage } from '../themes/slices/customColors';
200
+
182
201
  export {
183
202
  componentsToVars,
184
203
  getComponentOwnedVarNames,
@@ -197,6 +216,10 @@ export {
197
216
  markComponentSaved,
198
217
  } from '../themes/slices/components';
199
218
 
219
+ export { a11yReport, a11yFinishedReport, a11yIssues, startA11yRun } from '../a11y/a11yReport';
220
+ export type { ComponentA11yIssues } from '../a11y/a11yReport';
221
+ export type { A11yContrastFailure, A11yFinding, A11yReport } from '../a11y/a11yReportTypes';
222
+
200
223
  export {
201
224
  setFontSources,
202
225
  setFontStacks,
@@ -404,6 +427,7 @@ export function colorsAndTypeToState(colorsAndType: ColorsAndType): EditorState
404
427
  // begin with).
405
428
  for (const load of Object.values(domainLoaders)) load(next, rawVars);
406
429
  loadGradientsFromFile(next, colorsAndType.gradients, rawVars);
430
+ loadCustomColorsFromFile(next, colorsAndType.customColors, rawVars);
407
431
  next.harmonyAxes = sanitizeHarmonyAxes(renameBackgroundHarmonyFamily(colorsAndType.harmonyAxes), next.palettes);
408
432
  next.cssVars = rawVars;
409
433
  return next;
@@ -438,12 +462,17 @@ function colorsAndTypeContent(state: EditorState): Omit<ColorsAndType, 'name' |
438
462
  if (state.gradients.tokens.length > 0) {
439
463
  Object.assign(cssVariables, gradientsToVars(state.gradients));
440
464
  }
465
+ // An empty list writes no field, so a theme that never held a custom colour
466
+ // still compares equal to the buffer its load produced.
467
+ const hasCustomColors = state.customColors.tokens.length > 0;
468
+ if (hasCustomColors) Object.assign(cssVariables, customColorsToVars(state.customColors));
441
469
  return {
442
470
  editorConfigs: state.palettes,
443
471
  cssVariables,
444
472
  fontSources: state.fonts.sources,
445
473
  fontStacks: state.fonts.stacks,
446
474
  gradients: state.gradients.tokens,
475
+ ...(hasCustomColors ? { customColors: state.customColors.tokens } : {}),
447
476
  harmonyAxes: state.harmonyAxes,
448
477
  schemaVersion: CURRENT_COLORS_AND_TYPE_SCHEMA_VERSION,
449
478
  };
@@ -1,6 +1,7 @@
1
1
  import type { PaletteConfig, FontSource, FontStack } from '../themes/themeTypes';
2
2
  import type { GradientStop, GradientValue, LinearDirection } from '../themes/parsers/gradient';
3
3
  import type { HarmonyAxis } from '../palettes/colorHarmony';
4
+ import type { Oklch } from '../palettes/oklch';
4
5
  import type { ColumnViewport, ColumnViewportValues } from '../themes/slices/columns';
5
6
 
6
7
  export interface ShadowGlobals {
@@ -80,6 +81,13 @@ export interface GradientToken {
80
81
  stops: GradientTokenStop[];
81
82
  }
82
83
 
84
+ /** A theme-owned `--color-custom-N` colour. Slots name it the way they name a
85
+ * palette step, so one edit moves every slot that names it. */
86
+ export interface CustomColorToken {
87
+ variable: string;
88
+ color: Oklch;
89
+ }
90
+
83
91
  /** Structured gradient payload carried inline on a component alias.
84
92
  * `GradientToken` minus `variable` (the alias key itself is the binding).
85
93
  * Used when a component owns a per-instance gradient that doesn't share the
@@ -123,6 +131,7 @@ export interface EditorState {
123
131
  columns: ColumnsState;
124
132
  components: Record<string, ComponentSlice>;
125
133
  gradients: { tokens: GradientToken[] };
134
+ customColors: { tokens: CustomColorToken[] };
126
135
  /** Four fixed numbered axes (index 0 is the anchor); each owns a hue;
127
136
  * family is the live-bound color or null. */
128
137
  harmonyAxes: HarmonyAxis[];
@@ -1,13 +1,13 @@
1
1
  import { writable } from 'svelte/store';
2
2
 
3
- export type EditorView = 'tokens' | 'components' | 'colors' | 'sketch';
3
+ export type EditorView = 'tokens' | 'components' | 'colors' | 'sketch' | 'accessibility';
4
4
  export type SidebarCondensed = boolean | 'auto';
5
5
 
6
6
  const VIEW_KEY = 'lt.editorView';
7
7
  const CONDENSED_KEY = 'lt.sidebarCondensed';
8
8
 
9
9
  function isEditorView(v: unknown): v is EditorView {
10
- return v === 'tokens' || v === 'components' || v === 'colors' || v === 'sketch';
10
+ return v === 'tokens' || v === 'components' || v === 'colors' || v === 'sketch' || v === 'accessibility';
11
11
  }
12
12
 
13
13
  // Session-scoped, not persistent: opening the editor fresh always starts on
@@ -0,0 +1,10 @@
1
+ import { derived, type Readable } from 'svelte/store';
2
+ import { colorsAndTypeDirty, componentDirty } from './editorStore';
3
+ import { sketchOffTheme } from '../sketch/sketchStore';
4
+
5
+ /** The open theme differs from its saved file: colors and type, any component, or the sketch layer. */
6
+ export const unsavedEdits: Readable<boolean> = derived(
7
+ [colorsAndTypeDirty, componentDirty, sketchOffTheme],
8
+ ([$colorsAndType, $components, $sketch]) =>
9
+ $colorsAndType || Object.values($components).some(Boolean) || $sketch,
10
+ );
@@ -26,7 +26,7 @@ import { SHADOW_VAR_NAMES, parseShadowCss, shadowTokenCss } from './parsers/shad
26
26
  import { AA_BODY, AA_LARGE, contrastRatio, findLForContrast } from '../palettes/contrast';
27
27
  import { type HarmonyAxis, type HarmonyMode } from '../palettes/colorHarmony';
28
28
  import { defaultPaletteConfig } from '../../ui/palette/paletteMath';
29
- import type { GradientDiskToken, PaletteConfig } from './themeTypes';
29
+ import type { CustomColorDiskToken, GradientDiskToken, PaletteConfig } from './themeTypes';
30
30
 
31
31
  export interface ColorsInput {
32
32
  scheme: SchemeDirection;
@@ -41,12 +41,14 @@ export interface ColorsInput {
41
41
  }
42
42
 
43
43
  /** The live colors and type this pass reads before it replaces the color
44
- * state: the override bag, so every name no palette owns rides through, and
45
- * the swatch gradients, so user-tuned ones survive. Shadow opacity is
46
- * derived, not carried — see `shadowVars`. */
44
+ * state: the override bag, so every name no palette owns rides through, the
45
+ * swatch gradients, so user-tuned ones survive, and the custom colours, which
46
+ * ride through untouched. Shadow opacity is derived, not carried — see
47
+ * `shadowVars`. */
47
48
  export interface CarryForward {
48
49
  cssVariables?: Record<string, string>;
49
50
  gradients?: GradientDiskToken[];
51
+ customColors?: CustomColorDiskToken[];
50
52
  }
51
53
 
52
54
  export interface ContrastCheck {
@@ -80,6 +82,9 @@ export interface ColorsState {
80
82
  editorConfigs: Record<string, PaletteConfig>;
81
83
  harmonyAxes: HarmonyAxis[];
82
84
  gradients: GradientDiskToken[];
85
+ /** Present only when the carried colours and type held the field, so a
86
+ * recolor adds no key to a theme that never had one. */
87
+ customColors?: CustomColorDiskToken[];
83
88
  cssVariables: Record<string, string>;
84
89
  }
85
90
 
@@ -467,7 +472,13 @@ export function buildColors(input: ColorsInput, carry: CarryForward = {}): Build
467
472
  Object.assign(cssVariables, shadowVars(carry.cssVariables ?? {}, baseColors.Canvas));
468
473
 
469
474
  return {
470
- colors: { editorConfigs: configs, harmonyAxes, gradients, cssVariables },
475
+ colors: {
476
+ editorConfigs: configs,
477
+ harmonyAxes,
478
+ gradients,
479
+ ...(carry.customColors ? { customColors: carry.customColors } : {}),
480
+ cssVariables,
481
+ },
471
482
  report: {
472
483
  ...report,
473
484
  gradients: gradients === carry.gradients ? 'carried' : 'recipes',