@godxjp/ui 32.11.1 → 32.12.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "32.11.1",
4
- "godxUiMcp": "32.11.1",
3
+ "version": "32.12.0",
4
+ "godxUiMcp": "32.12.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
package/scripts/cli.mjs CHANGED
@@ -85,7 +85,7 @@ const HELP = {
85
85
  --dry-run list every change without writing
86
86
  What it cannot decide (a component move that changes props, an AppProvider that relied on the
87
87
  old vi default) is printed as a "note" for you to finish.`,
88
- "visual-audit": `godxjp-ui visual-audit [--format json] [--strict] [--widths WxH,…] [--storage-state FILE] <baseUrl> [route …]
88
+ "visual-audit": `godxjp-ui visual-audit [--format json] [--strict] [--widths WxH,…] [--storage-state FILE] [--text-spacing] [--text-zoom 200] [--screenshots DIR] <baseUrl> [route …]
89
89
 
90
90
  Runtime audit (Playwright) against an app you are ALREADY running locally, at six widths.
91
91
  baseUrl the running app, e.g. http://localhost:5173
@@ -95,6 +95,11 @@ const HELP = {
95
95
  --storage-state a Playwright storageState JSON (cookies + localStorage) so signed-in routes
96
96
  are audited signed in; save one with
97
97
  npx playwright codegen --save-storage=state.json <url>
98
+ --text-spacing also render each width with the WCAG 1.4.12 text-spacing override, then C01
99
+ --text-zoom N also render each width with text resized to N% (WCAG 1.4.4; e.g. 200)
100
+ Each reports the geometry delta against the unmodified render; a delta of
101
+ zero is an error ("override not applied"), never a pass
102
+ --screenshots a directory for the base + override render of each evidence mode
98
103
  --strict exit non-zero on any finding, not only errors
99
104
  --format json machine-readable: findings (viewport, element, threshold + origin), unmapped
100
105
  items (reviewer required), per-width rule status + reviewer checklist
@@ -3,7 +3,8 @@
3
3
  * godxjp-ui VISUAL audit — the runtime counterpart to the static ui-audit.mjs. Static audit
4
4
  * (ui-audit.mjs) reads SOURCE with regexes — zero deps, fast, runs on every save.
5
5
  */
6
- import { existsSync, realpathSync } from "node:fs";
6
+ import { existsSync, mkdirSync, realpathSync } from "node:fs";
7
+ import path from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import {
9
10
  VISUAL_RULES,
@@ -60,6 +61,7 @@ export function buildResult({
60
61
  viewports = [],
61
62
  matrix = [],
62
63
  unmapped = [],
64
+ evidence,
63
65
  } = {}) {
64
66
  const productErrors = findings.filter((f) => f.severity === "error");
65
67
  const warnings = findings.filter((f) => f.severity === "warn");
@@ -92,6 +94,8 @@ export function buildResult({
92
94
  findings,
93
95
  unmapped,
94
96
  matrix,
97
+ // Present only when an evidence mode ran (gh#1316): one entry per route × width × mode.
98
+ ...(evidence ? { evidence } : null),
95
99
  errors,
96
100
  };
97
101
  }
@@ -1469,6 +1473,195 @@ export function collectProbes(opts) {
1469
1473
  }
1470
1474
  /* c8 ignore stop */
1471
1475
 
1476
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1477
+ // Evidence modes (gh#1316): `--text-spacing` (WCAG 2.2 SC 1.4.12) and `--text-zoom 200` (SC 1.4.4).
1478
+ // A consumer reported "W12 0/0/0" from captures byte-identical to the base render — the override
1479
+ // never applied, and nothing said so. Each mode here measures the render BEFORE and AFTER the
1480
+ // override; an override that moves nothing is an infrastructure error ("override not applied"),
1481
+ // never a pass. Only an applied override runs the C01 content-loss probe.
1482
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1483
+
1484
+ /** The SC 1.4.12 values, unlayered and `!important`, so they sit above every `@layer`. */
1485
+ export const TEXT_SPACING_CSS =
1486
+ "*, *::before, *::after { line-height: 1.5 !important; letter-spacing: 0.12em !important; " +
1487
+ "word-spacing: 0.16em !important; } p { margin-block-end: 2em !important; }";
1488
+
1489
+ /** Parse `--text-zoom 200` (a percent, 110–400). Throws on garbage. */
1490
+ export function parseTextZoom(spec) {
1491
+ const n = Number(spec);
1492
+ if (!Number.isInteger(n) || n < 110 || n > 400)
1493
+ throw new Error(`--text-zoom: "${spec ?? ""}" is not a percent between 110 and 400`);
1494
+ return n;
1495
+ }
1496
+
1497
+ /**
1498
+ * Did the override reach the page? (pure). `before`/`after` are `snapshotText()` results of the
1499
+ * SAME document (same text-bearing elements, same order). Applied = the override's computed values
1500
+ * reached at least one text element AND the geometry moved; either half alone is not enough — a
1501
+ * computed value with zero geometry change is a render that was never re-laid out, and geometry
1502
+ * that moved without the values is something else moving.
1503
+ */
1504
+ export function textOverrideVerdict(mode, before, after, { zoom = 200 } = {}) {
1505
+ const n = Math.min(before.texts.length, after.texts.length);
1506
+ let moved = 0;
1507
+ let maxShiftPx = 0;
1508
+ let took = 0;
1509
+ for (let i = 0; i < n; i++) {
1510
+ const a = before.texts[i];
1511
+ const b = after.texts[i];
1512
+ const shift = Math.max(
1513
+ Math.abs(a.x - b.x),
1514
+ Math.abs(a.y - b.y),
1515
+ Math.abs(a.w - b.w),
1516
+ Math.abs(a.h - b.h),
1517
+ );
1518
+ if (shift > 0.5) moved++;
1519
+ maxShiftPx = Math.max(maxShiftPx, shift);
1520
+ const ok =
1521
+ mode === "text-spacing"
1522
+ ? Math.abs(b.letterSpacingPx - 0.12 * b.fontPx) <= 0.5 &&
1523
+ Math.abs(b.wordSpacingPx - 0.16 * b.fontPx) <= 0.5
1524
+ : Math.abs(b.fontPx - (a.fontPx * zoom) / 100) <= 0.5;
1525
+ if (ok) took++;
1526
+ }
1527
+ const delta = {
1528
+ textElements: n,
1529
+ movedTextElements: moved,
1530
+ maxShiftPx: +maxShiftPx.toFixed(1),
1531
+ documentHeightPx: +(after.docHeight - before.docHeight).toFixed(1),
1532
+ documentWidthPx: +(after.docWidth - before.docWidth).toFixed(1),
1533
+ };
1534
+ const appliedRatio = n ? +(took / n).toFixed(3) : 0;
1535
+ let reason = null;
1536
+ if (n === 0) reason = "no text to measure";
1537
+ else if (took === 0) reason = `the ${mode} values reached 0 of ${n} text elements`;
1538
+ else if (moved === 0 && delta.documentHeightPx === 0 && delta.documentWidthPx === 0)
1539
+ reason = "zero geometry delta against the unmodified render";
1540
+ return { applied: reason === null, reason, appliedRatio, delta };
1541
+ }
1542
+
1543
+ /** Text-bearing elements' boxes + computed text metrics, in document order. Runs in the page. */
1544
+ /* c8 ignore start — runs inside the browser, exercised by the visual-audit-text-evidence test. */
1545
+ export function snapshotText() {
1546
+ const se = document.scrollingElement || document.documentElement;
1547
+ const owners = [];
1548
+ const seen = new Set();
1549
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
1550
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
1551
+ const p = n.parentElement;
1552
+ if (!n.data.trim() || !p || seen.has(p) || p.closest("script, style, noscript, template"))
1553
+ continue;
1554
+ seen.add(p);
1555
+ owners.push(p);
1556
+ }
1557
+ const texts = owners.map((el) => {
1558
+ const r = el.getBoundingClientRect();
1559
+ const s = getComputedStyle(el);
1560
+ const fontPx = parseFloat(s.fontSize) || 0;
1561
+ const px = (v) => (v === "normal" ? 0 : parseFloat(v) || 0);
1562
+ return {
1563
+ x: r.left + se.scrollLeft,
1564
+ y: r.top + se.scrollTop,
1565
+ w: r.width,
1566
+ h: r.height,
1567
+ fontPx,
1568
+ letterSpacingPx: px(s.letterSpacing),
1569
+ wordSpacingPx: px(s.wordSpacing),
1570
+ };
1571
+ });
1572
+ return { texts, docHeight: se.scrollHeight, docWidth: se.scrollWidth };
1573
+ }
1574
+
1575
+ /** Append the SC 1.4.12 sheet LAST in <head>. A CSP without 'unsafe-inline' blocks it — measured. */
1576
+ export function injectTextSpacing(css) {
1577
+ const style = document.createElement("style");
1578
+ style.setAttribute("data-visual-audit", "text-spacing");
1579
+ style.textContent = css;
1580
+ document.head.appendChild(style);
1581
+ }
1582
+
1583
+ /**
1584
+ * Text-only zoom (SC 1.4.4): every element's computed font size × factor, as one appended sheet
1585
+ * keyed by the size each element had — px-sized text grows too, line boxes and widths stay
1586
+ * author-controlled, which is exactly what a browser's text-size setting does.
1587
+ */
1588
+ export function injectTextZoom(percent) {
1589
+ const sizes = new Set();
1590
+ for (const el of document.querySelectorAll("body, body *")) {
1591
+ const px = parseFloat(getComputedStyle(el).fontSize);
1592
+ if (!px) continue;
1593
+ const key = String(Math.round(px * 100) / 100);
1594
+ el.setAttribute("data-visual-audit-fz", key);
1595
+ sizes.add(key);
1596
+ }
1597
+ const style = document.createElement("style");
1598
+ style.setAttribute("data-visual-audit", "text-zoom");
1599
+ style.textContent = [...sizes]
1600
+ .map(
1601
+ (k) =>
1602
+ `[data-visual-audit-fz="${k}"] { font-size: ${(Number(k) * percent) / 100}px !important; }`,
1603
+ )
1604
+ .join("\n");
1605
+ document.head.appendChild(style);
1606
+ }
1607
+ /* c8 ignore stop */
1608
+
1609
+ /**
1610
+ * One evidence cell: reload the route clean, measure, inject, measure again, judge, and run C01
1611
+ * only when the override applied. Screenshots (base + override) when `screenshots` is a dir.
1612
+ */
1613
+ /* c8 ignore start — browser glue; exercised by the visual-audit-text-evidence spawn test. */
1614
+ async function runEvidence(page, url, viewport, mode, { zoom, screenshots }) {
1615
+ const vp = viewportLabel(viewport);
1616
+ await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
1617
+ await page.evaluate(() => document.fonts?.ready);
1618
+ const shot = async (suffix) => {
1619
+ if (!screenshots) return null;
1620
+ const slug = url
1621
+ .replace(/^https?:\/\/[^/]+/, "")
1622
+ .replace(/[^\w-]+/g, "_")
1623
+ .replace(/^_+|_+$/g, "");
1624
+ const file = path.join(screenshots, `${slug || "root"}-${vp}-${mode}-${suffix}.png`);
1625
+ await page.screenshot({ path: file, fullPage: true });
1626
+ return file;
1627
+ };
1628
+ const before = await page.evaluate(snapshotText);
1629
+ const base = await shot("base");
1630
+ if (mode === "text-spacing") await page.evaluate(injectTextSpacing, TEXT_SPACING_CSS);
1631
+ else await page.evaluate(injectTextZoom, zoom);
1632
+ await page.evaluate(
1633
+ () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))),
1634
+ );
1635
+ const after = await page.evaluate(snapshotText);
1636
+ const override = await shot("override");
1637
+ const verdict = textOverrideVerdict(mode, before, after, { zoom });
1638
+ const entry = {
1639
+ url,
1640
+ viewport: vp,
1641
+ mode,
1642
+ ...(mode === "text-zoom" ? { zoomPercent: zoom } : null),
1643
+ applied: verdict.applied,
1644
+ appliedRatio: verdict.appliedRatio,
1645
+ delta: verdict.delta,
1646
+ c01: "not-run",
1647
+ screenshots: screenshots ? { base, override } : null,
1648
+ };
1649
+ if (!verdict.applied)
1650
+ return {
1651
+ entry,
1652
+ findings: [],
1653
+ error: { url, viewport: vp, stage: mode, message: `override not applied: ${verdict.reason}` },
1654
+ };
1655
+ const opts = { width: viewport.width, compactBelow: COMPACT_BELOW, desktopFrom: DESKTOP_FROM };
1656
+ const probed = evaluateProbes(await page.evaluate(collectProbes, opts), { url, viewport });
1657
+ const findings = probed.findings
1658
+ .filter((f) => f.rule === "c01-content-reachable")
1659
+ .map((f) => ({ ...f, condition: mode, message: `[${mode}] ${f.message}` }));
1660
+ entry.c01 = probed.cell.rules["c01-content-reachable"];
1661
+ return { entry, findings, error: null };
1662
+ }
1663
+ /* c8 ignore stop */
1664
+
1472
1665
  /**
1473
1666
  * Run the probes for one route × viewport, including the interactions a static read cannot
1474
1667
  * answer: does the avatar open a menu, does the overflow menu hold actions, does a declared
@@ -1570,12 +1763,18 @@ async function loadDeps() {
1570
1763
  * (page/context/browser) is guaranteed via finally on BOTH success and failure.
1571
1764
  */
1572
1765
  /* c8 ignore start — the browser-driving glue; exercised by scripts/visual-audit-smoke.mjs in CI. */
1573
- async function audit(targets, { chromium }, viewports = DEFAULT_VIEWPORTS, { storageState } = {}) {
1766
+ async function audit(
1767
+ targets,
1768
+ { chromium },
1769
+ viewports = DEFAULT_VIEWPORTS,
1770
+ { storageState, modes = [], zoom = 200, screenshots } = {},
1771
+ ) {
1574
1772
  const findings = [];
1575
1773
  const errors = [];
1576
1774
  const matrix = [];
1577
1775
  const unmapped = [];
1578
1776
  const timings = [];
1777
+ const evidence = modes.length ? [] : undefined;
1579
1778
  let browser;
1580
1779
  let context;
1581
1780
  try {
@@ -1714,6 +1913,22 @@ async function audit(targets, { chromium }, viewports = DEFAULT_VIEWPORTS, { sto
1714
1913
  message: `audit engine failed: ${e.message}`,
1715
1914
  });
1716
1915
  }
1916
+ // 4) evidence modes (gh#1316) — each on a fresh load, after the base probes.
1917
+ for (const mode of modes) {
1918
+ try {
1919
+ const ev = await runEvidence(page, url, viewport, mode, { zoom, screenshots });
1920
+ evidence.push(ev.entry);
1921
+ findings.push(...ev.findings);
1922
+ if (ev.error) errors.push(ev.error);
1923
+ } catch (e) {
1924
+ errors.push({
1925
+ url,
1926
+ viewport: vp,
1927
+ stage: mode,
1928
+ message: `${mode} failed: ${e.message}`,
1929
+ });
1930
+ }
1931
+ }
1717
1932
  }
1718
1933
  timings.push({ url, viewports: viewports.length, ms: Date.now() - started });
1719
1934
  }
@@ -1729,6 +1944,7 @@ async function audit(targets, { chromium }, viewports = DEFAULT_VIEWPORTS, { sto
1729
1944
  viewports,
1730
1945
  matrix,
1731
1946
  unmapped,
1947
+ evidence,
1732
1948
  });
1733
1949
  result.timings = timings;
1734
1950
  return result;
@@ -1790,6 +2006,12 @@ function finish(result, { asJson, strict, pages }) {
1790
2006
  .join(" · ")} · checklist ${cell.checklist.length} pending${C.reset}`,
1791
2007
  );
