@fringetools/cli 0.2.0 → 0.2.2

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
@@ -23,7 +23,8 @@ Prefer a permanent command? `npm install -D @fringetools/cli`, then `npx fringe
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
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 29 rules. |
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*. |
27
28
  | `fringe check --json` | Full report as JSON, schema `fringe/check.v1` (stack, files, findings, counts, score). |
28
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. |
29
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`. |
@@ -118,6 +119,8 @@ After each edit the hook runs; with errors it returns, for example:
118
119
  {"decision":"block","reason":"Fringe found 2 accessibility error(s). Fix them before finishing:\n- FR-203 src/Signup.tsx:9 — input not associated with a label (add id + <label htmlFor>, or aria-label)\n…"}
119
120
  ```
120
121
 
122
+ The hook also keeps `.fringe/last.json` (the previous run's errors) and tells the agent what its **last edit broke**: "Your last edit introduced 1 new error(s) and fixed 2; 3 failing in total", with the new ones listed first and marked `NEW`. If that file is missing or unreadable it simply skips the comparison. It still blocks on every remaining error; on a legacy repo use the baseline so old debt is not nagged about.
123
+
121
124
  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.
122
125
 
123
126
  **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:*)`).
@@ -157,6 +160,12 @@ Severity `error` fails the run; `review` and `signal` are heuristics that need a
157
160
  | FR-213 | review | | dark pattern — infinite scroll without opt-out |
158
161
  | FR-214 | review | | dark pattern — bait-and-switch ("free", then a price) pricing |
159
162
  | FR-215 | review | | generic error copy ("something went wrong") with no recovery action — no retry, link, or contact |
163
+ | FR-216 | review | | form basics — email/phone field with the wrong `type`, password field with no `autocomplete` |
164
+ | FR-217 | review | | link text that says nothing out of context ("click here"), `href="#"` / `javascript:` links |
165
+ | 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) |
166
+ | FR-219 | review | | `@keyframes` animation in a stylesheet with no `prefers-reduced-motion` handling (judged per file) |
167
+ | FR-220 | review | 1.4.2 | `<video>`/`<audio>` that autoplays with sound |
168
+ | FR-221 | review | 1.4.13 | element that reacts to hover only (no focus, click or touch handler) |
160
169
 
161
170
  The dark-pattern taxonomy (FR-210–214) is adapted, with attribution, from [Design with Intent](https://designwithintent.ai)'s CC0 anti-pattern library.
162
171
 
@@ -180,6 +189,7 @@ The dark-pattern taxonomy (FR-210–214) is adapted, with attribution, from [Des
180
189
  | FR-367 | signal | | numbered section labels — "01 Discover" style kickers |
181
190
  | FR-368 | signal | | the purple AI gradient — violet/indigo Tailwind gradient or its CSS hex equivalent |
182
191
  | FR-369 | signal | | emoji in headings |
192
+ | FR-222 | signal | | lorem ipsum / TODO / `[insert …]` text left in the UI |
183
193
 
184
194
  **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.
185
195
 
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-ZK2GPUZF.js";
4
+ } from "./chunk-TNZKMKKQ.js";
5
5
 
6
6
  // src/bin.ts
7
7
  process.exitCode = await runCli(process.argv.slice(2));
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
2
  import { randomUUID } from "crypto";
3
- import { existsSync as existsSync3, readFileSync as readFileSync8, statSync } from "fs";
4
- import { dirname as dirname2, join as join8, resolve } from "path";
3
+ import { existsSync as existsSync3, readFileSync as readFileSync9, statSync } from "fs";
4
+ import { dirname as dirname2, join as join9, resolve } from "path";
5
5
 
6
6
  // src/version.ts
7
- var VERSION = "0.2.0";
7
+ var VERSION = "0.2.2";
8
8
 
9
9
  // src/check.ts
10
10
  import { readFileSync as readFileSync5 } from "fs";
@@ -144,7 +144,7 @@ function collectText(node) {
144
144
  }
145
145
  return t.trim();
146
146
  }
