@motion-proto/live-tokens 0.98.2 → 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.
Files changed (69) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/bin/contractRunner.mjs +2 -2
  3. package/bin/rules/a11y.mjs +1 -1
  4. package/dist-plugin/index.cjs +71 -15
  5. package/dist-plugin/index.js +71 -15
  6. package/package.json +1 -1
  7. package/src/editor/bootstrap.ts +3 -1
  8. package/src/editor/component-editor/SectionDividerEditor.svelte +9 -10
  9. package/src/editor/core/a11y/a11yReport.ts +85 -22
  10. package/src/editor/core/a11y/a11yReportTypes.ts +11 -3
  11. package/src/editor/core/a11y/contrastFixes.ts +41 -0
  12. package/src/editor/core/palettes/contrastPairs.ts +4 -1
  13. package/src/editor/docs/content/accessibility.md +67 -18
  14. package/src/editor/docs/content/editing-tokens.md +4 -0
  15. package/src/editor/docs/content.generated.ts +2 -2
  16. package/src/editor/pages/ComponentEditorPage.svelte +4 -3
  17. package/src/editor/pages/EditorShell.svelte +10 -8
  18. package/src/editor/styles/ui-editor.css +2 -0
  19. package/src/editor/ui/a11y/A11yByEye.svelte +264 -0
  20. package/src/editor/ui/a11y/A11yComponentIssues.svelte +34 -16
  21. package/src/editor/ui/a11y/A11yFindingRow.svelte +66 -4
  22. package/src/editor/ui/a11y/A11yIssueRow.svelte +55 -24
  23. package/src/editor/ui/a11y/A11yOverview.svelte +72 -68
  24. package/src/editor/ui/a11y/A11yPanel.svelte +29 -4
  25. package/src/editor/ui/a11y/A11yPropertyMark.svelte +8 -8
  26. package/src/editor/ui/a11y/A11yRailMark.svelte +11 -4
  27. package/src/editor/ui/a11y/AccessibilityTab.svelte +22 -35
  28. package/src/editor/ui/a11y/a11yView.ts +50 -11
  29. package/src/live-tokens/data/colors-and-type/halloween.json +1 -1
  30. package/src/live-tokens/data/themes/autumn.json +16 -16
  31. package/src/live-tokens/data/themes/halloween.json +17 -17
  32. package/src/live-tokens/data/themes/midnight-study.json +16 -16
  33. package/src/live-tokens/data/themes/ocean.json +16 -16
  34. package/src/live-tokens/data/themes/royal-velvet.json +16 -16
  35. package/src/live-tokens/data/themes/sketchy.json +16 -16
  36. package/src/live-tokens/data/themes/spring-meadow.json +16 -16
  37. package/src/live-tokens/data/themes/sunset.json +16 -16
  38. package/src/system/components/Button.svelte +3 -3
  39. package/src/system/components/InlineEditActions.svelte +1 -1
  40. package/src/system/components/Input.svelte +1 -1
  41. package/src/system/components/MenuSelect.svelte +1 -1
  42. package/src/system/components/ProgressBar.svelte +1 -1
  43. package/src/system/components/SlidePager.svelte +1 -1
  44. package/src/system/components/Slider.svelte +6 -6
  45. package/src/system/components/TabBar.svelte +1 -1
  46. package/src/system/components/Toggle.svelte +1 -1
  47. package/src/testing-js/{chunk-RWH46IU5.js → chunk-7QXUJRSO.js} +2 -2
  48. package/src/testing-js/chunk-GZKUYLDW.js +193 -0
  49. package/src/testing-js/chunk-GZKUYLDW.js.map +1 -0
  50. package/src/testing-js/{chunk-SHLPDNAQ.js → chunk-RTZQLW3F.js} +172 -181
  51. package/src/testing-js/chunk-RTZQLW3F.js.map +1 -0
  52. package/src/testing-js/{chunk-5HNZHIHG.js → chunk-RWOT2XDB.js} +2 -2
  53. package/src/testing-js/{chunk-5HNZHIHG.js.map → chunk-RWOT2XDB.js.map} +1 -1
  54. package/src/testing-js/{chunk-R54I7YL6.js → chunk-THWGMSRH.js} +2 -2
  55. package/src/testing-js/component-a11y.contract.js +53 -23
  56. package/src/testing-js/component-a11y.contract.js.map +1 -1
  57. package/src/testing-js/component-behavior.contract.js +1 -1
  58. package/src/testing-js/component-editor.contract.js +3 -3
  59. package/src/testing-js/component-render.contract.js +3 -3
  60. package/src/testing-js/index.js +4 -4
  61. package/src/testing-js/index.js.map +1 -1
  62. package/src/testing-js/page-compliance.contract.js +3 -3
  63. package/src/testing-js/vitest.js +2 -2
  64. package/template/_gitignore +3 -0
  65. package/src/testing-js/chunk-EWSGE27V.js +0 -168
  66. package/src/testing-js/chunk-EWSGE27V.js.map +0 -1
  67. package/src/testing-js/chunk-SHLPDNAQ.js.map +0 -1
  68. /package/src/testing-js/{chunk-RWH46IU5.js.map → chunk-7QXUJRSO.js.map} +0 -0
  69. /package/src/testing-js/{chunk-R54I7YL6.js.map → chunk-THWGMSRH.js.map} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.99.0 — Accessibility reports by theme
