@fringetools/cli 0.3.1 → 0.4.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,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
+ | `fringe scan <url> [--states file] [--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
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`. |
27
27
  | `fringe explain [FR-203]` | Why a rule matters, the preferred fix and the tempting fix to avoid. No id lists all 36 rules. |
28
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*. |
@@ -31,7 +31,8 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
31
31
  | `fringe check --hook` | One-line `fringe/hook.v1` JSON for CI / pre-commit. Exits `1` on errors. Claude Code does **not** feed this back — use `--claude-hook` for agents. Wins over `--json`. |
32
32
  | `fringe check --update-baseline` | Accept today's findings into `fringe.baseline.json` (commit it). Exit `0`. |
33
33
  | `fringe check --record [--manifest <path>]` | Also append this run to `.fringe/ledger.jsonl`, optionally merging an agent flow manifest. |
34
- | `fringe init [path]` | Add the Claude Code hook and install the five skills. Safe to re-run; refuses to touch an invalid `settings.json`. |
34
+ | `fringe` (no arguments) | Checks the current folder, and if the post-edit check is not set up yet, says how to set it up. |
35
+ | `fringe init [path] [--global]` | Add the Claude Code hook and install the six skills. `--global` writes to `~/.claude` so every project is covered. Uses the project's own installed copy of Fringe when there is one (faster), otherwise `npx`. Safe to re-run; refuses to touch an invalid `settings.json`. |
35
36
  | `fringe open [path] [--port <n>]` | Serve the ledger as a plain HTML page on `127.0.0.1:4173` (loopback only). Ctrl+C to stop. |
36
37
  | `fringe --help` · `--version` | Usage / version. |
37
38
 
@@ -72,7 +73,37 @@ npx @fringetools/cli scan http://localhost:3000
72
73
 
