trykeyline 0.1.0 → 0.1.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.
Files changed (73) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/LICENSE +0 -16
  3. package/bin/keyline.mjs +2 -1
  4. package/package.json +1 -1
  5. package/plugins/keyline/.claude-plugin/plugin.json +1 -1
  6. package/plugins/keyline/plugin.json +22 -0
  7. package/plugins/keyline/skills/keyline/SKILL.md +22 -32
  8. package/plugins/keyline/skills/keyline/commands/audit.md +8 -3
  9. package/plugins/keyline/skills/keyline/commands/critique.md +3 -26
  10. package/plugins/keyline/skills/keyline/commands/init.md +5 -4
  11. package/plugins/keyline/skills/keyline/commands/polish.md +2 -2
  12. package/plugins/keyline/skills/keyline/commands/refine.md +6 -16
  13. package/plugins/keyline/skills/keyline/commands/sync.md +1 -3
  14. package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
  15. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +1 -1
  16. package/plugins/keyline/skills/keyline/domains/color/guide.md +10 -46
  17. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +2 -2
  18. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +2 -8
  19. package/plugins/keyline/skills/keyline/domains/depth/guide.md +12 -41
  20. package/plugins/keyline/skills/keyline/domains/layout/guide.md +31 -56
  21. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +16 -16
  22. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +2 -2
  23. package/plugins/keyline/skills/keyline/domains/motion/guide.md +12 -47
  24. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +1 -1
  25. package/plugins/keyline/skills/keyline/domains/motion/performance.md +1 -1
  26. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +20 -62
  27. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +1 -9
  28. package/plugins/keyline/skills/keyline/domains/type/guide.md +13 -37
  29. package/plugins/keyline/skills/keyline/domains/type/ladders.md +1 -1
  30. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +5 -8
  31. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +34 -165
  32. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +10 -70
  33. package/plugins/keyline/skills/keyline/glass/color/guide.md +3 -30
  34. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +2 -34
  35. package/plugins/keyline/skills/keyline/glass/color/theming.md +5 -72
  36. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +14 -121
  37. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +29 -188
  38. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +1 -16
  39. package/plugins/keyline/skills/keyline/glass/depth/guide.md +4 -73
  40. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +3 -11
  41. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +5 -91
  42. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +3 -6
  43. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +11 -138
  44. package/plugins/keyline/skills/keyline/glass/layout/guide.md +10 -15
  45. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +4 -6
  46. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +3 -2
  47. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +4 -6
  48. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +10 -184
  49. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +21 -105
  50. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +2 -31
  51. package/plugins/keyline/skills/keyline/glass/motion/guide.md +10 -41
  52. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +1 -7
  53. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +3 -32
  54. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +1 -1
  55. package/plugins/keyline/skills/keyline/glass/motion/sound.md +10 -80
  56. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +8 -43
  57. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +49 -86
  58. package/plugins/keyline/skills/keyline/glass/type/guide.md +5 -7
  59. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +9 -74
  60. package/plugins/keyline/skills/keyline/glass/type/scale.md +2 -6
  61. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +4 -20
  62. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +5 -14
  63. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +3 -1
  64. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +70 -26
  65. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +3 -3
  66. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +9 -1
  67. package/plugins/keyline/skills/keyline/scripts/check.mjs +6 -3
  68. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +2 -0
  69. package/plugins/keyline/skills/keyline/scripts/scan.mjs +29 -45
  70. package/plugins/keyline/skills/keyline/system/design-md.md +4 -4
  71. package/plugins/keyline/skills/keyline/system/guide.md +4 -8
  72. package/plugins/keyline/skills/keyline/system/presets.md +16 -16
  73. package/plugins/keyline/skills/keyline/system/roles.md +8 -8
@@ -17,7 +17,7 @@ A modular scale gives too few sizes in the 11–16px band, where Glass needs 11,
17
17
  - 12.5px code. Monospace runs wide, so it steps down to occupy the same measure as the prose around it — the one size that compensates for a *family* rather than a role.
18
18
  - 12px. Tags, prev/next labels, hover-card titles; small enough to read as annotation.
19
19
  - 11px. Tooltips and avatar initials. The floor for anything a user is expected to read.
20
- - 8.5px lettermarks, 7.5px card lettering. Below the floor on purpose — recognised rather than read, which is also why 7.5px is the one place a 600 weight is allowed.
20
+ - 8.5px lettermarks, 7.5px card lettering. Below the floor on purpose — recognised rather than read (the 600 exception: §2 of the guide).
21
21
 
22
22
  **What was deleted**
23
23
 
