@fringetools/cli 0.2.1 → 0.3.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/README.md CHANGED
@@ -22,6 +22,7 @@ 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 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. |
25
26
  | `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`. |
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*. |
@@ -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. It does not feed `plan`, baseline or suppression 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.
@@ -119,6 +133,8 @@ After each edit the hook runs; with errors it returns, for example:
119
133
  {"decision":"block","reason":"Fringe found 2 accessibility error(s). Fix them before finishing:\n- FR-203 src/Signup.tsx:9 — input not associated with a label (add id + <label htmlFor>, or aria-label)\n…"}
120
134
  ```
121
135
 
136
+ The hook also keeps `.fringe/last.json` (the previous run's errors) and tells the agent what its **last edit broke**: "Your last edit introduced 1 new error(s) and fixed 2; 3 failing in total", with the new ones listed first and marked `NEW`. If that file is missing or unreadable it simply skips the comparison. It still blocks on every remaining error; on a legacy repo use the baseline so old debt is not nagged about.
137
+
122
138
  This was verified end to end with real agent runs: the agent receives the findings and fixes them (see [docs/architecture.md](../../docs/architecture.md)). Why `--claude-hook` and not `--hook`: Claude Code only passes a hook's output to the agent as `decision`/`reason` JSON on exit 0 (or stderr on exit 2); the CI contract `--hook` exits 1 and is ignored.
123
139
 
124
140
  **Skills** — `fringe init` installs five into `.claude/skills/` (sources in [skill/](skill/), one folder each); the Claude Code plugin ships the same folders, namespaced `/fringe:…`: `fringe` (below), `fringe-review` ("review this page": ranked, plain-English findings with fixes), `fringe-audit` (whole-repo plan: top rules and files, a fix order, offers `--update-baseline`), `fringe-debt` (lists `fringe-ignore` suppressions and the baseline) and `fringe-help` (quick reference). Invoke as `/fringe-review` etc. Reviews run `fringe check`, so Claude Code will ask you to approve that command once (or allow `Bash(fringe:*)` / `Bash(npx:*)`).
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-Z4UN7OMW.js";
4
+ } from "./chunk-5RPXF7S7.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
- import { existsSync as existsSync3, readFileSync as readFileSync8, statSync } from "fs";
4
- import { dirname as dirname2, join as join8, resolve } from "path";
3
+ import { existsSync as existsSync3, readFileSync as readFileSync9, statSync } from "fs";
4
+ import { dirname as dirname2, join as join10, resolve } from "path";
5
5
 
6
6
  // src/version.ts
7
- var VERSION = "0.2.1";
7
+ var VERSION = "0.3.0";
8
8
 
9
9
  // src/check.ts
10
10
  import { readFileSync as readFileSync5 } from "fs";
@@ -144,7 +144,7 @@ function collectText(node) {
144
144
  }
145
145
  return t.trim();
146
146
  }
147
- function parseJsx(file, source) {
147
+ function parseJsx(file2, source) {
148
148
  let ast;
149
149
  try {
150
150
  ast = parse(source, { sourceType: "unambiguous", plugins: ["jsx", "typescript"], errorRecovery: false });
@@ -162,7 +162,7 @@ function parseJsx(file, source) {
162
162
  for (const a of node.openingElement.attributes) {
163
163
  if (a.type === "JSXAttribute") attrs[a.name.name] = attrValue(a.value);
164
164
  }
165
- const el = { tag: tag2, attrs, text: collectText(node), loc: { file, line: node.loc?.start.line ?? 0, col: node.loc?.start.column ?? 0 } };
165
+ const el = { tag: tag2, attrs, text: collectText(node), loc: { file: file2, line: node.loc?.start.line ?? 0, col: node.loc?.start.column ?? 0 } };
166
166
  if (parent) el.parentTag = parent.tag;
167
167
  if (parent?.inLabel) el.inLabel = true;
168
168
  if (parent?.attrs?.className !== void 0) el.parentClass = parent.attrs.className;
@@ -181,7 +181,7 @@ function parseJsx(file, source) {
181
181
  // src/parse/html.ts
182
182
  import { parse as parse2 } from "parse5";
183
183
  var textOf = (n) => n.nodeName === "#text" ? n.value : (n.childNodes ?? []).map(textOf).join("");
184
- function parseHtml(file, source) {
184
+ function parseHtml(file2, source) {
185
185
  const doc = parse2(source, { sourceCodeLocationInfo: true });
186
186
  const elems = [];
187
187
  (function walkNode(node, parent) {
@@ -189,7 +189,7 @@ function parseHtml(file, source) {
189
189
  const attrs = {};
190
190
  for (const a of node.attrs ?? []) attrs[a.name] = a.value;
191
191
  const text = textOf(node).trim();
192
- const el = { tag: node.tagName, attrs, text, loc: { file, line: node.sourceCodeLocation?.startLine ?? 0, col: node.sourceCodeLocation?.startCol ?? 0 } };
192
+ const el = { tag: node.tagName, attrs, text, loc: { file: file2, line: node.sourceCodeLocation?.startLine ?? 0, col: node.sourceCodeLocation?.startCol ?? 0 } };
193
193
  if (parent) el.parentTag = parent.tag;
194
194
  if (parent?.inLabel) el.inLabel = true;
195
195
  if (parent?.attrs?.class !== void 0) el.parentClass = parent.attrs.class;
@@ -204,12 +204,12 @@ function parseHtml(file, source) {
204
204
 
205
205
  // src/parse/css.ts
206
206
  import postcss from "postcss";
207
- function parseCss(file, source) {
207
+ function parseCss(file2, source) {
208
208
  const decls = [];
209
209
  const rec = (node, selector) => {
210
210
  for (const child of node.nodes ?? []) {
211
211
  if (child.type === "decl") {
212
- decls.push({ prop: child.prop, value: String(child.value), selector, loc: { file, line: child.source?.start?.line ?? 0, col: child.source?.start?.column ?? 0 } });
212
+ decls.push({ prop: child.prop, value: String(child.value), selector, loc: { file: file2, line: child.source?.start?.line ?? 0, col: child.source?.start?.column ?? 0 } });
213
213
  } else if (child.type === "rule" || child.type === "atrule") {
214
214
  rec(child, child.type === "rule" ? child.selector : selector);
215
215
  }
@@ -1056,10 +1056,10 @@ function gatherTokenColors(root, cfg, cssSources) {
1056
1056
  const kind = cfg.tokenSource?.kind;
1057
1057
  if (kind === "design-tokens-json") {
1058
1058
  const rel = cfg.tokenSource?.path ?? "";
1059
- const file = isAbsolute(rel) ? rel : join4(root, rel);
1059
+ const file2 = isAbsolute(rel) ? rel : join4(root, rel);
1060
1060
  let json;
1061
1061
  try {
1062
- json = JSON.parse(readFileSync3(file, "utf8"));
1062
+ json = JSON.parse(readFileSync3(file2, "utf8"));
1063
1063
  } catch {
1064
1064
  return out;
1065
1065
  }
@@ -1386,25 +1386,25 @@ async function runCheck(root, cfg = loadConfig(root), useBaseline = true, only)
1386
1386
  const skipped = [];
1387
1387
  const cssSources = [];
1388
1388
  const contexts = [];
1389
- for (const file of files) {
1389
+ for (const file2 of files) {
1390
1390
  let source = "";
1391
1391
  try {
1392
- source = readFileSync5(file, "utf8");
1392
+ source = readFileSync5(file2, "utf8");
1393
1393
  } catch {
1394
- skipped.push(file);
1394
+ skipped.push(file2);
1395
1395
  continue;
1396
1396
  }
1397
- const ctx = { file, source, elems: [], css: [], tokenColors: [] };
1397
+ const ctx = { file: file2, source, elems: [], css: [], tokenColors: [] };
1398
1398
  try {
1399
- if (/\.(jsx|tsx|js|ts)$/.test(file)) ctx.elems = parseJsx(file, source);
1400
- else if (/\.html$/.test(file)) ctx.elems = parseHtml(file, source);
1401
- else if (/\.(css|scss)$/.test(file)) {
1402
- ctx.css = parseCss(file, source);
1403
- cssSources.push({ file, source });
1399
+ if (/\.(jsx|tsx|js|ts)$/.test(file2)) ctx.elems = parseJsx(file2, source);
1400
+ else if (/\.html$/.test(file2)) ctx.elems = parseHtml(file2, source);
1401
+ else if (/\.(css|scss)$/.test(file2)) {
1402
+ ctx.css = parseCss(file2, source);
1403
+ cssSources.push({ file: file2, source });
1404
1404
  }
1405
1405
  } catch (e) {
1406
1406
  if (e.message.startsWith("parse-skip")) {
1407
- skipped.push(file);
1407
+ skipped.push(file2);
1408
1408
  continue;
1409
1409
  }
1410
1410
  throw e;
@@ -1454,10 +1454,14 @@ function toHook(r) {
1454
1454
  reason
1455
1455
  });
1456
1456
  }
1457
- function toClaudeHook(r, root) {
1458
- const errs = r.findings.filter((f) => f.severity === "error");
1459
- if (errs.length === 0) return "";
1460
- const shown = errs.slice(0, 8).map((f) => `- ${f.rule} ${relative4(root, f.loc.file)}:${f.loc.line} \u2014 ${f.message} (${f.hint})`);
1457
+ function toClaudeHook(r, root, history) {
1458
+ const all = r.findings.filter((f) => f.severity === "error");
1459
+ if (all.length === 0) return "";
1460
+ const fresh = new Set(history?.introduced);
1461
+ const errs = [...all.filter((f) => fresh.has(f)), ...all.filter((f) => !fresh.has(f))];
1462
+ const note = history && (history.introduced.length > 0 || history.fixed > 0) ? `Your last edit introduced ${history.introduced.length} new error(s)${history.fixed ? ` and fixed ${history.fixed}` : ""}; ${errs.length} failing in total. New ones are marked NEW.
1463
+ ` : "";
1464
+ const shown = errs.slice(0, 8).map((f) => `- ${fresh.has(f) ? "NEW " : ""}${f.rule} ${relative4(root, f.loc.file)}:${f.loc.line} \u2014 ${f.message} (${f.hint})`);
1461
1465
  const shownRules = [...new Set(errs.slice(0, 8).map((f) => f.rule))];
1462
1466
  const recipes = shownRules.map((id) => {
1463
1467
  const g = GUIDANCE[id];
@@ -1467,7 +1471,7 @@ function toClaudeHook(r, root) {
1467
1471
  \u2026and ${errs.length - shown.length} more (run \`fringe check\`).` : "";
