@fringetools/cli 0.2.2 → 0.3.1

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/README.md CHANGED
@@ -22,7 +22,8 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
22
22
  | Command | What it does |
23
23
  |---|---|
24
24
  | `fringe check [dir\|file]` | Scan a directory (default `.`) or judge one file (the project root is found by climbing to the nearest `package.json`). Prints findings and the health score. Exit `1` on any `error`. |
25
- | `fringe plan [dir\|file] [--json] [--limit n]` | **Fix order for a repo with many findings**: P0 (errors in shared components or critical paths) → P1 (other errors, mechanical first) → P2 review → P3 polish, each cluster with its fix recipe. Never gates (exit 0). `--json` = `fringe/plan.v1`. |
25
+ | `fringe scan <url> [--json]` | **Runtime check of a live page** (needs `npm i -D playwright-core axe-core`; uses your installed Chrome). Runs axe-core for real contrast, computed names and ARIA relations (`FR-400`; critical/serious = `error`, the rest `review`), then walks the page with Tab and reports keyboard traps (`FR-401`, error) and controls Tab never reaches (review). Accepts `http(s)://` and `file://` URLs. Exit `1` on errors. |
26
+ | `fringe plan [dir\|file] [--scan url] [--json] [--limit n]` | **Fix order for a repo with many findings**: P0 (errors in shared components or critical paths) → P1 (other errors, mechanical first) → P2 review → P3 polish, each cluster with its fix recipe. Never gates (exit 0). `--scan <url>` adds the live page's runtime findings to the same plan (one cluster per axe rule, with element selectors). `--json` = `fringe/plan.v1`. |
26
27
  | `fringe explain [FR-203]` | Why a rule matters, the preferred fix and the tempting fix to avoid. No id lists all 36 rules. |
27
28
  | `fringe explain pattern [form]` | Build-time best-practice checklist for a component type (`form`, `modal`, `navigation`, `data-view`); each item is marked *verified* (a rule checks it) or *judged*. |
28
29
  | `fringe check --json` | Full report as JSON, schema `fringe/check.v1` (stack, files, findings, counts, score). |