4
+
5
+ ### Changed
6
+
7
+ - **Each theme keeps its own accessibility report, in the data directory.**
8
+ The dev server writes a theme's last finished run to
9
+ `<dataDir>/_a11y-reports/<theme>.json`, and the editor shows the open
10
+ theme's report. The single report used to sit in `test-results/live-tokens/`,
11
+ which Playwright and other test tools clear, so a test run could erase it.
12
+ A failed run now leaves the theme's last report in place, and deleting a
13
+ theme deletes its report. `GET /a11y/report` answers `{ running, reports }`.
14
+ Add `_a11y-reports/` to the project's `.gitignore`; projects made with
15
+ `create` now ignore it. Test runs leave it out of their copy of the data
16
+ tree.
17
+ - **A contrast failure fixed since the run turns green.** The editor
18
+ re-measures each contrast failure of the last run as you edit. Once one
19
+ passes, its row in the component's panel and in the Accessibility view
20
+ shows the run's ratio and the ratio now, and it no longer counts as an
21
+ issue.
22
+ - **Component defaults and the shipped themes pass WCAG AA contrast.**
23
+ Every contrast pair the editor checks now passes in the default theme and
24
+ all eight presets. Slider: fill `--border-brand-medium`, thumb and hover
25
+ thumb `--text-primary`. Toggle: resting thumb `--text-primary`, as its
26
+ other states already were. ProgressBar: fill `--border-special-medium`.
27
+ SlidePager: hover icon `--text-primary`. TabBar: selected underline
28
+ `--border-brand-strong`. Input: focus ring `--border-brand-strong`.
29
+ Button secondary, success and warning hovers and the MenuSelect hover sit
30
+ one surface step lighter, and InlineEditActions' cancel hover icon is
31
+ `--text-primary`. Halloween's page gradient ends on canvas 800. Themes you
32
+ saved keep their own values.
33
+ - **The Accessibility overview table lists components by the fixes a theme
34
+ can make.** Rows sort by "Fix with a property", then by "Fix in the
35
+ component's code". The bar column and its colour key are gone.
36
+ - **Results to check by eye carry an eye.** The component list, the
37
+ component panel, and each property row draw a grey eye for them in place of
38
+ the warning triangle or the "?". They no longer count toward a component's
39
+ issues.
40
+
41
+ ### Added
42
+
43
+ - **Looks right marks a result to check by eye as judged.** Each such row in
44
+ the Accessibility view and the component panel has the button at its right
45
+ edge. A marked row dims, stops counting, and drops its marks from the
46
+ editor. The open theme's report keeps the mark through every later run
47
+ that finds the same result. The dev server stores it in the report and
48
+ answers `PUT /a11y/confirmed` with `{ theme, messages, confirmed }`.
49
+ - **The By eye list gathers every result to check by eye.** It closes the
50
+ Accessibility view: the results to check first, under a tally and a
51
+ progress bar, then the marked ones in a closed group to revisit. Marking one moves focus to
52
+ the next. **Mark all as looking right** marks the rest in one step. A
53
+ result marked as looking right raises nothing elsewhere: no sidebar mark,
54
+ no component panel, and no property mark. A component whose results all
55
+ look right leaves the overview table, the component sections, and the
56
+ sidebar.
57
+
58
+ ### Fixed
59
+
60
+ - **Contrast checks pass parts that paint nothing or only decorate.** A non-text part
61
+ painted fully transparent, such as an unselected tab's underline, passes.
62
+ Section divider hairlines are decorative and carry no contrast pair.
63
+ - **The focus checks judge what focus changes.** A state view that forces
64
+ the focused look, such as Input's, lifts it while focus is measured, so
65
+ SIA-R65 sees the change and the focus ring's contrast is measured. A ring
66
+ drawn on a sibling, as Slider draws it, settles SIA-R65's "can't tell". A
67
+ ring that spills past the preview is measured against `--page-bg` and no
68
+ longer against the editor's own surfaces.
69
+
3
70
  ## 0.98.2 — Accessibility testing
