trykeyline 0.0.0-stage → 0.1.0

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.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/LICENSE +37 -0
  3. package/README.md +117 -2
  4. package/bin/keyline.mjs +280 -0
  5. package/package.json +38 -4
  6. package/plugins/keyline/.claude-plugin/plugin.json +14 -0
  7. package/plugins/keyline/skills/keyline/SKILL.md +57 -0
  8. package/plugins/keyline/skills/keyline/commands/audit.md +25 -0
  9. package/plugins/keyline/skills/keyline/commands/color.md +7 -0
  10. package/plugins/keyline/skills/keyline/commands/critique.md +43 -0
  11. package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
  12. package/plugins/keyline/skills/keyline/commands/init.md +15 -0
  13. package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
  14. package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
  15. package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
  16. package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
  17. package/plugins/keyline/skills/keyline/commands/refine.md +29 -0
  18. package/plugins/keyline/skills/keyline/commands/sync.md +17 -0
  19. package/plugins/keyline/skills/keyline/commands/type.md +7 -0
  20. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
  21. package/plugins/keyline/skills/keyline/domains/color/guide.md +243 -0
  22. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
  23. package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
  24. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +191 -0
  25. package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
  26. package/plugins/keyline/skills/keyline/domains/depth/guide.md +255 -0
  27. package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
  28. package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
  29. package/plugins/keyline/skills/keyline/domains/layout/guide.md +284 -0
  30. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
  31. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
  32. package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
  33. package/plugins/keyline/skills/keyline/domains/motion/guide.md +234 -0
  34. package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
  35. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
  36. package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
  37. package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
  38. package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
  39. package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
  40. package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
  41. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +216 -0
  42. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +78 -0
  43. package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
  44. package/plugins/keyline/skills/keyline/domains/type/guide.md +251 -0
  45. package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
  46. package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
  47. package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
  48. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +90 -0
  49. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +319 -0
  50. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
  51. package/plugins/keyline/skills/keyline/glass/color/guide.md +222 -0
  52. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
  53. package/plugins/keyline/skills/keyline/glass/color/theming.md +112 -0
  54. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
  55. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
  56. package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
  57. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
  58. package/plugins/keyline/skills/keyline/glass/depth/guide.md +216 -0
  59. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
  60. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
  61. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
  62. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +199 -0
  63. package/plugins/keyline/skills/keyline/glass/layout/guide.md +258 -0
  64. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
  65. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +102 -0
  66. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
  67. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
  68. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
  69. package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
  70. package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
  71. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
  72. package/plugins/keyline/skills/keyline/glass/motion/guide.md +286 -0
  73. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
  74. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
  75. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
  76. package/plugins/keyline/skills/keyline/glass/motion/sound.md +192 -0
  77. package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
  78. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
  79. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +414 -0
  80. package/plugins/keyline/skills/keyline/glass/type/guide.md +222 -0
  81. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
  82. package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
  83. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
  84. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
  85. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
  86. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
  87. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
  88. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
  89. package/plugins/keyline/skills/keyline/scripts/check.mjs +294 -0
  90. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
  91. package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
  92. package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
  93. package/plugins/keyline/skills/keyline/system/detection.md +53 -0
  94. package/plugins/keyline/skills/keyline/system/guide.md +101 -0
  95. package/plugins/keyline/skills/keyline/system/presets.md +58 -0
  96. package/plugins/keyline/skills/keyline/system/roles.md +150 -0
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Keyline's detector: the laws that can be checked from source text, with no
4
+ * model and no API key.
5
+ *
6
+ * node check.mjs [paths…] human report; exit 1 on any error
7
+ * node check.mjs src --json every finding, for CI or an agent
8
+ * node check.mjs --strict warnings fail the run too
9
+ * node check.mjs --ignore src/content --rule motion/transition-all
10
+ *
11
+ * It reads DESIGN.md at the root for the preset and the motion and z-index
12
+ * ladders, so a verdict follows the project's own decisions. Silence one line
13
+ * with a `keyline-ignore` comment on it or up to two lines above (optionally
14
+ * naming rules, then a reason: `keyline-ignore type/uppercase: a logo`), or a
15
+ * whole file with
16
+ * `keyline-ignore-file`.
17
+ *
18
+ * Exit codes: 0 clean (or only warnings and info), 1 findings that fail the
19
+ * run, 2 a usage error.
20
+ */
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { join, resolve } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { collect } from "./check/files.mjs";
25
+ import { lineIndex, parseCss } from "./check/css.mjs";
26
+ import { readMarkup } from "./check/markup.mjs";
27
+ import { readDesign } from "./check/design.mjs";
28
+ import { DOMAINS, RULES } from "./check/rules.mjs";
29
+
30
+ const USAGE = `keyline check — the interface laws that can be checked without a model.
31
+
32
+ npx trykeyline check [paths…] [options]
33
+ node <skill>/scripts/check.mjs [paths…] [options] (the same, from an installed skill)
34
+
35
+ Options:
36
+ --json every finding as JSON
37
+ --strict fail on warnings as well as errors
38
+ --quiet errors only
39
+ --all don't cap repeated findings in the report
40
+ --ignore <path> leave out a path (repeatable), e.g. prose or fixtures
41
+ --rule <id[,id…]> run only these rules (repeatable)
42
+ --root <dir> the project: paths are read from here, and DESIGN.md and
43
+ package.json found here (default: .)
44
+ --rules list the rules and exit
45
+ -h, --help this`;
46
+
47
+ const MOTION_IMPORT = /from\s+["'](?:motion(?:\/[\w-]+)?|framer-motion|gsap|@react-spring\/[\w-]+|react-spring|animejs)["']/;
48
+ const ROOT_LAYOUTS = [
49
+ "app/layout.tsx", "app/layout.jsx", "app/layout.js", "src/app/layout.tsx", "src/app/layout.jsx", "src/app/layout.js",
50
+ "pages/_document.tsx", "pages/_document.jsx", "src/pages/_document.tsx", "pages/_app.tsx", "src/pages/_app.tsx",
51
+ "index.html", "src/index.html", "src/app.html", "src/layouts/Layout.astro", "app.vue", "src/App.vue",
52
+ ];
53
+
54
+ /** Gather everything the rules look at, across every file. */
55
+ export function gather(root, files) {
56
+ const facts = {
57
+ decls: [], atRules: [], classLists: [], classes: [], labels: [], navs: [], zoomLocks: [], jsDurations: [],
58
+ project: { focusVisible: false, focusWithin: false, backdropSupports: false, reducedTransparency: false, reducedMotion: false, animation: null, selfHostedFonts: null, lightningCss: false, rootLayout: null, skipLink: false },
59
+ sources: new Map(),
60
+ };
61
+ const p = facts.project;
62
+ const noteAnimation = (loc) => (p.animation ??= loc);
63
+
64
+ for (const f of files) {
65
+ let source;
66
+ try {
67
+ source = readFileSync(f.abs, "utf8");
68
+ } catch {
69
+ continue;
70
+ }
71
+ if (/keyline-ignore-file/.test(source)) continue;
72
+ facts.sources.set(f.rel, source.split("\n"));
73
+ const sheets = [];
74
+
75
+ if (f.kind === "style") {
76
+ sheets.push(parseCss(source));
77
+ } else {
78
+ const m = readMarkup(source, f.ext);
79
+ sheets.push(...m.css);
80
+ facts.decls.push(...m.inline.map((d) => ({ ...d, file: f.rel })));
81
+ for (const list of m.classLists) {
82
+ facts.classLists.push({ ...list, file: f.rel });
83
+ for (const t of list.tokens) facts.classes.push({ ...t, file: f.rel });
84
+ }
85
+ facts.labels.push(...m.labels.map((l) => ({ ...l, file: f.rel })));
86
+ facts.navs.push(...m.navs.map((n) => ({ ...n, file: f.rel })));
87
+ const at = lineIndex(m.text);
88
+ // A zoom lock lives in a viewport value: a <meta name="viewport"> tag, a string
89
+ // that also sets width=device-width, or a viewport object's maximumScale /
90
+ // userScalable. The phrase alone in prose or docs ("never maximum-scale=1") isn't one.
91
+ const LOCK = /maximum-scale\s*=\s*1(?:\.0)?\b|user-scalable\s*=\s*(?:no|0)\b/g;
92
+ const VIEWPORT_VALUE = /<meta\b[^>]*\bviewport\b[^>]*>|["'`][^"'`\n<>]*width\s*=\s*device-width[^"'`\n<>]*["'`]/gi;
93
+ for (const v of m.text.matchAll(VIEWPORT_VALUE)) for (const z of v[0].matchAll(LOCK)) facts.zoomLocks.push({ file: f.rel, line: at(v.index + z.index) });
94
+ for (const z of m.text.matchAll(/\bmaximumScale\s*:\s*1\b|\buserScalable\s*:\s*false\b/g)) facts.zoomLocks.push({ file: f.rel, line: at(z.index) });
95
+ if (MOTION_IMPORT.test(m.text)) {
96
+ const imp = m.text.match(MOTION_IMPORT);
97
+ noteAnimation({ file: f.rel, line: at(imp.index) });
98
+ for (const d of m.text.matchAll(/\bduration\s*:\s*(\d*\.?\d+)\b/g)) {
99
+ // A spring's duration is a settling time set by its character, not a tier.
100
+ const open = m.text.lastIndexOf("{", d.index);
101
+ const close = m.text.indexOf("}", d.index);
102
+ if (/type\s*:\s*["']spring|\bbounce\s*:|\bstiffness\s*:/.test(m.text.slice(open, close))) continue;
103
+ const v = parseFloat(d[1]);
104
+ facts.jsDurations.push({ file: f.rel, line: at(d.index), ms: Math.round(v < 20 ? v * 1000 : v) });
105
+ }
106
+ }
107
+ if (/next\/font\/local|@fontsource/.test(m.text)) p.selfHostedFonts ??= { file: f.rel, line: 1 };
108
+ if (/href=\{?["']#(main|content|main-content)["']|>\s*Skip to\b|\bskip-link\b|\bskip-to-content\b/i.test(m.text)) p.skipLink = true;
109
+ }
110
+
111
+ for (const s of sheets) {
112
+ facts.decls.push(...s.decls.map((d) => ({ ...d, file: f.rel })));
113
+ facts.atRules.push(...s.atRules.map((a) => ({ ...a, file: f.rel })));
114
+ for (const a of s.atRules) {
115
+ if (/^@supports.*backdrop-filter/i.test(a.prelude)) p.backdropSupports = true;
116
+ if (/^@(-webkit-)?keyframes/i.test(a.prelude)) noteAnimation({ file: f.rel, line: a.line });
117
+ if (/^@font-face/i.test(a.prelude)) p.selfHostedFonts ??= { file: f.rel, line: a.line };
118
+ if (/^@import\s+["']tailwindcss/.test(a.prelude)) p.lightningCss = true;
119
+ }
120
+ for (const d of s.decls) {
121
+ if (/:focus-visible/.test(d.selector)) p.focusVisible = true;
122
+ if (/^(transition|transition-property|animation|animation-name)$/.test(d.prop) && !/^(none|0s?)$/.test(d.value.trim())) noteAnimation({ file: f.rel, line: d.line });
123
+ }
124
+ }
125
+
126
+ if (/prefers-reduced-motion|useReducedMotion|reducedMotion|motion-reduce:|motion-safe:/.test(source)) p.reducedMotion = true;
127
+ if (/prefers-reduced-transparency/.test(source)) p.reducedTransparency = true;
128
+ if (/supports-\[backdrop-filter|supports-backdrop|CSS\.supports\(\s*["']backdrop-filter/.test(source)) p.backdropSupports = true;
129
+ if (/:focus-visible|focus-visible:/.test(source)) p.focusVisible = true;
130
+ if (/:focus-within|focus-within:/.test(source)) p.focusWithin = true;
131
+ }
132
+
133
+ for (const c of facts.classes) {
134
+ if (/^(transition|animate-|duration-)/.test(c.base) && c.base !== "animate-none" && c.base !== "transition-none") {
135
+ noteAnimation({ file: c.file, line: c.line });
136
+ break;
137
+ }
138
+ }
139
+
140
+ // Tailwind v4 compiles through Lightning CSS; so does anything that depends on it directly.
141
+ try {
142
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
143
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
144
+ if (deps.lightningcss || /^[\^~]?4/.test(deps.tailwindcss ?? "") || deps["@tailwindcss/postcss"] || deps["@tailwindcss/vite"]) p.lightningCss = true;
145
+ } catch {}
146
+ p.rootLayout = ROOT_LAYOUTS.find((l) => existsSync(join(root, l))) ?? null;
147
+ return facts;
148
+ }
149
+
150
+ /** Whether a finding is silenced by a keyline-ignore comment on its line or up to two above. */
151
+ function suppressed(facts, f) {
152
+ const lines = facts.sources.get(f.file);
153
+ if (!lines || !f.line) return false;
154
+ for (const l of [lines[f.line - 1], lines[f.line - 2], lines[f.line - 3]]) {
155
+ const m = l?.match(/keyline-ignore(?!-file)((?:[\s,]+[a-z]+(?:\/[a-z-]+)?)*)/);
156
+ if (!m) continue;
157
+ // Rule ids or domains may follow; anything after them (": because…") is the reason.
158
+ const named = m[1].split(/[\s,]+/).filter((r) => r.includes("/") || DOMAINS.includes(r));
159
+ if (named.length === 0 || named.some((r) => r === f.rule || r === f.domain)) return true;
160
+ }
161
+ return false;
162
+ }
163
+
164
+ export function check({ root = ".", paths = ["."], ignores = [], rules = null } = {}) {
165
+ const absRoot = resolve(root);
166
+ const files = collect(absRoot, paths.map((p) => resolve(absRoot, p)), ignores);
167
+ // DESIGN.md lives at the project root. Checking one folder with no --root, look in it too.
168
+ let design = readDesign(absRoot);
169
+ if (!design.found && paths.length === 1 && existsSync(resolve(absRoot, paths[0], "DESIGN.md"))) design = readDesign(resolve(absRoot, paths[0]));
170
+ const facts = gather(absRoot, files);
171
+ const findings = [];
172
+ for (const rule of RULES) {
173
+ if (rules && !rules.some((r) => r === rule.id || r === rule.domain)) continue;
174
+ for (const f of rule.check(facts, { design })) {
175
+ const finding = { rule: rule.id, domain: rule.domain, law: rule.law, severity: f.severity, file: f.file, line: f.line, message: f.message };
176
+ if (!suppressed(facts, finding)) findings.push(finding);
177
+ }
178
+ }
179
+ const order = { error: 0, warning: 1, info: 2 };
180
+ findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || order[a.severity] - order[b.severity]);
181
+ return { files: files.length, design, findings, rules };
182
+ }
183
+
184
+ /* ── Report ─────────────────────────────────────────────────────────────── */
185
+
186
+ function summarise(findings) {
187
+ const bySeverity = { error: 0, warning: 0, info: 0 };
188
+ const byDomain = Object.fromEntries(DOMAINS.map((d) => [d, 0]));
189
+ for (const f of findings) {
190
+ bySeverity[f.severity]++;
191
+ byDomain[f.domain]++;
192
+ }
193
+ return { ...bySeverity, byDomain };
194
+ }
195
+
196
+ function human(result, { all, color }) {
197
+ const paint = (code, s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
198
+ const sev = { error: (s) => paint("31", s), warning: (s) => paint("33", s), info: (s) => paint("36", s) };
199
+ const dim = (s) => paint("2", s);
200
+ const out = [];
201
+ const d = result.design;
202
+ out.push(`Keyline check — ${result.files} files${d.found ? `, preset ${d.preset ?? "not set"} (${d.file})` : ", no DESIGN.md (run /keyline init for verdicts that follow your system)"}`);
203
+ out.push("");
204
+
205
+ const shown = new Map();
206
+ const hidden = new Map();
207
+ let file = null;
208
+ for (const f of result.findings) {
209
+ const n = (shown.get(f.rule) ?? 0) + 1;
210
+ if (!all && n > 8) {
211
+ hidden.set(f.rule, (hidden.get(f.rule) ?? 0) + 1);
212
+ continue;
213
+ }
214
+ shown.set(f.rule, n);
215
+ if (f.file !== file) {
216
+ if (file) out.push("");
217
+ out.push(paint("1", f.file));
218
+ file = f.file;
219
+ }
220
+ out.push(` ${String(f.line).padStart(4)} ${sev[f.severity](f.severity.padEnd(7))} ${f.rule}`);
221
+ out.push(` ${f.message} ${dim(`(${f.law})`)}`);
222
+ }
223
+ if (hidden.size) {
224
+ out.push("");
225
+ for (const [rule, n] of hidden) out.push(dim(` …${n} more ${rule} — run with --all to see them`));
226
+ }
227
+
228
+ const s = summarise(result.findings);
229
+ out.push("");
230
+ out.push(DOMAINS.map((dm) => `${dm} ${s.byDomain[dm]}`).join(dim(" · ")));
231
+ const total = result.findings.length;
232
+ const scope = result.rules ? ` in ${result.rules.join(", ")}` : "";
233
+ out.push(total === 0 ? paint("32", `✓ nothing the detector can see${scope} — run /keyline critique for what it can't`) : `${s.error ? sev.error(`✖ ${s.error} error${s.error === 1 ? "" : "s"}`) : "✓ no errors"}, ${s.warning} warning${s.warning === 1 ? "" : "s"}, ${s.info} info`);
234
+ return out.join("\n");
235
+ }
236
+
237
+ export function run(argv) {
238
+ const opts = { json: false, strict: false, quiet: false, all: false, ignores: [], rules: null, root: ".", paths: [] };
239
+ for (let i = 0; i < argv.length; i++) {
240
+ const a = argv[i];
241
+ const value = () => (a.includes("=") ? a.slice(a.indexOf("=") + 1) : argv[++i]);
242
+ if (a === "--json") opts.json = true;
243
+ else if (a === "--strict") opts.strict = true;
244
+ else if (a === "--quiet") opts.quiet = true;
245
+ else if (a === "--all") opts.all = true;
246
+ else if (a === "--ignore" || a.startsWith("--ignore=")) opts.ignores.push(value());
247
+ else if (a === "--rule" || a.startsWith("--rule=")) opts.rules = [...(opts.rules ?? []), ...value().split(",").map((s) => s.trim()).filter(Boolean)];
248
+ else if (a === "--root" || a.startsWith("--root=")) opts.root = value();
249
+ else if (a === "--rules") {
250
+ for (const r of RULES) console.log(`${r.id.padEnd(28)} ${r.title} (${r.law})`);
251
+ return 0;
252
+ } else if (a === "-h" || a === "--help") {
253
+ console.log(USAGE);
254
+ return 0;
255
+ } else if (a.startsWith("-")) {
256
+ console.error(`keyline check: unknown option ${a} (try --help)`);
257
+ return 2;
258
+ } else opts.paths.push(a);
259
+ }
260
+ if (opts.rules) {
261
+ const known = new Set([...RULES.map((r) => r.id), ...DOMAINS]);
262
+ const bad = opts.rules.filter((r) => !known.has(r));
263
+ if (bad.length) {
264
+ console.error(`keyline check: no rule called ${bad.join(", ")} (try --rules)`);
265
+ return 2;
266
+ }
267
+ }
268
+ if (!existsSync(resolve(opts.root))) {
269
+ console.error(`keyline check: no such directory: ${opts.root}`);
270
+ return 2;
271
+ }
272
+ for (const p of opts.paths) {
273
+ if (!existsSync(resolve(opts.root, p))) {
274
+ console.error(`keyline check: no such path: ${p}`);
275
+ return 2;
276
+ }
277
+ }
278
+
279
+ const result = check({ root: opts.root, paths: opts.paths.length ? opts.paths : ["."], ignores: opts.ignores, rules: opts.rules });
280
+ if (opts.quiet) result.findings = result.findings.filter((f) => f.severity === "error");
281
+ const s = summarise(result.findings);
282
+
283
+ if (opts.json) {
284
+ const design = { found: result.design.found, file: result.design.file ?? null, preset: result.design.preset, durations: result.design.durations ? [...result.design.durations] : null, zLadder: result.design.zLadder ? [...result.design.zLadder] : null };
285
+ console.log(JSON.stringify({ files: result.files, design, summary: s, findings: result.findings }, null, 2));
286
+ } else {
287
+ console.log(human(result, { all: opts.all, color: process.stdout.isTTY && !process.env.NO_COLOR }));
288
+ }
289
+ return s.error > 0 || (opts.strict && s.warning > 0) ? 1 : 0;
290
+ }
291
+
292
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
293
+ process.exitCode = run(process.argv.slice(2));
294
+ }
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WCAG 2 contrast ratios, with no dependencies.
4
+ *
5
+ * node contrast.mjs <fg> <bg> one pair
6
+ * node contrast.mjs --ladder <bg> <fg> <fg> … a text ladder, top tier first
7
+ * node contrast.mjs --min 3 <fg> <bg> a different floor (icons, borders, focus)
8
+ * node contrast.mjs --over <backdrop> <fg> <bg> a translucent background, over what's behind it
9
+ *
10
+ * Colours: #rgb, #rgba, #rrggbb, #rrggbbaa, rgb()/rgba(), hsl()/hsla(),
11
+ * oklch(). A translucent foreground is composited over the background first —
12
+ * which is the number that matters, since that's the colour the reader sees.
13
+ * A translucent background (a glass header, a hover wash, a status tint) needs
14
+ * --over with the opaque colour behind it; pass the worst one it can sit over.
15
+ *
16
+ * --ladder checks the two things a text ladder has to do at once: every tier
17
+ * clears the floor (4.5:1 unless --min says otherwise), and the tiers stay
18
+ * strictly descending. Passing the first while failing the second is the common
19
+ * failure — every tier readable, and no hierarchy left between them.
20
+ */
21
+
22
+ function die(message) {
23
+ console.error(`contrast: ${message}`);
24
+ process.exit(1);
25
+ }
26
+
27
+ function parse(input) {
28
+ const s = input.trim().toLowerCase();
29
+
30
+ let m = s.match(/^#([0-9a-f]{3,8})$/);
31
+ if (m) {
32
+ let h = m[1];
33
+ if (h.length === 3 || h.length === 4) h = [...h].map((c) => c + c).join("");
34
+ if (h.length !== 6 && h.length !== 8) die(`can't read ${input}`);
35
+ const n = (i) => parseInt(h.slice(i, i + 2), 16);
36
+ return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? n(6) / 255 : 1 };
37
+ }
38
+
39
+ m = s.match(/^(rgba?|hsla?|oklch)\((.*)\)$/);
40
+ if (!m) die(`can't read ${input} — use hex, rgb(), hsl() or oklch()`);
41
+ const parts = m[2].split(/[\s,/]+/).filter(Boolean);
42
+ const alpha = (v) => (v === undefined ? 1 : v.endsWith("%") ? parseFloat(v) / 100 : parseFloat(v));
43
+
44
+ if (m[1].startsWith("rgb")) {
45
+ const ch = (v) => (v.endsWith("%") ? (parseFloat(v) / 100) * 255 : parseFloat(v));
46
+ return { r: ch(parts[0]), g: ch(parts[1]), b: ch(parts[2]), a: alpha(parts[3]) };
47
+ }
48
+
49
+ if (m[1].startsWith("hsl")) {
50
+ const h = ((parseFloat(parts[0]) % 360) + 360) % 360;
51
+ const sat = parseFloat(parts[1]) / 100;
52
+ const l = parseFloat(parts[2]) / 100;
53
+ const k = (n) => (n + h / 30) % 12;
54
+ const a = sat * Math.min(l, 1 - l);
55
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
56
+ return { r: f(0) * 255, g: f(8) * 255, b: f(4) * 255, a: alpha(parts[3]) };
57
+ }
58
+
59
+ // oklch(L C H) → OKLab → linear sRGB → sRGB, clamped into gamut.
60
+ const L = parts[0].endsWith("%") ? parseFloat(parts[0]) / 100 : parseFloat(parts[0]);
61
+ const C = parseFloat(parts[1]);
62
+ const H = (parseFloat(parts[2]) * Math.PI) / 180;
63
+ const A = C * Math.cos(H);
64
+ const B = C * Math.sin(H);
65
+ const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
66
+ const mm = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
67
+ const sq = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
68
+ const lin = [
69
+ 4.0767416621 * l - 3.3077115913 * mm + 0.2309699292 * sq,
70
+ -1.2684380046 * l + 2.6097574011 * mm - 0.3413193965 * sq,
71
+ -0.0041960863 * l - 0.7034186147 * mm + 1.707614701 * sq,
72
+ ];
73
+ const enc = (v) => {
74
+ const c = Math.min(1, Math.max(0, v));
75
+ return (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055) * 255;
76
+ };
77
+ return { r: enc(lin[0]), g: enc(lin[1]), b: enc(lin[2]), a: alpha(parts[3]) };
78
+ }
79
+
80
+ /** A translucent colour as the reader sees it, over an opaque background. */
81
+ function over(fg, bg) {
82
+ return {
83
+ r: fg.r * fg.a + bg.r * (1 - fg.a),
84
+ g: fg.g * fg.a + bg.g * (1 - fg.a),
85
+ b: fg.b * fg.a + bg.b * (1 - fg.a),
86
+ a: 1,
87
+ };
88
+ }
89
+
90
+ function luminance({ r, g, b }) {
91
+ const lin = (v) => {
92
+ const c = v / 255;
93
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
94
+ };
95
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
96
+ }
97
+
98
+ let backdrop = null;
99
+
100
+ function ratio(fgInput, bgInput) {
101
+ let bg = parse(bgInput);
102
+ if (bg.a < 1) {
103
+ if (!backdrop) die(`the background ${bgInput} is translucent — add --over <the opaque colour behind it>`);
104
+ bg = over(bg, backdrop);
105
+ }
106
+ const fg = over(parse(fgInput), bg);
107
+ const [hi, lo] = [luminance(fg), luminance(bg)].sort((a, b) => b - a);
108
+ return (hi + 0.05) / (lo + 0.05);
109
+ }
110
+
111
+ const fmt = (n) => `${n.toFixed(2)}:1`;
112
+
113
+ const argv = process.argv.slice(2);
114
+ let floor = 4.5;
115
+ let ladder = false;
116
+ const rest = [];
117
+ for (let i = 0; i < argv.length; i++) {
118
+ if (argv[i] === "--min") floor = parseFloat(argv[++i]);
119
+ else if (argv[i] === "--over") {
120
+ const b = argv[++i];
121
+ if (!b) die("--over needs the opaque colour behind the background");
122
+ backdrop = parse(b);
123
+ if (backdrop.a < 1) die(`--over ${b} is translucent itself — give the opaque colour at the bottom of the stack`);
124
+ }
125
+ else if (argv[i] === "--ladder") ladder = true;
126
+ else if (argv[i] === "-h" || argv[i] === "--help") {
127
+ console.log("contrast.mjs <fg> <bg> | --ladder <bg> <fg>… | --min <n> … | --over <backdrop> …");
128
+ process.exit(0);
129
+ } else rest.push(argv[i]);
130
+ }
131
+ if (Number.isNaN(floor)) die("--min needs a number");
132
+
133
+ if (ladder) {
134
+ const [bg, ...tiers] = rest;
135
+ if (!bg || tiers.length === 0) die("--ladder needs a background, then the tiers top first");
136
+ let previous = Infinity;
137
+ let ok = true;
138
+ for (const tier of tiers) {
139
+ const r = ratio(tier, bg);
140
+ const problems = [];
141
+ if (r < floor) problems.push(`below ${floor}:1`);
142
+ if (r >= previous) problems.push("not below the tier above");
143
+ if (problems.length) ok = false;
144
+ console.log(`${problems.length ? "✗" : "✓"} ${tier.padEnd(24)} ${fmt(r).padStart(9)}${problems.length ? ` ${problems.join(", ")}` : ""}`);
145
+ previous = r;
146
+ }
147
+ process.exit(ok ? 0 : 2);
148
+ }
149
+
150
+ if (rest.length !== 2) die("give a foreground and a background (or --ladder)");
151
+ const r = ratio(rest[0], rest[1]);
152
+ console.log(`${r >= floor ? "✓" : "✗"} ${fmt(r)} (floor ${floor}:1)`);
153
+ process.exit(r >= floor ? 0 : 2);