1792
2008
  }
2009
+ for (const ev of result.evidence || []) {
2010
+ const d = ev.delta;
2011
+ console.log(
2012
+ `${ev.applied ? C.dim : C.red}${ev.mode} ${ev.url} @${ev.viewport}: ${ev.applied ? "applied" : "NOT APPLIED"} (values on ${Math.round(ev.appliedRatio * 100)}% of text) — ${d.movedTextElements}/${d.textElements} text elements moved, max ${d.maxShiftPx}px, document height ${d.documentHeightPx >= 0 ? "+" : ""}${d.documentHeightPx}px · C01 ${ev.c01}${ev.screenshots ? ` · ${ev.screenshots.override}` : ""}${C.reset}`,
2013
+ );
2014
+ }
1793
2015
  for (const e of result.errors) {
1794
2016
  console.log(
1795
2017
  `${C.red}infra${C.reset} ${C.bold}${e.url ?? "(bootstrap)"}${C.reset} ${C.dim}[${e.stage}]${C.reset}`,
@@ -1852,16 +2074,38 @@ async function main() {
1852
2074
  return;
1853
2075
  }
1854
2076
 
1855
- const urls = args.filter(
1856
- (a, i) =>
1857
- !a.startsWith("--") &&
1858
- args[i - 1] !== "--format" &&
1859
- args[i - 1] !== "--widths" &&
1860
- args[i - 1] !== "--storage-state",
1861
- );
2077
+ // Evidence modes (gh#1316): `--text-spacing` (SC 1.4.12), `--text-zoom <percent>` (SC 1.4.4),
2078
+ // `--screenshots <dir>` for the base + override render of each.
2079
+ const modes = [];
2080
+ if (args.includes("--text-spacing")) modes.push("text-spacing");
2081
+ const tzIdx = args.indexOf("--text-zoom");
2082
+ let zoom = 200;
2083
+ if (tzIdx !== -1) {
2084
+ try {
2085
+ zoom = parseTextZoom(args[tzIdx + 1]);
2086
+ } catch (e) {
2087
+ finish(buildBootstrapError(e.message), { asJson, strict, pages: 0 });
2088
+ return;
2089
+ }
2090
+ modes.push("text-zoom");
2091
+ }
2092
+ const shIdx = args.indexOf("--screenshots");
2093
+ const screenshots = shIdx === -1 ? undefined : args[shIdx + 1];
2094
+ if (shIdx !== -1) {
2095
+ try {
2096
+ if (!screenshots || screenshots.startsWith("--")) throw new Error("no directory given");
2097
+ mkdirSync(screenshots, { recursive: true });
2098
+ } catch (e) {
2099
+ finish(buildBootstrapError(`--screenshots: ${e.message}`), { asJson, strict, pages: 0 });
2100
+ return;
2101
+ }
2102
+ }
2103
+
2104
+ const VALUED = ["--format", "--widths", "--storage-state", "--text-zoom", "--screenshots"];
2105
+ const urls = args.filter((a, i) => !a.startsWith("--") && !VALUED.includes(args[i - 1]));
1862
2106
  if (urls.length === 0) {
1863
2107
  const msg =
1864
- "no baseUrl given — usage: visual-audit.mjs [--format json] [--strict] [--widths 390x844,1280x900] [--storage-state state.json] <baseUrl> [route …] " +
2108
+ "no baseUrl given — usage: visual-audit.mjs [--format json] [--strict] [--widths 390x844,1280x900] [--storage-state state.json] [--text-spacing] [--text-zoom 200] [--screenshots DIR] <baseUrl> [route …] " +
1865
2109
  "(baseUrl is the running app; extra args are routes appended to it)";
1866
2110
  finish(buildBootstrapError(msg), { asJson, strict, pages: 0 });
1867
2111
  return;
@@ -1882,7 +2126,12 @@ async function main() {
1882
2126
  }
1883
2127
 
1884
2128
  try {
1885
- const result = await audit(targets, deps, viewports, { storageState });
2129
+ const result = await audit(targets, deps, viewports, {
2130
+ storageState,
2131
+ modes,
2132
+ zoom,
2133
+ screenshots: screenshots && path.resolve(screenshots),
2134
+ });
1886
2135
  finish(result, { asJson, strict, pages: targets.length });
1887
2136
  } catch (e) {
1888
2137
  // A launch/setup failure = infrastructure error (never "zero violations").