73
74
  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
 
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
+ Example: on a real project page the static check reported no findings and `scan` reported 17 color-contrast errors. ### States: audit what only exists after a click
77
+
78
+ Most real problems live in states the first load never shows: an open menu, a modal, a form after a failed submit. Describe them in `fringe.scan.json` (found in the current directory, or pass `--states <file>`):
79
+
80
+ ```json
81
+ {
82
+ "states": [
83
+ { "name": "filters open", "steps": [ { "click": "#open-filters" } ] },
84
+ { "name": "bad login", "steps": [ { "fill": "#email", "value": "nobody@example.com" }, { "fill": "#password", "value": "wrong" }, { "click": "text=Sign in" }, { "waitFor": "[role=alert]" } ] }
85
+ ]
86
+ }
87
+ ```
88
+
89
+ For each state Fringe reloads the page, runs the steps, then audits the result (axe-core and the Tab walk). Steps are exactly one of `click`, `fill` (with `value`), `press`, `hover`, `waitFor`, `goto`; selectors are [Playwright selectors](https://playwright.dev/docs/other-locators) (CSS, `text=...`, `role=...`). Findings are labelled `[filters open]` and cluster separately from the same rule on the initial page. A step that fails (selector not found) is reported as `FR-402` ("state could not be reached") and nothing in that state is audited. Use throw-away test data in `fill` values and do not commit real credentials. `plan --scan` uses the same file.
90
+
91
+ ### Stress modes: how the page behaves under real-world conditions
92
+
93
+ ```sh
94
+ npx @fringetools/cli scan http://localhost:3000 --stress # all three
95
+ npx @fringetools/cli scan http://localhost:3000 --modes dark,reflow # a subset
96
+ ```
97
+
98
+ | Mode | What it does | Reports |
99
+ |---|---|---|
100
+ | `dark` | Re-audits with the browser asking for a dark color scheme | contrast and other axe findings that appear only in dark mode |
101
+ | `reduced` | Asks for reduced motion, then looks for animations still looping | `FR-403` (review) |
102
+ | `reflow` | Shrinks to 320px (a 400% zoom of a 1280px window) | `FR-404` (review, WCAG 1.4.10) if the page needs horizontal scrolling, plus new axe findings |
103
+
104
+ Each mode runs on a fresh page so one mode's emulation never leaks into the next, and only findings the plain page did not already have are kept. Findings are labelled `[dark mode]`, `[reduced motion]`, `[reflow]`. Modes apply to the initial page, not to named states.
105
+
106
+ Limits, plainly: states are independent (each starts from a fresh load of the URL, so there are no multi-page flows that depend on earlier state beyond `goto`); 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
107
 
77
108
  ## Priority, clusters and fix plans
78
109
 
@@ -137,7 +168,7 @@ The hook also keeps `.fringe/last.json` (the previous run's errors) and tells th
137
168
 
138
169
  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.
139
170
 
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:*)`).
171
+ **Skills** — `fringe init` installs six into `.claude/skills/` (sources in [skill/](skill/), one folder each); the Claude Code plugin ships the same folders, namespaced `/fringetool:…`: `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), `fringe-setup` (guided onboarding: environment check, hook, baseline offer, critical paths) 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:*)`).
141
172
 
142
173
  **The main skill** (`.claude/skills/fringe/SKILL.md`) teaches the agent the ladder: *Flow* (cut what users don't need, with evidence in `.fringe/manifest.json`) → *Edges* (fix every hook finding, never silence a rule to get green) → *Polish* (tokens, scales, no AI look), then record the run. It is plain Markdown, so any agent platform that reads `AGENTS.md`/skill files can use it as-is.
143
174
 
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-P5N5ETBT.js";
4
+ } from "./chunk-7ZFEVMML.js";
5
5
 
6
6
  // src/bin.ts
7
7
  process.exitCode = await runCli(process.argv.slice(2));
@@ -4,7 +4,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync9, statSync } fr
4
4
  import { dirname as dirname2, join as join10, resolve } from "path";
5
5
 
6
6
  // src/version.ts
7
- var VERSION = "0.3.1";
7
+ var VERSION = "0.4.1";
8
8
 
9
9
  // src/check.ts
10
10
  import { readFileSync as readFileSync5 } from "fs";
@@ -1639,7 +1639,91 @@ function tabWalkFindings(url, focusables, visits) {
1639
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
1640
  return out;
1641
1641
  }
1642
- async function scan(url, cwd = process.cwd()) {
1642
+ var STEP_KEYS = ["click", "fill", "press", "hover", "waitFor", "goto"];
1643
+ function parseStates(json) {
1644
+ const bad = (m) => {
1645
+ throw new Error(`fringe.scan.json: ${m}`);
1646
+ };
1647
+ const states = json?.states;
1648
+ if (!Array.isArray(states)) return bad('expected { "states": [ ... ] }');
1649
+ return states.map((s, i) => {
1650
+ if (typeof s?.name !== "string" || !s.name.trim()) bad(`states[${i}] needs a "name"`);
1651
+ if (!Array.isArray(s.steps) || s.steps.length === 0) bad(`state "${s.name}" needs a non-empty "steps" list`);
1652
+ for (const [k, step] of s.steps.entries()) {
1653
+ const keys = STEP_KEYS.filter((x) => step && typeof step === "object" && x in step);
1654
+ if (keys.length !== 1 || typeof step[keys[0]] !== "string") bad(`state "${s.name}" step ${k + 1} must be exactly one of ${STEP_KEYS.join(", ")} with a string value`);
1655
+ if (keys[0] === "fill" && typeof step.value !== "string") bad(`state "${s.name}" step ${k + 1}: fill needs a "value"`);
1656
+ }
1657
+ return { name: s.name.trim(), steps: s.steps };
1658
+ });
1659
+ }
1660
+ async function runStep(page, step, base) {
1661
+ const t = { timeout: 1e4 };
1662
+ if ("click" in step) await page.click(step.click, t);
1663
+ else if ("fill" in step) await page.fill(step.fill, step.value, t);
1664
+ else if ("press" in step) await page.keyboard.press(step.press);
1665
+ else if ("hover" in step) await page.hover(step.hover, t);
1666
+ else if ("waitFor" in step) await page.waitForSelector(step.waitFor, t);
1667
+ else await page.goto(new URL(step.goto, base).href, { waitUntil: "load", timeout: 3e4 });
1668
+ await page.waitForTimeout(150);
1669
+ }
1670
+ async function audit(page, axePath, url) {
1671
+ if (!await page.evaluate(() => !!globalThis.axe)) await page.addScriptTag({ path: axePath });
1672
+ const axeRes = await page.evaluate(() => globalThis.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }));
1673
+ const describe = `(el) => { const i = [...document.querySelectorAll(${JSON.stringify(FOCUSABLE)})].indexOf(el); return i < 0 ? null : el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + '[' + i + ']'; }`;
1674
+ 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))`);
1675
+ const visits = [];
1676
+ for (let i = 0; i < focusables.length + 6; i++) {
1677
+ await page.keyboard.press("Tab");
1678
+ await page.waitForTimeout(40);
1679
+ const now = await page.evaluate(`(${describe})(document.activeElement)`);
1680
+ if (now === null && visits.length > 0) break;
1681
+ visits.push(now);
1682
+ }
1683
+ return [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)];
1684
+ }
1685
+ var inState = (name, url, f) => ({ ...f, message: `[${name}] ${f.message}`, loc: { ...f.loc, file: `${url} [${name}]` }, ...f.cluster ? { cluster: `${f.cluster} [${name}]` } : {} });
1686
+ var MODES = ["dark", "reduced", "reflow"];
1687
+ function parseModes(list) {
1688
+ const out = list.split(",").map((m) => m.trim()).filter(Boolean);
1689
+ const bad = out.find((m) => !MODES.includes(m));
1690
+ if (bad || out.length === 0) throw new Error(`unknown scan mode '${bad ?? ""}': use ${MODES.join(", ")}`);
1691
+ return out;
1692
+ }
1693
+ function onlyNew(base, extra) {
1694
+ const seen = new Set(base.map((f) => `${f.rule}|${f.message}`));
1695
+ return extra.filter((f) => !seen.has(`${f.rule}|${f.message}`));
1696
+ }
1697
+ var countRunningLoops = `[...document.getAnimations()].filter((a) => a.playState === 'running' && a.effect && a.effect.getComputedTiming().iterations === Infinity).map((a) => { const t = a.effect.target; return t ? t.tagName.toLowerCase() + (t.id ? '#' + t.id : '') + (typeof t.className === 'string' && t.className ? '.' + t.className.trim().split(/\\s+/)[0] : '') : 'unknown'; })`;
1698
+ async function stress(browser, axePath, url, mode, base) {
1699
+ const page = await browser.newPage();
1700
+ try {
1701
+ return await stressOn(page, axePath, url, mode, base);
1702
+ } finally {
1703
+ await page.close();
1704
+ }
1705
+ }
1706
+ async function stressOn(page, axePath, url, mode, base) {
1707
+ const at0 = { file: `${url} [${mode}]`, line: 1, col: 0 };
1708
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1709
+ if (mode === "dark") {
1710
+ await page.emulateMedia({ colorScheme: "dark" });
1711
+ await page.waitForTimeout(150);
1712
+ return onlyNew(base, await audit(page, axePath, url)).map((f) => inState("dark mode", url, f));
1713
+ }
1714
+ if (mode === "reduced") {
1715
+ await page.emulateMedia({ reducedMotion: "reduce" });
1716
+ await page.waitForTimeout(300);
1717
+ const loops = await page.evaluate(countRunningLoops);
1718
+ return loops.length === 0 ? [] : [{ rule: "FR-403", severity: "review", message: `[reduced motion] ${loops.length} animation(s) keep looping when the user asks for reduced motion: ${loops.slice(0, 3).join(", ")}`, hint: "honour prefers-reduced-motion: stop or replace infinite animations under @media (prefers-reduced-motion: reduce)", loc: at0 }];
1719
+ }
1720
+ await page.setViewportSize({ width: 320, height: 256 });
1721
+ await page.waitForTimeout(250);
1722
+ const wide = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
1723
+ const out = wide > 1 ? [{ rule: "FR-404", severity: "review", message: `[reflow] content is ${wide}px wider than a 320px screen, so it needs horizontal scrolling`, hint: "let content reflow to one column at 320px (WCAG 1.4.10); wide tables and code blocks may scroll inside their own container", loc: at0, wcag: "1.4.10" }] : [];
1724
+ return [...out, ...onlyNew(base, await audit(page, axePath, url)).map((f) => inState("reflow", url, f))];
1725
+ }
1726
+ async function scan(url, cwd = process.cwd(), states = [], modes = []) {
1643
1727
  if (!/^(https?|file):\/\//i.test(url)) throw new Error(`fringe scan takes an http(s):// or file:// URL, got '${url}'`);
1644
1728
  const req = createRequire(join8(cwd, "noop.js"));
1645
1729
  let playwright, axePath;
@@ -1662,22 +1746,24 @@ async function scan(url, cwd = process.cwd()) {
1662
1746
  try {
1663
1747
  const page = await browser.newPage();
1664
1748
  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);
1749
+ const all = await audit(page, axePath, url);
1750
+ const plain = [...all];
1751
+ for (const st of states) {
1752
+ await page.goto(url, { waitUntil: "load", timeout: 3e4 });
1753
+ let step = 0;
1754
+ try {
1755
+ for (; step < st.steps.length; step++) await runStep(page, st.steps[step], url);
1756
+ } catch (e) {
1757
+ all.push({ rule: "FR-402", severity: "review", message: `[${st.name}] state could not be reached: step ${step + 1} ${JSON.stringify(st.steps[step])} failed (${String(e.message).split("\n")[0].slice(0, 120)})`, hint: "fix the step in fringe.scan.json or the page; nothing in this state was audited", loc: { file: `${url} [${st.name}]`, line: 1, col: 0 } });
1758
+ continue;
1759
+ }
1760
+ all.push(...(await audit(page, axePath, url)).map((f) => inState(st.name, url, f)));
1676
1761
  }
1677
- const { findings, clusters } = rankScan(url, [...axeToFindings(url, axeRes.violations), ...tabWalkFindings(url, focusables, visits)]);
1762
+ for (const m of modes) all.push(...await stress(browser, axePath, url, m, plain));
1763
+ const { findings, clusters } = rankScan(url, all);
1678
1764
  const counts = { error: 0, review: 0, signal: 0 };
1679
1765
  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) };
1766
+ return { stack: { jsx: false, tailwind: false, vue: false, svelte: false, html: true }, files: 1 + states.length + modes.length, skipped: [], findings, clusters, counts, score: score(counts, { error: 10, review: 3, signal: 1 }, 1) };
1681
1767
  } finally {
1682
1768
  await browser.close();
1683
1769
  }