@@ -60,6 +61,19 @@ When a baseline or inline suppressions are in play, the last line adds `· N bas
60
61
 
61
62
  Each finding costs its weight (default error 10, review 3, signal 1). The total is scaled to a 10-file baseline (`× 10 / max(files, 10)`), so repo size doesn't flatten the score. Score = `100 − penalty` up to a penalty of 75; beyond that it decays smoothly (`25·75/penalty`) and never pins at 0. Repos under 10 files score exactly `100 − total`. Weights live in `fringe.config.json`.
62
63
 
64
+ ## Runtime scan (`fringe scan`)
65
+
66
+ Static checks read source; `scan` looks at the page as a browser renders it, so it can see real colors (a CSS variable, a theme, text over an image), the names a browser actually computes, and how the keyboard behaves.
67
+
68
+ ```sh
69
+ npm i -D playwright-core axe-core # once, in the project being scanned (not installed by default)
70
+ npx @fringetools/cli scan http://localhost:3000
71
+ ```
72
+
73
+ What it reports: every axe-core WCAG 2.0 to 2.2 A/AA violation, one finding per element (`FR-400`, WCAG criterion attached), and a Tab walk (`FR-401`): focus that keeps landing on one element is a *keyboard trap*; interactive elements Tab never reaches are listed. Radio buttons (reached by arrow keys) and hidden controls are excluded.
74
+
75
+ Example: on a real project page the static check reported no findings and `scan` reported 17 color-contrast errors. Limits, plainly: it scans one URL in its initial state (no login, no opening of menus or modals yet); it needs the page running; findings have no source line (they carry a CSS selector); automated checks only find part of the real problems. `fringe plan --scan <url>` merges it with the static findings into one fix order. It does not feed the baseline or `fringe-ignore` yet, and is not part of the edit hook (it needs a running app and takes seconds).
76
+
63
77
  ## Priority, clusters and fix plans
64
78
 
65
79
  Every finding in `--json` carries `priority`, `cluster` and `fixability`, and the result has a `clusters` list. A cluster is one rule in one file: usually one root cause and one fix.
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-TNZKMKKQ.js";
4
+ } from "./chunk-P5N5ETBT.js";
5
5
 
6
6
  // src/bin.ts
7
7
  process.exitCode = await runCli(process.argv.slice(2));
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
2
  import { randomUUID } from "crypto";
3
3
  import { existsSync as existsSync3, readFileSync as readFileSync9, statSync } from "fs";
4
- import { dirname as dirname2, join as join9, resolve } from "path";
4
+ import { dirname as dirname2, join as join10, resolve } from "path";
5
5
 
6
6
  // src/version.ts
7
- var VERSION = "0.2.2";
7
+ var VERSION = "0.3.1";
8
8
 
9
9
  // src/check.ts
10
10
  import { readFileSync as readFileSync5 } from "fs";
@@ -1346,6 +1346,7 @@ var WCAG_A = /* @__PURE__ */ new Set(["1.1.1", "1.3.1", "2.1.1", "2.4.3", "3.3.2
1346
1346
  var WCAG_AA = /* @__PURE__ */ new Set(["1.4.3", "2.4.7", "2.5.8"]);
1347
1347
  var CRITICAL_BONUS = 40;
1348
1348
  var SHARED_FROM = 3;
1349
+ var wcagBonus = (wcag) => wcag && WCAG_A.has(wcag) ? 30 : wcag && WCAG_AA.has(wcag) ? 15 : 0;
1349
1350
  var IMPORT = /(?:from\s+|import\s*\(\s*|require\(\s*)['"]([^'"]+)['"]/g;
1350
1351
  function importerCounts(sources) {
1351
1352
  const counts = /* @__PURE__ */ new Map();
@@ -1598,15 +1599,117 @@ function receipts(root, findings) {
1598
1599
  return { introduced, fixed: [...left.values()].reduce((a, b) => a + b, 0) };
1599
1600
  }
1600
1601
 
1602
+ // src/scan.ts
1603
+ import { createRequire } from "module";
1604
+ import { join as join8 } from "path";
1605
+ var SCAN_HELP = "fringe scan needs two optional packages in your project: npm i -D playwright-core axe-core (it uses your installed Google Chrome; otherwise run npx playwright-core install chromium)";
1606
+ var WCAG_TAG = /^wcag(\d)(\d)(\d+)$/;
1607
+ var FOCUSABLE = 'a[href], button, input:not([type=hidden]):not([type=radio]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
1608
+ var at = (url) => ({ file: url, line: 1, col: 0 });
1609
+ function axeToFindings(url, violations) {
1610
+ return violations.flatMap((v) => {
1611
+ const tag2 = v.tags.map((t) => WCAG_TAG.exec(t)).find(Boolean);
1612
+ const wcag = tag2 ? `${tag2[1]}.${tag2[2]}.${tag2[3]}` : void 0;
1613
+ const severity = v.impact === "critical" || v.impact === "serious" ? "error" : "review";
1614
+ return v.nodes.map((n) => {
1615
+ const detail = (n.failureSummary ?? "").split("\n").slice(1, 2).join("").trim() || v.help;
1616
+ return {
1617
+ rule: "FR-400",
1618
+ severity,
1619
+ target: n.target.join(" "),
1620
+ cluster: `${v.id}@${url}`,
1621
+ message: `${v.id} at ${n.target.join(" ")}: ${detail}`,
1622
+ hint: `${v.help} (${v.helpUrl})`,
1623
+ loc: at(url),
1624
+ ...wcag ? { wcag } : {}
1625
+ };
1626
+ });
1627
+ });
1628
+ }
1629
+ function tabWalkFindings(url, focusables, visits) {
1630
+ const out = [];
1631
+ let run = 1;
1632
+ for (let i = 1; i < visits.length; i++) {
1633
+ run = visits[i] !== null && visits[i] === visits[i - 1] ? run + 1 : 1;
1634
+ if (run === 4) out.push({ rule: "FR-401", severity: "error", message: `keyboard trap: Tab keeps landing on ${visits[i]}`, hint: "let focus move on (Tab or Escape must leave), and trap it only inside an open modal dialog", loc: at(url), wcag: "2.1.2" });
1635
+ }
1636
+ const reached = new Set(visits.filter(Boolean));
1637
+ const missed = focusables.filter((f) => !reached.has(f));
1638
+ if (missed.length > 0)
1639
+ out.push({ rule: "FR-401", severity: "review", message: `${missed.length} interactive element(s) were not reached with Tab: ${missed.slice(0, 5).join(", ")}${missed.length > 5 ? ", \u2026" : ""}`, hint: "make sure each control is a real button/link/input or has tabIndex={0}; hidden-until-hover controls also land here", loc: at(url), wcag: "2.1.1" });
1640
+ return out;
1641
+ }
1642
+ async function scan(url, cwd = process.cwd()) {
1643
+ if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
1644
+ const req = createRequire(join8(cwd, "noop.js"));
1645
+ let playwright, axePath;
1646
+ try {
1647
+ playwright = req("playwright-core");
1648
+ axePath = req.resolve("axe-core/axe.min.js");
1649
+ } catch {
1650
+ throw new Error(SCAN_HELP);
1651
+ }
1652
+ let browser;
1653
+ try {
1654
+ browser = await playwright.chromium.launch({ channel: "chrome" });
1655
+ } catch {
1656
+ try {
1657
+ browser = await playwright.chromium.launch();
1658
+ } catch {
1659
+ throw new Error(SCAN_HELP);
1660
+ }
1661
+ }
1662
+ try {
1663
+ const page = await browser.newPage();
1664
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1665
+ await page.addScriptTag({ path: axePath });
1666
+ const axeRes = await page.evaluate(() => globalThis.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }));
1667
+ const describe = `(el) => { const i = [...document.querySelectorAll(${JSON.stringify(FOCUSABLE)})].indexOf(el); return i < 0 ? null : el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + '[' + i + ']'; }`;
1668
+ const focusables = await page.evaluate(`[...document.querySelectorAll(${JSON.stringify(FOCUSABLE)})].filter((el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && !el.disabled; }).map((el, _i, all) => (${describe})(el))`);
1669
+ const visits = [];
1670
+ for (let i = 0; i < focusables.length + 6; i++) {
1671
+ await page.keyboard.press("Tab");
1672
+ await page.waitForTimeout(40);
1673
+ const now = await page.evaluate(`(${describe})(document.activeElement)`);
1674
+ if (now === null && visits.length > 0) break;
1675
+ visits.push(now);
1676
+ }
1677
+ const { findings, clusters } = rankScan(url, [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)]);
1678
+ const counts = { error: 0, review: 0, signal: 0 };
1679
+ for (const f of findings) counts[f.severity]++;
1680
+ return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files: 1, skipped: [], findings, clusters, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1) };
1681
+ } finally {
1682
+ await browser.close();
1683
+ }
1684
+ }
1685
+ function rankScan(url, findings) {
1686
+ const clusters = /* @__PURE__ */ new Map();
1687
+ const ranked = findings.map((f) => {
1688
+ const priority = Math.round(TIER[f.severity] + wcagBonus(f.wcag));
1689
+ const id = f.cluster ?? `${f.rule}@${url}`;
1690
+ const c = clusters.get(id) ?? { id, rule: f.rule, file: url, count: 0, priority: 0, fixability: "judgment", rootCause: "local", importedBy: 0, critical: false };
1691
+ c.count++;
1692
+ c.priority = Math.max(c.priority, priority);
1693
+ clusters.set(id, c);
1694
+ return { ...f, priority, cluster: id, fixability: c.fixability };
1695
+ });
1696
+ for (const c of clusters.values()) c.priority += Math.round(Math.min(15, 3 * Math.log2(c.count)));
1697
+ return { findings: ranked, clusters: [...clusters.values()] };
1698
+ }
1699
+ function mergeScan(base, scanned, weights) {
1700
+ const counts = { error: base.counts.error + scanned.counts.error, review: base.counts.review + scanned.counts.review, signal: base.counts.signal + scanned.counts.signal };
1701
+ return { ...base, findings: [...base.findings, ...scanned.findings], clusters: [...base.clusters ?? [], ...scanned.clusters ?? []].sort((a, b) => b.priority - a.priority), counts, score: score(counts, weights, base.files) };
1702
+ }
1703
+
1601
1704
  // src/init.ts
1602
1705
  import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, writeFileSync as writeFileSync3, copyFileSync } from "fs";
1603
- import { dirname, join as join8 } from "path";
1706
+ import { dirname, join as join9 } from "path";
1604
1707
  import { fileURLToPath } from "url";
1605
1708
  var HOOK = "npx -y @fringetools/cli check --claude-hook";
1606
1709
  var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
1607
1710
  function initProject(root) {
1608
1711
  const done = [];
1609
- const settingsPath = join8(root, ".claude", "settings.json");
1712
+ const settingsPath = join9(root, ".claude", "settings.json");
1610
1713
  let settings = {};
1611
1714
  if (existsSync2(settingsPath)) {
1612
1715
  try {
@@ -1624,11 +1727,11 @@ function initProject(root) {
1624
1727
  done.push(`hook added to ${settingsPath}`);
1625
1728
  }
1626
1729
  for (const name of readdirSync3(SKILL_DIR)) {
1627
- const skillPath = join8(root, ".claude", "skills", name, "SKILL.md");
1730
+ const skillPath = join9(root, ".claude", "skills", name, "SKILL.md");
1628
1731
  if (existsSync2(skillPath)) done.push(`skill ${name} already present`);
1629
1732
  else {
1630
1733
  mkdirSync3(dirname(skillPath), { recursive: true });
1631
- copyFileSync(join8(SKILL_DIR, name, "SKILL.md"), skillPath);
1734
+ copyFileSync(join9(SKILL_DIR, name, "SKILL.md"), skillPath);
1632
1735
  done.push(`skill ${name} installed`);
1633
1736
  }
1634
1737
  }
@@ -1700,10 +1803,14 @@ var TITLES = {
1700
1803
  P3: ["Polish", "advice only; never blocks"]
1701
1804
  };
1702
1805
  function buildPlan(r, limit = 15) {
1703
- const lines = /* @__PURE__ */ new Map();
1704
- for (const f of r.findings) if (f.cluster) lines.set(f.cluster, [...lines.get(f.cluster) ?? [], f.loc.line]);
1806
+ const lines = /* @__PURE__ */ new Map(), targets = /* @__PURE__ */ new Map(), hints = /* @__PURE__ */ new Map();
1807
+ for (const f of r.findings) if (f.cluster) {
1808
+ if (f.target) targets.set(f.cluster, [...targets.get(f.cluster) ?? [], f.target]);
1809
+ else lines.set(f.cluster, [...lines.get(f.cluster) ?? [], f.loc.line]);
1810
+ if (!hints.has(f.cluster)) hints.set(f.cluster, f.hint);
1811
+ }
1705
1812
  const sev = new Map(r.findings.map((f) => [f.cluster, f.severity]));
1706
- const items = (r.clusters ?? []).map((c) => ({ ...c, lines: lines.get(c.id) ?? [], recipe: GUIDANCE[c.rule]?.recipe, avoid: GUIDANCE[c.rule]?.avoid }));
1813
+ const items = (r.clusters ?? []).map((c) => ({ ...c, lines: lines.get(c.id) ?? [], targets: targets.get(c.id) ?? [], recipe: GUIDANCE[c.rule]?.recipe ?? hints.get(c.id), avoid: GUIDANCE[c.rule]?.avoid }));
1707
1814
  const mechFirst = (a, b) => (a.fixability === b.fixability ? 0 : a.fixability === "mechanical" ? -1 : 1) || b.priority - a.priority;
1708
1815
  const pick = (id) => items.filter((c) => {
1709
1816
  const s = sev.get(c.id);
@@ -1737,8 +1844,10 @@ _${b.why}_`);
1737
1844
  const tags = [c.rootCause === "shared component" ? `shared component, imported by ${c.importedBy} files` : "", c.critical ? "critical path" : "", `${c.fixability} fix`].filter(Boolean);