4
71
 
5
72
  The v0.98.0 and v0.98.1 tags never reached npm. Release checks stopped both:
@@ -59,7 +59,7 @@ function testsTimeoutMs() {
59
59
  // Mirrors src/testing/isolation.ts's own list. Duplicated rather than shared:
60
60
  // that module compiles into src/testing-js, which does not exist until
61
61
  // `build:testing` runs, and this one ships unbuilt.
62
- const SESSION_FILES = new Set(['_working.json', '_active.json', '_production.json']);
62
+ const SESSION_FILES = new Set(['_working.json', '_active.json', '_production.json', '_a11y-reports']);
63
63
  const THEME_POINTER = join('themes', '_active.json');
64
64
 
65
65
  const REQUIRED_TOOLS = [
@@ -229,7 +229,7 @@ export function copyIsolatedDataDir(sourceDataDir, { keepActive = false } = {})
229
229
  cpSync(sourceDataDir, dataDir, { recursive: true });
230
230
  for (const entry of readdirSync(dataDir, { recursive: true })) {
231
231
  if (keepActive && entry === THEME_POINTER) continue;
232
- if (SESSION_FILES.has(basename(entry))) rmSync(join(dataDir, entry), { force: true });
232
+ if (SESSION_FILES.has(basename(entry))) rmSync(join(dataDir, entry), { recursive: true, force: true });
233
233
  }
234
234
  return dataDir;
235
235
  }
@@ -32,7 +32,7 @@ export const componentRules = {
32
32
  'a11y-focus-visible': {
33
33
  severity: 'warn',
34
34
  repair: 'authored',
35
- guidance: `Siteimprove Alfa rule SIA-R65 found no visible change when the element takes focus. ${HELP} Give the element a :focus-visible style, such as an outline, that differs from its resting style. Alfa reads only the element, its descendants, and its ancestors, so confirm by eye a focus style painted on a sibling.`,
35
+ guidance: `Siteimprove Alfa rule SIA-R65 found no visible change when the element takes focus. ${HELP} Give the element a :focus-visible style, such as an outline, that differs from its resting style. Alfa reads only the element, its descendants, and its ancestors. The suite's focus probe also reads the element's siblings and drops the finding when it finds the ring there.`,
36
36
  },
37
37
  'a11y-keyboard': {
38
38
  severity: 'warn',
@@ -5624,7 +5624,7 @@ var import_node_child_process = require("child_process");
5624
5624
  var import_node_fs = __toESM(require("fs"), 1);
5625
5625
  var import_node_path2 = __toESM(require("path"), 1);
5626
5626
  var A11Y_RUN_ARGS = ["check-component", "--tests", "--a11y", "--no-fix", "--json", "--open-theme"];
5627
- var A11Y_REPORT_PATH = import_node_path2.default.join("test-results", "live-tokens", "a11y-report.json");
5627
+ var A11Y_REPORTS_DIR_NAME = "_a11y-reports";
5628
5628
  var STDERR_TAIL = 2e3;
5629
5629
  var CONTRAST_ROLES = /* @__PURE__ */ new Set(["text", "non-text"]);
5630
5630
  function spawnCli(cliPath) {
@@ -5657,12 +5657,30 @@ function parseCliReport(stdout) {
5657
5657
  return null;
5658
5658
  }
5659
5659
  }
5660
- function a11yRunServer({ root, reportFile, runCli, broadcast }) {
5660
+ function a11yRunServer({ root, reportsDir, openTheme, runCli, broadcast }) {
5661
5661
  let running = null;
5662
- function current() {
5663
- if (running) return running.report;
5664
- if (!import_node_fs.default.existsSync(reportFile)) return null;
5665
- return JSON.parse(import_node_fs.default.readFileSync(reportFile, "utf-8"));
5662
+ const reportFile = (theme) => import_node_path2.default.join(reportsDir, `${theme}.json`);
5663
+ function readReport(theme) {
5664
+ const listed = import_node_fs.default.existsSync(reportsDir) && import_node_fs.default.readdirSync(reportsDir).includes(`${theme}.json`);
5665
+ return listed ? JSON.parse(import_node_fs.default.readFileSync(reportFile(theme), "utf-8")) : null;
5666
+ }
5667
+ function writeReport(report) {
5668
+ import_node_fs.default.mkdirSync(reportsDir, { recursive: true });
5669
+ import_node_fs.default.writeFileSync(reportFile(report.theme), `${JSON.stringify(report, null, 2)}
5670
+ `);
5671
+ }
5672
+ function withConfirmed(report, confirmed) {
5673
+ const review = new Set(report.findings.filter((f) => f.rule === "a11y-review").map((f) => f.message));
5674
+ const kept = [...new Set(confirmed)].filter((message) => review.has(message)).sort();
5675
+ const out = { ...report, confirmed: kept };
5676
+ if (kept.length === 0) delete out.confirmed;
5677
+ return out;
5678
+ }
5679
+ function reports() {
5680
+ if (!import_node_fs.default.existsSync(reportsDir)) return {};
5681
+ return Object.fromEntries(
5682
+ import_node_fs.default.readdirSync(reportsDir).filter((name) => name.endsWith(".json")).map((name) => [name.slice(0, -".json".length), JSON.parse(import_node_fs.default.readFileSync(import_node_path2.default.join(reportsDir, name), "utf-8"))])
5683
+ );
5666
5684
  }
5667
5685
  function settle(started, outcome) {
5668
5686
  const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -5681,7 +5699,6 @@ ${stderr}` : ""}`
5681
5699
  ...started,
5682
5700
  state: "finished",
5683
5701
  finishedAt,
5684
- ...cli.theme ? { theme: cli.theme } : {},
5685
5702
  findings: cli.findings.filter((f) => f.details?.engineRule !== "color-contrast")
5686
5703
  };
5687
5704
  }
@@ -5696,23 +5713,45 @@ ${stderr}` : ""}`
5696
5713
  body: { error: "Body must be { contrast: [...] } with one entry per failing pair", code: "INVALID_BODY" }
5697
5714
  };
5698
5715
  }
5699
- const report = { state: "running", startedAt: (/* @__PURE__ */ new Date()).toISOString(), contrast };
5716
+ const report = { state: "running", startedAt: (/* @__PURE__ */ new Date()).toISOString(), theme: openTheme(), contrast };
5700
5717
  const run = runCli(A11Y_RUN_ARGS, root);
5701
5718
  running = { report, stop: run.stop };
5702
5719
  broadcast(report);
5703
5720
  void run.done.then((outcome) => {
5704
5721
  const settled = settle(report, outcome);
5705
- import_node_fs.default.mkdirSync(import_node_path2.default.dirname(reportFile), { recursive: true });
5706
- import_node_fs.default.writeFileSync(reportFile, `${JSON.stringify(settled, null, 2)}
5707
- `);
5722
+ const final = settled.state === "finished" ? withConfirmed(settled, readReport(settled.theme)?.confirmed ?? []) : settled;
5723
+ if (final.state === "finished") writeReport(final);
5708
5724
  running = null;
5709
- broadcast(settled);
5725
+ broadcast(final);
5710
5726
  });
5711
5727
  return { status: 202, body: report };
5712
5728
  }
5729
+ function confirm(body) {
5730
+ const { theme, messages, confirmed } = body ?? {};
5731
+ if (typeof theme !== "string" || typeof confirmed !== "boolean" || !Array.isArray(messages) || !messages.every((m) => typeof m === "string")) {
5732
+ return {
5733
+ status: 400,
5734
+ body: { error: "Body must be { theme, messages: [...], confirmed: true | false }", code: "INVALID_BODY" }
5735
+ };
5736
+ }
5737
+ const report = readReport(theme);
5738
+ if (!report) return { status: 404, body: { error: `No report for theme "${theme}"`, code: "NO_REPORT" } };
5739
+ const marked = new Set(report.confirmed ?? []);
5740
+ for (const message of messages) {
5741
+ if (confirmed) marked.add(message);
5742
+ else marked.delete(message);
5743
+ }
5744
+ const updated = withConfirmed(report, marked);
5745
+ writeReport(updated);
5746
+ broadcast(updated);
5747
+ return { status: 200, body: updated };
5748
+ }
5713
5749
  return {
5714
5750
  start,
5715
- report: () => ({ status: 200, body: { report: current() } }),
5751
+ confirm,
5752
+ report: () => ({ status: 200, body: { running: running?.report ?? null, reports: reports() } }),
5753
+ /** Removes a deleted theme's report. */
5754
+ forget: (theme) => import_node_fs.default.rmSync(reportFile(theme), { force: true }),
5716
5755
  stop: () => running?.stop()
5717
5756
  };
5718
5757
  }
@@ -5780,7 +5819,7 @@ function themeFileApi(opts) {
5780
5819
  const ISOLATED_DATA_DIR = testDataDir();
5781
5820
  const GENERATED_CSS_PATH = ISOLATED_DATA_DIR ? import_path5.default.join(ISOLATED_DATA_DIR, "tokens.generated.css") : opts.tokensGeneratedCssPath ? import_path5.default.resolve(opts.tokensGeneratedCssPath) : import_path5.default.join(dataDirs.dataDir, "tokens.generated.css");
5782
5821
  const FONTS_CSS_PATH = ISOLATED_DATA_DIR ? import_path5.default.join(ISOLATED_DATA_DIR, "fonts.css") : opts.fontsCssPath ? import_path5.default.resolve(opts.fontsCssPath) : import_path5.default.join(import_path5.default.dirname(CSS_PATH), "fonts.css");
5783
- const A11Y_REPORT_FILE = ISOLATED_DATA_DIR ? import_path5.default.join(ISOLATED_DATA_DIR, import_path5.default.basename(A11Y_REPORT_PATH)) : import_path5.default.join(process.cwd(), A11Y_REPORT_PATH);
5822
+ const A11Y_REPORTS_DIR = import_path5.default.join(dataDirs.dataDir, A11Y_REPORTS_DIR_NAME);
5784
5823
  const API_BASE = opts.apiBase ?? "/api/live-tokens";
5785
5824
  const consumerComponentDirs = opts.componentsSrcDir ? [import_path5.default.resolve(opts.componentsSrcDir)] : [import_path5.default.resolve("src/components"), import_path5.default.resolve("src/system/components")];
5786
5825
  const packageComponentsDir = import_path5.default.resolve(
@@ -6464,6 +6503,7 @@ ${lines.join("\n")}
6464
6503
  const EVENTS_ROUTE = `${API_BASE}/events`;
6465
6504
  const A11Y_RUN_ROUTE = `${API_BASE}/a11y/run`;
6466
6505
  const A11Y_REPORT_ROUTE = `${API_BASE}/a11y/report`;
6506
+ const A11Y_CONFIRMED_ROUTE = `${API_BASE}/a11y/confirmed`;
6467
6507
  async function handleListColorsAndType(_ctx) {
6468
6508
  const files = colorsAndTypeResource.listNames().map((fileName) => {
6469
6509
  const data = colorsAndTypeResource.readJson(fileName);
@@ -6802,6 +6842,7 @@ ${lines.join("\n")}
6802
6842
  }
6803
6843
  }
6804
6844
  import_fs5.default.unlinkSync(filePath);
6845
+ a11yRuns.forget(fileName);
6805
6846
  if (themesResource.existingPath(fileName) === null && deletingActive) {
6806
6847
  themesResource.setActiveName("default");
6807
6848
  }
@@ -6899,7 +6940,8 @@ data: ${JSON.stringify(state)}
6899
6940
  const eventClients = /* @__PURE__ */ new Set();
6900
6941
  const a11yRuns = a11yRunServer({
6901
6942
  root: process.cwd(),
6902
- reportFile: A11Y_REPORT_FILE,
6943
+ reportsDir: A11Y_REPORTS_DIR,
6944
+ openTheme: () => themesResource.getActiveName(),
6903
6945
  runCli: spawnCli(PACKAGE_CLI_PATH),
6904
6946
  broadcast: (report) => {
6905
6947
  const frame = `event: ${A11Y_EVENT}
@@ -6919,6 +6961,16 @@ data: ${JSON.stringify(report)}
6919
6961
  const answer = a11yRuns.start(body);
6920
6962
  jsonResponse(res, answer.status, answer.body);
6921
6963
  }
6964
+ async function handleConfirmA11yReview({ req, res }) {
6965
+ let body;
6966
+ try {
6967
+ body = JSON.parse(await readBody(req));
6968
+ } catch {
6969
+ body = void 0;
6970
+ }
6971
+ const answer = a11yRuns.confirm(body);
6972
+ jsonResponse(res, answer.status, answer.body);
6973
+ }
6922
6974
  async function handleGetA11yReport({ res }) {
6923
6975
  const answer = a11yRuns.report();
6924
6976
  jsonResponse(res, answer.status, answer.body);
@@ -7202,6 +7254,10 @@ data: ${JSON.stringify(report)}
7202
7254
  { method: "PUT", pattern: A11Y_REPORT_ROUTE, handler: methodNotAllowed },
7203
7255
  { method: "POST", pattern: A11Y_REPORT_ROUTE, handler: methodNotAllowed },
7204
7256
  { method: "DELETE", pattern: A11Y_REPORT_ROUTE, handler: methodNotAllowed },
7257
+ { method: "PUT", pattern: A11Y_CONFIRMED_ROUTE, handler: handleConfirmA11yReview },
7258
+ { method: "GET", pattern: A11Y_CONFIRMED_ROUTE, handler: methodNotAllowed },
7259
+ { method: "POST", pattern: A11Y_CONFIRMED_ROUTE, handler: methodNotAllowed },
7260
+ { method: "DELETE", pattern: A11Y_CONFIRMED_ROUTE, handler: methodNotAllowed },
7205
7261
  { method: "PUT", pattern: PRODUCTION_ROUTE, handler: handleAdoptTheme },
7206
7262
  { method: "GET", pattern: PRODUCTION_ROUTE, handler: methodNotAllowed },
7207
7263
  { method: "POST", pattern: PRODUCTION_ROUTE, handler: methodNotAllowed },
@@ -753,7 +753,7 @@ import { spawn } from "child_process";
753
753
  import fs2 from "fs";
754
754
  import path3 from "path";
755
755
  var A11Y_RUN_ARGS = ["check-component", "--tests", "--a11y", "--no-fix", "--json", "--open-theme"];
756
- var A11Y_REPORT_PATH = path3.join("test-results", "live-tokens", "a11y-report.json");
756
+ var A11Y_REPORTS_DIR_NAME = "_a11y-reports";
757
757
  var STDERR_TAIL = 2e3;
758
758
  var CONTRAST_ROLES = /* @__PURE__ */ new Set(["text", "non-text"]);
759
759
  function spawnCli(cliPath) {
@@ -786,12 +786,30 @@ function parseCliReport(stdout) {
786
786
  return null;
787
787
  }
788
788
  }
789
- function a11yRunServer({ root, reportFile, runCli, broadcast }) {
789
+ function a11yRunServer({ root, reportsDir, openTheme, runCli, broadcast }) {
790
790
  let running = null;
791
- function current() {
792
- if (running) return running.report;
793
- if (!fs2.existsSync(reportFile)) return null;
794
- return JSON.parse(fs2.readFileSync(reportFile, "utf-8"));
791
+ const reportFile = (theme) => path3.join(reportsDir, `${theme}.json`);
792
+ function readReport(theme) {
793
+ const listed = fs2.existsSync(reportsDir) && fs2.readdirSync(reportsDir).includes(`${theme}.json`);
794
+ return listed ? JSON.parse(fs2.readFileSync(reportFile(theme), "utf-8")) : null;
795
+ }
796
+ function writeReport(report) {
797
+ fs2.mkdirSync(reportsDir, { recursive: true });
798
+ fs2.writeFileSync(reportFile(report.theme), `${JSON.stringify(report, null, 2)}
799
+ `);
800
+ }
801
+ function withConfirmed(report, confirmed) {
802
+ const review = new Set(report.findings.filter((f) => f.rule === "a11y-review").map((f) => f.message));
803
+ const kept = [...new Set(confirmed)].filter((message) => review.has(message)).sort();
804
+ const out = { ...report, confirmed: kept };
805
+ if (kept.length === 0) delete out.confirmed;
806
+ return out;
807
+ }
808
+ function reports() {
809
+ if (!fs2.existsSync(reportsDir)) return {};
810
+ return Object.fromEntries(
811
+ fs2.readdirSync(reportsDir).filter((name) => name.endsWith(".json")).map((name) => [name.slice(0, -".json".length), JSON.parse(fs2.readFileSync(path3.join(reportsDir, name), "utf-8"))])
812
+ );
795
813
  }
796
814
  function settle(started, outcome) {
797
815
  const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -810,7 +828,6 @@ ${stderr}` : ""}`
810
828
  ...started,
811
829
  state: "finished",
812
830
  finishedAt,
813
- ...cli.theme ? { theme: cli.theme } : {},
814
831
  findings: cli.findings.filter((f) => f.details?.engineRule !== "color-contrast")
815
832
  };
816
833
  }
@@ -825,23 +842,45 @@ ${stderr}` : ""}`
825
842
  body: { error: "Body must be { contrast: [...] } with one entry per failing pair", code: "INVALID_BODY" }
826
843
  };
827
844
  }
828
- const report = { state: "running", startedAt: (/* @__PURE__ */ new Date()).toISOString(), contrast };
845
+ const report = { state: "running", startedAt: (/* @__PURE__ */ new Date()).toISOString(), theme: openTheme(), contrast };
829
846
  const run = runCli(A11Y_RUN_ARGS, root);
830
847
  running = { report, stop: run.stop };
831
848
  broadcast(report);
832
849
  void run.done.then((outcome) => {
833
850
  const settled = settle(report, outcome);
834
- fs2.mkdirSync(path3.dirname(reportFile), { recursive: true });
835
- fs2.writeFileSync(reportFile, `${JSON.stringify(settled, null, 2)}
836
- `);
851
+ const final = settled.state === "finished" ? withConfirmed(settled, readReport(settled.theme)?.confirmed ?? []) : settled;
852
+ if (final.state === "finished") writeReport(final);
837
853
  running = null;
838
- broadcast(settled);
854
+ broadcast(final);
839
855
  });
840
856
  return { status: 202, body: report };
841
857
  }
858
+ function confirm(body) {
859
+ const { theme, messages, confirmed } = body ?? {};
860
+ if (typeof theme !== "string" || typeof confirmed !== "boolean" || !Array.isArray(messages) || !messages.every((m) => typeof m === "string")) {
861
+ return {
862
+ status: 400,
863
+ body: { error: "Body must be { theme, messages: [...], confirmed: true | false }", code: "INVALID_BODY" }
864
+ };
865
+ }
866
+ const report = readReport(theme);
867
+ if (!report) return { status: 404, body: { error: `No report for theme "${theme}"`, code: "NO_REPORT" } };
868
+ const marked = new Set(report.confirmed ?? []);
869
+ for (const message of messages) {
870
+ if (confirmed) marked.add(message);
871
+ else marked.delete(message);
872
+ }
873
+ const updated = withConfirmed(report, marked);
874
+ writeReport(updated);
875
+ broadcast(updated);
876
+ return { status: 200, body: updated };
877
+ }
842
878
  return {
843
879
  start,
844
- report: () => ({ status: 200, body: { report: current() } }),
880
+ confirm,
881
+ report: () => ({ status: 200, body: { running: running?.report ?? null, reports: reports() } }),
882
+ /** Removes a deleted theme's report. */
883
+ forget: (theme) => fs2.rmSync(reportFile(theme), { force: true }),
845
884
  stop: () => running?.stop()
846
885
  };
847
886
  }
@@ -908,7 +947,7 @@ function themeFileApi(opts) {
908
947
  const ISOLATED_DATA_DIR = testDataDir();
909
948
  const GENERATED_CSS_PATH = ISOLATED_DATA_DIR ? path4.join(ISOLATED_DATA_DIR, "tokens.generated.css") : opts.tokensGeneratedCssPath ? path4.resolve(opts.tokensGeneratedCssPath) : path4.join(dataDirs.dataDir, "tokens.generated.css");
910
949
  const FONTS_CSS_PATH = ISOLATED_DATA_DIR ? path4.join(ISOLATED_DATA_DIR, "fonts.css") : opts.fontsCssPath ? path4.resolve(opts.fontsCssPath) : path4.join(path4.dirname(CSS_PATH), "fonts.css");
911
- const A11Y_REPORT_FILE = ISOLATED_DATA_DIR ? path4.join(ISOLATED_DATA_DIR, path4.basename(A11Y_REPORT_PATH)) : path4.join(process.cwd(), A11Y_REPORT_PATH);
950
+ const A11Y_REPORTS_DIR = path4.join(dataDirs.dataDir, A11Y_REPORTS_DIR_NAME);
912
951
  const API_BASE = opts.apiBase ?? "/api/live-tokens";
913
952
  const consumerComponentDirs = opts.componentsSrcDir ? [path4.resolve(opts.componentsSrcDir)] : [path4.resolve("src/components"), path4.resolve("src/system/components")];
914
953
  const packageComponentsDir = path4.resolve(
@@ -1592,6 +1631,7 @@ ${lines.join("\n")}
1592
1631
  const EVENTS_ROUTE = `${API_BASE}/events`;
1593
1632
  const A11Y_RUN_ROUTE = `${API_BASE}/a11y/run`;
1594
1633
  const A11Y_REPORT_ROUTE = `${API_BASE}/a11y/report`;
1634
+ const A11Y_CONFIRMED_ROUTE = `${API_BASE}/a11y/confirmed`;
1595
1635
  async function handleListColorsAndType(_ctx) {
1596
1636
  const files = colorsAndTypeResource.listNames().map((fileName) => {
1597
1637
  const data = colorsAndTypeResource.readJson(fileName);
@@ -1930,6 +1970,7 @@ ${lines.join("\n")}
1930
1970
  }
1931
1971
  }
1932
1972
  fs3.unlinkSync(filePath);
1973
+ a11yRuns.forget(fileName);
1933
1974
  if (themesResource.existingPath(fileName) === null && deletingActive) {
1934
1975
  themesResource.setActiveName("default");
1935
1976
  }
@@ -2027,7 +2068,8 @@ data: ${JSON.stringify(state)}
2027
2068
  const eventClients = /* @__PURE__ */ new Set();
2028
2069
  const a11yRuns = a11yRunServer({
2029
2070
  root: process.cwd(),
2030
- reportFile: A11Y_REPORT_FILE,
2071
+ reportsDir: A11Y_REPORTS_DIR,
2072
+ openTheme: () => themesResource.getActiveName(),
2031
2073
  runCli: spawnCli(PACKAGE_CLI_PATH),
2032
2074
  broadcast: (report) => {
2033
2075
  const frame = `event: ${A11Y_EVENT}
@@ -2047,6 +2089,16 @@ data: ${JSON.stringify(report)}
2047
2089
  const answer = a11yRuns.start(body);
2048
2090
  jsonResponse(res, answer.status, answer.body);
2049
2091
  }
2092
+ async function handleConfirmA11yReview({ req, res }) {
2093
+ let body;
2094
+ try {
2095
+ body = JSON.parse(await readBody(req));
2096
+ } catch {
2097
+ body = void 0;
2098
+ }
2099
+ const answer = a11yRuns.confirm(body);
2100
+ jsonResponse(res, answer.status, answer.body);
2101
+ }
2050
2102
  async function handleGetA11yReport({ res }) {
2051
2103
  const answer = a11yRuns.report();
2052
2104
  jsonResponse(res, answer.status, answer.body);
@@ -2330,6 +2382,10 @@ data: ${JSON.stringify(report)}
2330
2382
  { method: "PUT", pattern: A11Y_REPORT_ROUTE, handler: methodNotAllowed },
2331
2383
  { method: "POST", pattern: A11Y_REPORT_ROUTE, handler: methodNotAllowed },
2332
2384
  { method: "DELETE", pattern: A11Y_REPORT_ROUTE, handler: methodNotAllowed },
2385
+ { method: "PUT", pattern: A11Y_CONFIRMED_ROUTE, handler: handleConfirmA11yReview },
2386
+ { method: "GET", pattern: A11Y_CONFIRMED_ROUTE, handler: methodNotAllowed },
2387
+ { method: "POST", pattern: A11Y_CONFIRMED_ROUTE, handler: methodNotAllowed },
2388
+ { method: "DELETE", pattern: A11Y_CONFIRMED_ROUTE, handler: methodNotAllowed },
2333
2389
  { method: "PUT", pattern: PRODUCTION_ROUTE, handler: handleAdoptTheme },
2334
2390
  { method: "GET", pattern: PRODUCTION_ROUTE, handler: methodNotAllowed },
2335
2391
  { method: "POST", pattern: PRODUCTION_ROUTE, handler: methodNotAllowed },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.98.2",
3
+ "version": "0.99.0",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -50,11 +50,12 @@ export async function bootLiveTokens(
50
50
  if (import.meta.env.DEV) {
51
51
  // Imported here, never statically, so a build drops the editor. The
52
52
  // registry alone imports every shipped component and editor.
53
- const [editorStore, themeDocumentSync, liveStateStream, a11yReport, { initializeTheme }] = await Promise.all([
53
+ const [editorStore, themeDocumentSync, liveStateStream, a11yReport, contrastFixes, { initializeTheme }] = await Promise.all([
54
54
  import('./core/store/editorStore'),
55
55
  import('./core/themes/themeDocumentSync'),
56
56
  import('./core/themes/liveStateStream'),
57
57
  import('./core/a11y/a11yReport'),
58
+ import('./core/a11y/contrastFixes'),
58
59
  import('./core/themes/themeInit'),
59
60
  import('./component-editor/registry'),
60
61
  ]);
@@ -62,6 +63,7 @@ export async function bootLiveTokens(
62
63
  themeDocumentSync.init();
63
64
  liveStateStream.init();
64
65
  void a11yReport.init();
66
+ contrastFixes.init();
65
67
  if (opts.components) {
66
68
  for (const entry of opts.components) {
67
69
  registerComponent(entry);
@@ -195,18 +195,17 @@
195
195
  ];
196
196
  const INTRINSIC_BY_KEY = new Map(intrinsics.map((i) => [i.key, i]));
197
197
 
198
+ // The hairline is decorative: the heading carries the section break, so
199
+ // WCAG 2.2 SC 1.4.11 sets no floor on it and it has no pair.
198
200
  function variantContrastPairs(v: Variant): ContrastPair[] {
199
201
  const backdrop = [`--sectiondivider-${v}-surface`];
200
- return [
201
- ...variantTypeGroups(v).map((g): ContrastPair => ({
202
- foreground: g.colorVariable,
203
- backdrop,
204
- role: 'text',
205
- fontSize: g.sizeVariable,
206
- fontWeight: g.weightVariable,
207
- })),
208
- { foreground: `--sectiondivider-${v}-hairline-color`, backdrop, role: 'non-text' },
209
- ];
202
+ return variantTypeGroups(v).map((g): ContrastPair => ({
203
+ foreground: g.colorVariable,
204
+ backdrop,
205
+ role: 'text',
206
+ fontSize: g.sizeVariable,
207
+ fontWeight: g.weightVariable,
208
+ }));
210
209
  }
211
210
 
212
211
  export const contrastPairs: ContrastPair[] = variants.flatMap((v) => variantContrastPairs(v.key));