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.
Files changed (98) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/LICENSE +21 -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/plugin.json +22 -0
  8. package/plugins/keyline/skills/keyline/SKILL.md +47 -0
  9. package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
  10. package/plugins/keyline/skills/keyline/commands/color.md +7 -0
  11. package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
  12. package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
  13. package/plugins/keyline/skills/keyline/commands/init.md +16 -0
  14. package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
  15. package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
  16. package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
  17. package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
  18. package/plugins/keyline/skills/keyline/commands/refine.md +19 -0
  19. package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
  20. package/plugins/keyline/skills/keyline/commands/type.md +7 -0
  21. package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
  22. package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
  23. package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -0
  24. package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
  25. package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
  26. package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +185 -0
  27. package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
  28. package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -0
  29. package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
  30. package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
  31. package/plugins/keyline/skills/keyline/domains/layout/guide.md +259 -0
  32. package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
  33. package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
  34. package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
  35. package/plugins/keyline/skills/keyline/domains/motion/guide.md +198 -0
  36. package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
  37. package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
  38. package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
  39. package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
  40. package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
  41. package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
  42. package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
  43. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +174 -0
  44. package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
  45. package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
  46. package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -0
  47. package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
  48. package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
  49. package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
  50. package/plugins/keyline/skills/keyline/domains/type/type-audit.md +87 -0
  51. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
  52. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
  53. package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
  54. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
  55. package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -0
  56. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
  57. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
  58. package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
  59. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
  60. package/plugins/keyline/skills/keyline/glass/depth/guide.md +147 -0
  61. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
  62. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
  63. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
  64. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +201 -0
  65. package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -0
  66. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
  67. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +103 -0
  68. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
  69. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
  70. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
  71. package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
  72. package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
  73. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
  74. package/plugins/keyline/skills/keyline/glass/motion/guide.md +255 -0
  75. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
  76. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
  77. package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
  78. package/plugins/keyline/skills/keyline/glass/motion/sound.md +195 -0
  79. package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
  80. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
  81. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +416 -0
  82. package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -0
  83. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
  84. package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
  85. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
  86. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
  87. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
  88. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
  89. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
  90. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
  91. package/plugins/keyline/skills/keyline/scripts/check.mjs +297 -0
  92. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
  93. package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
  94. package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
  95. package/plugins/keyline/skills/keyline/system/detection.md +53 -0
  96. package/plugins/keyline/skills/keyline/system/guide.md +97 -0
  97. package/plugins/keyline/skills/keyline/system/presets.md +58 -0
  98. package/plugins/keyline/skills/keyline/system/roles.md +150 -0
