@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 +35 -4
- package/dist/bin.js +1 -1
- package/dist/{chunk-P5N5ETBT.js → chunk-7ZFEVMML.js} +153 -24
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/skill/fringe/SKILL.md +1 -1
- package/skill/fringe-audit/SKILL.md +1 -1
- package/skill/fringe-debt/SKILL.md +1 -1
- package/skill/fringe-help/SKILL.md +3 -2
- package/skill/fringe-review/SKILL.md +1 -1
- package/skill/fringe-setup/SKILL.md +18 -0
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
|
|
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.
|
|
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
|
|
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
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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]
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
package/package.json
CHANGED
package/skill/fringe/SKILL.md
CHANGED
|
@@ -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.
|