1738
1845
  out.push(`
1739
1846
  ### ${c.rule} \xB7 \xD7${c.count} \xB7 ${c.file} (${tags.join("; ")})`);
1740
- out.push(`Lines: ${c.lines.slice(0, 8).join(", ")}${c.lines.length > 8 ? ", \u2026" : ""}`);
1741
- if (c.recipe && !shown.has(c.rule)) {
1847
+ out.push(c.targets.length ? `Elements: ${c.targets.slice(0, 4).join(" | ")}${c.targets.length > 4 ? " | \u2026" : ""}` : `Lines: ${c.lines.slice(0, 8).join(", ")}${c.lines.length > 8 ? ", \u2026" : ""}`);
1848
+ if (c.rule.startsWith("FR-4")) {
1849
+ if (c.recipe) out.push(`Fix: ${c.recipe}`);
1850
+ } else if (c.recipe && !shown.has(c.rule)) {
1742
1851
  shown.add(c.rule);
1743
1852
  out.push(`Fix: ${c.recipe}${c.avoid ? `
1744
1853
  Avoid: ${c.avoid}` : ""}`);
@@ -1785,7 +1894,7 @@ function resolveTarget(arg) {
1785
1894
  if (!statSync(arg).isFile()) return { root: arg };
1786
1895
  let dir = dirname2(arg);
1787
1896
  for (let d = dir, i = 0; i < 12; i++, d = dirname2(d)) {
1788
- if (existsSync3(join9(d, "package.json")) || existsSync3(join9(d, "fringe.config.json"))) {
1897
+ if (existsSync3(join10(d, "package.json")) || existsSync3(join10(d, "fringe.config.json"))) {
1789
1898
  dir = d;
1790
1899
  break;
1791
1900
  }
@@ -1797,7 +1906,8 @@ var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\
1797
1906
  var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
1798
1907
  fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
1799
1908
  fringe check --update-baseline [path] accept today's findings; only new ones gate
1800
- fringe plan [dir|file] [--json] [--limit n] ordered fix batches with recipes
1909
+ fringe scan <url> [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
1910
+ fringe plan [dir|file] [--scan url] [--json] [--limit n] ordered fix batches with recipes; --scan adds the live page
1801
1911
  fringe explain pattern [form] best-practice checklist for a component type
1802
1912
  fringe explain [FR-203] why a rule matters and how to fix it well
1803
1913
  fringe init [path] add the Claude Code hook + skill
@@ -1867,18 +1977,42 @@ async function runCli(argv, opts = {}) {
1867
1977
  return result.counts.error > 0 ? 1 : 0;
1868
1978
  } catch (e) {
1869
1979
  err(opts)(`fringe: internal error: ${e.message}
1980
+ `);
1981
+ return 2;
1982
+ }
1983
+ }
1984
+ if (cmd === "scan") {
1985
+ try {
1986
+ const url = rest.find((a) => !a.startsWith("--"));
1987
+ if (!url) {
1988
+ err(opts)("fringe: scan needs a URL, e.g. fringe scan http://localhost:3000\n");
1989
+ return 2;
1990
+ }
1991
+ const result = await scan(url, opts.cwd ?? process.cwd());
1992
+ log(opts)(rest.includes("--json") ? toJson(result) : toPretty(result));
1993
+ return result.counts.error > 0 ? 1 : 0;
1994
+ } catch (e) {
1995
+ err(opts)(`fringe: ${e.message}
1870
1996
  `);
1871
1997
  return 2;
1872
1998
  }
1873
1999
  }
1874
2000
  if (cmd === "plan") {
1875
2001
  try {
2002
+ const si = rest.indexOf("--scan");
2003
+ const scanUrl = si !== -1 ? rest[si + 1] : void 0;
2004
+ if (si !== -1 && (!scanUrl || scanUrl.startsWith("--"))) {
2005
+ err(opts)("fringe: --scan needs a URL, e.g. fringe plan --scan http://localhost:3000\n");
2006
+ return 2;
2007
+ }
1876
2008
  const li = rest.indexOf("--limit");
1877
2009
  const limit = li !== -1 ? Number(rest[li + 1]) || void 0 : void 0;
1878
- const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json"), err(opts));
2010
+ const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json" && a !== "--scan" && rest[i - 1] !== "--scan"), err(opts));
1879
2011
  const arg = resolve(opts.cwd ?? process.cwd(), paths[0] ?? ".");
1880
2012
  const { root, only } = resolveTarget(arg);
1881
- const result = await runCheck(root, loadConfig(root), true, only);
2013
+ const cfg = loadConfig(root);
2014
+ let result = await runCheck(root, cfg, true, only);
2015
+ if (scanUrl) result = mergeScan(result, await scan(scanUrl, opts.cwd ?? process.cwd()), cfg.weights);
1882
2016
  log(opts)(rest.includes("--json") ? toPlanJson(result, limit) : toPlanMarkdown(result, paths[0] ?? ".", limit));
1883
2017
  return 0;
1884
2018
  } catch (e) {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runCli
3
- } from "./chunk-TNZKMKKQ.js";
3
+ } from "./chunk-P5N5ETBT.js";
4
4
  export {
5
5
  runCli
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fringetools/cli",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,8 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
23
+ "axe-core": "^4.14.0",
24
+ "playwright-core": "^1.64.1",
23
25
  "tsup": "^8.0.0",
24
26
  "typescript": "^5.5.0",
25
27
  "vitest": "^2.0.0"
@@ -49,5 +51,17 @@
49
51
  "homepage": "https://github.com/skc-learn/fringe#readme",
50
52
  "bugs": {
51
53
  "url": "https://github.com/skc-learn/fringe/issues"
54
+ },
55
+ "peerDependencies": {
56
+ "playwright-core": ">=1.40",
57
+ "axe-core": ">=4.8"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "playwright-core": {
61
+ "optional": true
62
+ },
63
+ "axe-core": {
64
+ "optional": true
65
+ }
52
66
  }
53
67
  }
@@ -24,7 +24,8 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
24
24
  | `check [dir]` | Scan and print findings and the health score; exit 1 on any error |
25
25
  | `check --json` / `--hook` / `--claude-hook` | Machine output; CI gate; agent feedback form |
26
26
  | `check --update-baseline` | Accept today's findings; only new ones gate |
27
- | `plan [dir\|file]` | Ordered fix batches (shared components first) with recipes |
27
+ | `scan <url>` | Runtime check of a live page: real contrast, keyboard walk (needs `playwright-core` + `axe-core`) |
28
+ | `plan [dir\|file] [--scan url]` | Ordered fix batches (shared components first) with recipes; `--scan` adds the live page |
28
29
  | `explain [FR-203]` | Why a rule matters and how to fix it well |
29
30
  | `check --record [--manifest f]` | Append the run to `.fringe/ledger.jsonl` |
30
31
  | `init` | Add the hook and install the skills |