@@ -1687,7 +1773,7 @@ function rankScan(url, findings) {
1687
1773
  const ranked = findings.map((f) => {
1688
1774
  const priority = Math.round(TIER[f.severity] + wcagBonus(f.wcag));
1689
1775
  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 };
1776
+ const c = clusters.get(id) ?? { id, rule: f.rule, file: f.loc.file, count: 0, priority: 0, fixability: "judgment", rootCause: "local", importedBy: 0, critical: false };
1691
1777
  c.count++;
1692
1778
  c.priority = Math.max(c.priority, priority);
1693
1779
  clusters.set(id, c);
@@ -1707,6 +1793,18 @@ import { dirname, join as join9 } from "path";
1707
1793
  import { fileURLToPath } from "url";
1708
1794
  var HOOK = "npx -y @fringetools/cli check --claude-hook";
1709
1795
  var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
1796
+ function hookCommand(root) {
1797
+ return existsSync2(join9(root, "node_modules", "@fringetools", "cli", "package.json")) ? "npx --no-install fringe check --claude-hook" : HOOK;
1798
+ }
1799
+ function isWired(root, home) {
1800
+ for (const dir of [root, home]) {
1801
+ try {
1802
+ if (/claude-hook|fringe(tool)?@/.test(readFileSync8(join9(dir, ".claude", "settings.json"), "utf8"))) return true;
1803
+ } catch {
1804
+ }
1805
+ }
1806
+ return false;
1807
+ }
1710
1808
  function initProject(root) {
1711
1809
  const done = [];
1712
1810
  const settingsPath = join9(root, ".claude", "settings.json");
@@ -1721,7 +1819,7 @@ function initProject(root) {
1721
1819
  const post = (settings.hooks ??= {}).PostToolUse ??= [];
1722
1820
  if (/(fringe|@fringetools\/cli) check/.test(JSON.stringify(post))) done.push("hook already present");
1723
1821
  else {
1724
- post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: HOOK }] });
1822
+ post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: hookCommand(root) }] });
1725
1823
  mkdirSync3(dirname(settingsPath), { recursive: true });
