@x12i/kit-cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'fs';
3
- import { writeFile, readFile, stat, access, readdir } from 'fs/promises';
4
- import path2 from 'path';
3
+ import { writeFile, access, readFile, stat, readdir } from 'fs/promises';
4
+ import path3 from 'path';
5
5
  import { parseArgs } from 'util';
6
6
  import { themeNames, densityNames } from '@x12i/tokens';
7
+ import { createDesignMd } from '@x12i/tokens/design-md';
7
8
 
8
9
  var CONFIG_FILE = "kit.config.json";
9
10
  var CONFIG_VERSION = 1;
11
+ var RULE_SETTINGS = ["off", "warn", "error"];
10
12
  function isThemeName(value) {
11
13
  return themeNames.includes(value);
12
14
  }
@@ -24,7 +26,7 @@ function createConfig(options = {}) {
24
26
  async function loadConfig(cwd) {
25
27
  let text;
26
28
  try {
27
- text = await readFile(path2.join(cwd, CONFIG_FILE), "utf8");
29
+ text = await readFile(path3.join(cwd, CONFIG_FILE), "utf8");
28
30
  } catch (error) {
29
31
  if (error.code === "ENOENT") return null;
30
32
  throw error;
@@ -38,15 +40,344 @@ async function loadConfig(cwd) {
38
40
  const input = typeof raw === "object" && raw !== null ? raw : {};
39
41
  const defaults = createConfig();
40
42
  const ignore = input.doctor?.ignore;
43
+ const rawRules = input.doctor?.rules;
44
+ const rules = {};
45
+ if (typeof rawRules === "object" && rawRules !== null) {
46
+ for (const [id, setting] of Object.entries(rawRules)) {
47
+ if (typeof setting !== "string" || !RULE_SETTINGS.includes(setting)) {
48
+ throw new Error(`${CONFIG_FILE}: doctor.rules.${id} must be "off", "warn" or "error".`);
49
+ }
50
+ rules[id] = setting;
51
+ }
52
+ }
41
53
  return {
42
54
  version: CONFIG_VERSION,
43
55
  theme: typeof input.theme === "string" && isThemeName(input.theme) ? input.theme : defaults.theme,
44
56
  density: typeof input.density === "string" && isDensityName(input.density) ? input.density : defaults.density,
45
57
  doctor: {
46
- ignore: Array.isArray(ignore) ? ignore.filter((item) => typeof item === "string") : []
58
+ ignore: Array.isArray(ignore) ? ignore.filter((item) => typeof item === "string") : [],
59
+ rules
60
+ }
61
+ };
62
+ }
63
+ var RULES = [
64
+ { id: "hardcoded-color", severity: "error", description: "A #hex color. Use a --kit-color-* token." },
65
+ {
66
+ id: "hardcoded-color-function",
67
+ severity: "warn",
68
+ description: "An rgb(), hsl(), oklch() or similar color with literal numbers. Use a --kit-color-* token."
69
+ },
70
+ { id: "hardcoded-font-size", severity: "warn", description: "A literal font size. Use a --kit-text-*-size token." },
71
+ { id: "hardcoded-font-family", severity: "warn", description: "A literal font stack. Use --kit-font-family-sans or -mono." },
72
+ { id: "hardcoded-spacing", severity: "warn", description: "A literal padding, margin or gap. Use a --kit-space-* token." },
73
+ { id: "hardcoded-radius", severity: "warn", description: "A literal border radius. Use a --kit-radius-* token." },
74
+ { id: "hardcoded-duration", severity: "warn", description: "A literal animation time. Use a --kit-duration-* token." },
75
+ { id: "hardcoded-easing", severity: "warn", description: "A literal cubic-bezier curve. Use a --kit-easing-* token." },
76
+ { id: "bounce-easing", severity: "warn", description: "An easing curve that overshoots. Application UI should be crisp, not bouncy." },
77
+ { id: "transition-all", severity: "warn", description: "`transition: all`. List the properties you animate." },
78
+ {
79
+ id: "layout-animation",
80
+ severity: "warn",
81
+ description: "A transition on width, height, margin, padding or position. Animate transform and opacity instead."
82
+ },
83
+ {
84
+ id: "missing-reduced-motion",
85
+ severity: "warn",
86
+ description: "The project animates but nothing handles prefers-reduced-motion."
87
+ },
88
+ {
89
+ id: "physical-property",
90
+ severity: "warn",
91
+ description: "A left or right property. Use the logical one (inline-start, inline-end) so right to left works."
92
+ },
93
+ { id: "outline-none", severity: "warn", description: "The focus outline is removed with no replacement style." },
94
+ { id: "important", severity: "warn", description: "`!important`. Kit styles have zero specificity, so plain selectors win." }
95
+ ];
96
+ var RULE_IDS = new Set(RULES.map((rule) => rule.id));
97
+ var CSS_FILES = /* @__PURE__ */ new Set([".css", ".scss", ".less"]);
98
+ var SCRIPT_FILES = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
99
+ var MARKUP_FILES = /* @__PURE__ */ new Set([".html", ".vue", ".svelte"]);
100
+ var toKebab = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
101
+ function blankComments(text, lineComments) {
102
+ const blank = (match) => match.replace(/[^\n]/g, " ");
103
+ let result = text.replace(/\/\*[\s\S]*?\*\//g, blank);
104
+ if (lineComments) result = result.replace(/(?<![:"'`\w])\/\/[^\n]*/g, blank);
105
+ return result;
106
+ }
107
+ function styleRegions(text, extension) {
108
+ if (CSS_FILES.has(extension)) return [{ start: 0, end: text.length }];
109
+ const patterns = [];
110
+ if (SCRIPT_FILES.has(extension)) patterns.push(/`((?:[^`\\]|\\[\s\S])*)`/gd);
111
+ if (MARKUP_FILES.has(extension)) {
112
+ patterns.push(/<style\b[^>]*>([\s\S]*?)<\/style>/gid, /\sstyle\s*=\s*"([^"]*)"/gd, /\sstyle\s*=\s*'([^']*)'/gd);
113
+ }
114
+ const regions = [];
115
+ for (const pattern of patterns) {
116
+ for (const match of text.matchAll(pattern)) {
117
+ const span = match.indices?.[1];
118
+ if (span) regions.push({ start: span[0], end: span[1] });
119
+ }
120
+ }
121
+ return regions;
122
+ }
123
+ function cssDeclarations(text, region) {
124
+ const found = [];
125
+ const slice = text.slice(region.start, region.end);
126
+ const pattern = /(?<![\w$@#.:-])(-?[a-zA-Z][\w-]*)[ \t]*:[ \t\r\n]*([^;{}]*?)[ \t\r\n]*(?=;|}|$)/g;
127
+ for (const match of slice.matchAll(pattern)) {
128
+ const [, prop, raw] = match;
129
+ if (prop === void 0 || raw === void 0 || raw === "") continue;
130
+ const important = /!\s*important\s*$/i.test(raw);
131
+ const value = important ? raw.replace(/!\s*important\s*$/i, "").trim() : raw;
132
+ found.push({ prop: prop.toLowerCase(), value, important, index: region.start + (match.index ?? 0), source: "css" });
133
+ }
134
+ return found;
135
+ }
136
+ function jsDeclarations(text) {
137
+ const found = [];
138
+ const pattern = /(?<![\w$.])([a-z][A-Za-z]*)\s*:\s*(["'`])((?:(?!\2)[^\\\n]|\\.)*)\2/g;
139
+ for (const match of text.matchAll(pattern)) {
140
+ const [, prop, , value] = match;
141
+ if (prop === void 0 || value === void 0) continue;
142
+ found.push({ prop: toKebab(prop), value, important: false, index: match.index ?? 0, source: "js" });
143
+ }
144
+ return found;
145
+ }
146
+ var hasVar = (value) => /\bvar\(/.test(value);
147
+ function closingParen(text, open) {
148
+ let depth = 0;
149
+ for (let i = open; i < text.length; i += 1) {
150
+ if (text[i] === "(") depth += 1;
151
+ else if (text[i] === ")") {
152
+ depth -= 1;
153
+ if (depth === 0) return i;
154
+ }
155
+ }
156
+ return text.length - 1;
157
+ }
158
+ function literalPart(value) {
159
+ let result = "";
160
+ let cursor = 0;
161
+ for (const match of value.matchAll(/\b(var|calc|clamp|min|max)\(/g)) {
162
+ const start = match.index ?? 0;
163
+ if (start < cursor) continue;
164
+ const end = closingParen(value, start + match[0].length - 1);
165
+ const group = value.slice(start, end + 1);
166
+ if (match[1] === "var" || hasVar(group)) {
167
+ result += `${value.slice(cursor, start)} `;
168
+ cursor = end + 1;
169
+ }
170
+ }
171
+ return result + value.slice(cursor);
172
+ }
173
+ function lengths(value) {
174
+ return [...value.matchAll(/(?<![\w.#-])(-?\d*\.?\d+)(px|rem|em)\b/g)].map(
175
+ (match) => Number.parseFloat(match[1] ?? "0") * (match[2] === "px" ? 1 : 16)
176
+ );
177
+ }
178
+ function times(value) {
179
+ return [...value.matchAll(/(?<![\w.#-])(\d*\.?\d+)(ms|s)\b/g)].map(
180
+ (match) => Number.parseFloat(match[1] ?? "0") * (match[2] === "s" ? 1e3 : 1)
181
+ );
182
+ }
183
+ var SPACING = /^(?:(?:padding|margin)(?:-(?:top|right|bottom|left|block|inline)(?:-(?:start|end))?)?|(?:row-|column-)?gap)$/;
184
+ var RADIUS = /^border-(?:(?:top|bottom)-(?:left|right)-|(?:start|end)-(?:start|end)-)?radius$/;
185
+ var MOTION = /^(?:transition(?:-duration|-delay|-timing-function|-property)?|animation(?:-duration|-delay|-timing-function|-name)?)$/;
186
+ var TRANSITION = /^transition(?:-property)?$/;
187
+ var LAYOUT_PROPERTY = /^(?:width|height|min-width|min-height|max-width|max-height|top|right|bottom|left|inset(?:-[a-z-]+)?|margin(?:-[a-z-]+)?|padding(?:-[a-z-]+)?|border-width|font-size|flex-basis|gap)$/;
188
+ var splitTopLevel = (value) => value.split(/,(?![^(]*\))/).map((part) => part.trim());
189
+ var PHYSICAL = /* @__PURE__ */ new Map([
190
+ ["margin-left", "margin-inline-start"],
191
+ ["margin-right", "margin-inline-end"],
192
+ ["padding-left", "padding-inline-start"],
193
+ ["padding-right", "padding-inline-end"],
194
+ ["border-left", "border-inline-start"],
195
+ ["border-right", "border-inline-end"],
196
+ ["border-left-width", "border-inline-start-width"],
197
+ ["border-right-width", "border-inline-end-width"],
198
+ ["border-left-color", "border-inline-start-color"],
199
+ ["border-right-color", "border-inline-end-color"],
200
+ ["border-left-style", "border-inline-start-style"],
201
+ ["border-right-style", "border-inline-end-style"],
202
+ ["border-top-left-radius", "border-start-start-radius"],
203
+ ["border-top-right-radius", "border-start-end-radius"],
204
+ ["border-bottom-left-radius", "border-end-start-radius"],
205
+ ["border-bottom-right-radius", "border-end-end-radius"]
206
+ ]);
207
+ var PHYSICAL_SIDE_SELECTOR = /(?:^|[^a-z0-9])(?:left|right)(?![a-z0-9])/i;
208
+ var PHYSICAL_STYLESHEET_ONLY = /* @__PURE__ */ new Map([
209
+ ["left", "inset-inline-start"],
210
+ ["right", "inset-inline-end"]
211
+ ]);
212
+ var FOCUS_REPLACEMENT = /\b(?:box-shadow|border[\w-]*|background[\w-]*|text-decoration[\w-]*)\s*:/i;
213
+ function enclosing(text, index) {
214
+ const blocks = [];
215
+ let depth = 0;
216
+ for (let i = index - 1; i >= 0; i -= 1) {
217
+ const char = text[i];
218
+ if (char === "}") depth += 1;
219
+ else if (char === "{") {
220
+ if (depth > 0) {
221
+ depth -= 1;
222
+ continue;
223
+ }
224
+ let start = i;
225
+ while (start > 0 && !";{}".includes(text[start - 1] ?? "")) start -= 1;
226
+ blocks.push({ header: text.slice(start, i).trim(), open: i });
227
+ }
228
+ }
229
+ return blocks;
230
+ }
231
+ function bodyOf(text, open) {
232
+ let depth = 0;
233
+ for (let i = open + 1; i < text.length; i += 1) {
234
+ const char = text[i];
235
+ if (char === "{") depth += 1;
236
+ else if (char === "}") {
237
+ if (depth === 0) return text.slice(open + 1, i);
238
+ depth -= 1;
239
+ }
240
+ }
241
+ return text.slice(open + 1);
242
+ }
243
+ function snippet(declaration) {
244
+ const text = `${declaration.prop}: ${declaration.value}${declaration.important ? " !important" : ""}`;
245
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
246
+ }
247
+ function checkDeclaration(declaration, text, report) {
248
+ const { prop, value } = declaration;
249
+ const literal = literalPart(value);
250
+ if (prop === "font-size" && lengths(literal).some((px) => px !== 0)) {
251
+ report("hardcoded-font-size", declaration, "Use a --kit-text-*-size token.");
252
+ }
253
+ if (prop === "font-family" && !hasVar(value) && !/^(?:inherit|initial|unset|revert(?:-layer)?)$/i.test(value)) {
254
+ const isFontFace = declaration.source === "css" && enclosing(text, declaration.index).some((block) => /^@font-face\b/i.test(block.header));
255
+ if (!isFontFace) report("hardcoded-font-family", declaration, "Use --kit-font-family-sans or --kit-font-family-mono.");
256
+ }
257
+ if (SPACING.test(prop) && lengths(literal).some((px) => Math.abs(px) > 1)) {
258
+ report("hardcoded-spacing", declaration, "Use a --kit-space-* token.");
259
+ }
260
+ if (RADIUS.test(prop) && lengths(literal).some((px) => px > 0)) {
261
+ report("hardcoded-radius", declaration, "Use a --kit-radius-* token.");
262
+ }
263
+ if (MOTION.test(prop)) {
264
+ if (times(literal).some((ms) => ms > 0)) {
265
+ report("hardcoded-duration", declaration, "Use a --kit-duration-* token.");
266
+ }
267
+ const curves = [...literal.matchAll(/cubic-bezier\(([^)]*)\)/gi)];
268
+ if (curves.length > 0) {
269
+ report("hardcoded-easing", declaration, "Use a --kit-easing-* token.");
270
+ }
271
+ const overshoots = curves.some((curve) => {
272
+ const numbers = (curve[1] ?? "").split(",").map((part) => Number.parseFloat(part));
273
+ return [numbers[1], numbers[3]].some((y) => y !== void 0 && !Number.isNaN(y) && (y < 0 || y > 1));
274
+ });
275
+ if (overshoots) {
276
+ report("bounce-easing", declaration, "This curve overshoots, so the motion bounces. Use a --kit-easing-* token.");
277
+ }
278
+ }
279
+ if (TRANSITION.test(prop)) {
280
+ const properties = prop === "transition-property" ? value.split(/[\s,]+/).filter(Boolean) : splitTopLevel(value).map((part) => part.split(/\s+/)[0] ?? "");
281
+ if (properties.some((name) => name.toLowerCase() === "all")) {
282
+ report("transition-all", declaration, "List the properties you animate. `all` also animates layout and anything added later.");
283
+ }
284
+ const layout = properties.find((name) => LAYOUT_PROPERTY.test(name.toLowerCase()));
285
+ if (layout !== void 0) {
286
+ report("layout-animation", declaration, `Animating ${layout} runs layout and paint on every frame. Animate transform and opacity.`);
287
+ }
288
+ }
289
+ const namesSide = declaration.source === "css" && PHYSICAL_SIDE_SELECTOR.test(enclosing(text, declaration.index)[0]?.header ?? "");
290
+ const bareInset = declaration.source === "css" && !value.includes("%") ? PHYSICAL_STYLESHEET_ONLY.get(prop) : void 0;
291
+ const logical = PHYSICAL.get(prop) ?? bareInset;
292
+ if (namesSide) ; else if (logical !== void 0) {
293
+ report("physical-property", declaration, `Use ${logical}, so the layout mirrors in right to left languages.`);
294
+ } else if (/^(?:text-align|float|clear)$/.test(prop) && /^(?:left|right)$/i.test(value)) {
295
+ const side = value.toLowerCase() === "left" ? "start" : "end";
296
+ const replacement = prop === "text-align" ? side : `inline-${side}`;
297
+ report("physical-property", declaration, `Use ${prop}: ${replacement}, so the layout mirrors in right to left languages.`);
298
+ }
299
+ if (declaration.source !== "css") return;
300
+ const removesOutline = prop === "outline" && /^(?:none|0|0px)$/i.test(value) || prop === "outline-style" && /^none$/i.test(value) || prop === "outline-width" && /^0(?:px)?$/i.test(value);
301
+ if (removesOutline) {
302
+ const [block] = enclosing(text, declaration.index);
303
+ const replaced = block !== void 0 && (FOCUS_REPLACEMENT.test(bodyOf(text, block.open)) || /:not\(\s*:focus-visible\s*\)/i.test(block.header));
304
+ if (!replaced) {
305
+ report(
306
+ "outline-none",
307
+ declaration,
308
+ "Removing the outline hides keyboard focus. Add a visible focus style, such as --kit-color-border-focus."
309
+ );
310
+ }
311
+ }
312
+ if (declaration.important) {
313
+ const allowed = enclosing(text, declaration.index).some((block) => /prefers-reduced-motion|@media\s+print/i.test(block.header));
314
+ if (!allowed) report("important", declaration, "Kit styles have zero specificity, so a plain selector wins. Remove !important.");
315
+ }
316
+ }
317
+ function lineStarts(text) {
318
+ const starts = [0];
319
+ for (let i = 0; i < text.length; i += 1) if (text[i] === "\n") starts.push(i + 1);
320
+ return starts;
321
+ }
322
+ function positionOf(starts, index) {
323
+ let low = 0;
324
+ let high = starts.length - 1;
325
+ while (low < high) {
326
+ const middle = Math.ceil((low + high) / 2);
327
+ if ((starts[middle] ?? 0) <= index) low = middle;
328
+ else high = middle - 1;
329
+ }
330
+ return { line: low + 1, column: index - (starts[low] ?? 0) + 1 };
331
+ }
332
+ var FUNCTION_COLOR = /(?<![\w-])(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*[-+]?[\d.][^)]*\)/gi;
333
+ var REDUCED_MOTION = /prefers-reduced-motion|useReducedMotion|reducedMotion/i;
334
+ var NO_MOTION = /^(?:none|initial|unset)\b/i;
335
+ function checkText(text, file, ignoreMarker) {
336
+ const extension = path3.extname(file).toLowerCase();
337
+ const stripped = blankComments(text, extension !== ".css");
338
+ const starts = lineStarts(text);
339
+ const rawLines = text.split(/\r?\n/);
340
+ const hits = [];
341
+ const seen = /* @__PURE__ */ new Set();
342
+ const push = (rule, index, match, message) => {
343
+ const { line, column } = positionOf(starts, index);
344
+ if ((rawLines[line - 1] ?? "").includes(ignoreMarker)) return;
345
+ const key = `${rule}:${line}:${column}`;
346
+ if (seen.has(key)) return;
347
+ seen.add(key);
348
+ hits.push({ rule, line, column, match, message });
349
+ };
350
+ for (const match of stripped.matchAll(FUNCTION_COLOR)) {
351
+ push("hardcoded-color-function", match.index ?? 0, match[0], "Use a --kit-color-* token.");
352
+ }
353
+ const declarations = [
354
+ ...styleRegions(stripped, extension).flatMap((region) => cssDeclarations(stripped, region)),
355
+ ...SCRIPT_FILES.has(extension) ? jsDeclarations(stripped) : []
356
+ ];
357
+ let firstMotion = null;
358
+ const sawMotion = (index) => {
359
+ firstMotion = firstMotion === null ? index : Math.min(firstMotion, index);
360
+ };
361
+ for (const declaration of declarations) {
362
+ checkDeclaration(declaration, stripped, (rule, source, message) => push(rule, source.index, snippet(source), message));
363
+ const { prop, value } = declaration;
364
+ const animates = (/^animation(?:-name)?$/.test(prop) || TRANSITION.test(prop)) && !NO_MOTION.test(value) || /^(?:animation|transition)-duration$/.test(prop) && times(value).some((ms) => ms > 0);
365
+ if (animates) sawMotion(declaration.index);
366
+ }
367
+ if (CSS_FILES.has(extension)) {
368
+ for (const match of stripped.matchAll(/@keyframes\b/gi)) sawMotion(match.index ?? 0);
369
+ }
370
+ hits.sort((a, b) => a.line - b.line || a.column - b.column);
371
+ return {
372
+ hits,
373
+ motion: {
374
+ first: firstMotion === null ? null : positionOf(starts, firstMotion),
375
+ handlesReducedMotion: REDUCED_MOTION.test(stripped)
47
376
  }
48
377
  };
49
378
  }
379
+
380
+ // src/doctor.ts
50
381
  var SCANNED_EXTENSIONS = /* @__PURE__ */ new Set([
51
382
  ".css",
52
383
  ".scss",
@@ -77,7 +408,7 @@ var SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([
77
408
  var MAX_FILE_BYTES = 1e6;
78
409
  var HEX_COLOR = /(?<![\w&#/-])(?<!\bhref=["'])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-])/g;
79
410
  var IGNORE_MARKER = "kit-ignore";
80
- var toPosix = (value) => value.split(path2.sep).join("/");
411
+ var toPosix = (value) => value.split(path3.sep).join("/");
81
412
  function isIgnored(relativePath, ignore) {
82
413
  return ignore.some((entry) => {
83
414
  const prefix = entry.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
@@ -100,7 +431,7 @@ async function* walk(directory) {
100
431
  entries.sort((a, b) => a.name.localeCompare(b.name));
101
432
  for (const entry of entries) {
102
433
  if (entry.isSymbolicLink()) continue;
103
- const full = path2.join(directory, entry.name);
434
+ const full = path3.join(directory, entry.name);
104
435
  if (entry.isDirectory()) {
105
436
  if (!SKIPPED_DIRECTORIES.has(entry.name)) yield* walk(full);
106
437
  } else if (entry.isFile()) {
@@ -109,15 +440,14 @@ async function* walk(directory) {
109
440
  }
110
441
  }
111
442
  function isScannable(file) {
112
- const name = path2.basename(file);
443
+ const name = path3.basename(file);
113
444
  if (name.includes(".min.") || name.endsWith(".d.ts")) return false;
114
- return SCANNED_EXTENSIONS.has(path2.extname(name));
445
+ return SCANNED_EXTENSIONS.has(path3.extname(name));
115
446
  }
116
- async function scanForHardcodedColors(cwd, targets = ["."], { ignore = [] } = {}) {
117
- const findings = [];
447
+ async function* sourceFiles(cwd, targets, ignore) {
118
448
  const seen = /* @__PURE__ */ new Set();
119
449
  for (const target of targets) {
120
- const root = path2.resolve(cwd, target);
450
+ const root = path3.resolve(cwd, target);
121
451
  const info = await stat(root);
122
452
  const files = info.isDirectory() ? walk(root) : (async function* () {
123
453
  yield root;
@@ -125,15 +455,47 @@ async function scanForHardcodedColors(cwd, targets = ["."], { ignore = [] } = {}
125
455
  for await (const file of files) {
126
456
  if (seen.has(file)) continue;
127
457
  seen.add(file);
128
- const relative = toPosix(path2.relative(cwd, file));
458
+ const relative = toPosix(path3.relative(cwd, file));
129
459
  if (isIgnored(relative, ignore) || !isScannable(file)) continue;
130
460
  const { size } = await stat(file);
131
461
  if (size > MAX_FILE_BYTES) continue;
132
- const text = await readFile(file, "utf8");
133
- for (const finding of findHexColors(text)) {
134
- findings.push({ file: relative, ...finding });
135
- }
462
+ yield { relative, text: await readFile(file, "utf8") };
463
+ }
464
+ }
465
+ }
466
+ function severityOf(rule, overrides) {
467
+ const setting = overrides[rule] ?? RULES.find((item) => item.id === rule)?.severity ?? "warn";
468
+ return setting === "off" ? null : setting;
469
+ }
470
+ async function scanProject(cwd, targets = ["."], { ignore = [], rules = {} } = {}) {
471
+ const findings = [];
472
+ const animated = [];
473
+ let handlesReducedMotion = false;
474
+ const add2 = (finding) => {
475
+ const severity = severityOf(finding.rule, rules);
476
+ if (severity !== null) findings.push({ ...finding, severity });
477
+ };
478
+ for await (const { relative, text } of sourceFiles(cwd, targets, ignore)) {
479
+ const found = [];
480
+ for (const hex of findHexColors(text)) {
481
+ found.push({ file: relative, ...hex, rule: "hardcoded-color", message: "Use a --kit-color-* token." });
136
482
  }
483
+ const { hits, motion } = checkText(text, relative, IGNORE_MARKER);
484
+ for (const hit of hits) found.push({ file: relative, ...hit });
485
+ found.sort((a, b) => a.line - b.line || a.column - b.column);
486
+ for (const finding of found) add2(finding);
487
+ if (motion.first) animated.push({ file: relative, ...motion.first });
488
+ if (motion.handlesReducedMotion) handlesReducedMotion = true;
489
+ }
490
+ const [first] = animated;
491
+ if (first && !handlesReducedMotion) {
492
+ const files = animated.length === 1 ? "1 file animates" : `${animated.length} files animate`;
493
+ add2({
494
+ ...first,
495
+ match: "prefers-reduced-motion",
496
+ rule: "missing-reduced-motion",
497
+ message: `${files}, and nothing scanned handles prefers-reduced-motion. Add a @media (prefers-reduced-motion: reduce) rule that drops movement.`
498
+ });
137
499
  }
138
500
  return findings;
139
501
  }
@@ -151,9 +513,15 @@ Commands
151
513
  Add a component to your project. Planned.
152
514
  theme [name]
153
515
  List the themes and densities. Generating a theme is planned.
154
- doctor [paths...] [--json]
155
- Find hard-coded hex colors that should be --kit- tokens.
156
- Exits with 1 when it finds any.
516
+ doctor [paths...] [--json] [--strict] [--list-rules]
517
+ Check code against the kit's conventions: colors, sizes and motion that
518
+ should be --kit- tokens, left and right properties that will not mirror,
519
+ removed focus outlines, and more. Hex colors are errors and the rest are
520
+ warnings. Exits with 1 on an error, or on any finding with --strict.
521
+ Set a rule to off, warn or error in ${CONFIG_FILE} under doctor.rules.
522
+ design [--theme <name>] [--out <file>] [--force]
523
+ Write the kit's DESIGN.md, a description of the design system that coding
524
+ agents read. Defaults to DESIGN.md. Use --out - to print it.
157
525
 
158
526
  Options
159
527
  -h, --help Show this help.
@@ -182,7 +550,7 @@ async function init(values, io) {
182
550
  `);
183
551
  return 2;
184
552
  }
185
- const target = path2.join(io.cwd, CONFIG_FILE);
553
+ const target = path3.join(io.cwd, CONFIG_FILE);
186
554
  if (!values.force && await exists(target)) {
187
555
  io.err(`${CONFIG_FILE} already exists. Use --force to replace it.
188
556
  `);
@@ -222,12 +590,43 @@ function theme(name, io) {
222
590
  );
223
591
  return 1;
224
592
  }
225
- async function doctor(paths, json, io) {
226
- const config = await loadConfig(io.cwd);
593
+ async function readConfig(io) {
594
+ try {
595
+ return await loadConfig(io.cwd);
596
+ } catch (error) {
597
+ io.err(`${error.message}
598
+ `);
599
+ return void 0;
600
+ }
601
+ }
602
+ var plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
603
+ function listRules(io) {
604
+ const width = Math.max(...RULES.map((rule) => rule.id.length));
605
+ for (const rule of RULES) {
606
+ io.out(`${rule.id.padEnd(width)} ${rule.severity.padEnd(5)} ${rule.description}
607
+ `);
608
+ }
609
+ return 0;
610
+ }
611
+ async function doctor(paths, options, io) {
612
+ if (options.listRules) return listRules(io);
613
+ const config = await readConfig(io);
614
+ if (config === void 0) return 2;
615
+ const unknown = Object.keys(config?.doctor.rules ?? {}).filter((id) => !RULE_IDS.has(id));
616
+ if (unknown.length > 0) {
617
+ io.err(
618
+ `${CONFIG_FILE}: unknown doctor rule ${unknown.map((id) => `"${id}"`).join(", ")}. Run "kit doctor --list-rules" for the rule names.
619
+ `
620
+ );
621
+ return 2;
622
+ }
227
623
  const targets = paths.length > 0 ? paths : ["."];
228
624
  let findings;
229
625
  try {
230
- findings = await scanForHardcodedColors(io.cwd, targets, { ignore: config?.doctor.ignore });
626
+ findings = await scanProject(io.cwd, targets, {
627
+ ignore: config?.doctor.ignore,
628
+ rules: config?.doctor.rules
629
+ });
231
630
  } catch (error) {
232
631
  if (error.code === "ENOENT") {
233
632
  io.err(`Path not found: ${error.path ?? targets.join(", ")}
@@ -236,26 +635,65 @@ async function doctor(paths, json, io) {
236
635
  }
237
636
  throw error;
238
637
  }
239
- if (json) {
638
+ const errors = findings.filter((finding) => finding.severity === "error").length;
639
+ const failed = errors > 0 || options.strict && findings.length > 0;
640
+ if (options.json) {
240
641
  io.out(`${JSON.stringify(findings, null, 2)}
241
642
  `);
242
- return findings.length > 0 ? 1 : 0;
643
+ return failed ? 1 : 0;
243
644
  }
244
645
  if (findings.length === 0) {
245
- io.out("No hard-coded colors found.\n");
646
+ io.out("No problems found.\n");
246
647
  return 0;
247
648
  }
248
649
  for (const finding of findings) {
249
- io.out(`${finding.file}:${finding.line}:${finding.column} ${finding.match} Use a --kit- color token.
250
- `);
650
+ io.out(
651
+ `${finding.file}:${finding.line}:${finding.column} ${finding.match} ${finding.message} (${finding.severity} ${finding.rule})
652
+ `
653
+ );
251
654
  }
252
655
  const files = new Set(findings.map((finding) => finding.file)).size;
253
656
  io.out(
254
657
  `
255
- ${findings.length} hard-coded ${findings.length === 1 ? "color" : "colors"} found in ${files} ${files === 1 ? "file" : "files"}.
658
+ ${plural(errors, "error", "errors")} and ${plural(findings.length - errors, "warning", "warnings")} in ${plural(files, "file", "files")}.
256
659
  `
257
660
  );
258
- return 1;
661
+ return failed ? 1 : 0;
662
+ }
663
+ async function design(values, io) {
664
+ if (values.theme !== void 0 && !isThemeName(values.theme)) {
665
+ io.err(`Unknown theme "${values.theme}". Choose one of: ${themeNames.join(", ")}.
666
+ `);
667
+ return 2;
668
+ }
669
+ const config = await readConfig(io);
670
+ if (config === void 0) return 2;
671
+ const theme2 = values.theme !== void 0 && isThemeName(values.theme) ? values.theme : config?.theme ?? "light";
672
+ const markdown = createDesignMd({ theme: theme2 });
673
+ const out = values.out ?? "DESIGN.md";
674
+ if (out === "-") {
675
+ io.out(markdown);
676
+ return 0;
677
+ }
678
+ const target = path3.resolve(io.cwd, out);
679
+ if (!values.force && await exists(target)) {
680
+ io.err(`${out} already exists. Use --force to replace it, or --out to write somewhere else.
681
+ `);
682
+ return 1;
683
+ }
684
+ try {
685
+ await writeFile(target, markdown, "utf8");
686
+ } catch (error) {
687
+ if (error.code === "ENOENT") {
688
+ io.err(`Cannot write ${out}: the folder does not exist.
689
+ `);
690
+ return 2;
691
+ }
692
+ throw error;
693
+ }
694
+ io.out(`Created ${out} (${theme2} theme)
695
+ `);
696
+ return 0;
259
697
  }
260
698
  async function run(argv, io) {
261
699
  let parsed;
@@ -268,6 +706,9 @@ async function run(argv, io) {
268
706
  version: { type: "boolean", short: "v" },
269
707
  force: { type: "boolean" },
270
708
  json: { type: "boolean" },
709
+ strict: { type: "boolean" },
710
+ "list-rules": { type: "boolean" },
711
+ out: { type: "string" },
271
712
  theme: { type: "string" },
272
713
  density: { type: "string" }
273
714
  }
@@ -297,7 +738,13 @@ ${HELP}`);
297
738
  case "theme":
298
739
  return theme(rest[0], io);
299
740
  case "doctor":
300
- return doctor(rest, values.json ?? false, io);
741
+ return doctor(
742
+ rest,
743
+ { json: values.json ?? false, strict: values.strict ?? false, listRules: values["list-rules"] ?? false },
744
+ io
745
+ );
746
+ case "design":
747
+ return design(values, io);
301
748
  default:
302
749
  io.err(`Unknown command "${command}".
303
750