1468
1472
  return JSON.stringify({
1469
1473
  decision: "block",
1470
- reason: `Fringe found ${errs.length} accessibility error(s). Fix them before finishing:
1474
+ reason: `${note}Fringe found ${errs.length} accessibility error(s). Fix them before finishing:
1471
1475
  ${shown.join("\n")}${more}
1472
1476
  How to fix them well:
1473
1477
  ${recipes.join("\n")}
@@ -1562,19 +1566,130 @@ function startBoard(root, port) {
1562
1566
  });
1563
1567
  }
1564
1568
 
1569
+ // src/receipts.ts
1570
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "fs";
1571
+ import { join as join7 } from "path";
1572
+ var file = (root) => join7(root, ".fringe", "last.json");
1573
+ function receipts(root, findings) {
1574
+ const errs = findings.filter((f) => f.severity === "error");
1575
+ const keys = errs.map((f) => keyOf(root, f));
1576
+ let prev;
1577
+ try {
1578
+ const p = JSON.parse(readFileSync7(file(root), "utf8"));
1579
+ if (Array.isArray(p)) prev = p;
1580
+ } catch {
1581
+ }
1582
+ try {
1583
+ mkdirSync2(join7(root, ".fringe"), { recursive: true });
1584
+ writeFileSync2(file(root), JSON.stringify(keys));
1585
+ } catch {
1586
+ }
1587
+ if (!prev) return void 0;
1588
+ const left = /* @__PURE__ */ new Map();
1589
+ for (const k of prev) left.set(k, (left.get(k) ?? 0) + 1);
1590
+ const introduced = errs.filter((f, i) => {
1591
+ const n = left.get(keys[i]) ?? 0;
1592
+ if (n > 0) {
1593
+ left.set(keys[i], n - 1);
1594
+ return false;
1595
+ }
1596
+ return true;
1597
+ });
1598
+ return { introduced, fixed: [...left.values()].reduce((a, b) => a + b, 0) };
1599
+ }
1600
+
1601
+ // src/scan.ts
1602
+ import { createRequire } from "module";
1603
+ import { join as join8 } from "path";
1604
+ 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)";
1605
+ var WCAG_TAG = /^wcag(\d)(\d)(\d+)$/;
1606
+ var FOCUSABLE = 'a[href], button, input:not([type=hidden]):not([type=radio]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
1607
+ var at = (url) => ({ file: url, line: 1, col: 0 });
1608
+ function axeToFindings(url, violations) {
1609
+ return violations.flatMap((v) => {
1610
+ const tag2 = v.tags.map((t) => WCAG_TAG.exec(t)).find(Boolean);
1611
+ const wcag = tag2 ? `${tag2[1]}.${tag2[2]}.${tag2[3]}` : void 0;
1612
+ const severity = v.impact === "critical" || v.impact === "serious" ? "error" : "review";
1613
+ return v.nodes.map((n) => ({
1614
+ rule: "FR-400",
1615
+ severity,
1616
+ message: `${v.id} at ${n.target.join(" ")}: ${v.help}`,
1617
+ hint: `${(n.failureSummary ?? "").split("\n").slice(1, 2).join("").trim() || v.help} (${v.helpUrl})`,
1618
+ loc: at(url),
1619
+ ...wcag ? { wcag } : {}
1620
+ }));
1621
+ });
1622
+ }
1623
+ function tabWalkFindings(url, focusables, visits) {
1624
+ const out = [];
1625
+ let run = 1;
1626
+ for (let i = 1; i < visits.length; i++) {
1627
+ run = visits[i] !== null && visits[i] === visits[i - 1] ? run + 1 : 1;
1628
+ 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" });
1629
+ }
1630
+ const reached = new Set(visits.filter(Boolean));
1631
+ const missed = focusables.filter((f) => !reached.has(f));
1632
+ if (missed.length > 0)
1633
+ 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" });
1634
+ return out;
1635
+ }
1636
+ async function scan(url, cwd = process.cwd()) {
1637
+ if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
1638
+ const req = createRequire(join8(cwd, "noop.js"));
1639
+ let playwright, axePath;
1640
+ try {
1641
+ playwright = req("playwright-core");
1642
+ axePath = req.resolve("axe-core/axe.min.js");
1643
+ } catch {
1644
+ throw new Error(SCAN_HELP);
1645
+ }
1646
+ let browser;
1647
+ try {
1648
+ browser = await playwright.chromium.launch({ channel: "chrome" });
1649
+ } catch {
1650
+ try {
1651
+ browser = await playwright.chromium.launch();
1652
+ } catch {
1653
+ throw new Error(SCAN_HELP);
1654
+ }
1655
+ }
1656
+ try {
1657
+ const page = await browser.newPage();
1658
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1659
+ await page.addScriptTag({ path: axePath });
1660
+ const axeRes = await page.evaluate(() => globalThis.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }));
1661
+ const describe = `(el) => { const i = [...document.querySelectorAll(${JSON.stringify(FOCUSABLE)})].indexOf(el); return i < 0 ? null : el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + '[' + i + ']'; }`;
1662
+ 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))`);
1663
+ const visits = [];
1664
+ for (let i = 0; i < focusables.length + 6; i++) {
1665
+ await page.keyboard.press("Tab");
1666
+ await page.waitForTimeout(40);
1667
+ const now = await page.evaluate(`(${describe})(document.activeElement)`);
1668
+ if (now === null && visits.length > 0) break;
1669
+ visits.push(now);
1670
+ }
1671
+ const findings = [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)];
1672
+ const counts = { error: 0, review: 0, signal: 0 };
1673
+ for (const f of findings) counts[f.severity]++;
1674
+ return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files: 1, skipped: [], findings, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1) };
1675
+ } finally {
1676
+ await browser.close();
1677
+ }
1678
+ }
1679
+
1565
1680
  // src/init.ts
1566
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync7, readdirSync as readdirSync3, writeFileSync as writeFileSync2, copyFileSync } from "fs";
1567
- import { dirname, join as join7 } from "path";
1681
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, writeFileSync as writeFileSync3, copyFileSync } from "fs";
1682
+ import { dirname, join as join9 } from "path";
1568
1683
  import { fileURLToPath } from "url";
1569
1684
  var HOOK = "npx -y @fringetools/cli check --claude-hook";
1570
1685
  var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
1571
1686
  function initProject(root) {
1572
1687
  const done = [];
1573
- const settingsPath = join7(root, ".claude", "settings.json");
1688
+ const settingsPath = join9(root, ".claude", "settings.json");
1574
1689
  let settings = {};
1575
1690
  if (existsSync2(settingsPath)) {
1576
1691
  try {
1577
- settings = JSON.parse(readFileSync7(settingsPath, "utf8"));
1692
+ settings = JSON.parse(readFileSync8(settingsPath, "utf8"));
1578
1693
  } catch {
1579
1694
  throw new Error(`${settingsPath} is not valid JSON \u2014 fix it and rerun`);
1580
1695
  }
@@ -1583,16 +1698,16 @@ function initProject(root) {
1583
1698
  if (/(fringe|@fringetools\/cli) check/.test(JSON.stringify(post))) done.push("hook already present");
1584
1699
  else {
1585
1700
  post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: HOOK }] });
1586
- mkdirSync2(dirname(settingsPath), { recursive: true });
1587
- writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1701
+ mkdirSync3(dirname(settingsPath), { recursive: true });
1702
+ writeFileSync3(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1588
1703
  done.push(`hook added to ${settingsPath}`);
1589
1704
  }
1590
1705
  for (const name of readdirSync3(SKILL_DIR)) {
1591
- const skillPath = join7(root, ".claude", "skills", name, "SKILL.md");
1706
+ const skillPath = join9(root, ".claude", "skills", name, "SKILL.md");
1592
1707
  if (existsSync2(skillPath)) done.push(`skill ${name} already present`);
1593
1708
  else {
1594
- mkdirSync2(dirname(skillPath), { recursive: true });
1595
- copyFileSync(join7(SKILL_DIR, name, "SKILL.md"), skillPath);
1709
+ mkdirSync3(dirname(skillPath), { recursive: true });
1710
+ copyFileSync(join9(SKILL_DIR, name, "SKILL.md"), skillPath);
1596
1711
  done.push(`skill ${name} installed`);
1597
1712
  }
1598
1713
  }
@@ -1722,7 +1837,7 @@ var err = (o) => o.err ?? ((s) => process.stderr.write(s));
1722
1837
  function readManifest(path2, warn) {
1723
1838
  let raw;
1724
1839
  try {
1725
- raw = readFileSync8(path2, "utf8");
1840
+ raw = readFileSync9(path2, "utf8");
1726
1841
  } catch {
1727
1842
  warn(`fringe: warning: manifest not found at ${path2} \u2014 continuing without