@@ -44,11 +44,7 @@ In the 11–16px band the steps are 1px, and that is enough (`domains/type/ladde
44
44
 
45
45
  ## Fixed sizes, not `rem`, and why that's a choice
46
46
 
47
- The reference implementation uses `px` throughout (`text-[14px]`, not `text-sm` or `0.875rem`).
48
-
49
- The trade: `rem` respects a user's browser font-size setting and `px` doesn't, a real accessibility cost. But this hierarchy depends on precise 1px relationships in the 11–16 band, a root scaled to 20px turns them into 15px steps that break the ladder, and browser *zoom* — which everyone uses, and which scales `px` fine — already covers the need.
50
-
51
- If your audience skews toward users who set a root font size — documentation, government, healthcare — use `rem` and accept a looser ladder. Convert at a fixed 16px root: 18px → 1.125rem, 14px → 0.875rem, 13px → 0.8125rem. Never mix the two (the domain's unit dial).
47
+ The reference implementation uses `px` throughout (`text-[14px]`, not `text-sm` or `0.875rem`), to keep the 1px steps of the 11–16 band exact under a scaled root; the trade is the domain's unit dial. Moving to `rem`, convert at a fixed 16px root: 18px → 1.125rem, 14px → 0.875rem, 13px → 0.8125rem.
52
48
 
53
49
  ## Keeping every element on it
54
50
 
@@ -16,13 +16,11 @@ All body copy. All prose. Deks. Meta lines. Tags. Tooltips. Timestamps. Quiet na
16
16
  **500 — structure and state.**
17
17
  Headings h1–h4. `<strong>`. The active row in a table of contents. Names and wordmarks. Captions that label an image rather than describe it. Lettermarks. Company/role titles in a list, where the role is structural and the date supporting.
18
18
 
19
- ### Quiet and lifted, by scenario
19
+ The lists are defaults; the choice is made per spot (domain law 5): the same 13px line is 400 as a date under a title and 500 as the name heading a group.
20
20
 
21
- The lists above are defaults; the choice is made per spot, by the rules in domain law 5. The same 13px line is 400 as a date under a title and 500 as the name heading a group, because the job changed, not the importance. With two weights, a step is the whole axis, so when a screen feels heavy the fix is moving lines back to 400, one context at a time; there's never a lighter scale to reach for.
21
+ ### 300
22
22
 
23
- ### When 300 is legitimate
24
-
25
- Rarely, and never for text read at length: at a very large display size (48px+), where 400 starts to look heavy, or a decorative numeral. If your interface tops out at 30px — most do — you don't need it. The reference implementation loaded 300 for a while and never used it; dropping it from the font request was a straight performance win.
23
+ Not loaded. The reference implementation loaded it for a while and never used it; dropping it from the font request was a straight performance win.
26
24
 
27
25
  ### Weight as an accessibility signal
28
26
 
@@ -32,23 +30,9 @@ The active table-of-contents row takes the primary text colour *and* weight 500
32
30
  className={`… ${tone} ${state === "active" ? "font-medium" : ""}`}
33
31
  ```
34
32
 
35
- Weight is cheaper here than a background pill, border or icon, each of which adds visual weight to a list meant to be quiet.
36
-
37
33
  ## Tracking
38
34
 
39
- ### The ladder
40
-
41
- | Size | Tracking | Reasoning |
42
- |---|---|---|
43
- | 7.5px | −0.01em | Card lettering, set solid. The exception below the floor — see the note. |
44
- | 8.5–11px | +0.02em | Tiny glyphs and near-caps need air or the counters close up. |
45
- | 12–14px | 0 | Default metrics are designed for this range. Leave them. |
46
- | 16px | −0.015em | Enough to look set; small enough to be invisible as a decision. |
47
- | 18px | −0.02em | A page title is a tighter, more deliberate object than a section heading two pixels below. |
48
-
49
- Only two rows are the negative-tracking-at-size rule, because the ladder tops out at 18px. An earlier table ran to 30px at −0.025em, with separate values for a 19px heading and a 19px quote; all three sizes left the ladder and took their tracking with them.
50
-
51
- The 7.5px row doesn't contradict the 8.5–11px one: that lettering is set solid at near-caps size on a card, so the tightening compensates for the *setting*, not the size. It's also the one place a 600 weight is allowed. Two exceptions in one place hint you're outside the text system — which you are, since nobody reads it.
35
+ The values are §4 of `glass/type/guide.md`. Only 16px and 18px are the negative-tracking-at-size rule, because the ladder tops out at 18px. The 7.5px card lettering's −0.01em doesn't contradict the lettermarks' +0.02em: it is set solid at near-caps size, so the tightening compensates for the *setting*, not the size.
52
36
 
53
37
  ### The 8.5px lettermark
54
38
 
@@ -13,12 +13,6 @@ export function blankComments(text) {
13
13
  return text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
14
14
  }
15
15
 
16
- export function lineAt(text, offset) {
17
- let line = 1;
18
- for (let i = 0; i < offset && i < text.length; i++) if (text.charCodeAt(i) === 10) line++;
19
- return line;
20
- }
21
-
22
16
  /** Lines start offsets, for fast offset → line lookups on big files. */
23
17
  export function lineIndex(text) {
24
18
  const starts = [0];
@@ -44,8 +38,7 @@ export function parseCss(source, baseLine = 1) {
44
38
  const at = lineIndex(text);
45
39
  const decls = [];
46
40
  const atRules = [];
47
- const blocks = []; // { selector, atRules, decls }
48
- const stack = []; // open blocks: { kind: 'rule'|'at', prelude, block }
41
+ const stack = []; // open blocks: { kind: 'rule'|'at', prelude, block: { decls } }
49
42
  let start = 0;
50
43
  let quote = null;
51
44
  let paren = 0;
@@ -65,7 +58,7 @@ export function parseCss(source, baseLine = 1) {
65
58
  const value = raw.slice(colon + 1).trim();
66
59
  const lead = from + raw.search(/\S/);
67
60
  const ctx = context();
68
- const d = { prop, value, line: at(lead) + baseLine - 1, offset: lead, selector: ctx.selector, atRules: ctx.atRules, block: ctx.block };
61
+ const d = { prop, value, line: at(lead) + baseLine - 1, selector: ctx.selector, atRules: ctx.atRules, block: ctx.block };
69
62
  decls.push(d);
70
63
  ctx.block?.decls.push(d);
71
64
  };
@@ -85,10 +78,8 @@ export function parseCss(source, baseLine = 1) {
85
78
  const prelude = text.slice(start, i).trim().replace(/\s+/g, " ");
86
79
  const kind = prelude.startsWith("@") ? "at" : "rule";
87
80
  const lead = start + text.slice(start, i).search(/\S/);
88
- const block = { selector: kind === "rule" ? prelude : context().selector, atRules: [...context().atRules, ...(kind === "at" ? [prelude] : [])], decls: [], line: at(Math.max(lead, 0)) + baseLine - 1 };
89
- if (kind === "at") atRules.push({ prelude, line: block.line });
90
- blocks.push(block);
91
- stack.push({ kind, prelude, block });
81
+ if (kind === "at") atRules.push({ prelude, line: at(Math.max(lead, 0)) + baseLine - 1 });
82
+ stack.push({ kind, prelude, block: { decls: [] } });
92
83
  start = i + 1;
93
84
  } else if (c === ";") {
94
85
  if (stack.length) pushDecl(start, i);
@@ -103,7 +94,7 @@ export function parseCss(source, baseLine = 1) {
103
94
  start = i + 1;
104
95
  }
105
96
  }
106
- return { decls, atRules, blocks };
97
+ return { decls, atRules };
107
98
  }
108
99
 
109
100
  /** Parse a `style="..."` attribute's body. */
@@ -50,7 +50,9 @@ export function readDesign(root) {
50
50
  // or mentions one — their values are the ladder.
51
51
  const layout = section(md, "Layout");
52
52
  const zLadder = new Set();
53
- for (const row of layout.split("\n").filter((l) => /^\|\s*`?z\./.test(l) || (l.startsWith("|") && /`-?z-\[?-?\d|`z\./.test(l)))) {
53
+ // A project rung (`--z-header`, `$z-header`, a `z-header` utility) counts when it opens the token cell, the row's second.
54
+ const zCell = (c) => /^`(--|\$)?z-[\w-]+`/.test(c);
55
+ for (const row of layout.split("\n").filter((l) => /^\|\s*`?z\./.test(l) || (l.startsWith("|") && (/`-?z-\[?-?\d|`z\./.test(l) || zCell((l.split("|")[2] ?? "").trim()))))) {
54
56
  const cells = row.split("|").slice(1, -1).map((c) => c.trim());
55
57
  for (const c of cells.slice(1)) for (const m of c.matchAll(/(?<![\w.#-])(-?\d+)(?![\w.%])/g)) zLadder.add(Number(m[1]));
56
58
  }
@@ -1,44 +1,88 @@
1
1
  /**
2
- * Which files the detector reads. The same skips as scan.mjs, so the two never
3
- * disagree about what counts as the project's interface.
2
+ * Which files Keyline reads. One walker for the detector (check.mjs) and the
3
+ * inventory (scan.mjs), so the two skip the same folders and match ignores the
4
+ * same way; each passes only the extensions and filters its job needs.
4
5
  */
5
6
  import { readdirSync, statSync } from "node:fs";
6
- import { extname, join, relative, sep } from "node:path";
7
+ import { basename, extname, join, relative, sep } from "node:path";
7
8
 
9
+ /** Dependencies, build output, served assets and reports. Dot-folders are skipped wholesale, bar `.storybook`. */
8
10
  export const SKIP_DIRS = new Set([
9
- "node_modules", ".git", ".next", ".nuxt", ".output", ".svelte-kit", ".turbo",
10
- ".vercel", ".cache", "dist", "build", "out", "coverage", "vendor",
11
- "storybook-static", ".claude", ".agents", "public", "static",
11
+ "node_modules", "dist", "build", "out", "coverage", "vendor", "storybook-static", "public", "static",
12
+ "keyline-report", "smoke-report",
12
13
  ]);
13
14
  export const STYLE = new Set([".css", ".scss", ".sass", ".less", ".pcss"]);
14
15
  export const MARKUP = new Set([".tsx", ".jsx", ".ts", ".js", ".mjs", ".vue", ".svelte", ".astro", ".html", ".mdx"]);
15
- const SKIP_FILES = /\.(min|d|test|spec|stories|config)\.[a-z]+$|^(eslint|postcss|tailwind|next|vite|vitest|jest|playwright)\.config\./;
16
+ /** Minified, declaration, test and story files: generated, or not the interface. */
17
+ export const GENERATED = /\.(min|test|spec|stories)\.|\.d\.[a-z]+$/;
18
+ /** Tool configs. Not the interface, though a Tailwind config holds theme values. */
19
+ export const CONFIG = /\.config\.[a-z]+$|^(eslint|postcss|tailwind|next|vite|vitest|jest|playwright)\.config\./;
16
20
 
17
- /** Every style and markup file under `paths`, relative to `root`, minus ignores. */
18
- export function collect(root, paths, ignores) {
21
+ const clean = (ig) => ig.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
22
+
23
+ /** A test for paths relative to the root: `src/content` drops `src/content/a.tsx`, never `src/contentful`. */
24
+ export function ignoredBy(ignores = []) {
25
+ const list = ignores.map(clean).filter(Boolean);
26
+ return (rel) => list.some((ig) => rel === ig || rel.startsWith(ig + "/"));
27
+ }
28
+
29
+ /**
30
+ * Every file under `paths` whose extension is in `accept` and whose name `skip`
31
+ * doesn't match, up to `maxSize` bytes (bigger is a bundle, not source). Folders
32
+ * in SKIP_DIRS and dot-folders are passed over unless named in `paths`;
33
+ * symlinked folders aren't followed.
34
+ * @returns {{ abs: string, rel: string, ext: string }[]} rel is relative to root, with `/`
35
+ */
36
+ export function walk(root, { paths = [root], accept, skip = GENERATED, maxSize = 400_000, ignores = [] }) {
19
37
  const out = [];
20
- const ignored = (rel) => ignores.some((ig) => ig && (rel === ig || rel.startsWith(ig.endsWith("/") ? ig : ig + "/")));
21
- const visit = (abs) => {
22
- const rel = relative(root, abs).split(sep).join("/");
23
- if (rel && ignored(rel)) return;
24
- let st;
38
+ const ignored = ignoredBy(ignores);
39
+ const rel = (abs) => relative(root, abs).split(sep).join("/");
40
+ const file = (abs, name, size) => {
41
+ const ext = extname(name);
42
+ if (!accept.has(ext) || skip?.test(name)) return;
43
+ if ((size ?? statSync(abs).size) > maxSize) return;
44
+ out.push({ abs, rel: rel(abs) || name, ext });
45
+ };
46
+ const dir = (abs) => {
47
+ let entries;
25
48
  try {
26
- st = statSync(abs);
49
+ entries = readdirSync(abs, { withFileTypes: true });
27
50
  } catch {
28
51
  return;
29
52
  }
30
- if (st.isDirectory()) {
31
- const name = abs.split(sep).pop();
32
- if (rel && (SKIP_DIRS.has(name) || (name.startsWith(".") && name !== ".storybook"))) return;
33
- for (const e of readdirSync(abs)) visit(join(abs, e));
34
- return;
53
+ for (const e of entries) {
54
+ const child = join(abs, e.name);
55
+ if (ignored(rel(child))) continue;
56
+ if (e.isDirectory()) {
57
+ if (!SKIP_DIRS.has(e.name) && (!e.name.startsWith(".") || e.name === ".storybook")) dir(child);
58
+ } else {
59
+ try {
60
+ file(child, e.name);
61
+ } catch {}
62
+ }
35
63
  }
36
- const ext = extname(abs);
37
- if (!(STYLE.has(ext) || MARKUP.has(ext))) return;
38
- if (SKIP_FILES.test(abs.split(sep).pop())) return;
39
- if (st.size > 400_000) return; // generated bundles, not source
40
- out.push({ abs, rel: rel || abs.split(sep).pop(), ext, kind: STYLE.has(ext) ? "style" : "markup" });
41
64
  };
42
- for (const p of paths) visit(p);
65
+ for (const p of paths) {
66
+ const r = rel(p);
67
+ if (r && ignored(r)) continue;
68
+ let st;
69
+ try {
70
+ st = statSync(p);
71
+ } catch {
72
+ continue;
73
+ }
74
+ if (st.isDirectory()) dir(p);
75
+ else file(p, basename(p), st.size);
76
+ }
43
77
  return out;
44
78
  }
79
+
80
+ const DETECTOR_SKIP = new RegExp(`${GENERATED.source}|${CONFIG.source}`);
81
+
82
+ /** The detector's files: style and markup, minus generated files and tool configs. */
83
+ export function collect(root, paths, ignores) {
84
+ return walk(root, { paths, ignores, accept: new Set([...STYLE, ...MARKUP]), skip: DETECTOR_SKIP }).map((f) => ({
85
+ ...f,
86
+ kind: STYLE.has(f.ext) ? "style" : "markup",
87
+ }));
88
+ }
@@ -57,7 +57,7 @@ export function readMarkup(source, ext) {
57
57
  text = text.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "));
58
58
  const at = lineIndex(text);
59
59
 
60
- const classLists = []; // { value, offset, line, tokens: [{...splitClass, line}] }
60
+ const classLists = []; // { line, tokens: [{...splitClass, line}], tag }
61
61
  const seen = new Set();
62
62
  const addList = (value, offset, origin) => {
63
63
  if (seen.has(offset)) return;
@@ -66,7 +66,7 @@ export function readMarkup(source, ext) {
66
66
  const re = /\S+/g;
67
67
  let m;
68
68
  while ((m = re.exec(value))) tokens.push({ ...splitClass(m[0]), line: at(offset + m.index) });
69
- if (tokens.length) classLists.push({ value, offset, line: at(offset), tokens, origin, tag: origin === "attribute" ? tagAround(offset, value.length) : "" });
69
+ if (tokens.length) classLists.push({ line: at(offset), tokens, tag: origin === "attribute" ? tagAround(offset, value.length) : "" });
70
70
  };
71
71
 
72
72
  /** The opening tag a class attribute sits in, for rules that need its element or attributes. */
@@ -93,7 +93,7 @@ export function readMarkup(source, ext) {
93
93
  addList(s[1] === "`" ? staticParts(s[2]) : s[2], open + 1 + s.index + 1, "helper");
94
94
  }
95
95
  }
96
- if (!isHtmlLike || ext !== ".html") {
96
+ if (ext !== ".html") {
97
97
  for (const m of text.matchAll(EXPR_STRING)) {
98
98
  const value = m[2];
99
99
  if (value.length < 3 || !CLASSY.test(value) || !TAILWINDISH.test(value)) continue;
@@ -226,8 +226,16 @@ const colorRules = [
226
226
  // recipe, not a palette colour; the recipe itself is what depth law 4 wants
227
227
  // kept in one definition. A tinted shadow (a blue glow) still counts.
228
228
  if (/^(box-shadow|text-shadow)$/.test(d.prop) && neutralOnly(d.value)) continue;
229
+ // Gradient stops and filter lights only exist inside SVG artwork, which colour law 1 exempts.
230
+ if (/^(stop-color|flood-color|lighting-color)$/.test(d.prop)) continue;
229
231
  const c = literalColor(d.value);
230
- if (c) out.push({ ...where(d), severity: "warning", message: `\`${c}\` typed into \`${d.prop}\`${d.inline ? " in an inline style" : ""}. Give it a token named for its job (a text tier, a border, a status), so a theme or a contrast fix changes it once.` });
232
+ if (!c) continue;
233
+ // fill and stroke paint SVG: artwork (exempt) or an icon (should be currentColor). A regex can't tell which.
234
+ if (/^(fill|stroke)$/.test(d.prop)) {
235
+ out.push({ ...where(d), severity: "info", message: `\`${c}\` painted into \`${d.prop}\`. Inside artwork marked \`role="img"\` that's fine (colour law 1); on an icon, use \`currentColor\` so it follows its label.` });
236
+ continue;
237
+ }
238
+ out.push({ ...where(d), severity: "warning", message: `\`${c}\` typed into \`${d.prop}\`${d.inline ? " in an inline style" : ""}. Give it a token named for its job (a text tier, a border, a status), so a theme or a contrast fix changes it once.` });
231
239
  }
232
240
  for (const c of tw(facts, (b) => TAILWIND_ARBITRARY_COLOR.test(b))) {
233
241
  out.push({ ...where(c), severity: "warning", message: `\`${c.base}\` puts a raw colour in a class. Map it to a token named for its job.` });
@@ -92,8 +92,8 @@ export function gather(root, files) {
92
92
  const VIEWPORT_VALUE = /<meta\b[^>]*\bviewport\b[^>]*>|["'`][^"'`\n<>]*width\s*=\s*device-width[^"'`\n<>]*["'`]/gi;
93
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
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);
95
+ const imp = m.text.match(MOTION_IMPORT);
96
+ if (imp) {
97
97
  noteAnimation({ file: f.rel, line: at(imp.index) });
98
98
  for (const d of m.text.matchAll(/\bduration\s*:\s*(\d*\.?\d+)\b/g)) {
99
99
  // A spring's duration is a settling time set by its character, not a tier.
@@ -277,6 +277,9 @@ export function run(argv) {
277
277
  }
278
278
 
279
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);
280
283
  if (opts.quiet) result.findings = result.findings.filter((f) => f.severity === "error");
281
284
  const s = summarise(result.findings);
282
285
 
@@ -286,7 +289,7 @@ export function run(argv) {
286
289
  } else {
287
290
  console.log(human(result, { all: opts.all, color: process.stdout.isTTY && !process.env.NO_COLOR }));
288
291
  }
289
- return s.error > 0 || (opts.strict && s.warning > 0) ? 1 : 0;
292
+ return failed ? 1 : 0;
290
293
  }
291
294
 
292
295
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -150,4 +150,6 @@ if (ladder) {
150
150
  if (rest.length !== 2) die("give a foreground and a background (or --ladder)");
151
151
  const r = ratio(rest[0], rest[1]);
152
152
  console.log(`${r >= floor ? "✓" : "✗"} ${fmt(r)} (floor ${floor}:1)`);
153
+ // 1:1 means the two colours came out identical — nearly always the arguments, not the design.
154
+ if (r < 1.01) console.error(`contrast: ${rest[0]} and ${rest[1]} come out as the same colour. Check the order: <fg> <bg>, or --over <backdrop> <fg> <bg>, or --ladder <bg> <fg>…`);
153
155
  process.exit(r >= floor ? 0 : 2);
@@ -16,8 +16,9 @@
16
16
  * at runtime or a class built by string concatenation. When the summary and the
17
17
  * code disagree, the code wins — open the file the summary points at.
18
18
  */
19
- import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
20
- import { join, relative, extname, basename } from "node:path";
19
+ import { readFileSync, existsSync } from "node:fs";
20
+ import { join, basename, resolve } from "node:path";
21
+ import { walk, STYLE as CHECKED_STYLE, MARKUP as CHECKED_MARKUP } from "./check/files.mjs";
21
22
 
22
23
  const argv = process.argv.slice(2);
23
24
  const json = argv.includes("--json");
@@ -33,37 +34,14 @@ if (!existsSync(root)) {
33
34
  process.exit(1);
34
35
  }
35
36
 
36
- const SKIP_DIRS = new Set([
37
- "node_modules", ".git", ".next", ".nuxt", ".output", ".svelte-kit", ".turbo",
38
- ".vercel", ".cache", "dist", "build", "out", "coverage", "vendor",
39
- "storybook-static", ".claude", "public", "static",
40
- ]);
41
- const STYLE = new Set([".css", ".scss", ".sass", ".less", ".pcss", ".styl"]);
42
- const MARKUP = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte", ".astro", ".html", ".mdx"]);
43
-
44
- /* ── Walk ─────────────────────────────────────────────────────────────────── */
45
-
46
- const files = [];
47
- (function walk(dir) {
48
- let entries;
49
- try {
50
- entries = readdirSync(dir, { withFileTypes: true });
51
- } catch {
52
- return;
53
- }
54
- for (const e of entries) {
55
- if (e.name.startsWith(".") && e.isDirectory() && e.name !== ".storybook") continue;
56
- const path = join(dir, e.name);
57
- if (ignores.some((ig) => ig && relative(root, path).startsWith(ig))) continue;
58
- if (e.isDirectory()) {
59
- if (!SKIP_DIRS.has(e.name)) walk(path);
60
- } else if (STYLE.has(extname(e.name)) || MARKUP.has(extname(e.name))) {
61
- if (/\.min\.|\.d\.ts$|\.test\.|\.spec\.|\.stories\./.test(e.name)) continue;
62
- if (statSync(path).size > 1_000_000) continue;
63
- files.push(path);
64
- }
65
- }
66
- })(root);
37
+ /*
38
+ * The detector's walker and folder skips, read wider: an inventory wants every
39
+ * hand-written value, so it also reads Stylus and CommonJS, keeps tool configs
40
+ * (a Tailwind config is where a theme is declared) and allows files up to 1 MB.
41
+ */
42
+ const STYLE = new Set([...CHECKED_STYLE, ".styl"]);
43
+ const MARKUP = new Set([...CHECKED_MARKUP, ".cjs"]);
44
+ const files = walk(resolve(root), { accept: new Set([...STYLE, ...MARKUP]), maxSize: 1_000_000, ignores });
67
45
 
68
46
  /* ── Tallies ──────────────────────────────────────────────────────────────── */
69
47
 
@@ -74,7 +52,7 @@ function count(bucket, value, file) {
74
52
  tallies[bucket] ??= new Map();
75
53
  const entry = tallies[bucket].get(v) ?? { n: 0, files: new Set() };
76
54
  entry.n++;
77
- entry.files.add(relative(root, file));
55
+ entry.files.add(file);
78
56
  tallies[bucket].set(v, entry);
79
57
  }
80
58
 
@@ -91,7 +69,7 @@ const classWords = new Map();
91
69
  const signals = {};
92
70
  function signal(name, file) {
93
71
  signals[name] ??= new Set();
94
- signals[name].add(relative(root, file));
72
+ signals[name].add(file);
95
73
  }
96
74
 
97
75
  /** Tailwind's scale names, so `text-sm` counts as a size and `text-red-500` doesn't. */
@@ -156,9 +134,9 @@ const SIGNALS = [
156
134
  ["numerals: tabular-nums", /tabular-nums|font-variant-numeric/],
157
135
  ];
158
136
 
159
- for (const file of files) {
160
- const text = readFileSync(file, "utf8");
161
- const kind = STYLE.has(extname(file)) ? "style" : "markup";
137
+ for (const { abs, rel: file, ext } of files) {
138
+ const text = readFileSync(abs, "utf8");
139
+ const kind = STYLE.has(ext) ? "style" : "markup";
162
140
 
163
141
  for (const [bucket, re, group, only] of RULES) {
164
142
  if (only !== "all" && only !== kind) continue;
@@ -175,7 +153,7 @@ for (const file of files) {
175
153
  if (m[1].startsWith("--tw-")) continue;
176
154
  const entry = props.get(m[1]) ?? { values: [], files: new Set() };
177
155
  if (entry.values.length < 4 && !entry.values.includes(m[2].trim())) entry.values.push(m[2].trim());
178
- entry.files.add(relative(root, file));
156
+ entry.files.add(file);
179
157
  props.set(m[1], entry);
180
158
  }
181
159
  if (kind === "markup") {
@@ -196,14 +174,20 @@ for (const file of files) {
196
174
  * but it's the difference between "unused" and "used in 40 files".
197
175
  */
198
176
  const NAMESPACES = ["color", "font", "text", "radius", "shadow", "ease", "animate", "container", "spacing", "tracking", "leading", "breakpoint", "blur", "inset-shadow", "drop-shadow"];
199
- function utilityUses(prop) {
177
+ /** Class-word uses by every tail after a hyphen: `hover:text-ink-soft` counts for `ink-soft` and `soft`. */
178
+ const bySuffix = new Map();
179
+ for (const [word, c] of classWords) {
180
+ for (let i = word.indexOf("-"); i >= 0; i = word.indexOf("-", i + 1)) {
181
+ const tail = word.slice(i + 1);
182
+ bySuffix.set(tail, (bySuffix.get(tail) ?? 0) + c);
183
+ }
184
+ }
185
+ const utilityCount = new Map();
186
+ for (const prop of props.keys()) {
200
187
  const ns = NAMESPACES.find((n) => prop.startsWith(`--${n}-`));
201
- if (!ns) return 0;
202
- const name = prop.slice(ns.length + 3);
203
- let n = 0;
204
- for (const [word, c] of classWords) if (word.endsWith(`-${name}`) && word !== name) n += c;
205
- return n;
188
+ utilityCount.set(prop, ns ? (bySuffix.get(prop.slice(ns.length + 3)) ?? 0) : 0);
206
189
  }
190
+ const utilityUses = (prop) => utilityCount.get(prop) ?? 0;
207
191
  const uses = (prop) => (propUses.get(prop) ?? 0) + utilityUses(prop);
208
192
 
209
193
  /* ── Stack ────────────────────────────────────────────────────────────────── */
@@ -80,9 +80,9 @@ Copy this shape. Drop any table or row that doesn't apply; add project roles und
80
80
 
81
81
  | Role | Name here | Value |
82
82
  |---|---|---|
83
- | `z.popover` | `z-popover` | 50 |
84
- | `z.overlay` | `z-overlay` | 80 |
85
- | `z.toast` | `z-toast` | 90 |
83
+ | `z.popover` | `z-popover` | 20 |
84
+ | `z.overlay` | `z-overlay` | 60 |
85
+ | `z.toast` | `z-toast` | 70 |
86
86
 
87
87
  **Breakpoints**
88
88
 
@@ -123,7 +123,7 @@ Plus springs by character, and the reduced-motion policy in one sentence.
123
123
  ## Decisions
124
124
 
125
125
  - YYYY-MM-DD — Card titles at 500, not 600: they read heavy beside the pane title. (User decision; the type guide dial.)
126
- - YYYY-MM-DD — Three weights, not two: the billing table needs a middle weight for column totals. (Client feedback; the type guide dial override.)
126
+ - YYYY-MM-DD — Three weights, not two: the billing screens need a 600 heading weight above the 500 that lifts lines inside rows. (Client feedback; the type guide dial override.)
127
127
  - YYYY-MM-DD — Page on white `surface.page`, not the grey `bg-neutral-50`: the text tiers failed against the grey. (Law; the colour guide.)
128
128
 
129
129
  ## Open issues
@@ -33,7 +33,7 @@ Five places projects have quietly broken a law:
33
33
  - **One token doing two jobs.** A grey used for borders and text: a border needs 3:1, text 4.5:1, and one value rarely serves both. Record the coupling as an open issue; don't split it yet.
34
34
  - **A job with no token.** Captions at six greys, durations typed inline. Propose a name in the project's style (`design-md.md` says how to detect it) and mark it `proposed`. Where a whole domain has no tokens — no type tokens, no motion tokens — record that as one open issue, not one per role; that domain's refining pass proposes a full set in the project's style, marked `proposed`.
35
35
  - **Roles the list doesn't have.** A `keyline`, a `pull-quote`, a `kicker`: record each as a project role under its nearest canonical parent, with what separates it — size, weight, leading, colour or a rule. If nothing does, that's a finding.
36
- - **Boxes as the default.** Every section, stat and list in a card, cards inside cards, a bento grid of unlike sections, tinted strips behind labels, uppercase eyebrows, several chip shapes, a second hue that calls for attention (a hue family answering what, where or who under `product` is not one), success colour on done steps where completion isn't the news (a sequence the reader passes through, not a board or queue). Count the boxes per main screen and list each that holds more than one object (depth law 2, layout law 3). If the user has a reference interface they've already approved, screenshot each main screen beside it; the gaps are open issues.
36
+ - **Boxes as the default.** Every section, stat and list in a card, cards inside cards, a bento grid of unlike sections, tinted strips behind labels, uppercase eyebrows (outside an `expressive` eyebrow role), several chip shapes, a second hue that calls for attention (a hue family answering what, where or who under `product` is not one), success colour on done steps where completion isn't the news (a sequence the reader passes through, not a board or queue). Count the boxes per main screen and list each that holds more than one object (depth law 2, layout law 3); a panel that layout law 3's table gives a settings or form group is not one. If the user has a reference interface they've already approved, screenshot each main screen beside it; the gaps are open issues.
37
37
  - **Values on no ladder.** Twenty-three font sizes, fourteen radii. Cluster them by usage, propose the ladder, list the strays that could snap to a neighbour unseen.
38
38
 
39
39
  Compute contrast; don't estimate it:
@@ -46,7 +46,7 @@ node <keyline>/scripts/contrast.mjs --ladder "#ffffff" "#16161a" "#4b4b52" "#6b6
46
46
 
47
47
  ## 3. Choose the preset
48
48
 
49
- From what the reader is mostly doing — reading, working, or being persuaded — propose `editorial`, `product` or `expressive` (`presets.md`). Offer `glass` only when the user wants modimihir.com's exact system rather than their own; it fills the dials from `glass/`, under the project's existing names wherever it has them.
49
+ From what the reader is mostly doing — reading, working, or being persuaded — propose `editorial`, `product` or `expressive` (`presets.md`). Offer `glass` only when the user wants modimihir.com's exact system rather than their own; it fills the dials from the tables in each `glass/<domain>/guide.md`, under the project's existing names wherever it has them.
50
50
 
51
51
  State the evidence: *"Mostly long-form pages under `app/blog`, two weights in use, one accent — this reads as `editorial`."* Mixed projects get a base preset plus areas: `product`, with `app/(marketing)/** → expressive`.
52
52
 
@@ -70,20 +70,16 @@ Don't ask what you can find out, and don't ask for approval of the whole mapping
70
70
  Follow `system/design-md.md`: the project's names in `Name here`, canonical roles in `Role`, values as the source states them, measured contrast for every text tier, per-theme columns only where themes exist, nothing for roles the project lacks. Then:
71
71
 
72
72
  - **`## Decisions`** — every dial set differently from the preset, with its reason, or a question mark if unknown.
73
- - **`## Open issues`** — every law the code breaks, one line each with evidence (a ratio, a count, a file). Before writing them, read the law headings (the `### N.` lines) of each domain guide you cite, so every citation names the right law. **Don't fix anything** unless asked; a setup run that also rewrites forty components can't be reviewed.
73
+ - **`## Open issues`** — every law the code breaks, one line each with evidence (a ratio, a count, a file). Cite each law by the right number (`commands/init.md`). **Don't fix anything** unless asked; a setup run that also rewrites forty components can't be reviewed.
74
74
 
75
75
  Tell the user briefly: the preset and why, how many roles mapped cleanly, what you proposed names for, and the three most important open issues.
76
76
 
77
77
  ## 6. Generating tokens, only when asked
78
78
 
79
- The set ships no token files, because the right file depends on the stack. Asked for tokens — for a new project, or to fill `proposed` rows — generate them **in the project's existing mechanism**: a Tailwind v4 `@theme` block, a `tailwind.config` extension, CSS custom properties, a JS theme object, a design-tokens JSON. Match the existing layout and naming; never add a second token system beside the first.
79
+ Apart from the `glass` preset's drop-in files under `glass/<domain>/assets/`, the set ships no token files, because the right file depends on the stack. Asked for tokens — for a new project, or to fill `proposed` rows — generate them **in the project's existing mechanism**: a Tailwind v4 `@theme` block, a `tailwind.config` extension, CSS custom properties, a JS theme object, a design-tokens JSON. Match the existing layout and naming; never add a second token system beside the first.
80
80
 
81
81
  With no tokens yet, `presets.md` and each domain guide's `## Dials` section give defaults. Say they're defaults, choose names with the user, and record the choices in `DESIGN.md`.
82
82
 
83
- ## How the other commands use this file
84
-
85
- Each domain command reads `DESIGN.md`, finds its section, and works in the project's names. If the file is missing, it runs steps 1–2 for its own domain, does the task and recommends `/keyline init` — it doesn't refuse. A decision that changes the system — a new role, a moved dial, a law knowingly broken, a change a law forced that a later session might undo — goes back under `Decisions` — dated, marked user decision, client feedback or law, newest first, replacing what it supersedes — which is what keeps the file true after the first day.
86
-
87
83
  ## Checklist
88
84
 
89
85
  - [ ] An existing `DESIGN.md` was reconciled, not overwritten; every user-chosen name and decision survived.
@@ -25,34 +25,34 @@ When the evidence doesn't settle it, propose one, say what evidence you used, an
25
25
 
26
26
  *The interface should disappear so the reading doesn't.* Restraint is the point, and a single loud moment reads as craft because nothing else competes with it.
27
27
 
28
- - **Type** — a dense ladder that keeps the reading loudest, with an article's title clearly opening the piece (up to 2.25 × body); index and chrome titles lower. Two weights. A sans for interface text is allowed beside a serif reading face. Roles that share a size separate on weight, leading or a rule.
29
- - **Colour** — near-monochrome. One accent, spent on focus. At most one further chromatic exception, justified structurally. Links usually in the text colour.
30
- - **Layout** — one reading measure every page shares; rails and asides live outside it. Generous, asymmetric vertical rhythm.
31
- - **Surfaces** — mostly flat. Elevation reserved for things that float; translucency, if any, only on chrome.
32
- - **Motion** — short, and only where it makes a change legible. Nothing loops. One set piece, at most, once a session.
28
+ - **Type** — a dense ladder that keeps the reading loudest.
29
+ - **Colour** — near-monochrome, the accent spent on focus.
30
+ - **Layout** — one reading measure every page shares.
31
+ - **Surfaces** — mostly flat; translucency, if any, only on chrome.
32
+ - **Motion** — short, and only where it makes a change legible.
33
33
 
34
34
  ## `product`
35
35
 
36
36
  *The interface is a tool, and the reader is fluent in it.* Density, predictability and scannability beat atmosphere. Signal has to survive a screen full of data.
37
37
 
38
- - **Type** — tighter, smaller steps; UI and data roles dominate. Two weights, strong at 500; labels in sentence case, never uppercase eyebrows. Tabular figures on every number, scoped to numeric elements.
39
- - **Colour** — near-monochrome neutral ground with a real status palette (danger, warning, success, info), each a foreground/background pair. One accent hue for primary action, focus, selection (the current nav item on a 25% accent tint with accent icon and label) and the primary data series, and no second highlight hue. Brand colour already in the design stays where it has a job. Colour marks what needs a person, not what is finished. Colour for meaning, never for decoration.
40
- - **Layout** — an app shell rather than a single measure: one navigation surface — a sidebar holding every destination, no top bar repeating it — then content and an optional inspector, every page under its own header bar, forms and settings capped at a form measure. Improving one screen keeps the navigation it has. Sections stack, each opened by its heading, and peer sections are parted by a full-bleed hairline; no bento mosaics or stat tiles. That is the default for a new system; an existing design keeps its own section device (`commands/refine.md`, the limits). Tighter spacing, still grouped asymmetrically. Prose inside the product (settings help, empty-state copy) still sits at a reading measure.
41
- - **Surfaces** — content on the page, rows parted by hairlines; a data table may take one hairline frame; a settings group that saves together takes a hairline panel; a card only for one object, never a card in a card. A clear, small elevation ladder: flat page, raised cards, floating menus, modal overlays. Hairlines do more work than shadows.
42
- - **Motion** — shortest of the three. State feedback is near-instant; nothing delays a repeated action. Springs only where something is dragged or thrown.
38
+ - **Type** — tighter, smaller steps; UI and data roles dominate.
39
+ - **Colour** — near-monochrome with a real status palette, and one accent hue that acts.
40
+ - **Layout** — an app shell rather than a single measure; tighter spacing, still grouped.
41
+ - **Surfaces** — content on the page, rows parted by hairlines; a card only for one object.
42
+ - **Motion** — shortest of the three; nothing delays a repeated action.
43
43
 
44
44
  ## `expressive`
45
45
 
46
46
  *The interface is making an impression, usually once.* Scale, colour and motion are allowed to carry brand — but still by rule, or it turns into a template.
47
47
 
48
- - **Type** — a real display tier far above body, possibly a second family for it. Display, title and section headings fluid. Wider scale, still assigned to roles rather than derived from a ratio. Tracking correction matters most here.
49
- - **Colour** — a brand palette used broadly, with rules about where each colour may go. Chromatic exceptions still named and counted, just more of them.
50
- - **Layout** — sections may break the reading measure and go full-bleed; the measure still governs any paragraph longer than two lines.
51
- - **Surfaces** — depth, gradients and translucency allowed as brand material, each with its fallback.
52
- - **Motion** — longer and more characterful on entrances; exits still get out of the way. A few set pieces, each once. Reduced motion still gets a real alternative.
48
+ - **Type** — a real display tier far above body, possibly a second family.
49
+ - **Colour** — a brand palette used broadly, each colour placed by rule.
50
+ - **Layout** — sections may go full-bleed; paragraphs keep the measure.
51
+ - **Surfaces** — depth, gradients and translucency as brand material, each with its fallback.
52
+ - **Motion** — longer and more characterful on entrances; exits still get out of the way.
53
53
 
54
54
  ## `glass`
55
55
 
56
- The Glass preset's exact values: `editorial` in character, with every dial already decided on [modimihir.com](https://modimihir.com). Offer it only when the user wants that system itself rather than their own — `/keyline init` copies its values from `glass/` into `DESIGN.md`, in the project's own names wherever the project already has names, and `glass/<domain>/guide.md` supplies the exact recipes, with drop-in token files and components under each `assets/`.
56
+ The Glass preset's exact values: `editorial` in character, with every dial already decided on [modimihir.com](https://modimihir.com). Offer it only when the user wants that system itself rather than their own — `/keyline init` copies its values from the tables in each `glass/<domain>/guide.md` into `DESIGN.md`, in the project's own names wherever the project already has names, and each guide supplies the exact recipes, with drop-in token files and components under its `assets/`.
57
57
 
58
58
  A `glass` project can still override a dial. It should expect to: the Glass values were chosen for one text-heavy portfolio, and a project that isn't one will find the places they don't fit.