1726
1824
  writeFileSync3(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1727
1825
  done.push(`hook added to ${settingsPath}`);
@@ -1738,6 +1836,9 @@ function initProject(root) {
1738
1836
  return done;
1739
1837
  }
1740
1838
 
1839
+ // src/index.ts
1840
+ import { homedir } from "os";
1841
+
1741
1842
  // src/patterns.ts
1742
1843
  var PATTERNS = {
1743
1844
  form: {
@@ -1902,15 +2003,31 @@ function resolveTarget(arg) {
1902
2003
  }
1903
2004
  return { root: dir, only: /* @__PURE__ */ new Set([arg]) };
1904
2005
  }
2006
+ function loadStates(cwd, rest) {
2007
+ const i = rest.indexOf("--states");
2008
+ const file2 = i !== -1 ? resolve(cwd, rest[i + 1] ?? "") : join10(cwd, "fringe.scan.json");
2009
+ if (i === -1 && !existsSync3(file2)) return [];
2010
+ let raw;
2011
+ try {
2012
+ raw = JSON.parse(readFileSync9(file2, "utf8"));
2013
+ } catch (e) {
2014
+ throw new Error(`${file2}: ${e.message}`);
2015
+ }
2016
+ return parseStates(raw);
2017
+ }
2018
+ function loadModes(rest) {
2019
+ const i = rest.indexOf("--modes");
2020
+ return i !== -1 ? parseModes(rest[i + 1] ?? "") : rest.includes("--stress") ? [...MODES] : [];
2021
+ }
1905
2022
  var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\/?a|none|tbd|obvious|unnecessary|clutter|unused)\.?$/i.test(e.trim());
1906
2023
  var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
1907
2024
  fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
1908
2025
  fringe check --update-baseline [path] accept today's findings; only new ones gate
1909
- fringe scan <url> [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
2026
+ fringe scan <url> [--states f] [--stress | --modes dark,reduced,reflow] [--json] runtime check in a real browser (axe-core + keyboard walk); needs playwright-core and axe-core
1910
2027
  fringe plan [dir|file] [--scan url] [--json] [--limit n] ordered fix batches with recipes; --scan adds the live page
1911
2028
  fringe explain pattern [form] best-practice checklist for a component type
1912
2029
  fringe explain [FR-203] why a rule matters and how to fix it well
1913
- fringe init [path] add the Claude Code hook + skill
2030
+ fringe init [path] [--global] add the Claude Code hook + skills (--global: every project)
1914
2031
  fringe open [path] [--port <n>] serve the .fringe ledger on 127.0.0.1
1915
2032
  fringe --help | --version`;
1916
2033
  var KNOWN_FLAGS = /* @__PURE__ */ new Set(["--json", "--hook", "--claude-hook", "--record", "--manifest", "--update-baseline"]);
@@ -1933,6 +2050,11 @@ function parseArgs(rest, warn) {
1933
2050
  }
1934
2051
  async function runCli(argv, opts = {}) {
1935
2052
  const [cmd, ...rest] = argv;
2053
+ if (cmd === void 0) {
2054
+ const code = await runCli(["check"], opts);
2055
+ if (!isWired(opts.cwd ?? process.cwd(), opts.home ?? homedir())) log(opts)("\nfringe: not checking after your agent's edits yet. Run `npx @fringetools/cli init` (this project) or `init --global` (every project).");
2056
+ return code;
2057
+ }
1936
2058
  if (cmd === "--version" || cmd === "-v") {
1937
2059
  log(opts)(VERSION);
1938
2060
  return 0;
@@ -1983,12 +2105,13 @@ async function runCli(argv, opts = {}) {
1983
2105
  }
1984
2106
  if (cmd === "scan") {
1985
2107
  try {
1986
- const url = rest.find((a) => !a.startsWith("--"));
2108
+ const url = rest.find((a, i) => !a.startsWith("--") && rest[i - 1] !== "--states" && rest[i - 1] !== "--modes");
1987
2109
  if (!url) {
1988
2110
  err(opts)("fringe: scan needs a URL, e.g. fringe scan http://localhost:3000\n");
1989
2111
  return 2;
1990
2112
  }
1991
- const result = await scan(url, opts.cwd ?? process.cwd());
2113
+ const cwd = opts.cwd ?? process.cwd();
2114
+ const result = await scan(url, cwd, loadStates(cwd, rest), loadModes(rest));
1992
2115
  log(opts)(rest.includes("--json") ? toJson(result) : toPretty(result));
1993
2116
  return result.counts.error > 0 ? 1 : 0;
1994
2117
  } catch (e) {
@@ -2007,12 +2130,15 @@ async function runCli(argv, opts = {}) {
2007
2130
  }
2008
2131
  const li = rest.indexOf("--limit");
2009
2132
  const limit = li !== -1 ? Number(rest[li + 1]) || void 0 : void 0;
2010
- const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json" && a !== "--scan" && rest[i - 1] !== "--scan"), err(opts));
2133
+ const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json" && a !== "--scan" && rest[i - 1] !== "--scan" && a !== "--states" && rest[i - 1] !== "--states" && a !== "--stress" && a !== "--modes" && rest[i - 1] !== "--modes"), err(opts));
2011
2134
  const arg = resolve(opts.cwd ?? process.cwd(), paths[0] ?? ".");
2012
2135
  const { root, only } = resolveTarget(arg);
2013
2136
  const cfg = loadConfig(root);
2014
2137
  let result = await runCheck(root, cfg, true, only);
2015
- if (scanUrl) result = mergeScan(result, await scan(scanUrl, opts.cwd ?? process.cwd()), cfg.weights);
2138
+ if (scanUrl) {
2139
+ const cwd = opts.cwd ?? process.cwd();
2140
+ result = mergeScan(result, await scan(scanUrl, cwd, loadStates(cwd, rest), loadModes(rest)), cfg.weights);
2141
+ }
2016
2142
  log(opts)(rest.includes("--json") ? toPlanJson(result, limit) : toPlanMarkdown(result, paths[0] ?? ".", limit));
2017
2143
  return 0;
2018
2144
  } catch (e) {
@@ -2043,7 +2169,10 @@ async function runCli(argv, opts = {}) {
2043
2169
  }
2044
2170
  if (cmd === "init") {
2045
2171
  try {
2046
- for (const line of initProject(resolve(opts.cwd ?? process.cwd(), rest[0] ?? "."))) log(opts)(`fringe: ${line}`);
2172
+ const global = rest.includes("--global") || rest.includes("-g");
2173
+ const target = global ? opts.home ?? homedir() : resolve(opts.cwd ?? process.cwd(), rest.find((a) => !a.startsWith("-")) ?? ".");
2174
+ for (const line of initProject(target)) log(opts)(`fringe: ${line}`);
2175
+ if (global) log(opts)("fringe: set up for your user, so every project is covered (run without --global to set up one project only)");
2047
2176
  return 0;
2048
2177
  } catch (e) {
2049
2178
  err(opts)(`fringe: ${e.message}
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  interface CliOpts {
2
2
  log?: (s: string) => void;
3
3
  cwd?: string;
4
- err?: (s: string) => void;
4
+ err?: (s: string) => void; /** Home directory for `init --global` (tests override it). */
5
+ home?: string;
5
6
  }
6
7
  declare function runCli(argv: string[], opts?: CliOpts): Promise<number>;
7
8
 
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runCli
3
- } from "./chunk-P5N5ETBT.js";
3
+ } from "./chunk-7ZFEVMML.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.3.1",
3
+ "version": "0.4.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,7 +5,7 @@ description: Use when writing or changing frontend code (JSX/TSX, HTML, CSS, Vue
5
5
 
