@fringetools/cli 0.1.4 → 0.2.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
@@ -21,7 +21,10 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
21
21
 
22
22
  | Command | What it does |
23
23
  |---|---|
24
- | `fringe check [path]` | Scan a directory (default `.`) and print findings + health score. Exit `1` on any `error`. |
24
+ | `fringe check [dir\|file]` | Scan a directory (default `.`) or judge one file (the project root is found by climbing to the nearest `package.json`). Prints findings and the health score. Exit `1` on any `error`. |
25
+ | `fringe plan [dir\|file] [--json] [--limit n]` | **Fix order for a repo with many findings**: P0 (errors in shared components or critical paths) → P1 (other errors, mechanical first) → P2 review → P3 polish, each cluster with its fix recipe. Never gates (exit 0). `--json` = `fringe/plan.v1`. |
26
+ | `fringe explain [FR-203]` | Why a rule matters, the preferred fix and the tempting fix to avoid. No id lists all 36 rules. |
27
+ | `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*. |
25
28
  | `fringe check --json` | Full report as JSON, schema `fringe/check.v1` (stack, files, findings, counts, score). |
26
29
  | `fringe check --claude-hook` | **For Claude Code hooks.** Silent when clean; on errors prints `{"decision":"block","reason":…}` (exit 0) so the agent sees and fixes them. |
27
30
  | `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`. |
@@ -31,7 +34,7 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
31
34
  | `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. |
32
35
  | `fringe --help` · `--version` | Usage / version. |
33
36
 
34
- Notes: the first path is the check root (config + scan); extra paths are accepted and ignored so agent-invoked runs never crash. v0.1 takes directories, not single files. Unknown flags warn on stderr and are ignored.
37
+ Notes: the first path is the check root (config + scan); extra paths are accepted and ignored so agent-invoked runs never crash. Unknown flags warn on stderr and are ignored.
35
38
 
36
39
  Default (human) output:
37
40
 
@@ -57,6 +60,23 @@ When a baseline or inline suppressions are in play, the last line adds `· N bas
57
60
 
58
61
  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`.
59
62
 
63
+ ## Priority, clusters and fix plans
64
+
65
+ 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.
66
+
67
+ `priority = tier + WCAG level + critical path + blast radius`
68
+
69
+ | Part | Value |
70
+ |---|---|
71
+ | Tier | error 1000 · review 400 · signal 100 (so any error outranks any review, whatever else is true) |
72
+ | WCAG level | A +30 · AA +15 |
73
+ | Critical path | +40 when the file matches `critical` in `fringe.config.json` (e.g. `"critical": ["src/checkout/**", "src/auth/**"]`) |
74
+ | Blast radius | up to +20, growing with the number of files that import this one |
75
+
76
+ A cluster is a **shared component** when 3 or more files import its file; fix those first, since one change clears every use. Repeats lift a cluster slightly (never across a tier). Blast radius matches importers by file base name, a deliberate shortcut.
77
+
78
+ `fringe plan` turns this into the work order; `fringe explain FR-203` gives the recipe for one rule. The `--claude-hook` message includes each shown rule's recipe once, so the agent gets "how to fix it well", not just "what is wrong".
79
+
60
80
  ## Baseline
61
81
 
62
82
  Adopting Fringe on a repo that already has problems? Don't fix 300 findings first.
@@ -138,6 +158,12 @@ Severity `error` fails the run; `review` and `signal` are heuristics that need a
138
158
  | FR-213 | review | | dark pattern — infinite scroll without opt-out |
139
159
  | FR-214 | review | | dark pattern — bait-and-switch ("free", then a price) pricing |
140
160
  | FR-215 | review | | generic error copy ("something went wrong") with no recovery action — no retry, link, or contact |
161
+ | FR-216 | review | | form basics — email/phone field with the wrong `type`, password field with no `autocomplete` |
162
+ | FR-217 | review | | link text that says nothing out of context ("click here"), `href="#"` / `javascript:` links |
163
+ | FR-218 | review | 3.1.1 · 2.4.2 · 1.4.4 | full HTML documents: no `lang`, no `<title>`, viewport that blocks pinch-zoom (fragments are skipped) |
164
+ | FR-219 | review | | `@keyframes` animation in a stylesheet with no `prefers-reduced-motion` handling (judged per file) |
165
+ | FR-220 | review | 1.4.2 | `<video>`/`<audio>` that autoplays with sound |
166
+ | FR-221 | review | 1.4.13 | element that reacts to hover only (no focus, click or touch handler) |
141
167
 
