@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 +11 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-ZK2GPUZF.js → chunk-TNZKMKKQ.js} +290 -39
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/skill/fringe/SKILL.md +1 -0
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
|
|
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,10 +1,10 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
|
-
import { existsSync as existsSync3, readFileSync as
|
|
4
|
-
import { dirname as dirname2, join as
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
1059
|
+
const file2 = isAbsolute(rel) ? rel : join4(root, rel);
|
|
953
1060
|
let json;
|
|
954
1061
|
try {
|
|
955
|
-
json = JSON.parse(readFileSync3(
|
|
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
|
|
1389
|
+
for (const file2 of files) {
|
|
1243
1390
|
let source = "";
|
|
1244
1391
|
try {
|
|
1245
|
-
source = readFileSync5(
|
|
1392
|
+
source = readFileSync5(file2, "utf8");
|
|
1246
1393
|
} catch {
|
|
1247
|
-
skipped.push(
|
|
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(
|
|
1253
|
-
else if (/\.html$/.test(
|
|
1254
|
-
else if (/\.(css|scss)$/.test(
|
|
1255
|
-
ctx.css = parseCss(
|
|
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(
|
|
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
|
|
1312
|
-
if (
|
|
1313
|
-
const
|
|
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:
|
|
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
|
|
1420
|
-
import { dirname, join as
|
|
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 =
|
|
1609
|
+
const settingsPath = join8(root, ".claude", "settings.json");
|
|
1427
1610
|
let settings = {};
|
|
1428
1611
|
if (existsSync2(settingsPath)) {
|
|
1429
1612
|
try {
|
|
1430
|
-
settings = JSON.parse(
|
|
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
|
-
|
|
1440
|
-
|
|
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 =
|
|
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
|
-
|
|
1448
|
-
copyFileSync(
|
|
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 =
|
|
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(
|
|
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
package/package.json
CHANGED
package/skill/fringe/SKILL.md
CHANGED
|
@@ -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.
|