6
6
  # Fringe
7
7
 
8
- Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli` in its place (e.g. `npx -y @fringetools/cli check --json .`).
8
+ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.0` in its place (e.g. `npx -y @fringetools/cli@0.4.0 check --json .`).
9
9
 
10
10
  Ponytail trims your code. Fringe handles your edges. Work the ladder in order.
11
11
 
@@ -5,7 +5,7 @@ description: Use when the user wants a whole-repo UX/accessibility audit, a plan
5
5
 
6
6
  # Fringe audit
7
7
 
8
- Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli` in its place (e.g. `npx -y @fringetools/cli check --json .`).
8
+ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.0` in its place (e.g. `npx -y @fringetools/cli@0.4.0 check --json .`).
9
9
 
10
10
  1. Run `fringe plan --json --limit 5` (or `fringe plan` for Markdown). It already orders the work: P0 errors in shared components or critical paths, P1 other errors with mechanical fixes first, P2 review, P3 polish, each cluster with its recipe. Use that order; do not re-derive it. Add `"critical": ["src/checkout/**"]` to `fringe.config.json` if the user names the pages that matter most. Do not print every finding.
11
11
  2. Summarize as a plan:
@@ -7,7 +7,7 @@ description: Use when the user asks what Fringe findings have been silenced or d
7
7
 