142
168
  The dark-pattern taxonomy (FR-210–214) is adapted, with attribution, from [Design with Intent](https://designwithintent.ai)'s CC0 anti-pattern library.
143
169
 
@@ -161,6 +187,7 @@ The dark-pattern taxonomy (FR-210–214) is adapted, with attribution, from [Des
161
187
  | FR-367 | signal | | numbered section labels — "01 Discover" style kickers |
162
188
  | FR-368 | signal | | the purple AI gradient — violet/indigo Tailwind gradient or its CSS hex equivalent |
163
189
  | FR-369 | signal | | emoji in headings |
190
+ | FR-222 | signal | | lorem ipsum / TODO / `[insert …]` text left in the UI |
164
191
 
165
192
  **FR-304 scope in v1:** contrast is checked only on CSS pairs — a `color` declaration and a `background-color` (or pure-color `background`) in the *same* CSS rule block. Tailwind utility classes in markup are not pair-checked, and translucent colors (alpha < 1) are skipped because contrast over an unknown backdrop is unknowable.
166
193
 
@@ -170,12 +197,13 @@ Optional, at the check root. Everything defaults sensibly; all keys are optional
170
197
 
171
198
  ```json
172
199
  {
173
- "ignore": ["node_modules/**", "dist/**", "build/**", "out/**", "coverage/**", ".fringe/**", "*.min.*", "examples/**"],
200
+ "ignore": ["node_modules/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", ".fringe/**", "*.min.*", "examples/**"],
174
201
  "disable": ["FR-214"],
175
202
  "weights": { "error": 10, "review": 3, "signal": 1 }
176
203
  }
177
204
  ```
178
205
 
206
+ - `critical` — globs for the files that matter most (checkout, auth, sign-up). Their findings rank higher in `plan` and `--json`.
179
207
  - `ignore` — glob list matched against paths relative to the root. **Use the `dir/**` form to skip a directory** — a bare-dir glob like `"dist"` matches only the directory entry itself and does **not** prune the files inside it. (`node_modules` and dot-directories are always skipped.)
180
208
  - `disable` — rule IDs to turn off.
181
209
  - `weights` — per-finding penalties into the health score (see [Health score](#health-score)).
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-WXASOIWD.js";
4
+ } from "./chunk-Z4UN7OMW.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 { readFileSync as readFileSync8 } from "fs";
4
- import { resolve } from "path";
3
+ import { existsSync as existsSync3, readFileSync as readFileSync8, statSync } from "fs";
4
+ import { dirname as dirname2, join as join8, resolve } from "path";
5
5
 
6
6
  // src/version.ts
7
- var VERSION = "0.1.4";
7
+ var VERSION = "0.2.1";
8
8
 
9
9
  // src/check.ts
10
10
  import { readFileSync as readFileSync5 } from "fs";
@@ -13,7 +13,8 @@ import { readFileSync as readFileSync5 } from "fs";
13
13
  import { readFileSync } from "fs";
14
14
  import { join } from "path";
15
15
  var DEFAULT_CONFIG = {
16
- ignore: ["node_modules/**", "dist/**", "build/**", "out/**", "coverage/**", ".fringe/**", "*.min.*"],
16
+ // Both forms: `dist/**` matches only at the root, `**/dist/**` matches nested build output (e.g. frontend/dist).
17
+ ignore: ["node_modules/**", "dist/**", "build/**", "out/**", "coverage/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", ".fringe/**", "*.min.*"],
17
18
  disable: [],
18
19
  weights: { error: 10, review: 3, signal: 1 }
19
20
  };
@@ -28,6 +29,10 @@ function loadConfig(root) {
28
29
  weights: { ...DEFAULT_CONFIG.weights, ...user.weights ?? {} }
29
30
  };
30
31
  validateWeights(cfg.weights);
32
+ if (user.critical !== void 0) {
33
+ if (!Array.isArray(user.critical) || user.critical.some((g) => typeof g !== "string")) throw new Error("fringe.config.json: critical must be an array of glob strings");
34
+ cfg.critical = user.critical;
35
+ }
31
36
  if (user.tokenSource !== void 0) {
32
37
  cfg.tokenSource = validateTokenSource(user.tokenSource);
33
38
  }
@@ -452,6 +457,113 @@ var fr215 = {
452
457
  }
453
458
  };
454
459
 
460
+ // src/rules/fr216.ts
461
+ var NOT_TEXT = /* @__PURE__ */ new Set(["hidden", "checkbox", "radio", "submit", "button", "file", "range", "color", "image", "reset"]);
462
+ var short = (s = "") => s.length <= 24 ? s : "";
463
+ var cat = (a) => `${a.name ?? ""} ${a.id ?? ""} ${short(a.placeholder)} ${short(a["aria-label"])}`.toLowerCase();
464
+ var fr216 = {
465
+ id: "FR-216",
466
+ rung: 2,
467
+ severity: "review",
468
+ check: (ctx) => {
469
+ const out = [];
470
+ for (const e of ctx.elems) {
471
+ if (e.tag !== "input" || e.attrs.type === "{\u2026}") continue;
472
+ const type = (e.attrs.type ?? "text").toLowerCase();
473
+ if (NOT_TEXT.has(type)) continue;
474
+ const hint = cat(e.attrs);
475
+ if (/search|filter|find/.test(hint)) continue;
476
+ const flag = (message, h) => out.push({ rule: "FR-216", severity: "review", message, hint: h, loc: e.loc });
477
+ if (/e-?mail/.test(hint) && type !== "email") flag(`email field is type="${type}"`, 'use type="email" so phones show the right keyboard and the browser validates');
478
+ else if (/\b(tel|phone|mobile)\b/.test(hint) && type !== "tel") flag(`phone field is type="${type}"`, 'use type="tel" for the numeric keypad');
479
+ if (type === "password" && e.attrs.autocomplete === void 0 && e.attrs.autoComplete === void 0) flag("password field without autocomplete", 'add autoComplete="current-password" or "new-password" so password managers work');
480
+ }
481
+ return out;
482
+ }
483
+ };
484
+
485
+ // src/rules/fr217.ts
486
+ var VAGUE = /^(click here|here|read more|more|link|this link)$/i;
487
+ var fr217 = {
488
+ id: "FR-217",
489
+ rung: 2,
490
+ severity: "review",
491
+ check: (ctx) => {
492
+ const out = [];
493
+ for (const e of ctx.elems) {
494
+ if (e.tag !== "a") continue;
495
+ const href = e.attrs.href ?? "";
496
+ if (VAGUE.test(e.text) && !e.attrs["aria-label"] && !e.attrs["aria-labelledby"])
497
+ out.push({ rule: "FR-217", severity: "review", message: `link text "${e.text}" says nothing out of context`, hint: 'name the destination or action ("Read the pricing guide"); screen reader users list links on their own', loc: e.loc });
498
+ if (href === "#" || /^javascript:/i.test(href))
499
+ out.push({ rule: "FR-217", severity: "review", message: `link with href="${href.slice(0, 11)}" goes nowhere`, hint: "use a <button> for an action, or a real URL for navigation", loc: e.loc });
500
+ }
501
+ return out;
502
+ }
503
+ };
504
+
505
+ // src/rules/fr218.ts
506
+ var fr218 = {
507
+ id: "FR-218",
508
+ rung: 2,
509
+ severity: "review",
510
+ check: (ctx) => {
511
+ if (!/<html[\s>]/i.test(ctx.source)) return [];
512
+ const out = [];
513
+ const html = ctx.elems.find((e) => e.tag === "html");
514
+ if (html && html.attrs.lang === void 0)
515
+ out.push({ rule: "FR-218", severity: "review", message: "<html> has no lang attribute", hint: 'add lang="en" (or the page language) so screen readers pick the right voice', loc: html.loc, wcag: "3.1.1" });
516
+ if (!/<title[\s>]/i.test(ctx.source) && html && /<head[\s>]/i.test(ctx.source))
517
+ out.push({ rule: "FR-218", severity: "review", message: "page has no <title>", hint: "add a unique, descriptive <title>: it is the first thing a screen reader announces and the browser tab label", loc: html.loc, wcag: "2.4.2" });
518
+ for (const m of ctx.elems.filter((e) => e.tag === "meta" && e.attrs.name === "viewport")) {
519
+ if (/user-scalable\s*=\s*(no|0)|maximum-scale\s*=\s*1(\.0)?\b/i.test(m.attrs.content ?? ""))
520
+ out.push({ rule: "FR-218", severity: "review", message: "viewport meta blocks pinch-zoom", hint: "remove user-scalable=no / maximum-scale=1: low-vision users rely on zoom", loc: m.loc, wcag: "1.4.4" });
521
+ }
522
+ return out;
523
+ }
524
+ };
525
+
526
+ // src/rules/fr219.ts
527
+ var fr219 = {
528
+ id: "FR-219",
529
+ rung: 3,
530
+ severity: "review",
531
+ check: (ctx) => {
532
+ if (!/@keyframes/.test(ctx.source) || /prefers-reduced-motion/.test(ctx.source)) return [];
533
+ const d = ctx.css.find((x) => x.prop === "animation" || x.prop === "animation-name");
534
+ if (!d) return [];
535
+ return [{ rule: "FR-219", severity: "review", message: "animation defined with no prefers-reduced-motion handling", hint: "wrap it in @media (prefers-reduced-motion: no-preference), or turn it off under (prefers-reduced-motion: reduce)", loc: d.loc }];
536
+ }
537
+ };
538
+
539
+ // src/rules/fr220.ts
540
+ var fr220 = {
541
+ id: "FR-220",
542
+ rung: 2,
543
+ severity: "review",
544
+ check: (ctx) => ctx.elems.filter((e) => (e.tag === "video" || e.tag === "audio") && (e.attrs.autoPlay !== void 0 || e.attrs.autoplay !== void 0) && e.attrs.muted === void 0).map((e) => ({ rule: "FR-220", severity: "review", message: `<${e.tag}> autoplays with sound`, hint: "start muted (and offer controls), or wait for a user action; browsers block it anyway and it startles screen reader users", loc: e.loc, wcag: "1.4.2" }))
545
+ };
546
+
547
+ // src/rules/fr221.ts
548
+ var HOVER = ["onMouseEnter", "onMouseOver", "onmouseenter", "onmouseover"];
549
+ var FOCUS = ["onFocus", "onfocus"];
550
+ var PRESS = ["onClick", "onclick", "onTouchStart", "ontouchstart"];
551
+ var fr221 = {
552
+ id: "FR-221",
553
+ rung: 2,
554
+ severity: "review",
555
+ check: (ctx) => ctx.elems.filter((e) => HOVER.some((h) => e.attrs[h] !== void 0) && !FOCUS.some((f) => e.attrs[f] !== void 0) && !PRESS.some((p) => e.attrs[p] !== void 0)).map((e) => ({ rule: "FR-221", severity: "review", message: `<${e.tag}> reacts to hover only`, hint: "mirror it on focus (onFocus/onBlur) and tap: keyboard and touch users cannot hover", loc: e.loc, wcag: "1.4.13" }))
556
+ };
557
+
558
+ // src/rules/fr222.ts
559
+ var FILLER = /\blorem ipsum\b|\b(TODO|FIXME|TBD)\b|\[(insert|your)[^\]]*\]/i;
560
+ var fr222 = {
561
+ id: "FR-222",
562
+ rung: 3,
563
+ severity: "signal",
564
+ check: (ctx) => ctx.elems.filter((e) => FILLER.test(e.text) || FILLER.test(e.attrs.placeholder ?? "") || FILLER.test(e.attrs["aria-label"] ?? "")).map((e) => ({ rule: "FR-222", severity: "signal", message: "placeholder or unfinished text in the UI", hint: "replace lorem ipsum / TODO / [insert ...] with real copy before shipping", loc: e.loc }))
565
+ };
566
+
455
567
  // src/color.ts
456
568
  var clamp255 = (n) => Math.min(255, Math.max(0, Math.round(n)));
457
569
  function splitArgs(argStr) {
@@ -914,7 +1026,7 @@ var fr369 = {
914
1026
 
915
1027
  // src/rules/registry.ts
916
1028
  var ALL_RULES = [];
917
- ALL_RULES.push(fr201, fr202, fr203, fr204, fr205, fr206, fr207, fr208, fr209, fr210, fr211, fr212, fr213, fr214, fr215, fr301, fr302, fr303, fr304, fr305, fr361, fr362, fr363, fr364, fr366, fr365, fr367, fr368, fr369);
1029
+ ALL_RULES.push(fr201, fr202, fr203, fr204, fr205, fr206, fr207, fr208, fr209, fr210, fr211, fr212, fr213, fr214, fr215, fr216, fr217, fr218, fr219, fr220, fr221, fr222, fr301, fr302, fr303, fr304, fr305, fr361, fr362, fr363, fr364, fr366, fr365, fr367, fr368, fr369);
918
1030
  function activeRules(cfg) {
919
1031
  return ALL_RULES.filter((r) => !cfg.disable.includes(r.id));
920
1032
  }
@@ -1013,8 +1125,260 @@ function suppressedIds(lines, line) {
1013
1125
  return ids;
1014
1126
  }
1015
1127
 
1128
+ // src/prioritize.ts
1129
+ import { basename, extname, relative as relative3 } from "path";
1130
+
1131
+ // src/guidance.ts
1132
+ var GUIDANCE = {
1133
+ "FR-201": {
1134
+ why: "A screen reader announces an image with no alt as its file name, or skips it.",
1135
+ recipe: 'Informative image: describe its purpose, not its look (alt="Order #4521 receipt"). Decorative image: alt="".',
1136
+ avoid: 'alt="image", alt="photo", or the file name.',
1137
+ example: { before: '<img src="/hero.png" />', after: '<img src="/hero.png" alt="Team reviewing a dashboard" />' },
1138
+ fixability: "judgment"
1139
+ },
1140
+ "FR-202": {
1141
+ why: 'A button or link with no text is announced as just "button" or "link".',
1142
+ recipe: 'Put visible text inside it. For an icon-only control, add aria-label naming the action and aria-hidden="true" on the icon.',
1143
+ avoid: 'title alone (not reliably announced) or an aria-label that repeats the icon name ("cross").',
1144
+ example: { before: "<button><CloseIcon /></button>", after: '<button aria-label="Close dialog"><CloseIcon aria-hidden="true" /></button>' },
1145
+ fixability: "mechanical"
1146
+ },
1147
+ "FR-203": {
1148
+ why: "A form field with no label is announced without a name, and the placeholder vanishes as soon as the user types.",
1149
+ recipe: "Add a visible <label htmlFor> bound to the input id. Use aria-label only where a visible label is genuinely wrong for the design (e.g. a search box beside a magnifier button).",
1150
+ avoid: "Placeholder as the only label; aria-label when a visible label would fit.",
1151
+ example: { before: '<input id="email" placeholder="Email" />', after: '<label htmlFor="email">Email</label>\n<input id="email" />' },
1152
+ fixability: "mechanical"
1153
+ },
1154
+ "FR-204": {
1155
+ why: "Screen reader users navigate by headings; skipped levels or several h1s break the outline.",
1156
+ recipe: "Use one h1 per page, then h2, h3 in order. Choose the level by structure and style it with CSS.",
1157
+ avoid: "Picking h3 because it looks smaller.",
1158
+ fixability: "mechanical"
1159
+ },
1160
+ "FR-205": {
1161
+ why: "Keyboard users cannot see where focus is when the outline is removed.",
1162
+ recipe: "Keep a visible focus style. Replace outline-none with focus-visible:outline-2 focus-visible:outline-offset-2 (or a :focus-visible rule with a visible outline).",
1163
+ avoid: "outline: none with no replacement; styling :focus only for mouse users.",
1164
+ fixability: "mechanical"
1165
+ },
1166
+ "FR-206": {
1167
+ why: "A clickable div is not reachable by keyboard and has no role for assistive technology.",
1168
+ recipe: 'Use a <button> (or <a href> for navigation). Only if that is impossible, add role="button", tabIndex={0} and Enter/Space key handlers.',
1169
+ avoid: 'role="button" without keyboard handlers.',
1170
+ example: { before: "<div onClick={open}>Open</div>", after: '<button type="button" onClick={open}>Open</button>' },
1171
+ fixability: "mechanical"
1172
+ },
1173
+ "FR-207": {
1174
+ why: "A positive tabIndex pulls the element out of the natural order and makes focus jump unpredictably.",
1175
+ recipe: "Use tabIndex={0} (join the natural order) or -1 (focusable by script only), and fix the order by moving the element in the DOM.",
1176
+ avoid: "tabIndex={1}, {2}... to force an order.",
1177
+ fixability: "mechanical"
1178
+ },
1179
+ "FR-208": {
1180
+ why: "Data that loads, comes back empty or fails leaves the user staring at nothing.",
1181
+ recipe: "Render three states next to the data: loading (skeleton or spinner with a text label), empty (what to do next), error (what failed and a retry).",
1182
+ avoid: "A spinner with no failure path.",
1183
+ fixability: "judgment"
1184
+ },
1185
+ "FR-209": {
1186
+ why: "A misspelled aria-* attribute is ignored, so the intended name or state never reaches the user.",
1187
+ recipe: "Correct the attribute name against the ARIA spec (aria-labelledby, aria-describedby, aria-expanded...).",
1188
+ example: { before: '<input aria-labeledby="h" />', after: '<input aria-labelledby="h" />' },
1189
+ fixability: "mechanical"
1190
+ },
1191
+ "FR-210": {
1192
+ why: "A fake countdown pressures users into rushed decisions.",
1193
+ recipe: "State the real deadline as a date, or remove the urgency wording.",
1194
+ fixability: "judgment"
1195
+ },
1196
+ "FR-211": {
1197
+ why: "A pre-checked consent box opts people in without a decision.",
1198
+ recipe: "Leave consent boxes unchecked and make the choice explicit.",
1199
+ fixability: "mechanical"
1200
+ },
1201
+ "FR-212": {
1202
+ why: "Guilt-trip wording on the decline option manipulates the choice.",
1203
+ recipe: 'Make both options neutral ("No thanks").',
1204
+ fixability: "judgment"
1205
+ },
1206
+ "FR-213": {
1207
+ why: "Infinite scroll with no way to stop traps keyboard users and buries the footer.",
1208
+ recipe: 'Offer a "Load more" button or pagination, and keep the footer reachable.',
1209
+ fixability: "judgment"
1210
+ },
1211
+ "FR-214": {
1212
+ why: '"Free", then a price later, erodes trust.',
1213
+ recipe: "Show the full price up front, including recurring cost.",
1214
+ fixability: "judgment"
1215
+ },
1216
+ "FR-215": {
1217
+ why: '"Something went wrong" tells the user nothing about what to do next.',
1218
+ recipe: "Say what failed in plain words and give a next step: retry, a link, or a way to contact support.",
1219
+ fixability: "judgment"
1220
+ },
1221
+ "FR-216": {
1222
+ why: "The wrong input type gives the wrong keyboard on phones and skips built-in validation; no autocomplete blocks password managers and autofill.",
1223
+ recipe: 'Use type="email" / "tel" / "password", and set autoComplete (email, tel, current-password, new-password, name).',
1224
+ avoid: 'type="text" for everything.',
1225
+ example: { before: '<input type="text" name="email" />', after: '<input type="email" name="email" autoComplete="email" />' },
1226
+ fixability: "mechanical"
1227
+ },
1228
+ "FR-217": {
1229
+ why: 'Screen reader users browse a list of links out of context, so "click here" says nothing; href="#" links go nowhere.',
1230
+ recipe: "Name the destination or action in the link text. Use a <button> for an action, a real URL for navigation.",
1231
+ avoid: '"click here", "read more", or href="#" with an onClick.',
1232
+ example: { before: '<a href="#" onClick={save}>Save</a>', after: '<button type="button" onClick={save}>Save</button>' },
1233
+ fixability: "mechanical"
1234
+ },
1235
+ "FR-218": {
1236
+ why: "A missing lang makes screen readers use the wrong voice; a missing title leaves the tab and the page unnamed; a viewport that blocks zoom shuts out low-vision users.",
1237
+ recipe: 'Set <html lang="en"> (the real language), add a unique <title>, and remove user-scalable=no / maximum-scale=1 from the viewport meta.',
1238
+ avoid: 'Blocking zoom to "fix" a layout problem.',
1239
+ fixability: "mechanical"
1240
+ },
1241
+ "FR-219": {
1242
+ why: "Motion can trigger nausea or distraction for some users, and they can ask their system to reduce it.",
1243
+ recipe: "Wrap the animation in @media (prefers-reduced-motion: no-preference), or disable it under (prefers-reduced-motion: reduce).",
1244
+ fixability: "mechanical"
1245
+ },
1246
+ "FR-220": {
1247
+ why: "Sound that starts on its own startles users and talks over a screen reader; browsers block it anyway.",
1248
+ recipe: "Start muted and show controls, or play only after a user action.",
1249
+ fixability: "mechanical"
1250
+ },
1251
+ "FR-221": {
1252
+ why: "A hover-only reaction cannot be reached with a keyboard or a touchscreen.",
1253
+ recipe: "Do the same on focus (onFocus/onBlur) and on tap/click, and make the element focusable.",
1254
+ fixability: "mechanical"
1255
+ },
1256
+ "FR-222": {
1257
+ why: "Lorem ipsum and TODO text that ships makes the product look unfinished.",
1258
+ recipe: "Replace it with real copy before release.",
1259
+ fixability: "judgment"
1260
+ },
1261
+ "FR-301": {
1262
+ why: "Hardcoded colors drift from the design system and make theming painful.",
1263
+ recipe: "Use the project tokens (CSS variables or palette utilities) instead of literal hex values.",
1264
+ fixability: "mechanical"
1265
+ },
1266
+ "FR-302": {
1267
+ why: "Off-scale font sizes break the type rhythm.",
1268
+ recipe: "Use the type scale (text-sm, text-base, ...) or add the size to the theme.",
1269
+ fixability: "mechanical"
1270
+ },
1271
+ "FR-303": {
1272
+ why: "Off-scale spacing makes layouts inconsistent.",
1273
+ recipe: "Use the spacing scale (p-4, gap-2, ...) or extend the theme.",
1274
+ fixability: "mechanical"
1275
+ },
1276
+ "FR-304": {
1277
+ why: "Low contrast text is hard to read for many people, and unreadable in sunlight.",
1278
+ recipe: "Raise the contrast to at least 4.5:1 for body text (3:1 for large text) by darkening the text or lightening the background.",
1279
+ fixability: "judgment"
1280
+ },
1281
+ "FR-305": {
1282
+ why: "Tiny hit areas are hard to tap, especially for people with motor impairments.",
1283
+ recipe: "Give interactive elements at least 24x24px (44x44px on touch) with padding, not just font size.",
1284
+ fixability: "mechanical"
1285
+ },
1286
+ "FR-361": {
1287
+ why: "Italic serif display headlines are a recognisable AI-generated look.",
1288
+ recipe: "Choose type from the project design system for headlines.",
1289
+ fixability: "judgment"
1290
+ },
1291
+ "FR-362": {
1292
+ why: "The uppercase eyebrow chip above a headline is a recognisable AI-generated look.",
1293
+ recipe: "Drop the chip or fold the content into the heading or subheading.",
1294
+ fixability: "judgment"
1295
+ },
1296
+ "FR-363": {
1297
+ why: "Cards nested in cards add visual noise and hide hierarchy.",
1298
+ recipe: "Flatten: use spacing and one border level.",
1299
+ fixability: "judgment"
1300
+ },
1301
+ "FR-364": {
1302
+ why: "A stack of icon tiles is a recognisable AI-generated layout.",
1303
+ recipe: "Replace with content that carries information, or vary the structure.",
1304
+ fixability: "judgment"
1305
+ },
1306
+ "FR-365": {
1307
+ why: "A thin border with a wide shadow signals elevation inconsistently.",
1308
+ recipe: "Pick one: a border for flat surfaces or a soft shadow for raised ones.",
1309
+ fixability: "judgment"
1310
+ },
1311
+ "FR-366": {
1312
+ why: "Many status chips make a page read as noise.",
1313
+ recipe: "Keep chips that carry state the user acts on; merge or drop the rest.",
1314
+ fixability: "judgment"
1315
+ },
1316
+ "FR-367": {
1317
+ why: '"01 Discover" style labels are a recognisable AI-generated look.',
1318
+ recipe: "Use plain section headings.",
1319
+ fixability: "judgment"
1320
+ },
1321
+ "FR-368": {
1322
+ why: "The violet/indigo gradient is a recognisable AI-generated look.",
1323
+ recipe: "Use the project brand colors.",
1324
+ fixability: "judgment"
1325
+ },
1326
+ "FR-369": {
1327
+ why: "Emoji in headings read as decoration and can be announced noisily.",
1328
+ recipe: "Remove the emoji, or put a real icon beside the heading with aria-hidden.",
1329
+ fixability: "judgment"
1330
+ }
1331
+ };
1332
+ function renderExplain(id) {
1333
+ if (!id) return ALL_RULES.map((r) => `${r.id} ${r.severity.padEnd(6)} ${GUIDANCE[r.id].why}`).join("\n");
1334
+ const rule = ALL_RULES.find((r) => r.id === id.toUpperCase());
1335
+ if (!rule) return void 0;
1336
+ const g = GUIDANCE[rule.id];
1337
+ const out = [`${rule.id} \xB7 ${rule.severity} \xB7 ${g.fixability} fix`, `Why: ${g.why}`, `Fix: ${g.recipe}`];
1338
+ if (g.avoid) out.push(`Avoid: ${g.avoid}`);
1339
+ if (g.example) out.push("Before: " + g.example.before.replace(/\n/g, "\n "), "After: " + g.example.after.replace(/\n/g, "\n "));
1340
+ return out.join("\n");
1341
+ }
1342
+
1343
+ // src/prioritize.ts
1344
+ var TIER = { error: 1e3, review: 400, signal: 100 };
1345
+ var WCAG_A = /* @__PURE__ */ new Set(["1.1.1", "1.3.1", "2.1.1", "2.4.3", "3.3.2", "4.1.2"]);
1346
+ var WCAG_AA = /* @__PURE__ */ new Set(["1.4.3", "2.4.7", "2.5.8"]);
1347
+ var CRITICAL_BONUS = 40;
1348
+ var SHARED_FROM = 3;
1349
+ var IMPORT = /(?:from\s+|import\s*\(\s*|require\(\s*)['"]([^'"]+)['"]/g;
1350
+ function importerCounts(sources) {
1351
+ const counts = /* @__PURE__ */ new Map();
1352
+ for (const src of sources.values()) {
1353
+ const seen = /* @__PURE__ */ new Set();
1354
+ for (const m of src.matchAll(IMPORT)) seen.add(basename(m[1], extname(m[1])));
1355
+ for (const b of seen) counts.set(b, (counts.get(b) ?? 0) + 1);
1356
+ }
1357
+ return counts;
1358
+ }
1359
+ function prioritize(findings, sources, root, critical) {
1360
+ const crit = critical.map(globToRegex);
1361
+ const importers = importerCounts(sources);
1362
+ const clusters = /* @__PURE__ */ new Map();
1363
+ const out = findings.map((f) => {
1364
+ const rel = relative3(root, f.loc.file);
1365
+ const blast = Math.min(20, 4 * Math.log2(1 + (importers.get(basename(rel, extname(rel))) ?? 0)));
1366
+ const level = f.wcag && WCAG_A.has(f.wcag) ? 30 : f.wcag && WCAG_AA.has(f.wcag) ? 15 : 0;
1367
+ const priority = Math.round(TIER[f.severity] + level + (crit.some((r) => r.test(rel)) ? CRITICAL_BONUS : 0) + blast);
1368
+ const id = `${f.rule}@${rel}`;
1369
+ const by = importers.get(basename(rel, extname(rel))) ?? 0;
1370
+ const c = clusters.get(id) ?? { id, rule: f.rule, file: rel, count: 0, priority: 0, fixability: GUIDANCE[f.rule]?.fixability ?? "judgment", rootCause: by >= SHARED_FROM ? "shared component" : "local", importedBy: by, critical: crit.some((r) => r.test(rel)) };
1371
+ c.count++;
1372
+ c.priority = Math.max(c.priority, priority);
1373
+ clusters.set(id, c);
1374
+ return { ...f, priority, cluster: id, fixability: c.fixability };
1375
+ });
1376
+ for (const c of clusters.values()) c.priority += Math.round(Math.min(15, 3 * Math.log2(c.count)));
1377
+ return { findings: out, clusters: [...clusters.values()].sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id)) };
1378
+ }
1379
+
1016
1380
  // src/check.ts
1017
- async function runCheck(root, cfg = loadConfig(root), useBaseline = true) {
1381
+ async function runCheck(root, cfg = loadConfig(root), useBaseline = true, only) {
1018
1382
  const stack = detectStack(root);
1019
1383
  const files = walk(root, cfg);
1020
1384
  const rules = activeRules(cfg);
@@ -1050,6 +1414,7 @@ async function runCheck(root, cfg = loadConfig(root), useBaseline = true) {
1050
1414
  const tokenColors = gatherTokenColors(root, cfg, cssSources);
1051
1415
  for (const ctx of contexts) ctx.tokenColors = tokenColors;
1052
1416
  for (const ctx of contexts) {
1417
+ if (only && !only.has(ctx.file)) continue;
1053
1418
  for (const rule of rules) findings.push(...rule.check(ctx));
1054
1419
  }
1055
1420
  const lines = new Map(contexts.map((c) => [c.file, c.source.split("\n")]));
@@ -1058,7 +1423,8 @@ async function runCheck(root, cfg = loadConfig(root), useBaseline = true) {
1058
1423
  const { fresh, known } = useBaseline ? applyBaseline(root, live) : { fresh: live, known: 0 };
1059
1424
  const counts = { error: 0, review: 0, signal: 0 };
1060
1425
  for (const f of fresh) counts[f.severity]++;
1061
- return { stack, files: files.length, skipped, findings: fresh, counts, score: score(counts, cfg.weights, files.length), ...known ? { baselined: known } : {}, ...suppressed ? { suppressed } : {} };
1426
+ const ranked = prioritize(fresh, new Map(contexts.map((c) => [c.file, c.source])), root, cfg.critical ?? []);
1427
+ return { stack, files: only ? only.size : files.length, skipped, findings: ranked.findings, clusters: ranked.clusters, counts, score: score(counts, cfg.weights, only ? only.size : files.length), ...known ? { baselined: known } : {}, ...suppressed ? { suppressed } : {} };
1062
1428
  }
1063
1429
 
1064
1430
  // src/report/json.ts
@@ -1067,7 +1433,7 @@ function toJson(r) {
1067
1433
  }
1068
1434
 
1069
1435
  // src/report/hook.ts
1070
- import { relative as relative3 } from "path";
1436
+ import { relative as relative4 } from "path";
1071
1437
  var RANK = { error: 0, review: 1, signal: 2 };
1072
1438
  function topFinding(r) {
1073
1439
  return [...r.findings].sort((a, b) => {
@@ -1091,13 +1457,20 @@ function toHook(r) {
1091
1457
  function toClaudeHook(r, root) {
1092
1458
  const errs = r.findings.filter((f) => f.severity === "error");
1093
1459
  if (errs.length === 0) return "";
1094
- const shown = errs.slice(0, 8).map((f) => `- ${f.rule} ${relative3(root, f.loc.file)}:${f.loc.line} \u2014 ${f.message} (${f.hint})`);
1460
+ const shown = errs.slice(0, 8).map((f) => `- ${f.rule} ${relative4(root, f.loc.file)}:${f.loc.line} \u2014 ${f.message} (${f.hint})`);
1461
+ const shownRules = [...new Set(errs.slice(0, 8).map((f) => f.rule))];
1462
+ const recipes = shownRules.map((id) => {
1463
+ const g = GUIDANCE[id];
1464
+ return g ? `- ${id}: ${g.recipe}${g.avoid ? ` Avoid: ${g.avoid}` : ""}` : "";
1465
+ }).filter(Boolean);
1095
1466
  const more = errs.length > shown.length ? `
1096
1467
  \u2026and ${errs.length - shown.length} more (run \`fringe check\`).` : "";
1097
1468
  return JSON.stringify({
1098
1469
  decision: "block",
1099
1470
  reason: `Fringe found ${errs.length} accessibility error(s). Fix them before finishing:
1100
1471
  ${shown.join("\n")}${more}
1472
+ How to fix them well:
1473
+ ${recipes.join("\n")}
1101
1474
  Use a fringe-ignore comment only for a genuine false positive, and say so.`
1102
1475
  });
1103
1476
  }
@@ -1226,6 +1599,123 @@ function initProject(root) {
1226
1599
  return done;
1227
1600
  }
1228
1601
 
1602
+ // src/patterns.ts
1603
+ var PATTERNS = {
1604
+ form: {
1605
+ title: "Form",
1606
+ items: [
1607
+ { text: "Every field has a visible <label> bound with htmlFor/id", rules: ["FR-203"] },
1608
+ { text: "Right input type and autocomplete (email, tel, current-password, new-password, name)", rules: ["FR-216"] },
1609
+ { text: "Required fields are marked in text, not by color alone" },
1610
+ { text: "Errors appear next to the field in words, linked with aria-describedby and aria-invalid; focus moves to the first error on submit" },
1611
+ { text: "The submit button shows a pending state and cannot be double-submitted" },
1612
+ { text: "Input is kept when an error occurs; validation runs on blur, not on every keystroke" }
1613
+ ]
1614
+ },
1615
+ modal: {
1616
+ title: "Modal dialog",
1617
+ items: [
1618
+ { text: 'role="dialog" with aria-modal="true" and a name (aria-labelledby the title)', rules: ["FR-209"] },
1619
+ { text: "Focus moves in on open, stays trapped inside, and returns to the trigger on close" },
1620
+ { text: "A visible Close button with an accessible name, and Escape closes", rules: ["FR-202"] },
1621
+ { text: "Clicking the backdrop may close it but is never the only way", rules: ["FR-206"] },
1622
+ { text: "The page behind is inert and does not scroll" }
1623
+ ]
1624
+ },
1625
+ navigation: {
1626
+ title: "Navigation",
1627
+ items: [
1628
+ { text: "Links use real hrefs and describe the destination", rules: ["FR-217"] },
1629
+ { text: "A <nav> landmark (labelled if there are several), one <main>, and a skip-to-content link" },
1630
+ { text: 'The current page is marked with aria-current="page"' },
1631
+ { text: "Menus open by keyboard and tap, not by hover alone", rules: ["FR-221"] },
1632
+ { text: "Focus is always visible", rules: ["FR-205"] },
1633
+ { text: "Tap targets are large enough", rules: ["FR-305"] }
1634
+ ]
1635
+ },
1636
+ "data-view": {
1637
+ title: "Data view (list, table, dashboard)",
1638
+ items: [
1639
+ { text: "A loading state with a text label, an empty state that says what to do next, and an error state with a retry", rules: ["FR-208", "FR-215"] },
1640
+ { text: "Layout does not jump when data arrives (reserve the space)" },
1641
+ { text: "Async updates are announced (aria-live) when the user did not trigger them" },
1642
+ { text: "Table headers use <th> and a caption or accessible name" }
1643
+ ]
1644
+ }
1645
+ };
1646
+ function renderPatterns(name) {
1647
+ if (!name) return Object.entries(PATTERNS).map(([k, p2]) => `${k.padEnd(11)} ${p2.title}`).join("\n");
1648
+ const p = PATTERNS[name.toLowerCase()];
1649
+ if (!p) return void 0;
1650
+ const mark = (i) => i.rules ? `[verified: ${i.rules.join(", ")}]` : "[judged]";
1651
+ return [
1652
+ `${p.title}: build it like this`,
1653
+ ...p.items.map((i) => `- ${i.text} ${mark(i)}`),
1654
+ "",
1655
+ "verified = Fringe checks it; judged = you decide, so state your evidence in the final message."
1656
+ ].join("\n");
1657
+ }
1658
+
1659
+ // src/report/plan.ts
1660
+ var TITLES = {
1661
+ P0: ["Fix first", "errors in shared components or critical paths: one fix clears many uses"],
1662
+ P1: ["Then the other errors", "blocking problems; mechanical fixes first"],
1663
+ P2: ["Review", "heuristics that need your judgment"],
1664
+ P3: ["Polish", "advice only; never blocks"]
1665
+ };
1666
+ function buildPlan(r, limit = 15) {
1667
+ const lines = /* @__PURE__ */ new Map();
1668
+ for (const f of r.findings) if (f.cluster) lines.set(f.cluster, [...lines.get(f.cluster) ?? [], f.loc.line]);
1669
+ const sev = new Map(r.findings.map((f) => [f.cluster, f.severity]));
1670
+ const items = (r.clusters ?? []).map((c) => ({ ...c, lines: lines.get(c.id) ?? [], recipe: GUIDANCE[c.rule]?.recipe, avoid: GUIDANCE[c.rule]?.avoid }));
1671
+ const mechFirst = (a, b) => (a.fixability === b.fixability ? 0 : a.fixability === "mechanical" ? -1 : 1) || b.priority - a.priority;
1672
+ const pick = (id) => items.filter((c) => {
1673
+ const s = sev.get(c.id);
1674
+ if (id === "P0") return s === "error" && (c.rootCause === "shared component" || c.critical);
1675
+ if (id === "P1") return s === "error" && !(c.rootCause === "shared component" || c.critical);
1676
+ return s === (id === "P2" ? "review" : "signal");
1677
+ }).sort(id === "P1" ? mechFirst : (a, b) => b.priority - a.priority);
1678
+ return ["P0", "P1", "P2", "P3"].map((id) => {
1679
+ const all = pick(id);
1680
+ return { id, title: TITLES[id][0], why: TITLES[id][1], items: all.slice(0, limit), more: Math.max(0, all.length - limit) };
1681
+ }).filter((b) => b.items.length > 0);
1682
+ }
1683
+ function toPlanJson(r, limit) {
1684
+ return JSON.stringify({ schema: "fringe/plan.v1", score: r.score, counts: r.counts, batches: buildPlan(r, limit) }, null, 2);
1685
+ }
1686
+ function toPlanMarkdown(r, target, limit) {
1687
+ const batches = buildPlan(r, limit);
1688
+ const head = `# Fringe fix plan
1689
+
1690
+ health ${r.score}/100 \xB7 ${r.counts.error} errors \xB7 ${r.counts.review} review \xB7 ${r.counts.signal} signals`;
1691
+ if (batches.length === 0) return `${head}
1692
+
1693
+ Nothing to fix.`;
1694
+ const out = [head];
1695
+ for (const b of batches) {
1696
+ out.push(`
1697
+ ## ${b.id} \xB7 ${b.title}
1698
+ _${b.why}_`);
1699
+ const shown = /* @__PURE__ */ new Set();
1700
+ for (const c of b.items) {
1701
+ const tags = [c.rootCause === "shared component" ? `shared component, imported by ${c.importedBy} files` : "", c.critical ? "critical path" : "", `${c.fixability} fix`].filter(Boolean);
1702
+ out.push(`
1703
+ ### ${c.rule} \xB7 \xD7${c.count} \xB7 ${c.file} (${tags.join("; ")})`);
1704
+ out.push(`Lines: ${c.lines.slice(0, 8).join(", ")}${c.lines.length > 8 ? ", \u2026" : ""}`);
1705
+ if (c.recipe && !shown.has(c.rule)) {
1706
+ shown.add(c.rule);
1707
+ out.push(`Fix: ${c.recipe}${c.avoid ? `
1708
+ Avoid: ${c.avoid}` : ""}`);
1709
+ } else if (c.recipe) out.push(`Fix: as for ${c.rule} above (\`fringe explain ${c.rule}\`).`);
1710
+ }
1711
+ if (b.more) out.push(`
1712
+ \u2026and ${b.more} more in this batch (raise --limit).`);
1713
+ }
1714
+ out.push(`
1715
+ After each batch, verify: \`fringe check ${target}\``);
1716
+ return out.join("\n");
1717
+ }
1718
+
1229
1719
  // src/index.ts
1230
1720
  var log = (o) => o.log ?? ((s) => console.log(s));
1231
1721
  var err = (o) => o.err ?? ((s) => process.stderr.write(s));
@@ -1255,10 +1745,25 @@ function readManifest(path2, warn) {
1255
1745
  return void 0;
1256
1746
  }
1257
1747
  }
1748
+ function resolveTarget(arg) {
1749
+ if (!statSync(arg).isFile()) return { root: arg };
1750
+ let dir = dirname2(arg);
1751
+ 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"))) {
1753
+ dir = d;
1754
+ break;
1755
+ }
1756
+ if (dirname2(d) === d) break;
1757
+ }
1758
+ return { root: dir, only: /* @__PURE__ */ new Set([arg]) };
1759
+ }
1258
1760
  var weakEvidence = (e) => typeof e !== "string" || e.trim().length < 12 || /^(n\/?a|none|tbd|obvious|unnecessary|clutter|unused)\.?$/i.test(e.trim());
1259
1761
  var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
1260
1762
  fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
1261
1763
  fringe check --update-baseline [path] accept today's findings; only new ones gate
1764
+ fringe plan [dir|file] [--json] [--limit n] ordered fix batches with recipes
1765
+ fringe explain pattern [form] best-practice checklist for a component type
1766
+ fringe explain [FR-203] why a rule matters and how to fix it well
1262
1767
  fringe init [path] add the Claude Code hook + skill
1263
1768
  fringe open [path] [--port <n>] serve the .fringe ledger on 127.0.0.1
1264
1769
  fringe --help | --version`;
@@ -1290,7 +1795,7 @@ async function runCli(argv, opts = {}) {
1290
1795
  try {
1291
1796
  const paths = parseArgs(rest, err(opts));
1292
1797
  const cwd = opts.cwd ?? process.cwd();
1293
- const root = paths.length > 0 ? resolve(cwd, paths[0]) : cwd;
1798
+ const { root, only } = resolveTarget(paths.length > 0 ? resolve(cwd, paths[0]) : cwd);
1294
1799
  const cfg = loadConfig(root);
1295
1800
  if (rest.includes("--update-baseline")) {
1296
1801
  const all = await runCheck(root, cfg, false);
@@ -1298,7 +1803,7 @@ async function runCli(argv, opts = {}) {
1298
1803
  log(opts)(`fringe: baseline written \u2014 ${all.findings.length} known findings in fringe.baseline.json (commit it); only new ones gate from now on`);
1299
1804
  return 0;
1300
1805
  }
1301
- const result = await runCheck(root, cfg);
1806
+ const result = await runCheck(root, cfg, true, only);
1302
1807
  if (rest.includes("--claude-hook")) {
1303
1808
  const out = toClaudeHook(result, root);
1304
1809
  if (out) log(opts)(out);
@@ -1330,6 +1835,42 @@ async function runCli(argv, opts = {}) {
1330
1835
  return 2;
1331
1836
  }
1332
1837
  }
1838
+ if (cmd === "plan") {
1839
+ try {
1840
+ const li = rest.indexOf("--limit");
1841
+ const limit = li !== -1 ? Number(rest[li + 1]) || void 0 : void 0;
1842
+ const paths = parseArgs(rest.filter((a, i) => a !== "--limit" && rest[i - 1] !== "--limit" && a !== "--json"), err(opts));
1843
+ const arg = resolve(opts.cwd ?? process.cwd(), paths[0] ?? ".");
1844
+ const { root, only } = resolveTarget(arg);
1845
+ const result = await runCheck(root, loadConfig(root), true, only);
1846
+ log(opts)(rest.includes("--json") ? toPlanJson(result, limit) : toPlanMarkdown(result, paths[0] ?? ".", limit));
1847
+ return 0;
1848
+ } catch (e) {
1849
+ err(opts)(`fringe: internal error: ${e.message}
1850
+ `);
1851
+ return 2;
1852
+ }
1853
+ }
1854
+ if (cmd === "explain" && rest[0] === "pattern") {
1855
+ const out = renderPatterns(rest[1]);
1856
+ if (out === void 0) {
1857
+ err(opts)(`fringe: unknown pattern '${rest[1]}' \u2014 run 'fringe explain pattern' to list them
1858
+ `);
1859
+ return 2;
1860
+ }
1861
+ log(opts)(out);
1862
+ return 0;
1863
+ }
1864
+ if (cmd === "explain") {
1865
+ const out = renderExplain(rest[0]);
1866
+ if (out === void 0) {
1867
+ err(opts)(`fringe: unknown rule '${rest[0]}' \u2014 run 'fringe explain' to list them
1868
+ `);
1869
+ return 2;
1870
+ }
1871
+ log(opts)(out);
1872
+ return 0;
1873
+ }
1333
1874
  if (cmd === "init") {
1334
1875
  try {
1335
1876
  for (const line of initProject(resolve(opts.cwd ?? process.cwd(), rest[0] ?? "."))) log(opts)(`fringe: ${line}`);
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runCli
3
- } from "./chunk-WXASOIWD.js";
3
+ } from "./chunk-Z4UN7OMW.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.1.4",
3
+ "version": "0.2.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,8 +9,10 @@ Command: use `fringe` if it is on the PATH, otherwise `npx -y @fringetools/cli`
9
9
 
10
10
  Ponytail trims your code. Fringe handles your edges. Work the ladder in order.
11
11
 
12
+ 0. **Building a form, modal, navigation or data view?** Run `fringe explain pattern <form|modal|navigation|data-view>` first: a short best-practice checklist, each item marked verified (Fringe checks it) or judged (you decide and say why).
12
13
  1. **Flow (judged).** Before building, ask what the user can skip. Cut steps, screens or options nobody needs. Write each decision with its evidence to `.fringe/manifest.json`: `{"flows":[{"action":"cut"|"kept","what":"...","evidence":"..."}]}`. No evidence, no cut.
13
14
  2. **Edges (verified, gates).** After every edit the hook runs `fringe check --claude-hook` and feeds errors back to you. Fix every listed finding. If there is no hook, run `fringe check --hook` yourself from the project root and do not finish while `block` is `true`.
15
+ On a repo with many existing findings, run `fringe plan` and work it batch by batch (shared components first, since one fix clears many uses), re-running `fringe check` after each batch. `fringe explain <rule>` gives the recipe and what to avoid.
14
16
  3. **Polish (verified, never gates).** Run `fringe check` once before finishing. Fix `review` items you agree with. Fix `signal` items that are cheap. Say which you left and why.
15
17
 
16
18
  When done: `fringe check --record --manifest .fringe/manifest.json`. Report the health score as measured; never estimate it yourself.
@@ -7,7 +7,7 @@ description: Use when the user wants a whole-repo UX/accessibility audit, a plan
7
7
 
8
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 .`).
9
9
 
10
- 1. Run `fringe check --json .` and note the totals. Do not print every finding.
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:
12
12
  - Totals by severity and the health score, plus files scanned and skipped.
13
13
  - **Top rules** by count, and **top files** by error count (the 5 worst of each).
@@ -24,6 +24,8 @@ Fringe checks agent-written frontends for accessibility, state, dark-pattern and
24
24
  | `check [dir]` | Scan and print findings and the health score; exit 1 on any error |
25
25
  | `check --json` / `--hook` / `--claude-hook` | Machine output; CI gate; agent feedback form |
26
26
  | `check --update-baseline` | Accept today's findings; only new ones gate |
27
+ | `plan [dir\|file]` | Ordered fix batches (shared components first) with recipes |
28
+ | `explain [FR-203]` | Why a rule matters and how to fix it well |
27
29
  | `check --record [--manifest f]` | Append the run to `.fringe/ledger.jsonl` |
28
30
  | `init` | Add the hook and install the skills |
29
31
  | `open` | View the ledger at 127.0.0.1:4173 |
@@ -7,7 +7,7 @@ description: Use when the user asks to review, check or critique a UI, page or c
7
7
 
8
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 .`).
9
9
 
10
- 1. Run `fringe check --json .` from the project root. Fringe takes directories, not single files, so if the user named one file or page, run on the folder and keep only the findings whose `loc.file` matches it.
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:
12
12
  - **Must fix** — `error` findings. Group by rule. For each: where (`file:line`), what is wrong in one sentence, the exact fix, and who is hurt (for example "a screen reader says only 'button'"). Cite the `wcag` criterion when present.
13
13
  - **Worth a look** — `review` findings. These are heuristics: say they need your judgment, and give your call on each.