@motion-proto/live-tokens 0.97.0 → 0.99.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/.claude/skills/live-tokens-check-compliance/SKILL.md +5 -0
- package/.claude/skills/live-tokens-create-component/SKILL.md +4 -3
- package/.claude/skills/live-tokens-create-component/references/contract-tests.md +71 -11
- package/CHANGELOG.md +339 -0
- package/README.md +2 -2
- package/bin/check-component.mjs +8 -0
- package/bin/check-page.mjs +6 -0
- package/bin/cli.mjs +36 -6
- package/bin/contractRunner.mjs +144 -19
- package/bin/lib/findings.mjs +20 -4
- package/bin/rules/a11y.mjs +53 -0
- package/dist-plugin/{chunk-A4TBP4MM.js → chunk-SLICH4IH.js} +2 -2
- package/dist-plugin/index.cjs +204 -3
- package/dist-plugin/index.js +306 -105
- package/dist-plugin/setColors/index.js +1 -1
- package/package.json +47 -2
- package/src/demo/LabeledSelect.svelte +1 -1
- package/src/editor/bootstrap.ts +5 -1
- package/src/editor/component-editor/BadgeEditor.svelte +13 -0
- package/src/editor/component-editor/ButtonEditor.svelte +33 -2
- package/src/editor/component-editor/CalloutEditor.svelte +13 -0
- package/src/editor/component-editor/CardEditor.svelte +12 -2
- package/src/editor/component-editor/CodeSnippetEditor.svelte +11 -1
- package/src/editor/component-editor/CollapsibleSectionEditor.svelte +18 -1
- package/src/editor/component-editor/CornerBadgeEditor.svelte +13 -0
- package/src/editor/component-editor/DialogEditor.svelte +15 -0
- package/src/editor/component-editor/IconButtonEditor.svelte +29 -1
- package/src/editor/component-editor/ImageEditor.svelte +20 -9
- package/src/editor/component-editor/ImageLightboxEditor.svelte +44 -0
- package/src/editor/component-editor/InlineEditActionsEditor.svelte +13 -1
- package/src/editor/component-editor/InputEditor.svelte +23 -0
- package/src/editor/component-editor/MenuSelectEditor.svelte +27 -1
- package/src/editor/component-editor/NotificationEditor.svelte +15 -0
- package/src/editor/component-editor/PanelEditor.svelte +1 -1
- package/src/editor/component-editor/ProgressBarEditor.svelte +13 -0
- package/src/editor/component-editor/RadioButtonEditor.svelte +20 -1
- package/src/editor/component-editor/SectionDividerEditor.svelte +40 -13
- package/src/editor/component-editor/SegmentedControlEditor.svelte +29 -1
- package/src/editor/component-editor/SideNavigationEditor.svelte +40 -1
- package/src/editor/component-editor/SlidePagerEditor.svelte +20 -0
- package/src/editor/component-editor/SliderEditor.svelte +33 -0
- package/src/editor/component-editor/TabBarEditor.svelte +23 -2
- package/src/editor/component-editor/TableEditor.svelte +14 -0
- package/src/editor/component-editor/ToggleEditor.svelte +17 -0
- package/src/editor/component-editor/TooltipEditor.svelte +11 -2
- package/src/editor/component-editor/VideoFrameEditor.svelte +8 -0
- package/src/editor/component-editor/VideoLightboxEditor.svelte +10 -0
- package/src/editor/component-editor/registration.ts +2 -1
- package/src/editor/component-editor/registry.ts +69 -27
- package/src/editor/component-editor/scaffolding/DemoHeader.svelte +2 -0
- package/src/editor/component-editor/scaffolding/StateBlock.svelte +19 -8
- package/src/editor/component-editor/scaffolding/TokenLayout.svelte +3 -0
- package/src/editor/component-editor/scaffolding/VariantGroup.svelte +70 -3
- package/src/editor/component-editor/scaffolding/editorContext.ts +7 -0
- package/src/editor/core/a11y/a11yReport.ts +164 -0
- package/src/editor/core/a11y/a11yReportTypes.ts +77 -0
- package/src/editor/core/a11y/contrastFailures.ts +22 -0
- package/src/editor/core/a11y/contrastFixes.ts +41 -0
- package/src/editor/core/components/contrastPairRegistry.ts +18 -0
- package/src/editor/core/palettes/contrast.ts +44 -0
- package/src/editor/core/palettes/contrastPairs.ts +82 -0
- package/src/editor/core/palettes/resolveColor.ts +77 -0
- package/src/editor/core/store/editorStore.ts +4 -0
- package/src/editor/core/store/editorViewStore.ts +2 -2
- package/src/editor/core/store/unsavedEdits.ts +10 -0
- package/src/editor/core/themes/liveStateStream.ts +7 -1
- package/src/editor/core/themes/parsers/gradient.ts +5 -2
- package/src/editor/docs/chapters.ts +1 -0
- package/src/editor/docs/content/accessibility.md +411 -0
- package/src/editor/docs/content/editing-tokens.md +32 -1
- package/src/editor/docs/content.generated.ts +2 -1
- package/src/editor/pages/ComponentEditorPage.svelte +11 -1
- package/src/editor/pages/EditorShell.svelte +98 -10
- package/src/editor/skill-atlas/skillSources.generated.ts +3 -3
- package/src/editor/skill-atlas/trees/check-compliance.ts +3 -3
- package/src/editor/skill-atlas/trees/create-component.ts +12 -12
- package/src/editor/styles/ui-editor.css +2 -0
- package/src/editor/ui/EditorViewSwitcher.svelte +15 -2
- package/src/editor/ui/GradientEditor.svelte +9 -1
- package/src/editor/ui/ThemePanel.svelte +4 -4
- package/src/editor/ui/UIContrastReadout.svelte +186 -0
- package/src/editor/ui/UIInfoPopover.svelte +16 -2
- package/src/editor/ui/UIPaletteSelector.svelte +9 -0
- package/src/editor/ui/UITokenSelector.svelte +26 -2
- package/src/editor/ui/a11y/A11yByEye.svelte +264 -0
- package/src/editor/ui/a11y/A11yComponentIssues.svelte +121 -0
- package/src/editor/ui/a11y/A11yFindingRow.svelte +119 -0
- package/src/editor/ui/a11y/A11yIssueRow.svelte +152 -0
- package/src/editor/ui/a11y/A11yOverview.svelte +246 -0
- package/src/editor/ui/a11y/A11yPanel.svelte +153 -0
- package/src/editor/ui/a11y/A11yPropertyMark.svelte +84 -0
- package/src/editor/ui/a11y/A11yRailMark.svelte +41 -0
- package/src/editor/ui/a11y/AccessibilityTab.svelte +321 -0
- package/src/editor/ui/a11y/a11yView.ts +257 -0
- package/src/editor/ui/contrastReadout.ts +45 -0
- package/src/live-tokens/data/colors-and-type/halloween.json +1 -1
- package/src/live-tokens/data/themes/autumn.json +35 -35
- package/src/live-tokens/data/themes/halloween.json +36 -36
- package/src/live-tokens/data/themes/midnight-study.json +35 -35
- package/src/live-tokens/data/themes/ocean.json +35 -35
- package/src/live-tokens/data/themes/royal-velvet.json +35 -35
- package/src/live-tokens/data/themes/sketchy.json +35 -35
- package/src/live-tokens/data/themes/spring-meadow.json +35 -35
- package/src/live-tokens/data/themes/sunset.json +35 -35
- package/src/system/components/Badge.svelte +3 -3
- package/src/system/components/Button.svelte +12 -6
- package/src/system/components/CodeSnippet.svelte +8 -1
- package/src/system/components/CornerBadge.svelte +3 -3
- package/src/system/components/Dialog.svelte +43 -14
- package/src/system/components/IconButton.svelte +1 -1
- package/src/system/components/Image.svelte +7 -0
- package/src/system/components/ImageLightbox.svelte +18 -25
- package/src/system/components/InlineEditActions.svelte +1 -1
- package/src/system/components/Input.svelte +9 -2
- package/src/system/components/MenuSelect.svelte +31 -3
- package/src/system/components/Notification.svelte +1 -1
- package/src/system/components/ProgressBar.svelte +7 -1
- package/src/system/components/RadioButton.svelte +36 -3
- package/src/system/components/SegmentedControl.svelte +36 -2
- package/src/system/components/SlidePager.svelte +3 -3
- package/src/system/components/Slider.svelte +19 -15
- package/src/system/components/TabBar.svelte +33 -3
- package/src/system/components/Toggle.svelte +3 -3
- package/src/system/components/VideoFrame.svelte +1 -1
- package/src/system/components/VideoLightbox.svelte +2 -2
- package/src/system/internal/focusTrap.ts +35 -0
- package/src/testing-js/__aria__/badge.yml +40 -0
- package/src/testing-js/__aria__/button.yml +88 -0
- package/src/testing-js/__aria__/callout.yml +12 -0
- package/src/testing-js/__aria__/card.yml +8 -0
- package/src/testing-js/__aria__/codesnippet.yml +21 -0
- package/src/testing-js/__aria__/collapsiblesection.yml +23 -0
- package/src/testing-js/__aria__/cornerbadge.yml +24 -0
- package/src/testing-js/__aria__/dialog.yml +42 -0
- package/src/testing-js/__aria__/iconbutton.yml +57 -0
- package/src/testing-js/__aria__/image.yml +10 -0
- package/src/testing-js/__aria__/imagelightbox.yml +50 -0
- package/src/testing-js/__aria__/inlineeditactions.yml +16 -0
- package/src/testing-js/__aria__/input.yml +61 -0
- package/src/testing-js/__aria__/menuselect.yml +30 -0
- package/src/testing-js/__aria__/notification.yml +30 -0
- package/src/testing-js/__aria__/panel.yml +2 -0
- package/src/testing-js/__aria__/progressbar.yml +2 -0
- package/src/testing-js/__aria__/radiobutton.yml +20 -0
- package/src/testing-js/__aria__/sectiondivider.yml +9 -0
- package/src/testing-js/__aria__/segmentedcontrol.yml +35 -0
- package/src/testing-js/__aria__/sidenavigation.yml +304 -0
- package/src/testing-js/__aria__/slidepager.yml +72 -0
- package/src/testing-js/__aria__/slider.yml +28 -0
- package/src/testing-js/__aria__/tabbar.yml +35 -0
- package/src/testing-js/__aria__/table.yml +144 -0
- package/src/testing-js/__aria__/toggle.yml +12 -0
- package/src/testing-js/__aria__/tooltip.yml +2 -0
- package/src/testing-js/__aria__/videoframe.yml +6 -0
- package/src/testing-js/__aria__/videolightbox.yml +12 -0
- package/src/testing-js/{chunk-WIZ6W7UT.js → chunk-3WCLMOM7.js} +2 -2
- package/src/testing-js/{chunk-NB3NZRBM.js → chunk-7CM5D7PF.js} +5 -1
- package/src/testing-js/chunk-7CM5D7PF.js.map +1 -0
- package/src/testing-js/{chunk-X6KHEVIG.js → chunk-7QXUJRSO.js} +97 -29
- package/src/testing-js/chunk-7QXUJRSO.js.map +1 -0
- package/src/testing-js/chunk-GZKUYLDW.js +193 -0
- package/src/testing-js/chunk-GZKUYLDW.js.map +1 -0
- package/src/testing-js/chunk-RTZQLW3F.js +649 -0
- package/src/testing-js/chunk-RTZQLW3F.js.map +1 -0
- package/src/testing-js/{chunk-SHBYZUPD.js → chunk-RWOT2XDB.js} +212 -15
- package/src/testing-js/chunk-RWOT2XDB.js.map +1 -0
- package/src/testing-js/{chunk-GKO27NGN.js → chunk-THWGMSRH.js} +2 -2
- package/src/testing-js/{chunk-3UKGXCDL.js → chunk-YVVR3GJZ.js} +1 -1
- package/src/testing-js/chunk-YVVR3GJZ.js.map +1 -0
- package/src/testing-js/component-a11y.contract.js +313 -0
- package/src/testing-js/component-a11y.contract.js.map +1 -0
- package/src/testing-js/component-alias.contract.js +2 -2
- package/src/testing-js/component-behavior.contract.js +2 -2
- package/src/testing-js/component-editor.contract.js +6 -5
- package/src/testing-js/component-editor.contract.js.map +1 -1
- package/src/testing-js/component-render.contract.js +10 -6
- package/src/testing-js/component-render.contract.js.map +1 -1
- package/src/testing-js/index.d.ts +44 -16
- package/src/testing-js/index.js +11 -11
- package/src/testing-js/index.js.map +1 -1
- package/src/testing-js/page-compliance.contract.js +132 -13
- package/src/testing-js/page-compliance.contract.js.map +1 -1
- package/src/testing-js/vitest.js +3 -3
- package/template/_gitignore +3 -0
- package/template/package.json +9 -0
- package/src/testing-js/chunk-3UKGXCDL.js.map +0 -1
- package/src/testing-js/chunk-NB3NZRBM.js.map +0 -1
- package/src/testing-js/chunk-SHBYZUPD.js.map +0 -1
- package/src/testing-js/chunk-X6KHEVIG.js.map +0 -1
- /package/src/testing-js/{chunk-WIZ6W7UT.js.map → chunk-3WCLMOM7.js.map} +0 -0
- /package/src/testing-js/{chunk-GKO27NGN.js.map → chunk-THWGMSRH.js.map} +0 -0
|
@@ -5,6 +5,9 @@ const KEY = Symbol('editor-context');
|
|
|
5
5
|
|
|
6
6
|
export type VariantOption = { value: string; label: string };
|
|
7
7
|
|
|
8
|
+
/** A request to show a variable's row. A group that has to search its tabs adds the search, so the requester can wait for it. */
|
|
9
|
+
export type RevealRequest = { variable: string; searches: Promise<void>[] };
|
|
10
|
+
|
|
8
11
|
export type EditorContext = {
|
|
9
12
|
/** Per-variable rank for TokenLayout row alignment; null when no linked block. */
|
|
10
13
|
linkedOrder: Readable<Map<string, number> | null>;
|
|
@@ -20,6 +23,8 @@ export type EditorContext = {
|
|
|
20
23
|
on context (not local to CopyFromMenu) so it survives VariantGroup remounts
|
|
21
24
|
when the user changes which variant is focused. */
|
|
22
25
|
preserveColorFamily: Writable<boolean>;
|
|
26
|
+
/** Rebuilt per request. The VariantGroup holding the variable focuses its variant and opens its tab. */
|
|
27
|
+
revealVariable: Writable<RevealRequest | null>;
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
/** Mutable handle for ComponentEditorBase. */
|
|
@@ -35,6 +40,7 @@ export function createEditorContext(): EditorContextInternal {
|
|
|
35
40
|
const focusedState = writable<string | null>(null);
|
|
36
41
|
const hoveredLinkedVariable = writable<string | null>(null);
|
|
37
42
|
const preserveColorFamily = writable<boolean>(false);
|
|
43
|
+
const revealVariable = writable<RevealRequest | null>(null);
|
|
38
44
|
const ctx: EditorContextInternal = {
|
|
39
45
|
linkedOrder: _linkedOrder,
|
|
40
46
|
focusedVariant,
|
|
@@ -42,6 +48,7 @@ export function createEditorContext(): EditorContextInternal {
|
|
|
42
48
|
focusedState,
|
|
43
49
|
hoveredLinkedVariable,
|
|
44
50
|
preserveColorFamily,
|
|
51
|
+
revealVariable,
|
|
45
52
|
_linkedOrder,
|
|
46
53
|
_variants,
|
|
47
54
|
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { derived, get, writable, type Readable } from 'svelte/store';
|
|
2
|
+
import { API_BASE } from '../storage/apiBase';
|
|
3
|
+
import { openThemeSlug } from '../store/editorConfigStore';
|
|
4
|
+
import type {
|
|
5
|
+
A11yContrastFailure,
|
|
6
|
+
A11yFailedReport,
|
|
7
|
+
A11yFinding,
|
|
8
|
+
A11yFinishedReport,
|
|
9
|
+
A11yReport,
|
|
10
|
+
A11yReportState,
|
|
11
|
+
A11yRunningReport,
|
|
12
|
+
} from './a11yReportTypes';
|
|
13
|
+
|
|
14
|
+
/** One component's share of the last finished run, sorted by its fix (decision 6). */
|
|
15
|
+
export interface ComponentA11yIssues {
|
|
16
|
+
/** Fix with a property: the editor's own contrast failures. */
|
|
17
|
+
contrast: A11yContrastFailure[];
|
|
18
|
+
/** Fix with a property: the run's findings whose engine rule measures a property's value. */
|
|
19
|
+
property: A11yFinding[];
|
|
20
|
+
/** Fix in the component's code: every other `a11y-*` finding but `a11y-review`. */
|
|
21
|
+
code: A11yFinding[];
|
|
22
|
+
/** Check by eye, unmarked. */
|
|
23
|
+
review: A11yFinding[];
|
|
24
|
+
/** Check by eye, marked as looking right. They count as no issue. */
|
|
25
|
+
confirmed: A11yFinding[];
|
|
26
|
+
/** Contrast failures of the run that pass as the editor now measures them. They count as no issue. */
|
|
27
|
+
fixed: A11yFixedContrast[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface A11yFixedContrast {
|
|
31
|
+
failure: A11yContrastFailure;
|
|
32
|
+
/** The ratio the editor measures now. */
|
|
33
|
+
ratio: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Engine rules whose failure a property's value fixes. */
|
|
37
|
+
export const PROPERTY_FIX_RULES: ReadonlySet<string> = new Set(['focus-indicator-contrast', 'control-boundary-contrast']);
|
|
38
|
+
|
|
39
|
+
const running = writable<A11yRunningReport | null>(null);
|
|
40
|
+
const failed = writable<A11yFailedReport | null>(null);
|
|
41
|
+
const reports = writable<Record<string, A11yFinishedReport>>({});
|
|
42
|
+
const fixedRatios = writable<ReadonlyMap<A11yContrastFailure, number>>(new Map());
|
|
43
|
+
let heard = false;
|
|
44
|
+
|
|
45
|
+
/** The dev server's latest word for the open theme: a run going (for any theme, since one runs at a time), a failed run, or the theme's last finished report. */
|
|
46
|
+
export const a11yReport: Readable<A11yReport | null> = derived(
|
|
47
|
+
[running, failed, reports, openThemeSlug],
|
|
48
|
+
([$running, $failed, $reports, $open]) => $running ?? ($failed?.theme === $open ? $failed : null) ?? $reports[$open] ?? null,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/** The open theme's last finished run, which the markers follow while a new run goes (decision 7). */
|
|
52
|
+
export const a11yFinishedReport: Readable<A11yFinishedReport | null> = derived(
|
|
53
|
+
[reports, openThemeSlug],
|
|
54
|
+
([$reports, $open]) => $reports[$open] ?? null,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** Sets the run's contrast failures that pass now, with the ratio each measures. `contrastFixes` measures them. */
|
|
58
|
+
export function setFixedContrast(ratios: ReadonlyMap<A11yContrastFailure, number>): void {
|
|
59
|
+
fixedRatios.set(ratios);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const a11yIssues: Readable<Record<string, ComponentA11yIssues>> = derived([a11yFinishedReport, fixedRatios], ([$report, $fixed]) => {
|
|
63
|
+
const out: Record<string, ComponentA11yIssues> = {};
|
|
64
|
+
if (!$report) return out;
|
|
65
|
+
const confirmed = new Set($report.confirmed);
|
|
66
|
+
const entry = (id: string) => (out[id] ??= { contrast: [], property: [], code: [], review: [], confirmed: [], fixed: [] });
|
|
67
|
+
for (const failure of $report.contrast) {
|
|
68
|
+
const ratio = $fixed.get(failure);
|
|
69
|
+
if (ratio === undefined) entry(failure.component).contrast.push(failure);
|
|
70
|
+
else entry(failure.component).fixed.push({ failure, ratio });
|
|
71
|
+
}
|
|
72
|
+
for (const finding of $report.findings) {
|
|
73
|
+
if (!finding.component || !finding.rule.startsWith('a11y-')) continue;
|
|
74
|
+
const issues = entry(finding.component);
|
|
75
|
+
const group = finding.rule === 'a11y-review'
|
|
76
|
+
? (confirmed.has(finding.message) ? issues.confirmed : issues.review)
|
|
77
|
+
: PROPERTY_FIX_RULES.has(finding.details?.engineRule ?? '') ? issues.property : issues.code;
|
|
78
|
+
group.push(finding);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** The run's findings under each component property they trace to (decision 7). The editor's contrast failures and the results marked as looking right stay out. */
|
|
84
|
+
export const a11yPropertyFindings: Readable<Map<string, A11yFinding[]>> = derived(a11yFinishedReport, ($report) => {
|
|
85
|
+
const out = new Map<string, A11yFinding[]>();
|
|
86
|
+
const confirmed = new Set($report?.confirmed);
|
|
87
|
+
for (const finding of $report?.findings ?? []) {
|
|
88
|
+
if (!finding.component || !finding.rule.startsWith('a11y-') || confirmed.has(finding.message)) continue;
|
|
89
|
+
for (const variable of finding.details?.variables ?? []) {
|
|
90
|
+
const list = out.get(variable);
|
|
91
|
+
if (list) list.push(finding);
|
|
92
|
+
else out.set(variable, [finding]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export function receiveA11yReport(report: A11yReport): void {
|
|
99
|
+
heard = true;
|
|
100
|
+
if (report.state === 'running') {
|
|
101
|
+
running.set(report);
|
|
102
|
+
failed.set(null);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
running.set(null);
|
|
106
|
+
if (report.state === 'failed') failed.set(report);
|
|
107
|
+
else reports.update(($reports) => ({ ...$reports, [report.theme]: report }));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function init(): Promise<void> {
|
|
111
|
+
const res = await fetch(`${API_BASE}/a11y/report`);
|
|
112
|
+
if (!res.ok) return;
|
|
113
|
+
const state = (await res.json()) as A11yReportState;
|
|
114
|
+
// An event that landed while the read was in flight is the newer word.
|
|
115
|
+
reports.update(($reports) => ({ ...state.reports, ...$reports }));
|
|
116
|
+
if (!heard) running.set(state.running);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Starts a run with the contrast failures the editor measured. Answers 409
|
|
121
|
+
* `A11Y_RUN_IN_PROGRESS` while a run goes, and 409 `LEGACY_LAYOUT` on a tree
|
|
122
|
+
* `live-tokens migrate` has not healed; both arrive as the thrown error's `code`.
|
|
123
|
+
*/
|
|
124
|
+
export async function startA11yRun(contrast: A11yContrastFailure[]): Promise<A11yReport> {
|
|
125
|
+
const res = await fetch(`${API_BASE}/a11y/run`, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'Content-Type': 'application/json' },
|
|
128
|
+
body: JSON.stringify({ contrast }),
|
|
129
|
+
});
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
|
|
132
|
+
const err = new Error(body.error || 'The accessibility run did not start') as Error & { status?: number; code?: string };
|
|
133
|
+
err.status = res.status;
|
|
134
|
+
if (body.code) err.code = body.code;
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
const report = (await res.json()) as A11yReport;
|
|
138
|
+
receiveA11yReport(report);
|
|
139
|
+
return report;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Marks results to check by eye in the open theme's last report as looking right, or clears the mark. */
|
|
143
|
+
export async function confirmReview(findings: A11yFinding[], confirmed: boolean): Promise<void> {
|
|
144
|
+
const theme = get(a11yFinishedReport)?.theme;
|
|
145
|
+
if (!theme) return;
|
|
146
|
+
const res = await fetch(`${API_BASE}/a11y/confirmed`, {
|
|
147
|
+
method: 'PUT',
|
|
148
|
+
headers: { 'Content-Type': 'application/json' },
|
|
149
|
+
body: JSON.stringify({ theme, messages: findings.map((f) => f.message), confirmed }),
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
153
|
+
throw new Error(body.error || 'The mark did not save');
|
|
154
|
+
}
|
|
155
|
+
receiveA11yReport((await res.json()) as A11yReport);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function __resetForTests(): void {
|
|
159
|
+
heard = false;
|
|
160
|
+
running.set(null);
|
|
161
|
+
failed.set(null);
|
|
162
|
+
reports.set({});
|
|
163
|
+
fixedRatios.set(new Map());
|
|
164
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
/** The theme the run tests, the one open when it started. */
|
|
47
|
+
theme: string;
|
|
48
|
+
contrast: A11yContrastFailure[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface A11yRunningReport extends A11yReportBase {
|
|
52
|
+
state: 'running';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Holds every CLI finding except axe's `color-contrast`, which `contrast` covers. */
|
|
56
|
+
export interface A11yFinishedReport extends A11yReportBase {
|
|
57
|
+
state: 'finished';
|
|
58
|
+
finishedAt: string;
|
|
59
|
+
findings: A11yFinding[];
|
|
60
|
+
/** The messages of the results to check by eye that someone marked as looking right. A new run keeps each one it finds again. */
|
|
61
|
+
confirmed?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The CLI crashed or printed no report. The theme's last finished report stands. */
|
|
65
|
+
export interface A11yFailedReport extends A11yReportBase {
|
|
66
|
+
state: 'failed';
|
|
67
|
+
finishedAt: string;
|
|
68
|
+
error: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type A11yReport = A11yRunningReport | A11yFinishedReport | A11yFailedReport;
|
|
72
|
+
|
|
73
|
+
/** `GET /a11y/report`: the run going, if any, and each theme's last finished report by theme name. */
|
|
74
|
+
export interface A11yReportState {
|
|
75
|
+
running: A11yRunningReport | null;
|
|
76
|
+
reports: Record<string, A11yFinishedReport>;
|
|
77
|
+
}
|
|
@@ -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,41 @@
|
|
|
1
|
+
import { get } from 'svelte/store';
|
|
2
|
+
import { getContrastPairs } from '../components/contrastPairRegistry';
|
|
3
|
+
import { measurePair, type ContrastPair, type Resolver } from '../palettes/contrastPairs';
|
|
4
|
+
import { createDomResolver } from '../palettes/resolveColor';
|
|
5
|
+
import { editorState } from '../store/editorStore';
|
|
6
|
+
import { THEME_APPLIED_EVENT } from '../themes/themeDocumentSync';
|
|
7
|
+
import { a11yFinishedReport, setFixedContrast } from './a11yReport';
|
|
8
|
+
import type { A11yContrastFailure } from './a11yReportTypes';
|
|
9
|
+
|
|
10
|
+
function samePair(pair: ContrastPair, failure: A11yContrastFailure): boolean {
|
|
11
|
+
return pair.role === failure.role
|
|
12
|
+
&& pair.context === failure.context
|
|
13
|
+
&& pair.backdrop.join('\n') === failure.backdrop.join('\n');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Each failure whose pair passes now, with the ratio it measures. */
|
|
17
|
+
export function measureFixes(failures: A11yContrastFailure[], resolver: Resolver): Map<A11yContrastFailure, number> {
|
|
18
|
+
const out = new Map<A11yContrastFailure, number>();
|
|
19
|
+
for (const failure of failures) {
|
|
20
|
+
const pair = getContrastPairs(failure.component, failure.variable).find((p) => samePair(p, failure));
|
|
21
|
+
if (!pair) continue;
|
|
22
|
+
const measure = measurePair(pair, resolver);
|
|
23
|
+
if (measure.passes) out.set(failure, measure.ratio);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Re-measures the last run's contrast failures whenever the editor's state or the open theme changes. */
|
|
29
|
+
export function init(): void {
|
|
30
|
+
let frame = 0;
|
|
31
|
+
// Measuring reads the DOM, so it waits a frame for the renderer's CSS variables.
|
|
32
|
+
const schedule = () => {
|
|
33
|
+
cancelAnimationFrame(frame);
|
|
34
|
+
frame = requestAnimationFrame(() => {
|
|
35
|
+
setFixedContrast(measureFixes(get(a11yFinishedReport)?.contrast ?? [], createDomResolver()));
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
a11yFinishedReport.subscribe(schedule);
|
|
39
|
+
editorState.subscribe(schedule);
|
|
40
|
+
document.addEventListener(THEME_APPLIED_EVENT, schedule);
|
|
41
|
+
}
|
|
@@ -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,82 @@
|
|
|
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
|
+
// A non-text part painted fully transparent, such as an unselected tab's
|
|
72
|
+
// underline, draws nothing for SC 1.4.11 to measure.
|
|
73
|
+
const absent = pair.role === 'non-text' && fronts.every((front) => front[3] === 0);
|
|
74
|
+
return { pair, ratio, floor, large, passes: pair.inactive === true || absent || ratio >= floor };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An inactive pair applies no floor, so it ranks only when every pair is inactive. */
|
|
78
|
+
export function closestToFailing(measures: PairMeasure[]): PairMeasure {
|
|
79
|
+
const active = measures.filter((m) => !m.pair.inactive);
|
|
80
|
+
const pool = active.length > 0 ? active : measures;
|
|
81
|
+
return pool.reduce((worst, m) => (m.ratio / m.floor < worst.ratio / worst.floor ? m : worst));
|
|
82
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -216,6 +216,10 @@ export {
|
|
|
216
216
|
markComponentSaved,
|
|
217
217
|
} from '../themes/slices/components';
|
|
218
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
|
+
|
|
219
223
|
export {
|
|
220
224
|
setFontSources,
|
|
221
225
|
setFontStacks,
|
|
@@ -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
|
+
);
|
|
@@ -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(
|
|
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 (
|
|
195
|
+
else if (depth === 0 && isSeparator(c)) {
|
|
193
196
|
parts.push(s.slice(start, i));
|
|
194
197
|
start = i + 1;
|
|
195
198
|
}
|
|
@@ -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
|
|