8
8
  Fringe has two ways to defer a finding: an inline `fringe-ignore` comment and the `fringe.baseline.json` file. This lists both so a deferral can't quietly become permanent.
9
9
 
10
- Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli` in its place.
10
+ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.0` in its place.
11
11
 
12
12
  1. **Inline suppressions.** `git grep --no-index --exclude-standard -nE 'fringe-ignore +FR-[0-9]+'`. One row per hit: `<file>:<line>, <rule> — <why, if the comment or nearby code says>`. Skip hits that are only prose mentioning the convention.
13
13
  2. **Baseline.** If `fringe.baseline.json` exists, count entries per rule (each entry is `rule|file|message`) and list the files with the most. If it is missing, say there is no baseline.
@@ -15,16 +15,17 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
15
15
  | `/fringe-review` | Review a page or component: ranked findings with plain-English fixes |
16
16
  | `/fringe-audit` | Audit the whole repo: top rules and files, a fix order, offer a baseline |
17
17
  | `/fringe-debt` | List silenced findings (`fringe-ignore`, baseline) |
18
+ | `/fringe-setup` | Guided onboarding: wire the hook, offer a baseline, set critical paths |
18
19
  | `/fringe-help` | This reference |
19
20
 
20
- **Commands** (`fringe`, or `npx -y @fringetools/cli`)
21
+ **Commands** (`fringe`, or `npx -y @fringetools/cli@0.4.0`)
21
22
 
