@fringetools/cli 0.2.0 → 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 +9 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-ZK2GPUZF.js → chunk-Z4UN7OMW.js} +217 -2
- 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`. |
|
|
@@ -157,6 +158,12 @@ Severity `error` fails the run; `review` and `signal` are heuristics that need a
|
|
|
157
158
|
| FR-213 | review | | dark pattern — infinite scroll without opt-out |
|
|
158
159
|
| FR-214 | review | | dark pattern — bait-and-switch ("free", then a price) pricing |
|
|
159
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) |
|
|
160
167
|
|
|
161
168
|
The dark-pattern taxonomy (FR-210–214) is adapted, with attribution, from [Design with Intent](https://designwithintent.ai)'s CC0 anti-pattern library.
|
|
162
169
|
|
|
@@ -180,6 +187,7 @@ The dark-pattern taxonomy (FR-210–214) is adapted, with attribution, from [Des
|
|
|
180
187
|
| FR-367 | signal | | numbered section labels — "01 Discover" style kickers |
|
|
181
188
|
| FR-368 | signal | | the purple AI gradient — violet/indigo Tailwind gradient or its CSS hex equivalent |
|
|
182
189
|
| FR-369 | signal | | emoji in headings |
|
|
190
|
+
| FR-222 | signal | | lorem ipsum / TODO / `[insert …]` text left in the UI |
|
|
183
191
|
|
|
184
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.
|
|
185
193
|
|
package/dist/bin.js
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync8, statSync } fr
|
|
|
4
4
|
import { dirname as dirname2, join as join8, resolve } from "path";
|
|
5
5
|
|
|
6
6
|
// src/version.ts
|
|
7
|
-
var VERSION = "0.2.
|
|
7
|
+
var VERSION = "0.2.1";
|
|
8
8
|
|
|
9
9
|
// src/check.ts
|
|
10
10
|
import { readFileSync as readFileSync5 } from "fs";
|
|
@@ -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
|
}
|
|
@@ -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.",
|
|
@@ -1452,6 +1599,63 @@ function initProject(root) {
|
|
|
1452
1599
|
return done;
|
|
1453
1600
|
}
|
|
1454
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
|
+
|
|
1455
1659
|
// src/report/plan.ts
|
|
1456
1660
|
var TITLES = {
|
|
1457
1661
|
P0: ["Fix first", "errors in shared components or critical paths: one fix clears many uses"],
|
|
@@ -1558,6 +1762,7 @@ var HELP = `fringe check [--json] [--hook] [--record] [--manifest <path>] [path]
|
|
|
1558
1762
|
fringe check --claude-hook [path] for Claude Code hooks (feeds errors back to the agent)
|
|
1559
1763
|
fringe check --update-baseline [path] accept today's findings; only new ones gate
|
|
1560
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
|
|
1561
1766
|
fringe explain [FR-203] why a rule matters and how to fix it well
|
|
1562
1767
|
fringe init [path] add the Claude Code hook + skill
|
|
1563
1768
|
fringe open [path] [--port <n>] serve the .fringe ledger on 127.0.0.1
|
|
@@ -1646,6 +1851,16 @@ async function runCli(argv, opts = {}) {
|
|
|
1646
1851
|
return 2;
|
|
1647
1852
|
}
|
|
1648
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
|
+
}
|
|
1649
1864
|
if (cmd === "explain") {
|
|
1650
1865
|
const out = renderExplain(rest[0]);
|
|
1651
1866
|
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.
|