1728
1843
  `);
@@ -1749,7 +1864,7 @@ function resolveTarget(arg) {
1749
1864
  if (!statSync(arg).isFile()) return { root: arg };
1750
1865
  let dir = dirname2(arg);
1751
1866
  for (let d = dir, i = 0; i < 12; i++, d = dirname2(d)) {
1752
- if (existsSync3(join8(d, "package.json")) || existsSync3(join8(d, "fringe.config.json"))) {
1867
+ if (existsSync3(join10(d, "package.json")) || existsSync3(join10(d, "fringe.config.json"))) {
1753
1868
  dir = d;
1754
1869
  break;
1755
1870
  }
@@ -1761,6 +1876,7 @@ var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\
1761
1876
  var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
1762
1877
  fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
1763
1878
  fringe check --update-baseline [path] accept today's findings; only new ones gate
1879
+ fringe scan <url> [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
1764
1880
  fringe plan [dir|file] [--json] [--limit n] ordered fix batches with recipes
1765
1881
  fringe explain pattern [form] best-practice checklist for a component type
1766
1882
  fringe explain [FR-203] why a rule matters and how to fix it well
@@ -1805,7 +1921,7 @@ async function runCli(argv, opts = {}) {
1805
1921
  }
1806
1922
  const result = await runCheck(root, cfg, true, only);
1807
1923
  if (rest.includes("--claude-hook")) {
1808
- const out = toClaudeHook(result, root);
1924
+ const out = toClaudeHook(result, root, receipts(root, result.findings));
1809
1925
  if (out) log(opts)(out);
1810
1926
  return 0;
1811
1927
  }
@@ -1831,6 +1947,22 @@ async function runCli(argv, opts = {}) {
1831
1947
  return result.counts.error > 0 ? 1 : 0;
1832
1948
  } catch (e) {
1833
1949
  err(opts)(`fringe: internal error: ${e.message}
1950
+ `);
1951
+ return 2;
1952
+ }
1953
+ }
1954
+ if (cmd === "scan") {
1955
+ try {
1956
+ const url = rest.find((a) => !a.startsWith("--"));
1957
+ if (!url) {
1958
+ err(opts)("fringe: scan needs a URL, e.g. fringe scan http://localhost:3000\n");
1959
+ return 2;
1960
+ }
1961
+ const result = await scan(url, opts.cwd ?? process.cwd());
1962
+ log(opts)(rest.includes("--json") ? toJson(result) : toPretty(result));
1963
+ return result.counts.error > 0 ? 1 : 0;
1964
+ } catch (e) {
1965
+ err(opts)(`fringe: ${e.message}
1834
1966
  `);
1835
1967
  return 2;
1836
1968
  }
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runCli
3
- } from "./chunk-Z4UN7OMW.js";
3
+ } from "./chunk-5RPXF7S7.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.1",
3
+ "version": "0.3.0",
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,6 +24,7 @@ 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
+ | `scan <url>` | Runtime check of a live page: real contrast, keyboard walk (needs `playwright-core` + `axe-core`) |
27
28
  | `plan [dir\|file]` | Ordered fix batches (shared components first) with recipes |
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` |