22
23
  | Command | What it does |
23
24
  |---|---|
24
25
  | `check [dir]` | Scan and print findings and the health score; exit 1 on any error |
25
26
  | `check --json` / `--hook` / `--claude-hook` | Machine output; CI gate; agent feedback form |
26
27
  | `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`) |
28
+ | `scan <url>` | Runtime check of a live page: real contrast, keyboard walk, and named states from `fringe.scan.json` (needs `playwright-core` + `axe-core`) |
28
29
  | `plan [dir\|file] [--scan url]` | Ordered fix batches (shared components first) with recipes; `--scan` adds the live page |
29
30
  | `explain [FR-203]` | Why a rule matters and how to fix it well |
30
31
  | `check --record [--manifest f]` | Append the run to `.fringe/ledger.jsonl` |
@@ -5,7 +5,7 @@ description: Use when the user asks to review, check or critique a UI, page or c
5
5
 
6
6
  # Fringe review
7
7
 
8
- Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli` in its place (e.g. `npx -y @fringetools/cli check --json .`).
8
+ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli@0.4.0` in its place (e.g. `npx -y @fringetools/cli@0.4.0 check --json .`).
9
9
 
10
10
  1. Run `fringe check --json <file or folder>` from the project root: the file or page the user named, else `.`. Each finding already carries `priority` and `fixability`; report highest priority first. For the fix text use `fringe explain <rule>` (why, recipe, what to avoid) rather than improvising.
11
11
  2. Report in plain English, no jargon left unexplained:
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: fringe-setup
3
+ description: Use when the user wants to set up or get started with Fringe, or asks why it is not checking their edits ("set up fringe", "get started", "install fringe", /fringe-setup). Guided onboarding: checks the environment, wires the hook, offers a baseline, and records the critical pages.
4
+ ---
5
+
6
+ # Fringe setup
7
+
8
+ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli` in its place. Ask before each step that changes a file, and say what it will change.
9
+
10
+ 1. **Environment.** Run `node -v` (needs 18 or newer). If it is missing or older, stop and tell the user to install Node first.
11
+ 2. **Where things stand.** Run `fringe check .` and report the counts. If it says it is not checking after edits, the hook is not wired.
12
+ 3. **Wire the hook (skip if the Fringe plugin is already installed, or the check above reported it is set up).** Ask: this project only, or every project? Then run `fringe init` or `fringe init --global`. Both add the post-edit hook and the skills without touching other settings.
13
+ 4. **Existing problems.** If there are more than a handful of errors, explain that `fringe check --update-baseline` records today's findings so only new ones block, and offer it. Do not run it without a yes. Remind the user to commit `fringe.baseline.json`.
14
+ 5. **What matters most.** Ask which folders are most important to users (checkout, sign-up, login). Add them to `fringe.config.json` as `"critical": ["src/checkout/**", ...]`, merging into the file if it exists, never overwriting other keys.
15
+ 6. **Optional.** Mention `fringe scan <url>` (needs `npm i -D playwright-core axe-core` and Chrome) for real contrast and keyboard checks; install nothing unless asked.
16
+ 7. **Finish** with one short summary of what was changed and the commands to remember: `/fringe-review`, `/fringe-audit`, `fringe plan`, `fringe explain FR-203`.
17
+
18
+ If anything fails, report the exact message; do not retry blindly or edit `.claude/settings.json` by hand.