@@ -0,0 +1,584 @@
1
+ /**
2
+ * The detector's rules. Each is one law from a domain guide that can be checked
3
+ * from source text alone, with no model: a value, a pattern, or a signal that
4
+ * is missing from the whole project.
5
+ *
6
+ * A rule gets the facts gathered from every file and returns findings. Severity:
7
+ * error breaks accessibility or hides content — fails a check run
8
+ * warning breaks a law in a way a reader feels
9
+ * info a stray or a missing safeguard worth a look
10
+ *
11
+ * What a rule can't see — hierarchy, grouping, whether a card should be a
12
+ * section — is left to `/keyline critique` and `/keyline audit`.
13
+ */
14
+
15
+ const COLOR_FN = /\b(rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(([^()]*(?:\([^()]*\)[^()]*)*)\)/gi;
16
+ const HEX = /(^|[^\w&-])#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b/i;
17
+ const WEIGHT_WORDS = { thin: 100, hairline: 100, extralight: 200, ultralight: 200, light: 300, normal: 400, regular: 400, medium: 500, semibold: 600, demibold: 600, bold: 700, extrabold: 800, ultrabold: 800, black: 900, heavy: 900 };
18
+ const LAYOUT_PROPS = /\b(width|height|min-width|min-height|max-width|max-height|top|left|right|bottom|inset|margin(?:-\w+)?|padding(?:-\w+)?)\b/;
19
+ const TAILWIND_ARBITRARY_COLOR = /^-?(?:bg|text|border(?:-[trblxyse])?|ring|ring-offset|outline|fill|stroke|from|via|to|shadow|decoration|divide|placeholder|caret|accent)-\[(?:color:)?(#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab)\([^\]]*\))\]/i;
20
+
21
+ const inFontFace = (d) => d.atRules.some((a) => /^@font-face/i.test(a));
22
+ const inTheme = (d) => d.atRules.some((a) => /^@theme\b/i.test(a));
23
+ const inKeyframes = (d) => d.atRules.some((a) => /^@(-webkit-)?keyframes/i.test(a));
24
+ const isDefinition = (d) => d.prop.startsWith("--") || inTheme(d);
25
+ const stripUrls = (v) => v.replace(/url\((?:[^()]|\([^()]*\))*\)/gi, "url()");
26
+
27
+ /** A literal colour in a value, ignoring colour functions that wrap a var(). */
28
+ function literalColor(value) {
29
+ const v = stripUrls(value);
30
+ const hex = v.match(HEX);
31
+ if (hex) return `#${hex[2]}`;
32
+ for (const m of v.matchAll(COLOR_FN)) if (!/var\(/.test(m[2])) return m[0];
33
+ return null;
34
+ }
35
+
36
+ /** Every colour in a value is black or white, at any alpha. */
37
+ function neutralOnly(value) {
38
+ const v = stripUrls(value);
39
+ const colours = [...v.matchAll(/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?)\([^)]*\)/gi)].map((m) => m[0].toLowerCase());
40
+ return colours.length > 0 && colours.every((c) => /^#(0{3,4}|0{6}|0{8}|f{3,4}|f{6}|f{6}[0-9a-f]{2}|0{6}[0-9a-f]{2})$/.test(c) || /^rgba?\(\s*(0\s*[, ]\s*0\s*[, ]\s*0|255\s*[, ]\s*255\s*[, ]\s*255)\b/.test(c));
41
+ }
42
+
43
+ /**
44
+ * The duration in each part of a transition or animation shorthand — the first
45
+ * time in each comma-separated part. The second is a delay, which is choreography,
46
+ * not a tier on the ladder.
47
+ */
48
+ function shorthandDurations(value) {
49
+ const parts = [];
50
+ let depth = 0;
51
+ let start = 0;
52
+ for (let i = 0; i < value.length; i++) {
53
+ if (value[i] === "(") depth++;
54
+ else if (value[i] === ")") depth--;
55
+ else if (value[i] === "," && depth === 0) {
56
+ parts.push(value.slice(start, i));
57
+ start = i + 1;
58
+ }
59
+ }
60
+ parts.push(value.slice(start));
61
+ return parts.map((p) => durationsIn(p.replace(/(?:var|calc)\((?:[^()]|\([^()]*\))*\)/g, ""))[0]).filter((n) => n !== undefined);
62
+ }
63
+
64
+ function durationsIn(value) {
65
+ const out = [];
66
+ for (const m of value.matchAll(/(?<![\w.-])(\d*\.?\d+)(ms|s)\b/g)) {
67
+ const ms = m[2] === "s" ? parseFloat(m[1]) * 1000 : parseFloat(m[1]);
68
+ out.push(Math.round(ms));
69
+ }
70
+ return out;
71
+ }
72
+
73
+ const tw = (facts, test) => facts.classes.filter((c) => test(c.base, c));
74
+ const first = (arr) => arr[0];
75
+ const where = (x) => ({ file: x.file, line: x.line });
76
+ const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
77
+ const list = (xs, n = 4) => xs.slice(0, n).join(", ") + (xs.length > n ? `, +${xs.length - n} more` : "");
78
+
79
+ /* ── Type ───────────────────────────────────────────────────────────────── */
80
+
81
+ const typeRules = [
82
+ {
83
+ id: "type/weights",
84
+ domain: "type",
85
+ law: "type law 5",
86
+ title: "Too many font weights",
87
+ check(facts, { design }) {
88
+ const uses = new Map(); // weight → [locations]
89
+ const add = (w, loc) => {
90
+ if (!w || w < 100 || w > 1000) return;
91
+ uses.set(w, [...(uses.get(w) ?? []), loc]);
92
+ };
93
+ for (const d of facts.decls) {
94
+ if (inFontFace(d)) continue;
95
+ if (d.prop === "font-weight" || (d.prop.startsWith("--") && /weight/.test(d.prop))) {
96
+ const v = d.value.trim().toLowerCase();
97
+ add(/^\d{3}$/.test(v) ? Number(v) : WEIGHT_WORDS[v], where(d));
98
+ }
99
+ }
100
+ for (const c of tw(facts, (b) => /^font-(\w+|\[\d{3}\])$/.test(b))) {
101
+ const w = c.base.slice(5);
102
+ add(/^\[\d{3}\]$/.test(w) ? Number(w.slice(1, -1)) : WEIGHT_WORDS[w], where(c));
103
+ }
104
+ const allowed = { editorial: 2, glass: 2, product: 3, expressive: 3 }[design.preset] ?? 3;
105
+ if (uses.size <= allowed) return [];
106
+ const ranked = [...uses].sort((a, b) => b[1].length - a[1].length);
107
+ const rare = ranked.at(-1);
108
+ return [
109
+ {
110
+ ...first(rare[1]),
111
+ severity: "warning",
112
+ message: `${uses.size} font weights in use (${ranked.map(([w, l]) => `${w}×${l.length}`).join(", ")}); ${design.preset ? `the ${design.preset} preset allows ${design.preset === "product" ? "two, and a third only for a recorded job" : allowed}` : `more than ${allowed} rarely each have a job`}. Every weight needs a job you can name — fold ${rare[0]} into a neighbour, or record why it's needed under ## Decisions.`,
113
+ },
114
+ ];
115
+ },
116
+ },
117
+ {
118
+ id: "type/uppercase",
119
+ domain: "type",
120
+ law: "type dials: uppercase labels",
121
+ title: "Uppercase labels",
122
+ check(facts, { design }) {
123
+ if (design.preset === "expressive") return [];
124
+ const hits = [
125
+ ...tw(facts, (b) => b === "uppercase").map(where),
126
+ ...facts.decls.filter((d) => d.prop === "text-transform" && /uppercase/i.test(d.value)).map(where),
127
+ ];
128
+ const severity = design.preset ? "warning" : "info";
129
+ return hits.map((h) => ({
130
+ ...h,
131
+ severity,
132
+ message: `Uppercase label. ${design.preset ? `The ${design.preset} preset sets labels in sentence case` : "Capitals are a texture; a page of them reads as signage"} — separate a group label by tier and a rule instead of case.`,
133
+ }));
134
+ },
135
+ },
136
+ {
137
+ id: "type/tracking-px",
138
+ domain: "type",
139
+ law: "type law 7",
140
+ title: "Letter-spacing in px",
141
+ check(facts) {
142
+ const hits = [
143
+ ...facts.decls.filter((d) => d.prop === "letter-spacing" && /(?<![\w.])-?\d*\.?\d+px/.test(d.value) && !/^0(px)?$/.test(d.value.trim())),
144
+ ...tw(facts, (b) => /^tracking-\[-?[\d.]+px\]$/.test(b)),
145
+ ];
146
+ return hits.map((h) => ({ ...where(h), severity: "warning", message: "Letter-spacing in px. Tracking is a correction for size, so it has to scale with it: write it in em (−0.02em, not −0.32px)." }));
147
+ },
148
+ },
149
+ {
150
+ id: "type/viewport-font-size",
151
+ domain: "type",
152
+ law: "type dials: unit (WCAG 1.4.4)",
153
+ title: "Text sized in viewport units alone",
154
+ check(facts) {
155
+ const hits = [
156
+ ...facts.decls.filter((d) => (d.prop === "font-size" || (d.prop.startsWith("--") && /text|font-size/.test(d.prop))) && /\d(vw|vi|vmin|vmax|cqw)\b/.test(d.value) && !/clamp\(|calc\([^)]*(rem|px|em)/.test(d.value)),
157
+ ...tw(facts, (b) => /^text-\[[\d.]+(vw|vi|vmin|vmax)\]$/.test(b)),
158
+ ];
159
+ return hits.map((h) => ({ ...where(h), severity: "error", message: "Font size in viewport units alone ignores the reader's zoom. Use clamp() with a rem intercept, e.g. clamp(2rem, 1.2rem + 3vw, 4rem)." }));
160
+ },
161
+ },
162
+ {
163
+ id: "type/italic",
164
+ domain: "type",
165
+ law: "type law 9",
166
+ title: "Italic under a preset without it",
167
+ check(facts, { design }) {
168
+ if (!["product", "glass"].includes(design.preset)) return [];
169
+ const hits = [
170
+ ...tw(facts, (b) => b === "italic"),
171
+ ...facts.decls.filter((d) => d.prop === "font-style" && /italic|oblique/.test(d.value) && !inFontFace(d)),
172
+ ];
173
+ return hits.map((h) => ({ ...where(h), severity: "warning", message: `Italic. The ${design.preset} preset maps emphasis to ${design.preset === "glass" ? "weight and colour" : "a quieter text tier"}, not a slanted style.` }));
174
+ },
175
+ },
176
+ {
177
+ id: "type/font-synthesis",
178
+ domain: "type",
179
+ law: "type law 9",
180
+ title: "Browsers may fake missing styles",
181
+ check(facts) {
182
+ if (!facts.project.selfHostedFonts) return [];
183
+ if (facts.decls.some((d) => d.prop === "font-synthesis" || d.prop === "font-synthesis-weight" || d.prop === "font-synthesis-style")) return [];
184
+ return [{ ...facts.project.selfHostedFonts, severity: "info", message: "No font-synthesis rule. Set `font-synthesis: none` on the root, so a missing italic or bold shows up in review as a gap instead of shipping as a smeared fake." }];
185
+ },
186
+ },
187
+ {
188
+ id: "type/zoom-disabled",
189
+ domain: "type",
190
+ law: "type dials: responsive sizes",
191
+ title: "Zoom disabled",
192
+ check(facts) {
193
+ return facts.zoomLocks.map((z) => ({ ...z, severity: "error", message: "The viewport stops pinch-zoom. Remove maximum-scale / user-scalable — it takes zoom from every reader. Make inputs 16px on touch widths instead, which is what stops iOS zooming on focus." }));
194
+ },
195
+ },
196
+ {
197
+ id: "type/fractional-size",
198
+ domain: "type",
199
+ law: "type law 1",
200
+ title: "Font size off any ladder",
201
+ check(facts) {
202
+ const hits = [
203
+ ...facts.decls.filter((d) => d.prop === "font-size" && /(?<![\w.])\d+\.\d+px\b/.test(d.value)),
204
+ ...tw(facts, (b) => /^text-\[\d+\.\d+px\]$/.test(b)),
205
+ ];
206
+ return hits.map((h) => ({ ...where(h), severity: "info", message: "A fractional pixel font size is a value no role claimed — it hints differently across engines. Snap it to a size on the ladder, or name the role that needs it." }));
207
+ },
208
+ },
209
+ ];
210
+
211
+ /* ── Colour ─────────────────────────────────────────────────────────────── */
212
+
213
+ const colorRules = [
214
+ {
215
+ id: "color/raw-color",
216
+ domain: "color",
217
+ law: "color law 1",
218
+ title: "Colour typed inline",
219
+ check(facts) {
220
+ const out = [];
221
+ for (const d of facts.decls) {
222
+ if (isDefinition(d) || inFontFace(d) || /^(src|content|mask|mask-image|-webkit-mask-image|filter|font-family)$/.test(d.prop)) continue;
223
+ // Print is ink on paper: black on white is the point, not a stray.
224
+ if (d.atRules.some((a) => /^@media\b[^{]*\bprint\b/i.test(a))) continue;
225
+ // Light and shade in a shadow recipe — white or black at some alpha — is the
226
+ // recipe, not a palette colour; the recipe itself is what depth law 4 wants
227
+ // kept in one definition. A tinted shadow (a blue glow) still counts.
228
+ if (/^(box-shadow|text-shadow)$/.test(d.prop) && neutralOnly(d.value)) continue;
229
+ 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.` });
231
+ }
232
+ for (const c of tw(facts, (b) => TAILWIND_ARBITRARY_COLOR.test(b))) {
233
+ 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.` });
234
+ }
235
+ return out;
236
+ },
237
+ },
238
+ {
239
+ id: "color/focus-removed",
240
+ domain: "color",
241
+ law: "color law 5",
242
+ title: "Focus outline removed with no replacement",
243
+ check(facts) {
244
+ const out = [];
245
+ for (const d of facts.decls) {
246
+ if (!/^outline(-style)?$/.test(d.prop) || !/^(none|0)$/.test(d.value.trim())) continue;
247
+ const sel = d.selector;
248
+ if (/:focus-visible|:focus:not\(:focus-visible\)/.test(sel)) continue;
249
+ const restored = d.block?.decls.some((x) => /^(box-shadow|border-color|background|background-color)$/.test(x.prop)) && /:focus/.test(sel);
250
+ if (restored) continue;
251
+ if (facts.project.focusVisible && !/:focus\b/.test(sel)) continue;
252
+ out.push({ ...where(d), severity: facts.project.focusVisible ? "warning" : "error", message: `\`${sel || "this rule"}\` removes the outline${facts.project.focusVisible ? " on :focus" : " and nothing in the project styles :focus-visible"}. Keyboard users lose their place: replace it with a visible :focus-visible ring at 3:1 on every surface.` });
253
+ }
254
+ for (const list of facts.classLists) {
255
+ const removes = list.tokens.find((t) => (t.base === "outline-none" || t.base === "outline-0") && !t.variants.includes("focus-visible"));
256
+ if (!removes) continue;
257
+ const restores = list.tokens.some((t) => t.variants.includes("focus-visible") || t.variants.includes("focus-within") || (t.variants.includes("focus") && /^(ring|outline|border|shadow|bg)/.test(t.base)));
258
+ if (restores) continue;
259
+ // Focused only by script (roving focus, a listbox, a scroll region): the active item carries the signal.
260
+ if (/\btab[iI]ndex\s*=\s*(\{\s*-1\s*\}|["']-1["'])/.test(list.tag)) continue;
261
+ const field = /^<(input|textarea|select)\b|^<\w*(Input|Textarea|TextArea|Field)\b/.test(list.tag);
262
+ if (field && facts.project.focusWithin) {
263
+ out.push({ file: list.file, line: removes.line, severity: "info", message: `\`${removes.raw}\` on a field. Fine if its wrapper shows focus with \`focus-within:\` at 3:1 — mark it \`keyline-ignore\` then; otherwise give the field a visible focus-visible ring.` });
264
+ continue;
265
+ }
266
+ out.push({ file: list.file, line: removes.line, severity: facts.project.focusVisible ? "warning" : "error", message: `\`${removes.raw}\` with no focus-visible style on the same element. Pair it with a visible ring, e.g. \`focus-visible:ring-2\` in the focus colour.` });
267
+ }
268
+ return out;
269
+ },
270
+ },
271
+ ];
272
+
273
+ /* ── Layout ─────────────────────────────────────────────────────────────── */
274
+
275
+ const layoutRules = [
276
+ {
277
+ id: "layout/z-index",
278
+ domain: "layout",
279
+ law: "layout law 7",
280
+ title: "z-index off the ladder",
281
+ check(facts, { design }) {
282
+ const uses = [];
283
+ for (const d of facts.decls) {
284
+ if (d.prop === "z-index" && /^-?\d+$/.test(d.value.trim())) uses.push({ ...where(d), z: Number(d.value.trim()), how: `z-index: ${d.value.trim()}` });
285
+ }
286
+ for (const c of tw(facts, (b) => /^-?z-(\d+|\[-?\d+\])$/.test(b))) {
287
+ const n = Number(c.base.replace(/^-?z-\[?|\]$/g, "")) * (c.base.startsWith("-") ? -1 : 1);
288
+ uses.push({ ...where(c), z: n, how: c.base });
289
+ }
290
+ const exempt = (z) => z === 0 || z === -1 || z === 1;
291
+ if (design.zLadder) {
292
+ const rungs = [...design.zLadder].filter((z) => !exempt(z));
293
+ const long = rungs.length > 9 ? [{ file: design.file, line: design.lines.layout, severity: "info", message: `The z-index ladder in DESIGN.md has ${rungs.length} values. A ladder needs about seven named rungs; one that records every value in use approves them all.` }] : [];
294
+ return long.concat(uses.filter((u) => !exempt(u.z) && !design.zLadder.has(u.z)).map((u) => ({ ...u, severity: "warning", message: `\`${u.how}\` isn't on the z-index ladder in DESIGN.md (${[...design.zLadder].sort((a, b) => a - b).join(", ")}). Use the rung for what this is, or add a rung.` })));
295
+ }
296
+ const out = uses.filter((u) => u.z >= 1000).map((u) => ({ ...u, severity: "warning", message: `\`${u.how}\` — a literal this high is a bug waiting for its pair. Put every z-index on one named ladder, ordered by how long a thing stays on screen.` }));
297
+ const distinct = [...new Set(uses.map((u) => u.z).filter((z) => !exempt(z)))].sort((a, b) => a - b);
298
+ if (distinct.length > 7) out.push({ ...where(uses[0]), severity: "info", message: `${distinct.length} distinct z-index values (${list(distinct.map(String), 8)}). A ladder needs about seven named rungs; record it in DESIGN.md under ## Layout.` });
299
+ return out;
300
+ },
301
+ },
302
+ {
303
+ id: "layout/viewport-height",
304
+ domain: "layout",
305
+ law: "layout law 4",
306
+ title: "100vh",
307
+ check(facts) {
308
+ const out = [];
309
+ for (const d of facts.decls) {
310
+ if (!/(?<![\w.])100vh\b/.test(d.value) || d.prop.startsWith("--")) continue;
311
+ const fallback = d.block?.decls.some((x) => x !== d && x.prop === d.prop && /\b\d+(dvh|svh|lvh)\b/.test(x.value));
312
+ if (!fallback) out.push({ ...where(d), severity: "warning", message: "`100vh` puts the element's bottom under a mobile browser's address bar. Use `100dvh` (or `svh` for a hero that must never be cut)." });
313
+ }
314
+ for (const c of tw(facts, (b) => /^(h|min-h|max-h)-screen$/.test(b))) {
315
+ out.push({ ...where(c), severity: "warning", message: `\`${c.base}\` is 100vh, which ends under a mobile browser's address bar. Use \`${c.base.replace("screen", "dvh")}\`.` });
316
+ }
317
+ return out;
318
+ },
319
+ },
320
+ {
321
+ id: "layout/target-size",
322
+ domain: "layout",
323
+ law: "layout law 13",
324
+ title: "Target smaller than 24px",
325
+ check(facts) {
326
+ const out = [];
327
+ const MIN = 24;
328
+ // Tailwind: a button or link whose resting height and width both come in under 24px.
329
+ // With border-box sizing, padding doesn't grow a fixed size; a positioned pseudo-element does.
330
+ for (const list of facts.classLists) {
331
+ if (!/^<(button|Button|IconButton|a|Link|NavLink)\b|role\s*=\s*["']button/.test(list.tag || "")) continue;
332
+ const resting = list.tokens.filter((t) => t.variants.length === 0);
333
+ const size = (prefixes) => {
334
+ for (const t of resting) {
335
+ const m = t.base.match(new RegExp(`^(?:${prefixes})-(\\d*\\.?\\d+|\\[(\\d*\\.?\\d+)px\\]|px)$`));
336
+ if (!m) continue;
337
+ return m[1] === "px" ? 1 : m[2] !== undefined ? parseFloat(m[2]) : parseFloat(m[1]) * 4;
338
+ }
339
+ return null;
340
+ };
341
+ const h = size("size|h");
342
+ const w = size("size|w");
343
+ const grown = list.tokens.some((t) => t.variants.some((v) => v === "before" || v === "after") && /^-?inset/.test(t.base));
344
+ if (h === null || w === null || grown) continue;
345
+ if (Math.max(h, size("min-h") ?? 0) < MIN && Math.max(w, size("min-w") ?? 0) < MIN) {
346
+ out.push({ file: list.file, line: list.line, severity: "warning", message: `A ${w}×${h}px target is hard to hit (Fitts's law). Give it at least 24×24px of hit area — 44px on touch — by sizing the control and keeping the icon small, or by insetting a \`before:\` pseudo-element past its edges.` });
347
+ }
348
+ }
349
+ // CSS: a rule for a button or link that fixes both dimensions under 24px.
350
+ // Only the subject of the selector counts: `.icon-btn .dot` sizes a dot inside a
351
+ // button, not the button, so test the last compound of each selector in the list.
352
+ const INTERACTIVE_COMPOUND = /^(button|a)(?![\w-])|^:(is|where)\([^)]*\b(button|a)\b|\[role=["']?button|\.[\w-]*(btn|button)(?![\w-])/i;
353
+ const subjects = (sel) => sel.split(",").map((s) => s.trim().split(/\s*[>+~]\s*|\s+/).pop() ?? "");
354
+ const INTERACTIVE = { test: (sel) => subjects(sel).some((c) => INTERACTIVE_COMPOUND.test(c)) };
355
+ const px = (v) => {
356
+ const m = (v ?? "").trim().match(/^(\d*\.?\d+)px$/);
357
+ return m ? parseFloat(m[1]) : null;
358
+ };
359
+ const pseudoGrown = new Set(facts.decls.filter((d) => /::?(before|after)/.test(d.selector) && /^inset/.test(d.prop)).map((d) => d.selector.replace(/::?(before|after).*$/, "").trim()));
360
+ const seenBlocks = new Set();
361
+ for (const d of facts.decls) {
362
+ if (!d.block || seenBlocks.has(d.block) || d.inline || !INTERACTIVE.test(d.selector) || /::?(before|after)|:hover|:active|:focus/.test(d.selector)) continue;
363
+ seenBlocks.add(d.block);
364
+ const get = (...props) => d.block.decls.find((x) => props.includes(x.prop));
365
+ const hd = get("height", "block-size");
366
+ const wd = get("width", "inline-size");
367
+ if (!hd || !wd) continue;
368
+ const h = Math.max(px(hd.value) ?? Infinity, px(get("min-height", "min-block-size")?.value) ?? 0);
369
+ const w = Math.max(px(wd.value) ?? Infinity, px(get("min-width", "min-inline-size")?.value) ?? 0);
370
+ if (h < MIN && w < MIN && !pseudoGrown.has(d.selector.trim())) {
371
+ out.push({ file: d.file, line: hd.line, severity: "warning", message: `\`${d.selector.trim()}\` is a ${w}×${h}px target, hard to hit (Fitts's law). Give it at least 24×24px of hit area — 44px on touch — or inset a \`::before\` past its edges to grow the target without growing the drawing.` });
372
+ }
373
+ }
374
+ return out;
375
+ },
376
+ },
377
+ {
378
+ id: "layout/skip-link",
379
+ domain: "layout",
380
+ law: "layout law 12",
381
+ title: "No skip link",
382
+ check(facts) {
383
+ if (!facts.project.rootLayout || facts.project.skipLink) return [];
384
+ return [{ file: facts.project.rootLayout, line: 1, severity: "info", message: "No skip link found. Make the first focusable element a link to `#main`, shown on focus, and give the main landmark that id." }];
385
+ },
386
+ },
387
+ ];
388
+
389
+ /* ── Depth ──────────────────────────────────────────────────────────────── */
390
+
391
+ const backdropUses = (facts) => [
392
+ ...facts.decls.filter((d) => /^(-webkit-)?backdrop-filter$/.test(d.prop) && !/^none$/.test(d.value.trim()) && !d.atRules.some((a) => /^@supports/i.test(a))),
393
+ ...tw(facts, (b) => /^backdrop-(blur|saturate|brightness|contrast)/.test(b)),
394
+ ];
395
+
396
+ const depthRules = [
397
+ {
398
+ id: "depth/backdrop-guard",
399
+ domain: "depth",
400
+ law: "depth law 15",
401
+ title: "Translucency with no fallback",
402
+ check(facts) {
403
+ const uses = backdropUses(facts);
404
+ if (!uses.length || facts.project.backdropSupports) return [];
405
+ return [{ ...where(uses[0]), severity: "warning", message: `${plural(uses.length, "backdrop blur")} and no \`@supports (backdrop-filter: …)\` guard anywhere. Where the filter is missing the tint is all that's left: start opaque, and turn the translucency on inside the guard.` }];
406
+ },
407
+ },
408
+ {
409
+ id: "depth/prefix-pair",
410
+ domain: "depth",
411
+ law: "depth law 15",
412
+ title: "Hand-written -webkit-backdrop-filter",
413
+ check(facts) {
414
+ if (!facts.project.lightningCss) return [];
415
+ const out = [];
416
+ for (const d of facts.decls) {
417
+ if (d.prop !== "-webkit-backdrop-filter" || !d.block) continue;
418
+ if (d.block.decls.some((x) => x.prop === "backdrop-filter")) {
419
+ out.push({ ...where(d), severity: "warning", message: "Hand-written `-webkit-backdrop-filter` beside `backdrop-filter`. Lightning CSS (Tailwind v4) keeps only the last of the pair, so one engine loses the blur. Write only `backdrop-filter`; the pipeline adds the prefix." });
420
+ }
421
+ }
422
+ return out;
423
+ },
424
+ },
425
+ {
426
+ id: "depth/reduced-transparency",
427
+ domain: "depth",
428
+ law: "depth law 15",
429
+ title: "No reduced-transparency path",
430
+ check(facts) {
431
+ const uses = backdropUses(facts);
432
+ if (!uses.length || facts.project.reducedTransparency) return [];
433
+ return [{ ...where(uses[0]), severity: "info", message: "Translucent surfaces with no `prefers-reduced-transparency` branch. Under it, make each surface opaque and drop the filter — both halves." }];
434
+ },
435
+ },
436
+ ];
437
+
438
+ /* ── Motion ─────────────────────────────────────────────────────────────── */
439
+
440
+ const motionRules = [
441
+ {
442
+ id: "motion/transition-all",
443
+ domain: "motion",
444
+ law: "motion law 13",
445
+ title: "transition: all",
446
+ check(facts) {
447
+ const hits = [
448
+ ...facts.decls.filter((d) => (d.prop === "transition" && /^all\b|,\s*all\b/.test(d.value.trim())) || (d.prop === "transition-property" && /\ball\b/.test(d.value))),
449
+ ...tw(facts, (b) => b === "transition-all"),
450
+ ];
451
+ return hits.map((h) => ({ ...where(h), severity: "warning", message: "`all` transitions properties nobody chose, layout ones included. Name each: e.g. `color, background-color, opacity, transform`." }));
452
+ },
453
+ },
454
+ {
455
+ id: "motion/layout-property",
456
+ domain: "motion",
457
+ law: "motion law 13",
458
+ title: "Animating layout",
459
+ check(facts) {
460
+ const out = [];
461
+ for (const d of facts.decls) {
462
+ if ((d.prop === "transition" || d.prop === "transition-property") && !/^all\b/.test(d.value.trim())) {
463
+ const m = d.value.match(LAYOUT_PROPS);
464
+ if (m) out.push({ ...where(d), severity: "warning", message: `Transitions \`${m[1]}\`, which re-lays out the page every frame. Animate \`transform\` and \`opacity\` instead, or record why this one can't.` });
465
+ } else if (inKeyframes(d) && LAYOUT_PROPS.test(d.prop) && !d.prop.startsWith("--")) {
466
+ out.push({ ...where(d), severity: "warning", message: `A keyframe animates \`${d.prop}\`, which re-lays out the page every frame. Use \`transform\` instead.` });
467
+ }
468
+ }
469
+ for (const c of tw(facts, (b) => /^transition-\[[^\]]*(width|height|top|left|margin|padding)/.test(b))) {
470
+ out.push({ ...where(c), severity: "warning", message: `\`${c.base}\` animates layout every frame. Animate \`transform\` and \`opacity\` instead.` });
471
+ }
472
+ return out;
473
+ },
474
+ },
475
+ {
476
+ id: "motion/reduced-motion",
477
+ domain: "motion",
478
+ law: "motion law 10",
479
+ title: "No reduced-motion branch",
480
+ check(facts) {
481
+ if (facts.project.reducedMotion) {
482
+ // A branch that zeroes every duration on * is a kill switch, not a substitute:
483
+ // the fades that carry meaning go with the movement.
484
+ const kill = facts.decls.find((d) => /^\*|[\s,]\*(?:::?(?:before|after))?\s*(?:,|$)/.test(d.selector.trim()) && d.atRules.some((a) => /prefers-reduced-motion\s*:\s*reduce/i.test(a)) && /^(transition|animation)(-duration)?$/.test(d.prop) && /^(none|0m?s|0\.0*1m?s)\b/i.test(d.value.trim()));
485
+ return kill ? [{ ...where(kill), severity: "info", message: "The reduced-motion branch zeroes every duration on `*`. That turns motion off rather than substituting it: keep the fades and colour changes that say something changed, and drop only the travel and scale." }] : [];
486
+ }
487
+ if (!facts.project.animation) return [];
488
+ return [{ ...facts.project.animation, severity: "error", message: "The project animates, and nothing anywhere reads `prefers-reduced-motion`. Give every movement a substitute under it — usually a short fade, or the end state at once." }];
489
+ },
490
+ },
491
+ {
492
+ id: "motion/duration-ladder",
493
+ domain: "motion",
494
+ law: "motion law 4",
495
+ title: "Durations off the ladder",
496
+ check(facts, { design }) {
497
+ const uses = [];
498
+ for (const d of facts.decls) {
499
+ if (isDefinition(d)) continue;
500
+ if (/^(transition|animation)$/.test(d.prop)) for (const ms of shorthandDurations(d.value)) uses.push({ ...where(d), ms });
501
+ if (/^(transition|animation)-duration$/.test(d.prop)) for (const ms of durationsIn(d.value)) uses.push({ ...where(d), ms });
502
+ }
503
+ for (const c of tw(facts, (b) => /^duration-(\d+|\[[\d.]+m?s\])$/.test(b))) {
504
+ const v = c.base.slice(9);
505
+ uses.push({ ...where(c), ms: /^\d+$/.test(v) ? Number(v) : durationsIn(v.slice(1, -1))[0] });
506
+ }
507
+ uses.push(...facts.jsDurations);
508
+ const real = uses.filter((u) => u.ms > 0);
509
+ if (design.durations) {
510
+ const ladder = [...design.durations].sort((a, b) => a - b);
511
+ const out = real.filter((u) => !design.durations.has(u.ms)).map((u) => ({ ...u, severity: "info", message: `${u.ms}ms isn't on the motion ladder in DESIGN.md (${ladder.map((n) => n + "ms").join(", ")}). Use the tier for how much of the screen changes, or name the exemption.` }));
512
+ // roles.md's one unrenameable rule: duration.* grows with the size of the change.
513
+ // A ladder written down as found can break it, and then approves the inversion.
514
+ const ORDER = ["micro", "state", "popover", "overlay", "route"];
515
+ let prev = null;
516
+ for (const role of ORDER.filter((r) => design.roles.has(r))) {
517
+ const ms = design.roles.get(role);
518
+ if (prev && ms <= prev.ms) out.push({ file: design.file, line: design.lines.motion, severity: "warning", message: `\`duration.${role}\` (${ms}ms) is no longer than \`duration.${prev.role}\` (${prev.ms}ms) in DESIGN.md. Durations grow with the size of the change, so a ladder written down as found needs its strays snapped to a tier, not recorded as one.` });
519
+ if (!prev || ms > prev.ms) prev = { role, ms };
520
+ }
521
+ return out;
522
+ }
523
+ const distinct = [...new Set(real.map((u) => u.ms))].sort((a, b) => a - b);
524
+ if (distinct.length <= 6) return [];
525
+ return [{ ...where(real[0]), severity: "warning", message: `${distinct.length} different durations (${list(distinct.map((n) => n + "ms"), 8)}). Duration should come from a short ladder set by how much of the screen changes — three or four tiers, recorded in DESIGN.md under ## Motion.` }];
526
+ },
527
+ },
528
+ ];
529
+
530
+ /* ── Patterns ───────────────────────────────────────────────────────────── */
531
+
532
+ const VAGUE = /^(ok|okay|yes|no|submit|click here|click|here|go|continue|confirm|done)$/i;
533
+ const SMALL_WORDS = new Set(["a", "an", "and", "as", "at", "by", "for", "in", "of", "on", "or", "the", "to", "with", "vs"]);
534
+
535
+ const patternRules = [
536
+ {
537
+ id: "patterns/vague-label",
538
+ domain: "patterns",
539
+ law: "patterns law 1",
540
+ title: "Label says nothing about the action",
541
+ check(facts) {
542
+ const out = [];
543
+ for (const l of facts.labels) {
544
+ if (/click here/i.test(l.text) || (/^(button|Button)$/.test(l.element) && VAGUE.test(l.text))) {
545
+ out.push({ ...where(l), severity: "info", message: `"${l.text}" doesn't say what happens. A button names its action ("Save changes", "Delete project"); a confirm button repeats its dialog's verb; a link is the thing itself, not "click here".` });
546
+ }
547
+ }
548
+ return out;
549
+ },
550
+ },
551
+ {
552
+ id: "patterns/title-case",
553
+ domain: "patterns",
554
+ law: "patterns law 1",
555
+ title: "Title Case label",
556
+ check(facts) {
557
+ const out = [];
558
+ // Buttons only: link text is often a name, a title or a product, where capitals are right.
559
+ for (const l of facts.labels.filter((x) => /^(button|Button)$/.test(x.element))) {
560
+ const words = l.text.split(/\s+/).filter((w) => /^[A-Za-z][a-z']*$/.test(w) && !SMALL_WORDS.has(w.toLowerCase()));
561
+ if (words.length < 2 || l.text.split(/\s+/).length > 6) continue;
562
+ if (words.every((w) => /^[A-Z][a-z']+$/.test(w))) {
563
+ const sentence = l.text.replace(/(\s)([A-Z])([a-z'])/g, (m, s, c, r) => s + c.toLowerCase() + r);
564
+ out.push({ ...where(l), severity: "info", message: `"${l.text}" is in Title Case. Labels go in sentence case — "${sentence}" — keeping proper nouns as written.` });
565
+ }
566
+ }
567
+ return out;
568
+ },
569
+ },
570
+ {
571
+ id: "patterns/nav-count",
572
+ domain: "patterns",
573
+ law: "patterns law 8",
574
+ title: "Long navigation with no groups",
575
+ check(facts) {
576
+ return facts.navs
577
+ .filter((n) => n.links > 9 && !n.grouped)
578
+ .map((n) => ({ file: n.file, line: n.line, severity: "info", message: `This navigation writes out ${n.links} links in one flat run. Every option shown taxes the choice the reader came to make (Hick's law), and a flat run past a handful reads as noise (Miller's law): put the primary destinations first and group or disclose the rest.` }));
579
+ },
580
+ },
581
+ ];
582
+
583
+ export const RULES = [...typeRules, ...colorRules, ...layoutRules, ...depthRules, ...motionRules, ...patternRules];
584
+ export const DOMAINS = ["type", "color", "layout", "depth", "motion", "patterns"];