147
- function parseJsx(file, source) {
147
+ function parseJsx(file2, source) {
148
148
  let ast;
149
149
  try {
150
150
  ast = parse(source, { sourceType: "unambiguous", plugins: ["jsx", "typescript"], errorRecovery: false });
@@ -162,7 +162,7 @@ function parseJsx(file, source) {
162
162
  for (const a of node.openingElement.attributes) {
163
163
  if (a.type === "JSXAttribute") attrs[a.name.name] = attrValue(a.value);
164
164
  }
165
- const el = { tag: tag2, attrs, text: collectText(node), loc: { file, line: node.loc?.start.line ?? 0, col: node.loc?.start.column ?? 0 } };
165
+ const el = { tag: tag2, attrs, text: collectText(node), loc: { file: file2, line: node.loc?.start.line ?? 0, col: node.loc?.start.column ?? 0 } };
166
166
  if (parent) el.parentTag = parent.tag;
167
167
  if (parent?.inLabel) el.inLabel = true;
168
168
  if (parent?.attrs?.className !== void 0) el.parentClass = parent.attrs.className;
@@ -181,7 +181,7 @@ function parseJsx(file, source) {
181
181
  // src/parse/html.ts
182
182
  import { parse as parse2 } from "parse5";
183
183
  var textOf = (n) => n.nodeName === "#text" ? n.value : (n.childNodes ?? []).map(textOf).join("");
184
- function parseHtml(file, source) {
184
+ function parseHtml(file2, source) {
185
185
  const doc = parse2(source, { sourceCodeLocationInfo: true });
186
186
  const elems = [];
187
187
  (function walkNode(node, parent) {
@@ -189,7 +189,7 @@ function parseHtml(file, source) {
189
189
  const attrs = {};
190
190
  for (const a of node.attrs ?? []) attrs[a.name] = a.value;
191
191
  const text = textOf(node).trim();
192
- const el = { tag: node.tagName, attrs, text, loc: { file, line: node.sourceCodeLocation?.startLine ?? 0, col: node.sourceCodeLocation?.startCol ?? 0 } };
192
+ const el = { tag: node.tagName, attrs, text, loc: { file: file2, line: node.sourceCodeLocation?.startLine ?? 0, col: node.sourceCodeLocation?.startCol ?? 0 } };
193
193
  if (parent) el.parentTag = parent.tag;
194
194
  if (parent?.inLabel) el.inLabel = true;
195
195
  if (parent?.attrs?.class !== void 0) el.parentClass = parent.attrs.class;
@@ -204,12 +204,12 @@ function parseHtml(file, source) {
204
204
 
205
205
  // src/parse/css.ts
206
206
  import postcss from "postcss";
207
- function parseCss(file, source) {
207
+ function parseCss(file2, source) {
208
208
  const decls = [];
209
209
  const rec = (node, selector) => {
210
210
  for (const child of node.nodes ?? []) {
211
211
  if (child.type === "decl") {
212
- decls.push({ prop: child.prop, value: String(child.value), selector, loc: { file, line: child.source?.start?.line ?? 0, col: child.source?.start?.column ?? 0 } });
212
+ decls.push({ prop: child.prop, value: String(child.value), selector, loc: { file: file2, line: child.source?.start?.line ?? 0, col: child.source?.start?.column ?? 0 } });
213
213
  } else if (child.type === "rule" || child.type === "atrule") {
214
214
  rec(child, child.type === "rule" ? child.selector : selector);
215
215
  }
@@ -457,6 +457,113 @@ var fr215 = {
457
457
  }
458
458
  };
459
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
+
460
567
  // src/color.ts
461
568
  var clamp255 = (n) => Math.min(255, Math.max(0, Math.round(n)));
462
569
  function splitArgs(argStr) {
@@ -919,7 +1026,7 @@ var fr369 = {
919
1026
 
920
1027
  // src/rules/registry.ts
921
1028
  var ALL_RULES = [];
922
- 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);
923
1030
  function activeRules(cfg) {
924
1031
  return ALL_RULES.filter((r) => !cfg.disable.includes(r.id));
925
1032
  }
@@ -949,10 +1056,10 @@ function gatherTokenColors(root, cfg, cssSources) {
949
1056
  const kind = cfg.tokenSource?.kind;
950
1057
  if (kind === "design-tokens-json") {
951
1058
  const rel = cfg.tokenSource?.path ?? "";
952
- const file = isAbsolute(rel) ? rel : join4(root, rel);
1059
+ const file2 = isAbsolute(rel) ? rel : join4(root, rel);
953
1060
  let json;
954
1061
  try {
955
- json = JSON.parse(readFileSync3(file, "utf8"));
1062
+ json = JSON.parse(readFileSync3(file2, "utf8"));
956
1063
  } catch {
957
1064
  return out;
958
1065
  }
@@ -1111,6 +1218,46 @@ var GUIDANCE = {
1111
1218
  recipe: "Say what failed in plain words and give a next step: retry, a link, or a way to contact support.",
1112
1219
  fixability: "judgment"
1113
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
+ },
1114
1261
  "FR-301": {
1115
1262
  why: "Hardcoded colors drift from the design system and make theming painful.",
1116
1263
  recipe: "Use the project tokens (CSS variables or palette utilities) instead of literal hex values.",
@@ -1239,25 +1386,25 @@ async function runCheck(root, cfg = loadConfig(root), useBaseline = true, only)
1239
1386
  const skipped = [];
1240
1387
  const cssSources = [];
1241
1388
  const contexts = [];
1242
- for (const file of files) {
1389
+ for (const file2 of files) {
1243
1390
  let source = "";
1244
1391
  try {
1245
- source = readFileSync5(file, "utf8");
1392
+ source = readFileSync5(file2, "utf8");
1246
1393
  } catch {
1247
- skipped.push(file);
1394
+ skipped.push(file2);
1248
1395
  continue;
1249
1396
  }
1250
- const ctx = { file, source, elems: [], css: [], tokenColors: [] };
1397
+ const ctx = { file: file2, source, elems: [], css: [], tokenColors: [] };
1251
1398
  try {
1252
- if (/\.(jsx|tsx|js|ts)$/.test(file)) ctx.elems = parseJsx(file, source);
1253
- else if (/\.html$/.test(file)) ctx.elems = parseHtml(file, source);
1254
- else if (/\.(css|scss)$/.test(file)) {
1255
- ctx.css = parseCss(file, source);
1256
- cssSources.push({ file, source });
1399
+ if (/\.(jsx|tsx|js|ts)$/.test(file2)) ctx.elems = parseJsx(file2, source);
1400
+ else if (/\.html$/.test(file2)) ctx.elems = parseHtml(file2, source);
1401
+ else if (/\.(css|scss)$/.test(file2)) {
1402
+ ctx.css = parseCss(file2, source);
1403
+ cssSources.push({ file: file2, source });
1257
1404
  }
1258
1405
  } catch (e) {
1259
1406
  if (e.message.startsWith("parse-skip")) {
1260
- skipped.push(file);
1407
+ skipped.push(file2);
1261
1408
  continue;
1262
1409
  }
1263
1410
  throw e;
@@ -1307,10 +1454,14 @@ function toHook(r) {
1307
1454
  reason
1308
1455
  });
1309
1456
  }
1310
- function toClaudeHook(r, root) {
1311
- const errs = r.findings.filter((f) => f.severity === "error");
1312
- if (errs.length === 0) return "";
1313
- const shown = errs.slice(0, 8).map((f) => `- ${f.rule} ${relative4(root, f.loc.file)}:${f.loc.line} \u2014 ${f.message} (${f.hint})`);
1457
+ function toClaudeHook(r, root, history) {
1458
+ const all = r.findings.filter((f) => f.severity === "error");
1459
+ if (all.length === 0) return "";
1460
+ const fresh = new Set(history?.introduced);
1461
+ const errs = [...all.filter((f) => fresh.has(f)), ...all.filter((f) => !fresh.has(f))];
1462
+ const note = history && (history.introduced.length > 0 || history.fixed > 0) ? `Your last edit introduced ${history.introduced.length} new error(s)${history.fixed ? ` and fixed ${history.fixed}` : ""}; ${errs.length} failing in total. New ones are marked NEW.
1463
+ ` : "";
1464
+ const shown = errs.slice(0, 8).map((f) => `- ${fresh.has(f) ? "NEW " : ""}${f.rule} ${relative4(root, f.loc.file)}:${f.loc.line} \u2014 ${f.message} (${f.hint})`);
1314
1465
  const shownRules = [...new Set(errs.slice(0, 8).map((f) => f.rule))];
1315
1466
  const recipes = shownRules.map((id) => {
1316
1467
  const g = GUIDANCE[id];
@@ -1320,7 +1471,7 @@ function toClaudeHook(r, root) {
1320
1471
  \u2026and ${errs.length - shown.length} more (run \`fringe check\`).` : "";
1321
1472
  return JSON.stringify({
1322
1473
  decision: "block",
1323
- reason: `Fringe found ${errs.length} accessibility error(s). Fix them before finishing:
1474
+ reason: `${note}Fringe found ${errs.length} accessibility error(s). Fix them before finishing:
1324
1475
  ${shown.join("\n")}${more}
1325
1476
  How to fix them well:
1326
1477
  ${recipes.join("\n")}
@@ -1415,19 +1566,51 @@ function startBoard(root, port) {
1415
1566
  });
1416
1567
  }
1417
1568
 
1569
+ // src/receipts.ts
1570
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "fs";
1571
+ import { join as join7 } from "path";
1572
+ var file = (root) => join7(root, ".fringe", "last.json");
1573
+ function receipts(root, findings) {
1574
+ const errs = findings.filter((f) => f.severity === "error");
1575
+ const keys = errs.map((f) => keyOf(root, f));
1576
+ let prev;
1577
+ try {
1578
+ const p = JSON.parse(readFileSync7(file(root), "utf8"));
1579
+ if (Array.isArray(p)) prev = p;
1580
+ } catch {
1581
+ }
1582
+ try {
1583
+ mkdirSync2(join7(root, ".fringe"), { recursive: true });
1584
+ writeFileSync2(file(root), JSON.stringify(keys));
1585
+ } catch {
1586
+ }
1587
+ if (!prev) return void 0;
1588
+ const left = /* @__PURE__ */ new Map();
1589
+ for (const k of prev) left.set(k, (left.get(k) ?? 0) + 1);
1590
+ const introduced = errs.filter((f, i) => {
1591
+ const n = left.get(keys[i]) ?? 0;
1592
+ if (n > 0) {
1593
+ left.set(keys[i], n - 1);
1594
+ return false;
1595
+ }
1596
+ return true;
1597
+ });
1598
+ return { introduced, fixed: [...left.values()].reduce((a, b) => a + b, 0) };
1599
+ }
1600
+
1418
1601
  // src/init.ts
1419
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync7, readdirSync as readdirSync3, writeFileSync as writeFileSync2, copyFileSync } from "fs";
1420
- import { dirname, join as join7 } from "path";
1602
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync8, readdirSync as readdirSync3, writeFileSync as writeFileSync3, copyFileSync } from "fs";
1603
+ import { dirname, join as join8 } from "path";
1421
1604
  import { fileURLToPath } from "url";
1422
1605
  var HOOK = "npx -y @fringetools/cli check --claude-hook";
1423
1606
  var SKILL_DIR = fileURLToPath(new URL("../skill/", import.meta.url));
1424
1607
  function initProject(root) {
1425
1608
  const done = [];
1426
- const settingsPath = join7(root, ".claude", "settings.json");
1609
+ const settingsPath = join8(root, ".claude", "settings.json");
1427
1610
  let settings = {};
1428
1611
  if (existsSync2(settingsPath)) {
1429
1612
  try {
1430
- settings = JSON.parse(readFileSync7(settingsPath, "utf8"));
1613
+ settings = JSON.parse(readFileSync8(settingsPath, "utf8"));
1431
1614
  } catch {
1432
1615
  throw new Error(`${settingsPath} is not valid JSON \u2014 fix it and rerun`);
1433
1616
  }
@@ -1436,22 +1619,79 @@ function initProject(root) {
1436
1619
  if (/(fringe|@fringetools\/cli) check/.test(JSON.stringify(post))) done.push("hook already present");
1437
1620
  else {
1438
1621
  post.push({ matcher: "Edit|Write", hooks: [{ type: "command", command: HOOK }] });
1439
- mkdirSync2(dirname(settingsPath), { recursive: true });
1440
- writeFileSync2(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1622
+ mkdirSync3(dirname(settingsPath), { recursive: true });
1623
+ writeFileSync3(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1441
1624
  done.push(`hook added to ${settingsPath}`);
1442
1625
  }
1443
1626
  for (const name of readdirSync3(SKILL_DIR)) {
1444
- const skillPath = join7(root, ".claude", "skills", name, "SKILL.md");
1627
+ const skillPath = join8(root, ".claude", "skills", name, "SKILL.md");
1445
1628
  if (existsSync2(skillPath)) done.push(`skill ${name} already present`);
1446
1629
  else {
1447
- mkdirSync2(dirname(skillPath), { recursive: true });
1448
- copyFileSync(join7(SKILL_DIR, name, "SKILL.md"), skillPath);
1630
+ mkdirSync3(dirname(skillPath), { recursive: true });
1631
+ copyFileSync(join8(SKILL_DIR, name, "SKILL.md"), skillPath);
1449
1632
  done.push(`skill ${name} installed`);
1450
1633
  }
1451
1634
  }
1452
1635
  return done;
1453
1636
  }
1454
1637
 
1638
+ // src/patterns.ts
1639
+ var PATTERNS = {
1640
+ form: {
1641
+ title: "Form",
1642
+ items: [
1643
+ { text: "Every field has a visible <label> bound with htmlFor/id", rules: ["FR-203"] },
1644
+ { text: "Right input type and autocomplete (email, tel, current-password, new-password, name)", rules: ["FR-216"] },
1645
+ { text: "Required fields are marked in text, not by color alone" },
1646
+ { text: "Errors appear next to the field in words, linked with aria-describedby and aria-invalid; focus moves to the first error on submit" },
1647
+ { text: "The submit button shows a pending state and cannot be double-submitted" },
1648
+ { text: "Input is kept when an error occurs; validation runs on blur, not on every keystroke" }
1649
+ ]
1650
+ },
1651
+ modal: {
1652
+ title: "Modal dialog",
1653
+ items: [
1654
+ { text: 'role="dialog" with aria-modal="true" and a name (aria-labelledby the title)', rules: ["FR-209"] },
1655
+ { text: "Focus moves in on open, stays trapped inside, and returns to the trigger on close" },
1656
+ { text: "A visible Close button with an accessible name, and Escape closes", rules: ["FR-202"] },
1657
+ { text: "Clicking the backdrop may close it but is never the only way", rules: ["FR-206"] },
1658
+ { text: "The page behind is inert and does not scroll" }
1659
+ ]
1660
+ },
1661
+ navigation: {
1662
+ title: "Navigation",
1663
+ items: [
1664
+ { text: "Links use real hrefs and describe the destination", rules: ["FR-217"] },
1665
+ { text: "A <nav> landmark (labelled if there are several), one <main>, and a skip-to-content link" },
1666
+ { text: 'The current page is marked with aria-current="page"' },
1667
+ { text: "Menus open by keyboard and tap, not by hover alone", rules: ["FR-221"] },
1668
+ { text: "Focus is always visible", rules: ["FR-205"] },
1669
+ { text: "Tap targets are large enough", rules: ["FR-305"] }
1670
+ ]
1671
+ },
1672
+ "data-view": {
1673
+ title: "Data view (list, table, dashboard)",
1674
+ items: [
1675
+ { 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"] },
1676
+ { text: "Layout does not jump when data arrives (reserve the space)" },
1677
+ { text: "Async updates are announced (aria-live) when the user did not trigger them" },
1678
+ { text: "Table headers use <th> and a caption or accessible name" }
1679
+ ]
1680
+ }
1681
+ };
1682
+ function renderPatterns(name) {
1683
+ if (!name) return Object.entries(PATTERNS).map(([k, p2]) => `${k.padEnd(11)} ${p2.title}`).join("\n");
1684
+ const p = PATTERNS[name.toLowerCase()];
1685
+ if (!p) return void 0;
1686
+ const mark = (i) => i.rules ? `[verified: ${i.rules.join(", ")}]` : "[judged]";
1687
+ return [
1688
+ `${p.title}: build it like this`,
1689
+ ...p.items.map((i) => `- ${i.text} ${mark(i)}`),
1690
+ "",
1691
+ "verified = Fringe checks it; judged = you decide, so state your evidence in the final message."
1692
+ ].join("\n");
1693
+ }
1694
+
1455
1695
  // src/report/plan.ts
1456
1696
  var TITLES = {
1457
1697
  P0: ["Fix first", "errors in shared components or critical paths: one fix clears many uses"],
@@ -1518,7 +1758,7 @@ var err = (o) => o.err ?? ((s) => process.stderr.write(s));
1518
1758
  function readManifest(path2, warn) {
1519
1759
  let raw;
1520
1760
  try {
1521
- raw = readFileSync8(path2, "utf8");
1761
+ raw = readFileSync9(path2, "utf8");
1522
1762
  } catch {
1523
1763
  warn(`fringe: warning: manifest not found at ${path2} \u2014 continuing without
1524
1764
  `);
@@ -1545,7 +1785,7 @@ function resolveTarget(arg) {
1545
1785
  if (!statSync(arg).isFile()) return { root: arg };
1546
1786
  let dir = dirname2(arg);
1547
1787
  for (let d = dir, i = 0; i < 12; i++, d = dirname2(d)) {
1548
- if (existsSync3(join8(d, "package.json")) || existsSync3(join8(d, "fringe.config.json"))) {
1788
+ if (existsSync3(join9(d, "package.json")) || existsSync3(join9(d, "fringe.config.json"))) {
1549
1789
  dir = d;
1550
1790
  break;
1551
1791
  }
@@ -1558,6 +1798,7 @@ var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
1558
1798
  fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
1559
1799
  fringe check --update-baseline [path] accept today's findings; only new ones gate
1560
1800
  fringe plan [dir|file] [--json] [--limit n] ordered fix batches with recipes
1801
+ fringe explain pattern [form] best-practice checklist for a component type
1561
1802
  fringe explain [FR-203] why a rule matters and how to fix it well
1562
1803
  fringe init [path] add the Claude Code hook + skill
1563
1804
  fringe open [path] [--port <n>] serve the .fringe ledger on 127.0.0.1
@@ -1600,7 +1841,7 @@ async function runCli(argv, opts = {}) {
1600
1841
  }
1601
1842
  const result = await runCheck(root, cfg, true, only);
1602
1843
  if (rest.includes("--claude-hook")) {
1603
- const out = toClaudeHook(result, root);
1844
+ const out = toClaudeHook(result, root, receipts(root, result.findings));
1604
1845
  if (out) log(opts)(out);
1605
1846
  return 0;
1606
1847
  }
@@ -1646,6 +1887,16 @@ async function runCli(argv, opts = {}) {
1646
1887
  return 2;
1647
1888
  }
1648
1889
  }
1890
+ if (cmd === "explain" && rest[0] === "pattern") {
1891
+ const out = renderPatterns(rest[1]);
1892
+ if (out === void 0) {
1893
+ err(opts)(`fringe: unknown pattern '${rest[1]}' \u2014 run 'fringe explain pattern' to list them
1894
+ `);
1895
+ return 2;
1896
+ }
1897
+ log(opts)(out);
1898
+ return 0;
1899
+ }
1649
1900
  if (cmd === "explain") {
1650
1901
  const out = renderExplain(rest[0]);
1651
1902
  if (out === void 0) {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runCli
3
- } from "./chunk-ZK2GPUZF.js";
3
+ } from "./chunk-TNZKMKKQ.js";
4
4
  export {
5
5
  runCli
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fringetools/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@ 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`.
14
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.