trykeyline 0.0.0-stage → 0.1.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/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/bin/keyline.mjs +280 -0
- package/package.json +38 -4
- package/plugins/keyline/.claude-plugin/plugin.json +14 -0
- package/plugins/keyline/plugin.json +22 -0
- package/plugins/keyline/skills/keyline/SKILL.md +47 -0
- package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
- package/plugins/keyline/skills/keyline/commands/color.md +7 -0
- package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
- package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
- package/plugins/keyline/skills/keyline/commands/init.md +16 -0
- package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
- package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
- package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
- package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
- package/plugins/keyline/skills/keyline/commands/refine.md +19 -0
- package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
- package/plugins/keyline/skills/keyline/commands/type.md +7 -0
- package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
- package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
- package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -0
- package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
- package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
- package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +185 -0
- package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
- package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -0
- package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
- package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
- package/plugins/keyline/skills/keyline/domains/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
- package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
- package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
- package/plugins/keyline/skills/keyline/domains/motion/guide.md +198 -0
- package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
- package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
- package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
- package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
- package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
- package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
- package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
- package/plugins/keyline/skills/keyline/domains/patterns/guide.md +174 -0
- package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
- package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
- package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -0
- package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
- package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
- package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
- package/plugins/keyline/skills/keyline/domains/type/type-audit.md +87 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
- package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
- package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
- package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
- package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
- package/plugins/keyline/skills/keyline/glass/depth/guide.md +147 -0
- package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
- package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +201 -0
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
- package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +103 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
- package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
- package/plugins/keyline/skills/keyline/glass/motion/guide.md +255 -0
- package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
- package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
- package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
- package/plugins/keyline/skills/keyline/glass/motion/sound.md +195 -0
- package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +416 -0
- package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -0
- package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
- package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
- package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
- package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
- package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
- package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
- package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
- package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
- package/plugins/keyline/skills/keyline/scripts/check.mjs +297 -0
- package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
- package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
- package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
- package/plugins/keyline/skills/keyline/system/detection.md +53 -0
- package/plugins/keyline/skills/keyline/system/guide.md +97 -0
- package/plugins/keyline/skills/keyline/system/presets.md +58 -0
- package/plugins/keyline/skills/keyline/system/roles.md +150 -0
|
@@ -0,0 +1,297 @@
|
|
|
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
|
+
// The verdict counts every finding, so --quiet trims the output, never the exit code.
|
|
281
|
+
const all = summarise(result.findings);
|
|
282
|
+
const failed = all.error > 0 || (opts.strict && all.warning > 0);
|
|
283
|
+
if (opts.quiet) result.findings = result.findings.filter((f) => f.severity === "error");
|
|
284
|
+
const s = summarise(result.findings);
|
|
285
|
+
|
|
286
|
+
if (opts.json) {
|
|
287
|
+
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 };
|
|
288
|
+
console.log(JSON.stringify({ files: result.files, design, summary: s, findings: result.findings }, null, 2));
|
|
289
|
+
} else {
|
|
290
|
+
console.log(human(result, { all: opts.all, color: process.stdout.isTTY && !process.env.NO_COLOR }));
|
|
291
|
+
}
|
|
292
|
+
return failed ? 1 : 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
296
|
+
process.exitCode = run(process.argv.slice(2));
|
|
297
|
+
}
|
|
@@ -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);
|