@urbicon-ui/design 6.1.8
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/README.md +215 -0
- package/dist/cli.js +2732 -0
- package/package.json +50 -0
- package/skill/SKILL.md +61 -0
- package/skill/verbs/adopt.md +33 -0
- package/skill/verbs/audit.md +29 -0
- package/skill/verbs/compose.md +38 -0
- package/skill/verbs/critique.md +27 -0
- package/skill/verbs/fix.md +29 -0
- package/skill/verbs/migrate.md +30 -0
- package/skill/verbs/onboard.md +33 -0
- package/skill/verbs/polish.md +25 -0
- package/skill/verbs/redesign.md +29 -0
- package/skill/verbs/retheme.md +29 -0
- package/templates/AGENTS.md +84 -0
- package/templates/ci-github.yml +26 -0
- package/templates/claude-settings.json +15 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2732 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { readFile as readFile10 } from "node:fs/promises";
|
|
5
|
+
import { resolve as resolve9 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/cli/args.ts
|
|
8
|
+
var BOOLEAN_FLAGS = new Set([
|
|
9
|
+
"json",
|
|
10
|
+
"strict",
|
|
11
|
+
"skip-heuristics",
|
|
12
|
+
"record",
|
|
13
|
+
"hook",
|
|
14
|
+
"ci",
|
|
15
|
+
"help",
|
|
16
|
+
"version"
|
|
17
|
+
]);
|
|
18
|
+
function parseArgs(argv, booleans = BOOLEAN_FLAGS) {
|
|
19
|
+
let command;
|
|
20
|
+
const positionals = [];
|
|
21
|
+
const flags = {};
|
|
22
|
+
for (let i = 0;i < argv.length; i++) {
|
|
23
|
+
const arg = argv[i];
|
|
24
|
+
if (arg === undefined)
|
|
25
|
+
continue;
|
|
26
|
+
if (arg.startsWith("--")) {
|
|
27
|
+
const body = arg.slice(2);
|
|
28
|
+
const eq = body.indexOf("=");
|
|
29
|
+
if (eq !== -1) {
|
|
30
|
+
flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (booleans.has(body)) {
|
|
34
|
+
flags[body] = true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const next = argv[i + 1];
|
|
38
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
39
|
+
flags[body] = next;
|
|
40
|
+
i++;
|
|
41
|
+
} else {
|
|
42
|
+
flags[body] = true;
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (command === undefined)
|
|
47
|
+
command = arg;
|
|
48
|
+
else
|
|
49
|
+
positionals.push(arg);
|
|
50
|
+
}
|
|
51
|
+
return { command, positionals, flags };
|
|
52
|
+
}
|
|
53
|
+
function stringFlag(flags, key) {
|
|
54
|
+
const value = flags[key];
|
|
55
|
+
return typeof value === "string" ? value : undefined;
|
|
56
|
+
}
|
|
57
|
+
function boolFlag(flags, key) {
|
|
58
|
+
const value = flags[key];
|
|
59
|
+
return value === true || value === "true";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/cli/commands/context.ts
|
|
63
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
64
|
+
|
|
65
|
+
// ../design-engine/src/manifest/history.ts
|
|
66
|
+
function serializeHistoryEntry(entry) {
|
|
67
|
+
return JSON.stringify(entry);
|
|
68
|
+
}
|
|
69
|
+
function isEntry(value) {
|
|
70
|
+
if (typeof value !== "object" || value === null)
|
|
71
|
+
return false;
|
|
72
|
+
const e = value;
|
|
73
|
+
return typeof e.date === "string" && typeof e.files === "number" && typeof e.errors === "number" && typeof e.warnings === "number" && typeof e.infos === "number" && typeof e.correctness === "number" && typeof e.slop === "number";
|
|
74
|
+
}
|
|
75
|
+
function parseHistory(ndjson) {
|
|
76
|
+
const entries = [];
|
|
77
|
+
for (const line of ndjson.split(`
|
|
78
|
+
`)) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (!trimmed)
|
|
81
|
+
continue;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(trimmed);
|
|
84
|
+
if (isEntry(parsed))
|
|
85
|
+
entries.push(parsed);
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
return entries;
|
|
89
|
+
}
|
|
90
|
+
// ../design-engine/src/manifest/manifest.ts
|
|
91
|
+
var INTENT_HEADING = "## Product Intent";
|
|
92
|
+
var TOKEN_OVERRIDES_HEADING = "## Token Overrides";
|
|
93
|
+
var USAGES_HEADING = "## Pattern Usages";
|
|
94
|
+
var DECISIONS_HEADING = "## Design Decisions";
|
|
95
|
+
var USAGES_MARKER_PREFIX = "<!-- AUTO-GENERATED pattern usages";
|
|
96
|
+
var USAGES_START = `${USAGES_MARKER_PREFIX} — regenerated from data-design-pattern markers; do not edit by hand -->`;
|
|
97
|
+
var USAGES_END = "<!-- END pattern usages -->";
|
|
98
|
+
function parseFrontmatter(content) {
|
|
99
|
+
const data = {};
|
|
100
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
101
|
+
if (!match)
|
|
102
|
+
return { data, body: content };
|
|
103
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
104
|
+
const kv = line.match(/^([a-zA-Z][\w-]*)\s*:\s*(.*)$/);
|
|
105
|
+
if (kv) {
|
|
106
|
+
const value = kv[2].trim().replace(/^["']|["']$/g, "");
|
|
107
|
+
if (value)
|
|
108
|
+
data[kv[1]] = value;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { data, body: content.slice(match[0].length) };
|
|
112
|
+
}
|
|
113
|
+
function extractSection(body, heading) {
|
|
114
|
+
const lines = body.split(`
|
|
115
|
+
`);
|
|
116
|
+
const start = lines.findIndex((l) => l.trim() === heading);
|
|
117
|
+
if (start === -1)
|
|
118
|
+
return null;
|
|
119
|
+
let end = lines.length;
|
|
120
|
+
for (let i = start + 1;i < lines.length; i++) {
|
|
121
|
+
if (/^## /.test(lines[i])) {
|
|
122
|
+
end = i;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return lines.slice(start + 1, end).join(`
|
|
127
|
+
`);
|
|
128
|
+
}
|
|
129
|
+
function parseUsages(body) {
|
|
130
|
+
const section = extractSection(body, USAGES_HEADING);
|
|
131
|
+
if (!section)
|
|
132
|
+
return [];
|
|
133
|
+
const usages = [];
|
|
134
|
+
for (const m of section.matchAll(/^- `([a-z0-9-]+)`\s+—\s+(.+)$/gm)) {
|
|
135
|
+
usages.push({ pattern: m[1], file: m[2].trim() });
|
|
136
|
+
}
|
|
137
|
+
return usages;
|
|
138
|
+
}
|
|
139
|
+
function parseDecisions(body) {
|
|
140
|
+
const section = extractSection(body, DECISIONS_HEADING);
|
|
141
|
+
if (!section)
|
|
142
|
+
return [];
|
|
143
|
+
const decisions = [];
|
|
144
|
+
const blocks = section.split(/^### /m).slice(1);
|
|
145
|
+
for (const block of blocks) {
|
|
146
|
+
const headerLine = block.split(`
|
|
147
|
+
`, 1)[0];
|
|
148
|
+
const header = headerLine.match(/^(\d{4}-\d{2}-\d{2})\s+—\s+(.+)$/);
|
|
149
|
+
if (!header)
|
|
150
|
+
continue;
|
|
151
|
+
const field = (name) => block.match(new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+)`))?.[1]?.trim();
|
|
152
|
+
decisions.push({
|
|
153
|
+
date: header[1],
|
|
154
|
+
title: header[2].trim(),
|
|
155
|
+
status: field("Status") ?? "accepted",
|
|
156
|
+
decision: field("Decision") ?? "",
|
|
157
|
+
rationale: field("Rationale")
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return decisions;
|
|
161
|
+
}
|
|
162
|
+
function emptyIntent() {
|
|
163
|
+
return { voice: [], references: [], antiReferences: [] };
|
|
164
|
+
}
|
|
165
|
+
function parseIntent(body) {
|
|
166
|
+
const section = extractSection(body, INTENT_HEADING);
|
|
167
|
+
if (section === null)
|
|
168
|
+
return emptyIntent();
|
|
169
|
+
const lines = section.split(`
|
|
170
|
+
`);
|
|
171
|
+
const inlineField = (label) => {
|
|
172
|
+
const re = new RegExp(`^\\*\\*${label}:\\*\\*\\s*(.+)$`);
|
|
173
|
+
for (const l of lines) {
|
|
174
|
+
const m = l.match(re);
|
|
175
|
+
if (m)
|
|
176
|
+
return m[1].trim();
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
};
|
|
180
|
+
const listField = (label) => {
|
|
181
|
+
const items = [];
|
|
182
|
+
const labelRe = new RegExp(`^\\*\\*${label}:\\*\\*\\s*(.*)$`);
|
|
183
|
+
let capturing = false;
|
|
184
|
+
for (const l of lines) {
|
|
185
|
+
if (!capturing) {
|
|
186
|
+
const m = l.match(labelRe);
|
|
187
|
+
if (m) {
|
|
188
|
+
capturing = true;
|
|
189
|
+
const inline = m[1].trim();
|
|
190
|
+
if (inline)
|
|
191
|
+
items.push(...splitList(inline));
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (/^\*\*[^*]+:\*\*/.test(l))
|
|
196
|
+
break;
|
|
197
|
+
const bullet = l.match(/^\s*[-*]\s+(.+)$/);
|
|
198
|
+
if (bullet)
|
|
199
|
+
items.push(bullet[1].trim());
|
|
200
|
+
}
|
|
201
|
+
return items;
|
|
202
|
+
};
|
|
203
|
+
return {
|
|
204
|
+
audience: inlineField("Audience"),
|
|
205
|
+
voice: listField("Voice"),
|
|
206
|
+
references: listField("References"),
|
|
207
|
+
antiReferences: listField("Anti-references")
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function splitList(value) {
|
|
211
|
+
return value.split(",").map((part) => part.trim()).filter(Boolean);
|
|
212
|
+
}
|
|
213
|
+
function parseTokenOverrides(body) {
|
|
214
|
+
const section = extractSection(body, TOKEN_OVERRIDES_HEADING);
|
|
215
|
+
if (!section)
|
|
216
|
+
return [];
|
|
217
|
+
const cores = [];
|
|
218
|
+
const seen = new Set;
|
|
219
|
+
for (const m of section.matchAll(/^\s*[-*]\s+`([a-z][a-z0-9-]*)`/gm)) {
|
|
220
|
+
const core = m[1];
|
|
221
|
+
if (!seen.has(core)) {
|
|
222
|
+
seen.add(core);
|
|
223
|
+
cores.push(core);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return cores;
|
|
227
|
+
}
|
|
228
|
+
function parseManifest(content, exists = true) {
|
|
229
|
+
const { data, body } = parseFrontmatter(content);
|
|
230
|
+
return {
|
|
231
|
+
frontmatter: data,
|
|
232
|
+
intent: parseIntent(body),
|
|
233
|
+
tokenOverrides: parseTokenOverrides(body),
|
|
234
|
+
usages: parseUsages(body),
|
|
235
|
+
decisions: parseDecisions(body),
|
|
236
|
+
exists
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function emptyManifest() {
|
|
240
|
+
return {
|
|
241
|
+
frontmatter: {},
|
|
242
|
+
intent: emptyIntent(),
|
|
243
|
+
tokenOverrides: [],
|
|
244
|
+
usages: [],
|
|
245
|
+
decisions: [],
|
|
246
|
+
exists: false
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function renderUsagesBlock(usages) {
|
|
250
|
+
const lines = [USAGES_START];
|
|
251
|
+
if (usages.length === 0) {
|
|
252
|
+
lines.push("", "_No `data-design-pattern` markers found yet._", "");
|
|
253
|
+
} else {
|
|
254
|
+
lines.push("");
|
|
255
|
+
const sorted = [...usages].sort((a, b) => a.pattern.localeCompare(b.pattern) || a.file.localeCompare(b.file));
|
|
256
|
+
for (const u of sorted)
|
|
257
|
+
lines.push(`- \`${u.pattern}\` — ${u.file}`);
|
|
258
|
+
lines.push("");
|
|
259
|
+
}
|
|
260
|
+
lines.push(USAGES_END);
|
|
261
|
+
return lines.join(`
|
|
262
|
+
`);
|
|
263
|
+
}
|
|
264
|
+
function upsertUsagesSection(content, usages) {
|
|
265
|
+
const block = renderUsagesBlock(usages);
|
|
266
|
+
const startIdx = content.indexOf(USAGES_MARKER_PREFIX);
|
|
267
|
+
if (startIdx !== -1) {
|
|
268
|
+
const endIdx = content.indexOf(USAGES_END, startIdx);
|
|
269
|
+
if (endIdx !== -1) {
|
|
270
|
+
const after = endIdx + USAGES_END.length;
|
|
271
|
+
return content.slice(0, startIdx) + block + content.slice(after);
|
|
272
|
+
}
|
|
273
|
+
const nextSection = content.indexOf(`
|
|
274
|
+
## `, startIdx);
|
|
275
|
+
const truncateAt = nextSection !== -1 ? nextSection : content.length;
|
|
276
|
+
return content.slice(0, startIdx) + block + content.slice(truncateAt);
|
|
277
|
+
}
|
|
278
|
+
if (content.includes(`
|
|
279
|
+
${USAGES_HEADING}`) || content.startsWith(USAGES_HEADING)) {
|
|
280
|
+
return content.replace(new RegExp(`(${USAGES_HEADING}\\n)`), (_m, heading) => `${heading}
|
|
281
|
+
${block}
|
|
282
|
+
`);
|
|
283
|
+
}
|
|
284
|
+
const sep = content.endsWith(`
|
|
285
|
+
`) ? `
|
|
286
|
+
` : `
|
|
287
|
+
|
|
288
|
+
`;
|
|
289
|
+
return `${content}${sep}${USAGES_HEADING}
|
|
290
|
+
|
|
291
|
+
${block}
|
|
292
|
+
`;
|
|
293
|
+
}
|
|
294
|
+
function oneLine(s) {
|
|
295
|
+
return s.replace(/[\r\n]+/g, " ").trim();
|
|
296
|
+
}
|
|
297
|
+
function renderDecision(d) {
|
|
298
|
+
const lines = [
|
|
299
|
+
`### ${d.date} — ${oneLine(d.title)}`,
|
|
300
|
+
"",
|
|
301
|
+
`**Status:** ${oneLine(d.status)}`,
|
|
302
|
+
"",
|
|
303
|
+
`**Decision:** ${oneLine(d.decision)}`
|
|
304
|
+
];
|
|
305
|
+
if (d.rationale)
|
|
306
|
+
lines.push("", `**Rationale:** ${oneLine(d.rationale)}`);
|
|
307
|
+
return `${lines.join(`
|
|
308
|
+
`)}
|
|
309
|
+
`;
|
|
310
|
+
}
|
|
311
|
+
function appendDecision(content, decision) {
|
|
312
|
+
const block = renderDecision(decision);
|
|
313
|
+
const m = content.match(/(?:^|\n)## Design Decisions[^\n]*\n/);
|
|
314
|
+
if (m && m.index !== undefined) {
|
|
315
|
+
let pos = m.index + m[0].length;
|
|
316
|
+
let prefix = "";
|
|
317
|
+
if (content[pos] === `
|
|
318
|
+
`)
|
|
319
|
+
pos += 1;
|
|
320
|
+
else
|
|
321
|
+
prefix = `
|
|
322
|
+
`;
|
|
323
|
+
return `${content.slice(0, pos) + prefix + block}
|
|
324
|
+
${content.slice(pos)}`;
|
|
325
|
+
}
|
|
326
|
+
const sep = content.endsWith(`
|
|
327
|
+
`) ? `
|
|
328
|
+
` : `
|
|
329
|
+
|
|
330
|
+
`;
|
|
331
|
+
return `${content}${sep}${DECISIONS_HEADING}
|
|
332
|
+
|
|
333
|
+
${block}`;
|
|
334
|
+
}
|
|
335
|
+
function createManifestTemplate(opts) {
|
|
336
|
+
const fm = [
|
|
337
|
+
"---",
|
|
338
|
+
`paradigm: ${opts.paradigm ?? "minimal"}`,
|
|
339
|
+
`theme: ${opts.theme ?? "default"}`,
|
|
340
|
+
`density: ${opts.density ?? "comfortable"}`,
|
|
341
|
+
"---"
|
|
342
|
+
].join(`
|
|
343
|
+
`);
|
|
344
|
+
return [
|
|
345
|
+
fm,
|
|
346
|
+
"",
|
|
347
|
+
`# Design Manifest${opts.projectName ? ` — ${opts.projectName}` : ""}`,
|
|
348
|
+
"",
|
|
349
|
+
"The persistent design intent for this project. Frontmatter records the enforced intake",
|
|
350
|
+
"decisions (paradigm, theme, density). `## Product Intent` is the target identity.",
|
|
351
|
+
"`## Token Overrides` lists project-specific tokens `urbicon validate` should accept.",
|
|
352
|
+
"`## Pattern Usages` is regenerated from `data-design-pattern` markers by",
|
|
353
|
+
"`urbicon sync-manifest`. `## Design Decisions` is an append-only ADR log written by",
|
|
354
|
+
"`urbicon record-decision`.",
|
|
355
|
+
"",
|
|
356
|
+
INTENT_HEADING,
|
|
357
|
+
"",
|
|
358
|
+
"<!-- The identity this project designs toward — read at the start of every design task.",
|
|
359
|
+
' Fill each field; an empty field is simply "not set yet". Voice = a few adjectives. -->',
|
|
360
|
+
"",
|
|
361
|
+
"**Audience:**",
|
|
362
|
+
"",
|
|
363
|
+
"**Voice:**",
|
|
364
|
+
"",
|
|
365
|
+
"**References:**",
|
|
366
|
+
"",
|
|
367
|
+
"**Anti-references:**",
|
|
368
|
+
"",
|
|
369
|
+
TOKEN_OVERRIDES_HEADING,
|
|
370
|
+
"",
|
|
371
|
+
"<!-- Project-specific semantic token cores defined on top of Urbicon’s. Listed here as a",
|
|
372
|
+
" bullet of `core` (the part after the utility prefix — `surface-brand`, not",
|
|
373
|
+
" `bg-surface-brand`), they are treated as valid by `urbicon validate`. -->",
|
|
374
|
+
"",
|
|
375
|
+
"_None yet._",
|
|
376
|
+
"",
|
|
377
|
+
USAGES_HEADING,
|
|
378
|
+
"",
|
|
379
|
+
renderUsagesBlock([]),
|
|
380
|
+
"",
|
|
381
|
+
DECISIONS_HEADING,
|
|
382
|
+
""
|
|
383
|
+
].join(`
|
|
384
|
+
`);
|
|
385
|
+
}
|
|
386
|
+
function intentIsEmpty(intent) {
|
|
387
|
+
return !intent.audience && intent.voice.length === 0 && intent.references.length === 0 && intent.antiReferences.length === 0;
|
|
388
|
+
}
|
|
389
|
+
function formatDrift(history) {
|
|
390
|
+
const recent = history.slice(-5);
|
|
391
|
+
const last = recent[recent.length - 1];
|
|
392
|
+
let md = `## Validation Drift
|
|
393
|
+
|
|
394
|
+
`;
|
|
395
|
+
md += `Last run (${last.date}): correctness ${last.correctness}/100 · slop ${last.slop}/100 — ` + `${last.files} file(s), ${last.errors} error(s), ${last.warnings} warning(s).
|
|
396
|
+
`;
|
|
397
|
+
if (recent.length > 1) {
|
|
398
|
+
md += `
|
|
399
|
+
Recent correctness: ${recent.map((e) => e.correctness).join(" → ")}
|
|
400
|
+
`;
|
|
401
|
+
md += `Recent slop-floor: ${recent.map((e) => e.slop).join(" → ")}
|
|
402
|
+
`;
|
|
403
|
+
}
|
|
404
|
+
return md;
|
|
405
|
+
}
|
|
406
|
+
function formatContext(manifest, history = []) {
|
|
407
|
+
let md = `# Design Context
|
|
408
|
+
|
|
409
|
+
`;
|
|
410
|
+
const fm = manifest.frontmatter;
|
|
411
|
+
const keys = Object.keys(fm);
|
|
412
|
+
if (keys.length > 0) {
|
|
413
|
+
md += `## Intake
|
|
414
|
+
|
|
415
|
+
`;
|
|
416
|
+
for (const k of keys)
|
|
417
|
+
md += `- **${k}:** ${fm[k]}
|
|
418
|
+
`;
|
|
419
|
+
md += `
|
|
420
|
+
`;
|
|
421
|
+
if (fm.paradigm) {
|
|
422
|
+
md += `> Stay within the **${fm.paradigm}** paradigm. Call \`get_design_principles(topic="theming")\` for its token profile.
|
|
423
|
+
|
|
424
|
+
`;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
md += `## Product Intent
|
|
428
|
+
|
|
429
|
+
`;
|
|
430
|
+
const intent = manifest.intent;
|
|
431
|
+
if (intentIsEmpty(intent)) {
|
|
432
|
+
md += `_Not set._ Define audience, voice, references and anti-references so design stays consistent with a target identity, not merely generic.
|
|
433
|
+
|
|
434
|
+
`;
|
|
435
|
+
} else {
|
|
436
|
+
if (intent.audience)
|
|
437
|
+
md += `- **Audience:** ${intent.audience}
|
|
438
|
+
`;
|
|
439
|
+
if (intent.voice.length > 0)
|
|
440
|
+
md += `- **Voice:** ${intent.voice.join(", ")}
|
|
441
|
+
`;
|
|
442
|
+
if (intent.references.length > 0)
|
|
443
|
+
md += `- **References:** ${intent.references.join("; ")}
|
|
444
|
+
`;
|
|
445
|
+
if (intent.antiReferences.length > 0)
|
|
446
|
+
md += `- **Anti-references:** ${intent.antiReferences.join("; ")}
|
|
447
|
+
`;
|
|
448
|
+
md += `
|
|
449
|
+
`;
|
|
450
|
+
}
|
|
451
|
+
if (manifest.tokenOverrides.length > 0) {
|
|
452
|
+
md += `## Token Overrides
|
|
453
|
+
|
|
454
|
+
`;
|
|
455
|
+
md += `${manifest.tokenOverrides.map((c) => `\`${c}\``).join(", ")}
|
|
456
|
+
|
|
457
|
+
`;
|
|
458
|
+
md += "> Treated as valid by `urbicon validate` (passed as extra tokens for this project).\n\n";
|
|
459
|
+
}
|
|
460
|
+
md += `## Pattern Usages
|
|
461
|
+
|
|
462
|
+
`;
|
|
463
|
+
if (manifest.usages.length === 0) {
|
|
464
|
+
md += '_None recorded._ Add `data-design-pattern="<name>"` to page roots, then run `urbicon sync-manifest`.\n\n';
|
|
465
|
+
} else {
|
|
466
|
+
const byPattern = new Map;
|
|
467
|
+
for (const u of manifest.usages) {
|
|
468
|
+
(byPattern.get(u.pattern) ?? byPattern.set(u.pattern, []).get(u.pattern)).push(u.file);
|
|
469
|
+
}
|
|
470
|
+
for (const [pattern, files] of [...byPattern].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
471
|
+
md += `- \`${pattern}\` (${files.length}): ${files.join(", ")}
|
|
472
|
+
`;
|
|
473
|
+
}
|
|
474
|
+
md += `
|
|
475
|
+
> To change a pattern across the app, migrate every file listed under it.
|
|
476
|
+
|
|
477
|
+
`;
|
|
478
|
+
}
|
|
479
|
+
md += `## Design Decisions
|
|
480
|
+
|
|
481
|
+
`;
|
|
482
|
+
if (manifest.decisions.length === 0) {
|
|
483
|
+
md += "_None recorded._ Use `urbicon record-decision` when you deviate from a pattern or principle.\n";
|
|
484
|
+
} else {
|
|
485
|
+
for (const d of manifest.decisions) {
|
|
486
|
+
md += `- **${d.date} — ${d.title}** (${d.status}): ${d.decision}
|
|
487
|
+
`;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (history.length > 0)
|
|
491
|
+
md += `
|
|
492
|
+
${formatDrift(history)}`;
|
|
493
|
+
return md;
|
|
494
|
+
}
|
|
495
|
+
// ../design-engine/src/manifest/scan.ts
|
|
496
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
497
|
+
import { join, relative, sep } from "node:path";
|
|
498
|
+
var SCANNED_EXT = /\.(svelte|html|tsx|jsx|astro|vue)$/;
|
|
499
|
+
var SKIP_DIRS = new Set([
|
|
500
|
+
"node_modules",
|
|
501
|
+
".svelte-kit",
|
|
502
|
+
".git",
|
|
503
|
+
"dist",
|
|
504
|
+
"build",
|
|
505
|
+
".next",
|
|
506
|
+
".turbo",
|
|
507
|
+
"coverage"
|
|
508
|
+
]);
|
|
509
|
+
var MARKER_RE = /data-design-pattern\s*=\s*["'`]([a-z0-9-]+)["'`]/g;
|
|
510
|
+
var MAX_DEPTH = 24;
|
|
511
|
+
async function scanMarkers(dir, baseDir = dir, depth = 0) {
|
|
512
|
+
const usages = [];
|
|
513
|
+
if (depth > MAX_DEPTH)
|
|
514
|
+
return usages;
|
|
515
|
+
let names;
|
|
516
|
+
try {
|
|
517
|
+
names = await readdir(dir);
|
|
518
|
+
} catch {
|
|
519
|
+
return usages;
|
|
520
|
+
}
|
|
521
|
+
for (const name of names) {
|
|
522
|
+
const full = join(dir, name);
|
|
523
|
+
const info = await stat(full).catch(() => null);
|
|
524
|
+
if (!info)
|
|
525
|
+
continue;
|
|
526
|
+
if (info.isDirectory()) {
|
|
527
|
+
if (SKIP_DIRS.has(name) || name.startsWith("."))
|
|
528
|
+
continue;
|
|
529
|
+
usages.push(...await scanMarkers(full, baseDir, depth + 1));
|
|
530
|
+
} else if (info.isFile() && SCANNED_EXT.test(name)) {
|
|
531
|
+
let content;
|
|
532
|
+
try {
|
|
533
|
+
content = await readFile(full, "utf-8");
|
|
534
|
+
} catch {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const seen = new Set;
|
|
538
|
+
for (const m of content.matchAll(MARKER_RE)) {
|
|
539
|
+
const pattern = m[1];
|
|
540
|
+
if (seen.has(pattern))
|
|
541
|
+
continue;
|
|
542
|
+
seen.add(pattern);
|
|
543
|
+
usages.push({ pattern, file: relative(baseDir, full).split(sep).join("/") });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return usages;
|
|
548
|
+
}
|
|
549
|
+
// src/cli/manifest-io.ts
|
|
550
|
+
import { appendFile, readFile as readFile2, writeFile } from "node:fs/promises";
|
|
551
|
+
import { resolve } from "node:path";
|
|
552
|
+
function resolveManifestPath(flag) {
|
|
553
|
+
return resolve(flag ?? "design.manifest.md");
|
|
554
|
+
}
|
|
555
|
+
function resolveSourceDir(flag) {
|
|
556
|
+
return resolve(flag ?? "src");
|
|
557
|
+
}
|
|
558
|
+
function resolveHistoryPath(manifestPath) {
|
|
559
|
+
return manifestPath.endsWith(".md") ? `${manifestPath.slice(0, -".md".length)}.history.ndjson` : `${manifestPath}.history.ndjson`;
|
|
560
|
+
}
|
|
561
|
+
async function readTokenOverrides(manifestPath) {
|
|
562
|
+
try {
|
|
563
|
+
return parseManifest(await readFile2(manifestPath, "utf-8")).tokenOverrides;
|
|
564
|
+
} catch {
|
|
565
|
+
return [];
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
async function readHistory(manifestPath) {
|
|
569
|
+
try {
|
|
570
|
+
return parseHistory(await readFile2(resolveHistoryPath(manifestPath), "utf-8"));
|
|
571
|
+
} catch {
|
|
572
|
+
return [];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function appendHistory(manifestPath, line) {
|
|
576
|
+
return appendFile(resolveHistoryPath(manifestPath), `${line}
|
|
577
|
+
`, "utf-8");
|
|
578
|
+
}
|
|
579
|
+
async function readOrCreateManifest(path) {
|
|
580
|
+
try {
|
|
581
|
+
return { content: await readFile2(path, "utf-8"), created: false };
|
|
582
|
+
} catch {
|
|
583
|
+
return { content: createManifestTemplate({}), created: true };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function writeManifest(path, content) {
|
|
587
|
+
return writeFile(path, content, "utf-8");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/cli/output.ts
|
|
591
|
+
var EXIT = { OK: 0, FAIL: 1, USAGE: 2 };
|
|
592
|
+
var SEVERITY_ICON = { error: "✗", warning: "!", info: "·" };
|
|
593
|
+
function formatFinding(f) {
|
|
594
|
+
const loc = f.line ? `:${f.line}` : "";
|
|
595
|
+
const match = f.match ? ` \`${f.match}\`` : "";
|
|
596
|
+
return ` ${SEVERITY_ICON[f.severity]} [${f.ruleId}]${loc}${match} — ${f.message}
|
|
597
|
+
↳ ${f.fix}`;
|
|
598
|
+
}
|
|
599
|
+
function formatReport(report) {
|
|
600
|
+
const { scores, counts, findings, filename } = report;
|
|
601
|
+
const head = `${filename ?? "<stdin>"} — correctness ${scores.correctness}/100 · slop ${scores.slop}/100 · ` + `${counts.error} error(s), ${counts.warning} warning(s), ${counts.info} slop note(s)`;
|
|
602
|
+
if (findings.length === 0)
|
|
603
|
+
return `${head}
|
|
604
|
+
✓ no issues`;
|
|
605
|
+
return `${head}
|
|
606
|
+
${findings.map(formatFinding).join(`
|
|
607
|
+
`)}`;
|
|
608
|
+
}
|
|
609
|
+
function printError(message) {
|
|
610
|
+
console.error(`urbicon: ${message}`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/cli/commands/context.ts
|
|
614
|
+
async function runContext(_positionals, flags) {
|
|
615
|
+
const path = resolveManifestPath(stringFlag(flags, "manifest"));
|
|
616
|
+
let manifest;
|
|
617
|
+
try {
|
|
618
|
+
manifest = parseManifest(await readFile3(path, "utf-8"));
|
|
619
|
+
} catch {
|
|
620
|
+
manifest = emptyManifest();
|
|
621
|
+
}
|
|
622
|
+
const history = await readHistory(path);
|
|
623
|
+
if (boolFlag(flags, "json")) {
|
|
624
|
+
console.log(JSON.stringify({ ...manifest, history }, null, 2));
|
|
625
|
+
return EXIT.OK;
|
|
626
|
+
}
|
|
627
|
+
let text = formatContext(manifest, history);
|
|
628
|
+
if (!manifest.exists) {
|
|
629
|
+
text += `
|
|
630
|
+
|
|
631
|
+
> No manifest at \`${path}\`. Create one with \`urbicon sync-manifest\` ` + "or record the first decision with `urbicon record-decision`.";
|
|
632
|
+
}
|
|
633
|
+
console.log(text);
|
|
634
|
+
return EXIT.OK;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ../design-engine/src/search/match.ts
|
|
638
|
+
function matchComponents(components, query, tags, limit = 5) {
|
|
639
|
+
const keywords = query.toLowerCase().split(/[\s,\-_]+/).filter((w) => w.length > 1);
|
|
640
|
+
const scored = components.map((entry) => {
|
|
641
|
+
let score = 0;
|
|
642
|
+
const nameLower = entry.name.toLowerCase();
|
|
643
|
+
const slugLower = entry.slug.toLowerCase();
|
|
644
|
+
const descLower = entry.description.toLowerCase();
|
|
645
|
+
for (const kw of keywords) {
|
|
646
|
+
if (nameLower === kw || slugLower === kw) {
|
|
647
|
+
score += 10;
|
|
648
|
+
} else if (nameLower.includes(kw) || slugLower.includes(kw)) {
|
|
649
|
+
score += 7;
|
|
650
|
+
} else {
|
|
651
|
+
const nameDist = levenshtein(nameLower, kw);
|
|
652
|
+
const slugDist = levenshtein(slugLower, kw);
|
|
653
|
+
const minDist = Math.min(nameDist, slugDist);
|
|
654
|
+
if (minDist <= 1) {
|
|
655
|
+
score += 6;
|
|
656
|
+
} else if (minDist <= 2) {
|
|
657
|
+
score += 3;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (entry.tags.some((t) => t.toLowerCase() === kw)) {
|
|
661
|
+
score += 5;
|
|
662
|
+
}
|
|
663
|
+
if (descLower.includes(kw)) {
|
|
664
|
+
score += 3;
|
|
665
|
+
}
|
|
666
|
+
if (entry.keyProps.some((p) => p.toLowerCase().includes(kw))) {
|
|
667
|
+
score += 1;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (tags && tags.length > 0) {
|
|
671
|
+
const entryTags = entry.tags.map((t) => t.toLowerCase());
|
|
672
|
+
for (const tag of tags) {
|
|
673
|
+
if (entryTags.includes(tag.toLowerCase())) {
|
|
674
|
+
score += 5;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return { entry, score };
|
|
679
|
+
});
|
|
680
|
+
return scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.entry);
|
|
681
|
+
}
|
|
682
|
+
function levenshtein(a, b) {
|
|
683
|
+
if (a.length === 0)
|
|
684
|
+
return b.length;
|
|
685
|
+
if (b.length === 0)
|
|
686
|
+
return a.length;
|
|
687
|
+
if (Math.abs(a.length - b.length) > 2)
|
|
688
|
+
return 3;
|
|
689
|
+
const matrix = [];
|
|
690
|
+
for (let i = 0;i <= a.length; i++) {
|
|
691
|
+
matrix[i] = [i];
|
|
692
|
+
}
|
|
693
|
+
for (let j = 0;j <= b.length; j++) {
|
|
694
|
+
matrix[0][j] = j;
|
|
695
|
+
}
|
|
696
|
+
for (let i = 1;i <= a.length; i++) {
|
|
697
|
+
for (let j = 1;j <= b.length; j++) {
|
|
698
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
699
|
+
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return matrix[a.length][b.length];
|
|
703
|
+
}
|
|
704
|
+
// ../design-engine/src/search/section.ts
|
|
705
|
+
var SECTION_HEADING_MAP = {
|
|
706
|
+
examples: "examples",
|
|
707
|
+
variants: "variants",
|
|
708
|
+
api: "api",
|
|
709
|
+
"slots (slotclasses keys)": "slots"
|
|
710
|
+
};
|
|
711
|
+
function extractSection2(content, section) {
|
|
712
|
+
if (section === "overview") {
|
|
713
|
+
const firstH3 = content.indexOf(`
|
|
714
|
+
### `);
|
|
715
|
+
if (firstH3 === -1)
|
|
716
|
+
return content.trim();
|
|
717
|
+
return content.slice(0, firstH3).trim();
|
|
718
|
+
}
|
|
719
|
+
const lines = content.split(`
|
|
720
|
+
`);
|
|
721
|
+
let capturing = false;
|
|
722
|
+
const result = [];
|
|
723
|
+
for (const line of lines) {
|
|
724
|
+
if (line.startsWith("### ")) {
|
|
725
|
+
const heading = line.slice(4).trim().toLowerCase();
|
|
726
|
+
const mapped = SECTION_HEADING_MAP[heading];
|
|
727
|
+
if (mapped === section) {
|
|
728
|
+
capturing = true;
|
|
729
|
+
result.push(line);
|
|
730
|
+
continue;
|
|
731
|
+
} else if (capturing) {
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (capturing) {
|
|
736
|
+
result.push(line);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return result.length > 0 ? result.join(`
|
|
740
|
+
`).trim() : null;
|
|
741
|
+
}
|
|
742
|
+
// src/cli/content.ts
|
|
743
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
744
|
+
import { createRequire } from "node:module";
|
|
745
|
+
import { dirname as dirname2, resolve as resolve3 } from "node:path";
|
|
746
|
+
|
|
747
|
+
// ../design-content/src/content-loader.ts
|
|
748
|
+
import { dirname, normalize, resolve as resolve2 } from "node:path";
|
|
749
|
+
import { fileURLToPath } from "node:url";
|
|
750
|
+
var moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
751
|
+
function getContentDir() {
|
|
752
|
+
return process.env.URBICON_CONTENT_DIR ?? resolve2(moduleDir, "..", "content");
|
|
753
|
+
}
|
|
754
|
+
function getCatalogPath() {
|
|
755
|
+
return resolve2(getContentDir(), "component-catalog.json");
|
|
756
|
+
}
|
|
757
|
+
var SAFE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
758
|
+
function getComponentLlmPath(group, slug) {
|
|
759
|
+
if (!SAFE_SLUG.test(slug)) {
|
|
760
|
+
throw new Error(`Invalid component slug: "${slug}"`);
|
|
761
|
+
}
|
|
762
|
+
const resolved = resolve2(getContentDir(), group, slug, "llm.txt");
|
|
763
|
+
const base = normalize(getContentDir());
|
|
764
|
+
if (!resolved.startsWith(base)) {
|
|
765
|
+
throw new Error(`Path traversal blocked for slug: "${slug}"`);
|
|
766
|
+
}
|
|
767
|
+
return resolved;
|
|
768
|
+
}
|
|
769
|
+
// src/cli/content.ts
|
|
770
|
+
var SEARCH_GROUPS = [
|
|
771
|
+
"blocks/primitives",
|
|
772
|
+
"blocks/components",
|
|
773
|
+
"docs/components",
|
|
774
|
+
"table",
|
|
775
|
+
"auth/components"
|
|
776
|
+
];
|
|
777
|
+
var contentDirEnsured = false;
|
|
778
|
+
function ensureContentDir() {
|
|
779
|
+
if (contentDirEnsured)
|
|
780
|
+
return;
|
|
781
|
+
contentDirEnsured = true;
|
|
782
|
+
if (process.env.URBICON_CONTENT_DIR)
|
|
783
|
+
return;
|
|
784
|
+
try {
|
|
785
|
+
const require2 = createRequire(import.meta.url);
|
|
786
|
+
const pkgJson = require2.resolve("@urbicon-ui/design-content/package.json");
|
|
787
|
+
process.env.URBICON_CONTENT_DIR = resolve3(dirname2(pkgJson), "content");
|
|
788
|
+
} catch {}
|
|
789
|
+
}
|
|
790
|
+
async function loadCatalog() {
|
|
791
|
+
ensureContentDir();
|
|
792
|
+
const raw = await readFile4(getCatalogPath(), "utf-8");
|
|
793
|
+
return JSON.parse(raw);
|
|
794
|
+
}
|
|
795
|
+
async function loadComponentLlm(slug) {
|
|
796
|
+
ensureContentDir();
|
|
797
|
+
for (const group of SEARCH_GROUPS) {
|
|
798
|
+
try {
|
|
799
|
+
return await readFile4(getComponentLlmPath(group, slug), "utf-8");
|
|
800
|
+
} catch (err) {
|
|
801
|
+
if (err.code === "ENOENT")
|
|
802
|
+
continue;
|
|
803
|
+
throw err;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
try {
|
|
807
|
+
await readFile4(getCatalogPath(), "utf-8");
|
|
808
|
+
} catch {
|
|
809
|
+
throw new Error("design-content bundle missing — reinstall @urbicon-ui/design-content, or run `docs:gen:all` in the monorepo");
|
|
810
|
+
}
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// src/cli/commands/find.ts
|
|
815
|
+
function variantSummary(entry) {
|
|
816
|
+
return entry.variants.filter((v) => !v.values.every((x) => x === "true" || x === "false")).map((v) => `${v.name}: ${v.values.join("/")}`).join(" · ");
|
|
817
|
+
}
|
|
818
|
+
function shortDescription(description) {
|
|
819
|
+
const firstLine = description.split(`
|
|
820
|
+
`)[0]?.trim() ?? "";
|
|
821
|
+
return firstLine.length > 140 ? `${firstLine.slice(0, 139)}…` : firstLine;
|
|
822
|
+
}
|
|
823
|
+
function formatEntry(entry) {
|
|
824
|
+
const lines = [` ${entry.name} · ${entry.slug}`, ` ${shortDescription(entry.description)}`];
|
|
825
|
+
const variants = variantSummary(entry);
|
|
826
|
+
if (variants)
|
|
827
|
+
lines.push(` ${variants}`);
|
|
828
|
+
if (entry.relatedComponents.length > 0) {
|
|
829
|
+
lines.push(` related: ${entry.relatedComponents.join(", ")}`);
|
|
830
|
+
}
|
|
831
|
+
return lines.join(`
|
|
832
|
+
`);
|
|
833
|
+
}
|
|
834
|
+
async function runFind(positionals, flags) {
|
|
835
|
+
const query = positionals.join(" ").trim();
|
|
836
|
+
const asJson = boolFlag(flags, "json");
|
|
837
|
+
const tag = stringFlag(flags, "tag");
|
|
838
|
+
const tags = tag ? [tag] : undefined;
|
|
839
|
+
const limitRaw = stringFlag(flags, "limit");
|
|
840
|
+
const limit = limitRaw === undefined ? 10 : Number(limitRaw);
|
|
841
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
842
|
+
printError("--limit needs a positive integer, e.g. --limit 10");
|
|
843
|
+
return EXIT.USAGE;
|
|
844
|
+
}
|
|
845
|
+
let components;
|
|
846
|
+
try {
|
|
847
|
+
components = (await loadCatalog()).components;
|
|
848
|
+
} catch (err) {
|
|
849
|
+
printError(`could not read the component catalog (${err.message}). ` + "Reinstall @urbicon-ui/design-content, or run `docs:gen:all` in the monorepo.");
|
|
850
|
+
return EXIT.FAIL;
|
|
851
|
+
}
|
|
852
|
+
const results = query ? matchComponents(components, query, tags, limit) : components.filter((c) => !tags || c.tags.some((t) => tags.includes(t)));
|
|
853
|
+
if (asJson) {
|
|
854
|
+
console.log(JSON.stringify(results, null, 2));
|
|
855
|
+
return EXIT.OK;
|
|
856
|
+
}
|
|
857
|
+
if (results.length === 0) {
|
|
858
|
+
console.log(query ? `No components match "${query}". Try broader terms, or run \`urbicon find\` with no query to list all.` : `No components${tag ? ` tagged "${tag}"` : ""} in the catalog.`);
|
|
859
|
+
return EXIT.OK;
|
|
860
|
+
}
|
|
861
|
+
const header = query ? `${results.length} component(s) matching "${query}":` : `${results.length} component(s)${tag ? ` tagged "${tag}"` : ""}:`;
|
|
862
|
+
console.log(`${header}
|
|
863
|
+
`);
|
|
864
|
+
for (const entry of results) {
|
|
865
|
+
console.log(`${formatEntry(entry)}
|
|
866
|
+
`);
|
|
867
|
+
}
|
|
868
|
+
console.log("→ `urbicon get-component <slug>` for the full API · `get_css_reference` for tokens.");
|
|
869
|
+
return EXIT.OK;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// src/cli/commands/get-component.ts
|
|
873
|
+
var SECTIONS = ["overview", "examples", "variants", "api", "slots"];
|
|
874
|
+
async function runGetComponent(positionals, flags) {
|
|
875
|
+
const slug = positionals[0];
|
|
876
|
+
if (!slug) {
|
|
877
|
+
printError("get-component needs a component slug, e.g. `urbicon get-component button`");
|
|
878
|
+
return EXIT.USAGE;
|
|
879
|
+
}
|
|
880
|
+
const section = stringFlag(flags, "section");
|
|
881
|
+
if (section && section !== "full" && !SECTIONS.includes(section)) {
|
|
882
|
+
printError(`--section must be one of: ${SECTIONS.join(", ")}, full`);
|
|
883
|
+
return EXIT.USAGE;
|
|
884
|
+
}
|
|
885
|
+
let content;
|
|
886
|
+
try {
|
|
887
|
+
content = await loadComponentLlm(slug);
|
|
888
|
+
} catch (err) {
|
|
889
|
+
printError(`could not read component "${slug}" (${err.message}).`);
|
|
890
|
+
return EXIT.FAIL;
|
|
891
|
+
}
|
|
892
|
+
if (content === null) {
|
|
893
|
+
printError(`component "${slug}" not found. Run \`urbicon find <query>\` to discover the slug.`);
|
|
894
|
+
return EXIT.FAIL;
|
|
895
|
+
}
|
|
896
|
+
if (!section || section === "full") {
|
|
897
|
+
console.log(content.trim());
|
|
898
|
+
return EXIT.OK;
|
|
899
|
+
}
|
|
900
|
+
const extracted = extractSection2(content, section);
|
|
901
|
+
if (extracted === null) {
|
|
902
|
+
printError(`component "${slug}" has no "${section}" section. Try \`--section full\` or another of: ${SECTIONS.join(", ")}.`);
|
|
903
|
+
return EXIT.FAIL;
|
|
904
|
+
}
|
|
905
|
+
console.log(extracted);
|
|
906
|
+
return EXIT.OK;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// src/cli/commands/hook.ts
|
|
910
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
911
|
+
import { resolve as resolve4 } from "node:path";
|
|
912
|
+
// ../design-engine/src/linter/heuristics.ts
|
|
913
|
+
var CHROMATIC_INTENTS = ["primary", "secondary", "success", "warning", "danger", "info"];
|
|
914
|
+
var HEURISTIC_THRESHOLDS = {
|
|
915
|
+
rainbowIntentFamilies: 4,
|
|
916
|
+
minSpacingUtilities: 6,
|
|
917
|
+
minCards: 4,
|
|
918
|
+
minSurfacesForRadius: 3,
|
|
919
|
+
minFontWeights: 5,
|
|
920
|
+
touchTargetUnitCeil: 7,
|
|
921
|
+
hairlinePxFloor: 3
|
|
922
|
+
};
|
|
923
|
+
function collectAtoms(code) {
|
|
924
|
+
const atoms = [];
|
|
925
|
+
const stringRe = /["'`]([^"'`]*?)["'`]/g;
|
|
926
|
+
for (const m of code.matchAll(stringRe)) {
|
|
927
|
+
const body = m[1];
|
|
928
|
+
if (!/[a-z]-/.test(body) && !/\b(flex|grid|block|hidden|relative|absolute)\b/.test(body))
|
|
929
|
+
continue;
|
|
930
|
+
for (const atom of body.split(/\s+/)) {
|
|
931
|
+
if (atom)
|
|
932
|
+
atoms.push(atom);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return atoms;
|
|
936
|
+
}
|
|
937
|
+
function collectHits(lines, re) {
|
|
938
|
+
const hits = [];
|
|
939
|
+
lines.forEach((line, i) => {
|
|
940
|
+
for (const m of line.matchAll(re))
|
|
941
|
+
hits.push({ line: i + 1, match: m[0] });
|
|
942
|
+
});
|
|
943
|
+
return hits;
|
|
944
|
+
}
|
|
945
|
+
function lineOf(code, index) {
|
|
946
|
+
let line = 1;
|
|
947
|
+
for (let i = 0;i < index && i < code.length; i++) {
|
|
948
|
+
if (code[i] === `
|
|
949
|
+
`)
|
|
950
|
+
line++;
|
|
951
|
+
}
|
|
952
|
+
return line;
|
|
953
|
+
}
|
|
954
|
+
function classValues(text) {
|
|
955
|
+
const out = [];
|
|
956
|
+
for (const m of text.matchAll(/\bclass=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/g)) {
|
|
957
|
+
out.push(m[1] ?? m[2] ?? m[3] ?? "");
|
|
958
|
+
}
|
|
959
|
+
return out;
|
|
960
|
+
}
|
|
961
|
+
function slop(ruleId, hits, message, fix) {
|
|
962
|
+
if (hits.length === 0)
|
|
963
|
+
return [];
|
|
964
|
+
const first = hits[0];
|
|
965
|
+
return [
|
|
966
|
+
{
|
|
967
|
+
ruleId,
|
|
968
|
+
severity: "info",
|
|
969
|
+
kind: "heuristic",
|
|
970
|
+
message: typeof message === "function" ? message(hits.length, first) : message,
|
|
971
|
+
fix,
|
|
972
|
+
line: first.line,
|
|
973
|
+
match: first.match
|
|
974
|
+
}
|
|
975
|
+
];
|
|
976
|
+
}
|
|
977
|
+
function checkIntentRainbow(atoms) {
|
|
978
|
+
const families = new Set;
|
|
979
|
+
const bgIntent = new RegExp(`^bg-(${CHROMATIC_INTENTS.join("|")})(?:-(?:hover|active|subtle|emphasis|\\d{2,3}))?(?:\\/\\d{1,3})?$`);
|
|
980
|
+
for (const atom of atoms) {
|
|
981
|
+
const m = atom.match(bgIntent);
|
|
982
|
+
if (m)
|
|
983
|
+
families.add(m[1]);
|
|
984
|
+
}
|
|
985
|
+
if (families.size >= HEURISTIC_THRESHOLDS.rainbowIntentFamilies) {
|
|
986
|
+
return [
|
|
987
|
+
{
|
|
988
|
+
ruleId: "intent-rainbow",
|
|
989
|
+
severity: "info",
|
|
990
|
+
kind: "heuristic",
|
|
991
|
+
message: `${families.size} different intent hues used as backgrounds (${[...families].join(", ")}). Reads as decoration, not meaning.`,
|
|
992
|
+
fix: "Let neutral surfaces dominate (80–90%). Reserve intent colour for genuine status/severity/action signals."
|
|
993
|
+
}
|
|
994
|
+
];
|
|
995
|
+
}
|
|
996
|
+
return [];
|
|
997
|
+
}
|
|
998
|
+
function checkSpacingUniformity(atoms) {
|
|
999
|
+
const values = new Set;
|
|
1000
|
+
let total = 0;
|
|
1001
|
+
const spacingRe = /^(?:gap|gap-x|gap-y|space-x|space-y)-(\d+(?:\.\d+)?|px)$/;
|
|
1002
|
+
for (const atom of atoms) {
|
|
1003
|
+
const m = atom.match(spacingRe);
|
|
1004
|
+
if (m) {
|
|
1005
|
+
values.add(m[1]);
|
|
1006
|
+
total++;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (total >= HEURISTIC_THRESHOLDS.minSpacingUtilities && values.size <= 1) {
|
|
1010
|
+
return [
|
|
1011
|
+
{
|
|
1012
|
+
ruleId: "spacing-uniform",
|
|
1013
|
+
severity: "info",
|
|
1014
|
+
kind: "heuristic",
|
|
1015
|
+
message: `All ${total} spacing utilities use one value (\`${[...values][0] ?? "?"}\`). No tight-within vs generous-between rhythm.`,
|
|
1016
|
+
fix: "Use two tiers: tight (`gap-2`/`gap-3`) within related items, generous (`gap-8`/`gap-10`) between sections."
|
|
1017
|
+
}
|
|
1018
|
+
];
|
|
1019
|
+
}
|
|
1020
|
+
return [];
|
|
1021
|
+
}
|
|
1022
|
+
function checkCardMonotony(code) {
|
|
1023
|
+
const cardRe = /<Card\b([^>]*)>/g;
|
|
1024
|
+
const signatures = [];
|
|
1025
|
+
for (const m of code.matchAll(cardRe)) {
|
|
1026
|
+
const attrs = m[1];
|
|
1027
|
+
const variant = attrs.match(/\bvariant=(?:"([^"]*)"|'([^']*)'|\{['"]([^'"]*)['"]\})/);
|
|
1028
|
+
const padding = attrs.match(/\bpadding=(?:"([^"]*)"|'([^']*)'|\{['"]([^'"]*)['"]\})/);
|
|
1029
|
+
const v = variant ? variant[1] ?? variant[2] ?? variant[3] : "default";
|
|
1030
|
+
const p = padding ? padding[1] ?? padding[2] ?? padding[3] : "default";
|
|
1031
|
+
signatures.push(`${v}/${p}`);
|
|
1032
|
+
}
|
|
1033
|
+
if (signatures.length >= HEURISTIC_THRESHOLDS.minCards) {
|
|
1034
|
+
const distinct = new Set(signatures);
|
|
1035
|
+
if (distinct.size === 1) {
|
|
1036
|
+
return [
|
|
1037
|
+
{
|
|
1038
|
+
ruleId: "card-monotony",
|
|
1039
|
+
severity: "info",
|
|
1040
|
+
kind: "heuristic",
|
|
1041
|
+
message: `All ${signatures.length} Cards share one look (\`${[...distinct][0]}\`). Visual weight does not vary.`,
|
|
1042
|
+
fix: 'Differentiate: prominent content `variant="elevated"`/`padding="lg"`, secondary `variant="outlined"`/`padding="md"`.'
|
|
1043
|
+
}
|
|
1044
|
+
];
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return [];
|
|
1048
|
+
}
|
|
1049
|
+
function checkRadiusStrategy(code, atoms) {
|
|
1050
|
+
const surfaceCount = (code.match(/<Card\b/g)?.length ?? 0) + (code.match(/<(?:Dialog|Drawer|Popover)\b/g)?.length ?? 0);
|
|
1051
|
+
const hasRadiusOverride = atoms.some((a) => /^rounded(?:-|$)/.test(a));
|
|
1052
|
+
if (surfaceCount >= HEURISTIC_THRESHOLDS.minSurfacesForRadius && !hasRadiusOverride) {
|
|
1053
|
+
return [
|
|
1054
|
+
{
|
|
1055
|
+
ruleId: "no-radius-strategy",
|
|
1056
|
+
severity: "info",
|
|
1057
|
+
kind: "heuristic",
|
|
1058
|
+
message: "Multiple surfaces but no explicit radius — relying solely on component defaults.",
|
|
1059
|
+
fix: "If the default tier radii do not match your design identity, commit to one philosophy (`rounded-lg`/`rounded-xl`/`rounded-2xl`) applied consistently via `class`/`slotClasses`."
|
|
1060
|
+
}
|
|
1061
|
+
];
|
|
1062
|
+
}
|
|
1063
|
+
return [];
|
|
1064
|
+
}
|
|
1065
|
+
function checkFontWeightUniformity(atoms) {
|
|
1066
|
+
const weightRe = /^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/;
|
|
1067
|
+
const values = new Set;
|
|
1068
|
+
let total = 0;
|
|
1069
|
+
for (const atom of atoms) {
|
|
1070
|
+
const m = atom.match(weightRe);
|
|
1071
|
+
if (m) {
|
|
1072
|
+
values.add(m[1]);
|
|
1073
|
+
total++;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
if (total >= HEURISTIC_THRESHOLDS.minFontWeights && values.size === 1) {
|
|
1077
|
+
return [
|
|
1078
|
+
{
|
|
1079
|
+
ruleId: "font-weight-uniform",
|
|
1080
|
+
severity: "info",
|
|
1081
|
+
kind: "heuristic",
|
|
1082
|
+
message: `All ${total} explicit font-weights are \`font-${[...values][0]}\`. No typographic hierarchy.`,
|
|
1083
|
+
fix: "Vary weight to rank content: headings `font-semibold`/`font-bold`, body `font-normal`, captions `font-medium text-text-tertiary`."
|
|
1084
|
+
}
|
|
1085
|
+
];
|
|
1086
|
+
}
|
|
1087
|
+
return [];
|
|
1088
|
+
}
|
|
1089
|
+
var GENERIC_FONT_NAMES = "arial|helvetica|system-ui|-apple-system|blinkmacsystemfont|segoe ui|segoe|roboto|times new roman|times|georgia|courier|verdana|tahoma|sans-serif|monospace";
|
|
1090
|
+
var GENERIC_FONT_RE = new RegExp(`(?:font-\\[[^\\]]*?(?:${GENERIC_FONT_NAMES})[^\\]]*?\\]|font-family\\s*:\\s*[^;"'}]*(?:${GENERIC_FONT_NAMES}))`, "gi");
|
|
1091
|
+
function checkGenericFont(lines) {
|
|
1092
|
+
return slop("generic-font", collectHits(lines, GENERIC_FONT_RE), "Hardcoded generic font stack (Arial/Helvetica/system-ui…). Defaults look like an unstyled draft, not a brand.", "Use the design-system typeface via the `font-*` family tokens (e.g. `font-sans`/`font-display`) so type carries identity.");
|
|
1093
|
+
}
|
|
1094
|
+
var ARBITRARY_COLOR_RE = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|outline|decoration|shadow|divide|accent|caret|placeholder)-\[(?:#[0-9a-f]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color|hwb)\()/gi;
|
|
1095
|
+
function checkArbitraryColor(lines) {
|
|
1096
|
+
return slop("arbitrary-color", collectHits(lines, ARBITRARY_COLOR_RE), "Arbitrary colour literal in a utility — outside the token system, so no dark-mode adaptation, no theming, no cohesion.", "Use a semantic token (`bg-surface-*`, `text-text-*`, intents) or `…-[var(--color-*)]` if you must reference a token by variable.");
|
|
1097
|
+
}
|
|
1098
|
+
var TRANSITION_ALL_RE = /\btransition-all\b/g;
|
|
1099
|
+
function checkTransitionAll(lines) {
|
|
1100
|
+
return slop("transition-all", collectHits(lines, TRANSITION_ALL_RE), "`transition-all` animates every property that changes — including layout — which is janky and rarely intended.", "Transition only what changes: `transition-colors`, `transition-opacity`, `transition-transform`.");
|
|
1101
|
+
}
|
|
1102
|
+
var ANIMATED_DIM_BRACKET_RE = /\btransition-\[([^\]]*)\]/g;
|
|
1103
|
+
var ANIMATED_DIM_KEYWORD_RE = /\b(?:width|height|size|top|left|right|bottom|margin|padding)\b/;
|
|
1104
|
+
function checkAnimatedDimensions(lines) {
|
|
1105
|
+
const hits = [];
|
|
1106
|
+
lines.forEach((line, i) => {
|
|
1107
|
+
for (const m of line.matchAll(ANIMATED_DIM_BRACKET_RE)) {
|
|
1108
|
+
if (ANIMATED_DIM_KEYWORD_RE.test(m[1]))
|
|
1109
|
+
hits.push({ line: i + 1, match: m[0] });
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
return slop("animated-dimensions", hits, "Transitioning a layout dimension (width/height/inset). These trigger layout on every frame and stutter.", "Animate `transform` (`scale`/`translate`) or `opacity` instead — they composite on the GPU. For size, use a `grid-template` trick or accept an instant change.");
|
|
1113
|
+
}
|
|
1114
|
+
var MAGIC_DIM_RE = /\b(?:w|h|min-w|min-h|max-w|max-h|size|gap|gap-x|gap-y|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|top|left|right|bottom|inset)-\[(\d+(?:\.\d+)?)px\]/g;
|
|
1115
|
+
function checkMagicDimensions(lines) {
|
|
1116
|
+
const hits = [];
|
|
1117
|
+
lines.forEach((line, i) => {
|
|
1118
|
+
for (const m of line.matchAll(MAGIC_DIM_RE)) {
|
|
1119
|
+
if (Number.parseFloat(m[1]) >= HEURISTIC_THRESHOLDS.hairlinePxFloor) {
|
|
1120
|
+
hits.push({ line: i + 1, match: m[0] });
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
return slop("magic-dimension", hits, (count, first) => `Arbitrary px dimension(s) off the spacing scale (${count} found, first \`${first.match}\`). Magic numbers drift from the rhythm.`, "Use scale utilities (`w-64`, `h-12`, `max-w-md`) or a relative bound (`max-w-prose`, `w-full`). Reserve arbitrary px for true one-offs.");
|
|
1125
|
+
}
|
|
1126
|
+
var IMPORTANT_RE = /(?<=[\s"'`])![a-z][a-z0-9]*-[a-z0-9[]/g;
|
|
1127
|
+
function checkImportant(lines) {
|
|
1128
|
+
return slop("important-modifier", collectHits(lines, IMPORTANT_RE), (count, first) => `\`!important\` modifier(s) (${count} found, first \`${first.match}\`). Overriding the cascade by force is a smell, not a fix.`, "Remove the `!` and resolve the specificity conflict at its source (ordering, the component’s own props, or `slotClasses`).");
|
|
1129
|
+
}
|
|
1130
|
+
var INLINE_PAINT_RE = /(?:^|;|\s)(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left))?-color|box-shadow|text-shadow|font-family|font-size|font-weight|fill|stroke|opacity)\s*:/i;
|
|
1131
|
+
function checkInlineStyle(lines) {
|
|
1132
|
+
const hits = [];
|
|
1133
|
+
const styleRe = /\bstyle=(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g;
|
|
1134
|
+
lines.forEach((line, i) => {
|
|
1135
|
+
for (const m of line.matchAll(styleRe)) {
|
|
1136
|
+
const body = m[1] ?? m[2] ?? m[3] ?? "";
|
|
1137
|
+
if (/\{[^}]*\}|\$\{/.test(body))
|
|
1138
|
+
continue;
|
|
1139
|
+
if (INLINE_PAINT_RE.test(body))
|
|
1140
|
+
hits.push({ line: i + 1, match: "style=…" });
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
return slop("inline-style", hits, (count) => `Inline \`style\` hardcodes colour/typography (${count} found). Bypasses the token system — no theming, no dark-mode adaptation.`, "Use semantic utilities/tokens (`bg-surface-*`, `text-text-*`, `font-*`). Keep inline `style` for genuinely dynamic values (interpolated sizes/positions, CSS custom properties).");
|
|
1144
|
+
}
|
|
1145
|
+
var GRADIENT_TEXT_RE = /\bbg-clip-text\b/g;
|
|
1146
|
+
function checkGradientText(lines) {
|
|
1147
|
+
return slop("gradient-text", collectHits(lines, GRADIENT_TEXT_RE), "Gradient-clipped text (`bg-clip-text` + transparent fill) — a stock flourish that reads as generic and often fails contrast.", "Prefer a solid `text-*` token. If you need emphasis, vary weight/size or use a single intent colour.");
|
|
1148
|
+
}
|
|
1149
|
+
var INTENT_BG_RE = /\bbg-(?:primary|secondary|success|warning|danger|info)(?:-emphasis)?\b(?!-)/;
|
|
1150
|
+
var MUTED_TEXT_RE = /\btext-(?:text-(?:tertiary|quaternary|disabled|secondary)|muted|neutral-[345]00|gray-[345]00|slate-[345]00|zinc-[345]00|stone-[345]00)\b/;
|
|
1151
|
+
function checkGreyOnIntent(lines) {
|
|
1152
|
+
const hits = [];
|
|
1153
|
+
lines.forEach((line, i) => {
|
|
1154
|
+
for (const value of classValues(line)) {
|
|
1155
|
+
if (INTENT_BG_RE.test(value) && MUTED_TEXT_RE.test(value)) {
|
|
1156
|
+
hits.push({ line: i + 1, match: value.trim().slice(0, 40) });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
return slop("grey-on-intent", hits, "Muted/grey text on a saturated intent background. Low contrast and muddy — the colour stops carrying meaning.", "On an intent surface use the on-colour token (`text-on-primary`/`text-on-dark`). Reserve muted greys for neutral surfaces.");
|
|
1161
|
+
}
|
|
1162
|
+
var PARA_CLASS_RE = /<p\b[^>]*?\bclass=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/g;
|
|
1163
|
+
function checkCenteredBodyText(code) {
|
|
1164
|
+
const hits = [];
|
|
1165
|
+
for (const m of code.matchAll(PARA_CLASS_RE)) {
|
|
1166
|
+
const cls = m[1] ?? m[2] ?? m[3] ?? "";
|
|
1167
|
+
if (/\btext-center\b/.test(cls)) {
|
|
1168
|
+
hits.push({ line: lineOf(code, m.index ?? 0), match: "<p … text-center>" });
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return slop("centered-bodytext", hits, "Centred paragraph text. Ragged left edges make multi-line copy hard to scan.", "Left-align body copy (`text-left`). Reserve `text-center` for short headings, single labels, or empty states.");
|
|
1172
|
+
}
|
|
1173
|
+
var JUSTIFIED_RE = /\btext-justify\b/g;
|
|
1174
|
+
function checkJustifiedText(lines) {
|
|
1175
|
+
return slop("justified-text", collectHits(lines, JUSTIFIED_RE), 'Justified text. Without hyphenation the browser stretches word spacing into uneven "rivers" that hurt readability.', "Use `text-left` (`text-right` for RTL). Justification needs typographic control the web does not give by default.");
|
|
1176
|
+
}
|
|
1177
|
+
var PLACEHOLDER_RE = /\b(?:lorem ipsum|dolor sit amet|consectetur adipiscing|sed do eiusmod|the quick brown fox)\b/gi;
|
|
1178
|
+
function checkPlaceholderContent(lines) {
|
|
1179
|
+
return slop("placeholder-content", collectHits(lines, PLACEHOLDER_RE), 'Lorem-ipsum / filler copy in the output. Placeholder text ships as "unfinished".', "Replace with real, representative content — it changes layout, length, and tone decisions.");
|
|
1180
|
+
}
|
|
1181
|
+
var EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2705}\u{2728}\u{274C}\u{274E}\u{2753}-\u{2755}\u{2757}\u{2795}-\u{2797}\u{27B0}\u{27BF}\u{2B1B}\u{2B1C}\u{2B50}\u{2B55}\u{26A1}\u{2614}\u{2615}]|[\u{2300}-\u{27BF}\u{2B00}-\u{2BFF}]\u{FE0F}/gu;
|
|
1182
|
+
function checkEmojiAsIcon(lines) {
|
|
1183
|
+
return slop("emoji-as-icon", collectHits(lines, EMOJI_RE), "Emoji in the markup as iconography. They render inconsistently across platforms and clash with a real icon set.", "Use the `Icon` component / a `*Icon` from the 315-icon set (`find_icons`) — consistent stroke, size, and theming.");
|
|
1184
|
+
}
|
|
1185
|
+
function checkHeadingSkip(code) {
|
|
1186
|
+
const headings = [];
|
|
1187
|
+
for (const m of code.matchAll(/<h([1-6])\b/g)) {
|
|
1188
|
+
headings.push({ level: Number(m[1]), index: m.index ?? 0 });
|
|
1189
|
+
}
|
|
1190
|
+
for (let i = 1;i < headings.length; i++) {
|
|
1191
|
+
const prev = headings[i - 1];
|
|
1192
|
+
const cur = headings[i];
|
|
1193
|
+
if (cur.level - prev.level >= 2) {
|
|
1194
|
+
return [
|
|
1195
|
+
{
|
|
1196
|
+
ruleId: "heading-skip",
|
|
1197
|
+
severity: "info",
|
|
1198
|
+
kind: "heuristic",
|
|
1199
|
+
message: `Heading jumps from <h${prev.level}> to <h${cur.level}>, skipping a level. Breaks the outline and screen-reader navigation.`,
|
|
1200
|
+
fix: `Use sequential ranks (<h${prev.level}> → <h${prev.level + 1}>). Style size with classes, not by picking a smaller heading tag.`,
|
|
1201
|
+
line: lineOf(code, cur.index)
|
|
1202
|
+
}
|
|
1203
|
+
];
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return [];
|
|
1207
|
+
}
|
|
1208
|
+
var SMALL_TOUCH_RE = new RegExp(`<(?:button|a)\\b[^>]*?(?<![\\w-])(?:h|size)-(?:[1-${HEURISTIC_THRESHOLDS.touchTargetUnitCeil}]|0\\.5|1\\.5|2\\.5|3\\.5)\\b`, "g");
|
|
1209
|
+
function checkTouchTarget(code) {
|
|
1210
|
+
const hits = [];
|
|
1211
|
+
for (const m of code.matchAll(SMALL_TOUCH_RE)) {
|
|
1212
|
+
hits.push({ line: lineOf(code, m.index ?? 0), match: m[0].replace(/\s+/g, " ").slice(0, 40) });
|
|
1213
|
+
}
|
|
1214
|
+
return slop("touch-target-small", hits, "Interactive element with a fixed sub-44px height. Hard to tap; fails the 44×44 touch-target guideline.", "Give tappable controls ≥ `h-11` (44px) or enough padding (`py-2.5`+). Keep tiny sizes for decorative icons only.");
|
|
1215
|
+
}
|
|
1216
|
+
function runHeuristics(code) {
|
|
1217
|
+
const atoms = collectAtoms(code);
|
|
1218
|
+
const lines = code.split(`
|
|
1219
|
+
`);
|
|
1220
|
+
return [
|
|
1221
|
+
...checkIntentRainbow(atoms),
|
|
1222
|
+
...checkSpacingUniformity(atoms),
|
|
1223
|
+
...checkCardMonotony(code),
|
|
1224
|
+
...checkRadiusStrategy(code, atoms),
|
|
1225
|
+
...checkFontWeightUniformity(atoms),
|
|
1226
|
+
...checkGenericFont(lines),
|
|
1227
|
+
...checkArbitraryColor(lines),
|
|
1228
|
+
...checkTransitionAll(lines),
|
|
1229
|
+
...checkAnimatedDimensions(lines),
|
|
1230
|
+
...checkMagicDimensions(lines),
|
|
1231
|
+
...checkImportant(lines),
|
|
1232
|
+
...checkInlineStyle(lines),
|
|
1233
|
+
...checkGradientText(lines),
|
|
1234
|
+
...checkGreyOnIntent(lines),
|
|
1235
|
+
...checkCenteredBodyText(code),
|
|
1236
|
+
...checkJustifiedText(lines),
|
|
1237
|
+
...checkPlaceholderContent(lines),
|
|
1238
|
+
...checkEmojiAsIcon(lines),
|
|
1239
|
+
...checkHeadingSkip(code),
|
|
1240
|
+
...checkTouchTarget(code)
|
|
1241
|
+
];
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// ../design-engine/src/linter/markup.ts
|
|
1245
|
+
var blankRegion = (s) => s.replace(/[^\n]/g, " ");
|
|
1246
|
+
function blankNonMarkup(src) {
|
|
1247
|
+
return src.replace(/<!--[\s\S]*?-->/g, blankRegion).replace(/<script[\s\S]*?<\/script>/gi, blankRegion).replace(/<style[\s\S]*?<\/style>/gi, blankRegion);
|
|
1248
|
+
}
|
|
1249
|
+
var isNameStart = (c) => c !== undefined && /[A-Za-z]/.test(c);
|
|
1250
|
+
var isTagNameChar = (c) => c !== undefined && /[A-Za-z0-9.\-:]/.test(c);
|
|
1251
|
+
var isAttrNameChar = (c) => c !== undefined && !/[\s=/>]/.test(c) && c !== "<";
|
|
1252
|
+
function readQuoted(src, i) {
|
|
1253
|
+
const quote = src[i];
|
|
1254
|
+
let j = i + 1;
|
|
1255
|
+
while (j < src.length && src[j] !== quote)
|
|
1256
|
+
j++;
|
|
1257
|
+
return { value: src.slice(i + 1, j), end: j + 1 };
|
|
1258
|
+
}
|
|
1259
|
+
function readBraced(src, i) {
|
|
1260
|
+
let depth = 0;
|
|
1261
|
+
let str = null;
|
|
1262
|
+
for (let j = i;j < src.length; j++) {
|
|
1263
|
+
const c = src[j];
|
|
1264
|
+
if (str !== null) {
|
|
1265
|
+
if (c === "\\") {
|
|
1266
|
+
j++;
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
if (c === str)
|
|
1270
|
+
str = null;
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
if (c === '"' || c === "'")
|
|
1274
|
+
str = c;
|
|
1275
|
+
else if (c === "{")
|
|
1276
|
+
depth++;
|
|
1277
|
+
else if (c === "}") {
|
|
1278
|
+
depth--;
|
|
1279
|
+
if (depth === 0)
|
|
1280
|
+
return { value: src.slice(i + 1, j), end: j + 1 };
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return { value: "", end: -1 };
|
|
1284
|
+
}
|
|
1285
|
+
function parseAttr(src, i, line) {
|
|
1286
|
+
if (src[i] === "{") {
|
|
1287
|
+
const { value, end } = readBraced(src, i);
|
|
1288
|
+
if (end === -1)
|
|
1289
|
+
return null;
|
|
1290
|
+
const trimmed = value.trim();
|
|
1291
|
+
const spread = trimmed.startsWith("...");
|
|
1292
|
+
return {
|
|
1293
|
+
attr: {
|
|
1294
|
+
name: spread ? "" : trimmed,
|
|
1295
|
+
value: spread ? trimmed.slice(3).trim() : trimmed,
|
|
1296
|
+
kind: spread ? "spread" : "shorthand",
|
|
1297
|
+
line
|
|
1298
|
+
},
|
|
1299
|
+
end
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
let j = i;
|
|
1303
|
+
while (isAttrNameChar(src[j]))
|
|
1304
|
+
j++;
|
|
1305
|
+
const name = src.slice(i, j);
|
|
1306
|
+
if (name === "")
|
|
1307
|
+
return null;
|
|
1308
|
+
let k = j;
|
|
1309
|
+
while (k < src.length && /\s/.test(src[k]))
|
|
1310
|
+
k++;
|
|
1311
|
+
if (src[k] !== "=") {
|
|
1312
|
+
return { attr: { name, value: null, kind: "boolean", line }, end: j };
|
|
1313
|
+
}
|
|
1314
|
+
k++;
|
|
1315
|
+
while (k < src.length && /\s/.test(src[k]))
|
|
1316
|
+
k++;
|
|
1317
|
+
const c = src[k];
|
|
1318
|
+
if (c === '"' || c === "'") {
|
|
1319
|
+
const { value, end } = readQuoted(src, k);
|
|
1320
|
+
return { attr: { name, value, kind: "string", line }, end };
|
|
1321
|
+
}
|
|
1322
|
+
if (c === "{") {
|
|
1323
|
+
const { value, end } = readBraced(src, k);
|
|
1324
|
+
if (end === -1)
|
|
1325
|
+
return null;
|
|
1326
|
+
return { attr: { name, value, kind: "expression", line }, end };
|
|
1327
|
+
}
|
|
1328
|
+
let m = k;
|
|
1329
|
+
while (m < src.length && !/[\s/>]/.test(src[m]))
|
|
1330
|
+
m++;
|
|
1331
|
+
return { attr: { name, value: src.slice(k, m), kind: "string", line }, end: m };
|
|
1332
|
+
}
|
|
1333
|
+
function parseOpenTag(src, start, line) {
|
|
1334
|
+
let i = start + 1;
|
|
1335
|
+
while (isTagNameChar(src[i]))
|
|
1336
|
+
i++;
|
|
1337
|
+
const tag = src.slice(start + 1, i);
|
|
1338
|
+
if (tag === "")
|
|
1339
|
+
return null;
|
|
1340
|
+
const attrs = [];
|
|
1341
|
+
let curLine = line;
|
|
1342
|
+
const bump = (from, to) => {
|
|
1343
|
+
for (let p = from;p < to; p++)
|
|
1344
|
+
if (src[p] === `
|
|
1345
|
+
`)
|
|
1346
|
+
curLine++;
|
|
1347
|
+
};
|
|
1348
|
+
let selfClosing = false;
|
|
1349
|
+
let closed = false;
|
|
1350
|
+
while (i < src.length) {
|
|
1351
|
+
const before = i;
|
|
1352
|
+
while (i < src.length && /\s/.test(src[i]))
|
|
1353
|
+
i++;
|
|
1354
|
+
bump(before, i);
|
|
1355
|
+
const c = src[i];
|
|
1356
|
+
if (c === undefined)
|
|
1357
|
+
break;
|
|
1358
|
+
if (c === ">") {
|
|
1359
|
+
i++;
|
|
1360
|
+
closed = true;
|
|
1361
|
+
break;
|
|
1362
|
+
}
|
|
1363
|
+
if (c === "/" && src[i + 1] === ">") {
|
|
1364
|
+
selfClosing = true;
|
|
1365
|
+
i += 2;
|
|
1366
|
+
closed = true;
|
|
1367
|
+
break;
|
|
1368
|
+
}
|
|
1369
|
+
const parsed = parseAttr(src, i, curLine);
|
|
1370
|
+
if (!parsed)
|
|
1371
|
+
return null;
|
|
1372
|
+
attrs.push(parsed.attr);
|
|
1373
|
+
bump(i, parsed.end);
|
|
1374
|
+
i = parsed.end;
|
|
1375
|
+
}
|
|
1376
|
+
if (!closed)
|
|
1377
|
+
return null;
|
|
1378
|
+
const isComponent = /^[A-Z]/.test(tag) || tag.includes(".");
|
|
1379
|
+
return {
|
|
1380
|
+
element: { tag, isComponent, attrs, line, selfClosing, openStart: start, openEnd: i },
|
|
1381
|
+
end: i
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
function scanMarkup(source) {
|
|
1385
|
+
const src = blankNonMarkup(source);
|
|
1386
|
+
const elements = [];
|
|
1387
|
+
let line = 1;
|
|
1388
|
+
let i = 0;
|
|
1389
|
+
while (i < src.length) {
|
|
1390
|
+
const c = src[i];
|
|
1391
|
+
if (c === `
|
|
1392
|
+
`) {
|
|
1393
|
+
line++;
|
|
1394
|
+
i++;
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
if (c === "<" && isNameStart(src[i + 1])) {
|
|
1398
|
+
const parsed = parseOpenTag(src, i, line);
|
|
1399
|
+
if (parsed) {
|
|
1400
|
+
elements.push(parsed.element);
|
|
1401
|
+
for (let p = i;p < parsed.end; p++)
|
|
1402
|
+
if (src[p] === `
|
|
1403
|
+
`)
|
|
1404
|
+
line++;
|
|
1405
|
+
i = parsed.end;
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
i++;
|
|
1410
|
+
}
|
|
1411
|
+
return elements;
|
|
1412
|
+
}
|
|
1413
|
+
function tagAt(src, pos, tag, closing) {
|
|
1414
|
+
const lead = closing ? `</${tag}` : `<${tag}`;
|
|
1415
|
+
if (!src.startsWith(lead, pos))
|
|
1416
|
+
return false;
|
|
1417
|
+
const after = src[pos + lead.length];
|
|
1418
|
+
return after === undefined || /[\s/>]/.test(after);
|
|
1419
|
+
}
|
|
1420
|
+
function innerContent(source, el) {
|
|
1421
|
+
if (el.selfClosing)
|
|
1422
|
+
return null;
|
|
1423
|
+
const src = blankNonMarkup(source);
|
|
1424
|
+
let depth = 1;
|
|
1425
|
+
let i = el.openEnd;
|
|
1426
|
+
while (i < src.length) {
|
|
1427
|
+
if (src[i] === "<") {
|
|
1428
|
+
if (tagAt(src, i, el.tag, true)) {
|
|
1429
|
+
depth--;
|
|
1430
|
+
if (depth === 0)
|
|
1431
|
+
return src.slice(el.openEnd, i);
|
|
1432
|
+
} else if (tagAt(src, i, el.tag, false)) {
|
|
1433
|
+
depth++;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
i++;
|
|
1437
|
+
}
|
|
1438
|
+
return null;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// ../design-engine/src/linter/markup-rules.ts
|
|
1442
|
+
var URBICON_COMPONENTS = new Set([
|
|
1443
|
+
"Accordion",
|
|
1444
|
+
"Alert",
|
|
1445
|
+
"Avatar",
|
|
1446
|
+
"Badge",
|
|
1447
|
+
"Breadcrumb",
|
|
1448
|
+
"Button",
|
|
1449
|
+
"ButtonGroup",
|
|
1450
|
+
"Card",
|
|
1451
|
+
"Checkbox",
|
|
1452
|
+
"Collapsible",
|
|
1453
|
+
"Combobox",
|
|
1454
|
+
"ConfirmDialog",
|
|
1455
|
+
"Dialog",
|
|
1456
|
+
"Drawer",
|
|
1457
|
+
"FormField",
|
|
1458
|
+
"Input",
|
|
1459
|
+
"Menu",
|
|
1460
|
+
"Pagination",
|
|
1461
|
+
"Popover",
|
|
1462
|
+
"Progress",
|
|
1463
|
+
"RadioGroup",
|
|
1464
|
+
"SegmentGroup",
|
|
1465
|
+
"Select",
|
|
1466
|
+
"Separator",
|
|
1467
|
+
"Sidebar",
|
|
1468
|
+
"Skeleton",
|
|
1469
|
+
"Slider",
|
|
1470
|
+
"Spinner",
|
|
1471
|
+
"Stepper",
|
|
1472
|
+
"Tab",
|
|
1473
|
+
"Textarea",
|
|
1474
|
+
"Toast",
|
|
1475
|
+
"Toggle",
|
|
1476
|
+
"Toolbar",
|
|
1477
|
+
"Tooltip",
|
|
1478
|
+
"AreaChart",
|
|
1479
|
+
"BarChart",
|
|
1480
|
+
"Calendar",
|
|
1481
|
+
"ChartFrame",
|
|
1482
|
+
"CommandPalette",
|
|
1483
|
+
"CompositionBar",
|
|
1484
|
+
"CurrencyInput",
|
|
1485
|
+
"DatePicker",
|
|
1486
|
+
"DonutChart",
|
|
1487
|
+
"EmptyState",
|
|
1488
|
+
"FileUpload",
|
|
1489
|
+
"LineChart",
|
|
1490
|
+
"LocaleSwitcher",
|
|
1491
|
+
"Sankey",
|
|
1492
|
+
"SidebarLayout",
|
|
1493
|
+
"Sparkline",
|
|
1494
|
+
"ThemeSwitcher",
|
|
1495
|
+
"Table"
|
|
1496
|
+
]);
|
|
1497
|
+
var PROP_NAME_CONFUSIONS = {
|
|
1498
|
+
tone: "intent",
|
|
1499
|
+
colour: "intent",
|
|
1500
|
+
colorScheme: "intent",
|
|
1501
|
+
isLoading: "loading",
|
|
1502
|
+
isDisabled: "disabled"
|
|
1503
|
+
};
|
|
1504
|
+
var VALUE_CONFUSIONS = {
|
|
1505
|
+
variant: { outline: "outlined" }
|
|
1506
|
+
};
|
|
1507
|
+
var cache = null;
|
|
1508
|
+
function scanCached(raw) {
|
|
1509
|
+
if (cache?.raw !== raw)
|
|
1510
|
+
cache = { raw, els: scanMarkup(raw) };
|
|
1511
|
+
return cache.els;
|
|
1512
|
+
}
|
|
1513
|
+
var apiHallucination = {
|
|
1514
|
+
id: "api-hallucination",
|
|
1515
|
+
severity: "warning",
|
|
1516
|
+
description: 'Component prop/value from another UI library (e.g. `tone=`, `variant="outline"`) that Urbicon UI does not have.',
|
|
1517
|
+
check(_lines, raw) {
|
|
1518
|
+
const findings = [];
|
|
1519
|
+
for (const el of scanCached(raw)) {
|
|
1520
|
+
if (!el.isComponent || !URBICON_COMPONENTS.has(el.tag))
|
|
1521
|
+
continue;
|
|
1522
|
+
for (const attr of el.attrs) {
|
|
1523
|
+
const rightName = PROP_NAME_CONFUSIONS[attr.name];
|
|
1524
|
+
if (rightName) {
|
|
1525
|
+
findings.push({
|
|
1526
|
+
ruleId: this.id,
|
|
1527
|
+
severity: this.severity,
|
|
1528
|
+
kind: "deterministic",
|
|
1529
|
+
message: `\`${el.tag}\` has no \`${attr.name}\` prop — that is another library's name.`,
|
|
1530
|
+
fix: `Use \`${rightName}\` instead.`,
|
|
1531
|
+
line: attr.line,
|
|
1532
|
+
match: attr.name
|
|
1533
|
+
});
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
if (attr.kind === "string" && attr.value !== null) {
|
|
1537
|
+
const right = VALUE_CONFUSIONS[attr.name]?.[attr.value];
|
|
1538
|
+
if (right) {
|
|
1539
|
+
findings.push({
|
|
1540
|
+
ruleId: this.id,
|
|
1541
|
+
severity: this.severity,
|
|
1542
|
+
kind: "deterministic",
|
|
1543
|
+
message: `\`${attr.name}="${attr.value}"\` is not an Urbicon UI value.`,
|
|
1544
|
+
fix: `Use \`${attr.name}="${right}"\`.`,
|
|
1545
|
+
line: attr.line,
|
|
1546
|
+
match: `${attr.name}="${attr.value}"`
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
return findings;
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
var LABEL_ATTRS = new Set(["aria-label", "aria-labelledby", "title"]);
|
|
1556
|
+
var ICON_CONTROL_TAGS = new Set(["Button", "button"]);
|
|
1557
|
+
function hasTextLabel(inner) {
|
|
1558
|
+
return inner.replace(/<[^>]*>/g, "").trim().length > 0;
|
|
1559
|
+
}
|
|
1560
|
+
function hasIconChild(inner) {
|
|
1561
|
+
return /<svg\b/i.test(inner) || /<[A-Za-z][\w.]*Icon\b/.test(inner) || /<Icon\b/.test(inner);
|
|
1562
|
+
}
|
|
1563
|
+
var iconButtonNoLabel = {
|
|
1564
|
+
id: "icon-button-no-label",
|
|
1565
|
+
severity: "warning",
|
|
1566
|
+
description: "Icon-only Button/button with no accessible name (aria-label / title / text).",
|
|
1567
|
+
check(_lines, raw) {
|
|
1568
|
+
const findings = [];
|
|
1569
|
+
for (const el of scanCached(raw)) {
|
|
1570
|
+
if (!ICON_CONTROL_TAGS.has(el.tag))
|
|
1571
|
+
continue;
|
|
1572
|
+
if (el.attrs.some((a) => a.kind === "spread"))
|
|
1573
|
+
continue;
|
|
1574
|
+
const labelled = el.attrs.some((a) => LABEL_ATTRS.has(a.name) && a.value !== null && a.value.trim() !== "");
|
|
1575
|
+
if (labelled)
|
|
1576
|
+
continue;
|
|
1577
|
+
const inner = innerContent(raw, el);
|
|
1578
|
+
if (inner === null)
|
|
1579
|
+
continue;
|
|
1580
|
+
if (inner.includes("{"))
|
|
1581
|
+
continue;
|
|
1582
|
+
if (hasTextLabel(inner))
|
|
1583
|
+
continue;
|
|
1584
|
+
if (!hasIconChild(inner))
|
|
1585
|
+
continue;
|
|
1586
|
+
findings.push({
|
|
1587
|
+
ruleId: this.id,
|
|
1588
|
+
severity: this.severity,
|
|
1589
|
+
kind: "deterministic",
|
|
1590
|
+
message: `Icon-only \`<${el.tag}>\` has no accessible name — a screen reader announces nothing.`,
|
|
1591
|
+
fix: 'Add an `aria-label="…"` (or visually-hidden text) naming the action.',
|
|
1592
|
+
line: el.line
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
return findings;
|
|
1596
|
+
}
|
|
1597
|
+
};
|
|
1598
|
+
var MARKUP_RULES = [apiHallucination, iconButtonNoLabel];
|
|
1599
|
+
|
|
1600
|
+
// ../design-engine/src/linter/tokens.ts
|
|
1601
|
+
var SURFACE_CORES = [
|
|
1602
|
+
"surface-base",
|
|
1603
|
+
"surface-quiet",
|
|
1604
|
+
"surface-subtle",
|
|
1605
|
+
"surface-elevated",
|
|
1606
|
+
"surface-overlay",
|
|
1607
|
+
"surface-interactive",
|
|
1608
|
+
"surface-hover",
|
|
1609
|
+
"surface-active",
|
|
1610
|
+
"surface-disabled",
|
|
1611
|
+
"surface-selected",
|
|
1612
|
+
"surface-inverted"
|
|
1613
|
+
];
|
|
1614
|
+
var TEXT_CORES = [
|
|
1615
|
+
"text-primary",
|
|
1616
|
+
"text-secondary",
|
|
1617
|
+
"text-tertiary",
|
|
1618
|
+
"text-quaternary",
|
|
1619
|
+
"text-disabled",
|
|
1620
|
+
"text-inverted",
|
|
1621
|
+
"text-on-primary",
|
|
1622
|
+
"text-on-dark",
|
|
1623
|
+
"text-on-surface"
|
|
1624
|
+
];
|
|
1625
|
+
var BORDER_CORES = [
|
|
1626
|
+
"border-subtle",
|
|
1627
|
+
"border-default",
|
|
1628
|
+
"border-emphasis",
|
|
1629
|
+
"border-strong",
|
|
1630
|
+
"border-hairline"
|
|
1631
|
+
];
|
|
1632
|
+
var INTENT_NAMES = [
|
|
1633
|
+
"primary",
|
|
1634
|
+
"secondary",
|
|
1635
|
+
"success",
|
|
1636
|
+
"warning",
|
|
1637
|
+
"danger",
|
|
1638
|
+
"info",
|
|
1639
|
+
"neutral"
|
|
1640
|
+
];
|
|
1641
|
+
var INTENT_VARIANTS = ["hover", "active", "subtle", "emphasis"];
|
|
1642
|
+
var SCALE_STEPS = [
|
|
1643
|
+
"50",
|
|
1644
|
+
"100",
|
|
1645
|
+
"200",
|
|
1646
|
+
"300",
|
|
1647
|
+
"400",
|
|
1648
|
+
"500",
|
|
1649
|
+
"600",
|
|
1650
|
+
"700",
|
|
1651
|
+
"800",
|
|
1652
|
+
"900",
|
|
1653
|
+
"950"
|
|
1654
|
+
];
|
|
1655
|
+
var NEUTRAL_EXTRA_STEPS = ["0", "25", "650", "750", "850"];
|
|
1656
|
+
var WARM_NEUTRAL_STEPS = SCALE_STEPS;
|
|
1657
|
+
var FEEDBACK_CORES = [
|
|
1658
|
+
"feedback-info",
|
|
1659
|
+
"feedback-info-subtle",
|
|
1660
|
+
"feedback-success",
|
|
1661
|
+
"feedback-success-subtle",
|
|
1662
|
+
"feedback-warning",
|
|
1663
|
+
"feedback-warning-subtle",
|
|
1664
|
+
"feedback-error",
|
|
1665
|
+
"feedback-error-subtle"
|
|
1666
|
+
];
|
|
1667
|
+
var INTERACTIVE_CORES = [
|
|
1668
|
+
"interactive-hover",
|
|
1669
|
+
"interactive-active",
|
|
1670
|
+
"interactive-focus",
|
|
1671
|
+
"interactive-disabled"
|
|
1672
|
+
];
|
|
1673
|
+
var CHART_CORES = ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5", "chart-6"];
|
|
1674
|
+
function buildIntentCores() {
|
|
1675
|
+
const cores = [];
|
|
1676
|
+
for (const intent of INTENT_NAMES) {
|
|
1677
|
+
cores.push(intent);
|
|
1678
|
+
for (const variant of INTENT_VARIANTS)
|
|
1679
|
+
cores.push(`${intent}-${variant}`);
|
|
1680
|
+
for (const step of SCALE_STEPS)
|
|
1681
|
+
cores.push(`${intent}-${step}`);
|
|
1682
|
+
}
|
|
1683
|
+
return cores;
|
|
1684
|
+
}
|
|
1685
|
+
var VALID_TOKEN_CORES = new Set([
|
|
1686
|
+
...SURFACE_CORES,
|
|
1687
|
+
...TEXT_CORES,
|
|
1688
|
+
...BORDER_CORES,
|
|
1689
|
+
...buildIntentCores(),
|
|
1690
|
+
...NEUTRAL_EXTRA_STEPS.map((s) => `neutral-${s}`),
|
|
1691
|
+
...WARM_NEUTRAL_STEPS.map((s) => `warm-neutral-${s}`),
|
|
1692
|
+
...FEEDBACK_CORES,
|
|
1693
|
+
...INTERACTIVE_CORES,
|
|
1694
|
+
...CHART_CORES
|
|
1695
|
+
]);
|
|
1696
|
+
function normalizeExtraTokens(extra) {
|
|
1697
|
+
return extra.map((token) => token.trim()).filter((token) => token.length > 0);
|
|
1698
|
+
}
|
|
1699
|
+
function resolveValidTokenCores(extra) {
|
|
1700
|
+
if (!extra || extra.length === 0)
|
|
1701
|
+
return VALID_TOKEN_CORES;
|
|
1702
|
+
const normalized = normalizeExtraTokens(extra);
|
|
1703
|
+
if (normalized.length === 0)
|
|
1704
|
+
return VALID_TOKEN_CORES;
|
|
1705
|
+
const merged = new Set(VALID_TOKEN_CORES);
|
|
1706
|
+
for (const core of normalized)
|
|
1707
|
+
merged.add(core);
|
|
1708
|
+
return merged;
|
|
1709
|
+
}
|
|
1710
|
+
var SEMANTIC_NAMESPACES = [
|
|
1711
|
+
"surface-",
|
|
1712
|
+
"text-",
|
|
1713
|
+
"border-",
|
|
1714
|
+
"feedback-",
|
|
1715
|
+
"interactive-",
|
|
1716
|
+
"chart-"
|
|
1717
|
+
];
|
|
1718
|
+
var INTENT_PREFIXES = INTENT_NAMES;
|
|
1719
|
+
var KNOWN_FOREIGN_CORES = new Set([
|
|
1720
|
+
"foreground",
|
|
1721
|
+
"background",
|
|
1722
|
+
"muted",
|
|
1723
|
+
"accent",
|
|
1724
|
+
"card",
|
|
1725
|
+
"popover",
|
|
1726
|
+
"input",
|
|
1727
|
+
"destructive",
|
|
1728
|
+
"surface",
|
|
1729
|
+
"muted-foreground",
|
|
1730
|
+
"accent-foreground",
|
|
1731
|
+
"card-foreground",
|
|
1732
|
+
"popover-foreground",
|
|
1733
|
+
"primary-foreground",
|
|
1734
|
+
"secondary-foreground",
|
|
1735
|
+
"destructive-foreground"
|
|
1736
|
+
]);
|
|
1737
|
+
var KNOWN_BAD_NAMESPACES = {
|
|
1738
|
+
"status-": "Use a `feedback-*` token (feedback-success, feedback-error, …) or a bare intent (`success`, `danger`).",
|
|
1739
|
+
"-fg": "Use `text-on-primary` / `text-on-surface` for foreground-on-intent text."
|
|
1740
|
+
};
|
|
1741
|
+
|
|
1742
|
+
// ../design-engine/src/linter/rules.ts
|
|
1743
|
+
var SHADCN_FIX = "This is shadcn/ui vocabulary, not Urbicon UI. Use surface tokens (`bg-surface-base`/`-elevated`), text tokens (`text-text-primary`/`-secondary`), or intents (`bg-primary`, `text-success`).";
|
|
1744
|
+
function isForeignVocab(core) {
|
|
1745
|
+
return KNOWN_FOREIGN_CORES.has(core) || core.endsWith("-foreground") || core === "fg" || core.startsWith("fg-");
|
|
1746
|
+
}
|
|
1747
|
+
var RAW_PALETTE = "slate|gray|zinc|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
|
|
1748
|
+
var COLOR_PREFIXES = [
|
|
1749
|
+
"ring-offset",
|
|
1750
|
+
"border-x",
|
|
1751
|
+
"border-y",
|
|
1752
|
+
"border-t",
|
|
1753
|
+
"border-r",
|
|
1754
|
+
"border-b",
|
|
1755
|
+
"border-l",
|
|
1756
|
+
"border-s",
|
|
1757
|
+
"border-e",
|
|
1758
|
+
"bg",
|
|
1759
|
+
"text",
|
|
1760
|
+
"border",
|
|
1761
|
+
"ring",
|
|
1762
|
+
"divide",
|
|
1763
|
+
"outline",
|
|
1764
|
+
"decoration",
|
|
1765
|
+
"fill",
|
|
1766
|
+
"stroke",
|
|
1767
|
+
"from",
|
|
1768
|
+
"via",
|
|
1769
|
+
"to",
|
|
1770
|
+
"accent",
|
|
1771
|
+
"caret",
|
|
1772
|
+
"placeholder"
|
|
1773
|
+
];
|
|
1774
|
+
var DYNAMIC_UTILITY_ROOTS = [
|
|
1775
|
+
"gap",
|
|
1776
|
+
"gap-x",
|
|
1777
|
+
"gap-y",
|
|
1778
|
+
"space-x",
|
|
1779
|
+
"space-y",
|
|
1780
|
+
"p",
|
|
1781
|
+
"px",
|
|
1782
|
+
"py",
|
|
1783
|
+
"pt",
|
|
1784
|
+
"pb",
|
|
1785
|
+
"pl",
|
|
1786
|
+
"pr",
|
|
1787
|
+
"m",
|
|
1788
|
+
"mx",
|
|
1789
|
+
"my",
|
|
1790
|
+
"mt",
|
|
1791
|
+
"mb",
|
|
1792
|
+
"ml",
|
|
1793
|
+
"mr",
|
|
1794
|
+
"w",
|
|
1795
|
+
"h",
|
|
1796
|
+
"min-w",
|
|
1797
|
+
"min-h",
|
|
1798
|
+
"max-w",
|
|
1799
|
+
"max-h",
|
|
1800
|
+
"size",
|
|
1801
|
+
"text",
|
|
1802
|
+
"bg",
|
|
1803
|
+
"border",
|
|
1804
|
+
"rounded",
|
|
1805
|
+
"grid-cols",
|
|
1806
|
+
"grid-rows",
|
|
1807
|
+
"col-span",
|
|
1808
|
+
"row-span",
|
|
1809
|
+
"top",
|
|
1810
|
+
"left",
|
|
1811
|
+
"right",
|
|
1812
|
+
"bottom",
|
|
1813
|
+
"inset",
|
|
1814
|
+
"z",
|
|
1815
|
+
"leading",
|
|
1816
|
+
"tracking",
|
|
1817
|
+
"opacity",
|
|
1818
|
+
"scale",
|
|
1819
|
+
"rotate",
|
|
1820
|
+
"translate-x",
|
|
1821
|
+
"translate-y",
|
|
1822
|
+
"duration",
|
|
1823
|
+
"delay"
|
|
1824
|
+
];
|
|
1825
|
+
function dedupeByLine(findings) {
|
|
1826
|
+
const seen = new Set;
|
|
1827
|
+
const out = [];
|
|
1828
|
+
for (const f of findings) {
|
|
1829
|
+
const key = `${f.ruleId}:${f.line}:${f.match}`;
|
|
1830
|
+
if (seen.has(key))
|
|
1831
|
+
continue;
|
|
1832
|
+
seen.add(key);
|
|
1833
|
+
out.push(f);
|
|
1834
|
+
}
|
|
1835
|
+
return out;
|
|
1836
|
+
}
|
|
1837
|
+
var rawTailwindColor = {
|
|
1838
|
+
id: "raw-tailwind-color",
|
|
1839
|
+
severity: "error",
|
|
1840
|
+
description: "Raw Tailwind palette colour (e.g. `bg-blue-500`) instead of a semantic token.",
|
|
1841
|
+
check(lines) {
|
|
1842
|
+
const re = new RegExp(`\\b(?:${COLOR_PREFIXES.join("|")})-(?:${RAW_PALETTE})-(?:50|100|200|300|400|500|600|700|800|900|950)(?:\\/\\d{1,3})?(?![a-z0-9-])`, "g");
|
|
1843
|
+
const findings = [];
|
|
1844
|
+
lines.forEach((line, i) => {
|
|
1845
|
+
for (const m of line.matchAll(re)) {
|
|
1846
|
+
findings.push({
|
|
1847
|
+
ruleId: this.id,
|
|
1848
|
+
severity: this.severity,
|
|
1849
|
+
kind: "deterministic",
|
|
1850
|
+
message: `Raw Tailwind colour \`${m[0]}\` bypasses the token system (no dark-mode adaptation, no theming).`,
|
|
1851
|
+
fix: "Use a semantic token: `bg-surface-*`, `text-text-*`, `border-border-*`, or an intent (`bg-primary`, `text-success`).",
|
|
1852
|
+
line: i + 1,
|
|
1853
|
+
match: m[0]
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
});
|
|
1857
|
+
return dedupeByLine(findings);
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
var darkModeOverride = {
|
|
1861
|
+
id: "dark-mode-override",
|
|
1862
|
+
severity: "error",
|
|
1863
|
+
description: "Manual `dark:` override instead of automatic `light-dark()` semantic tokens.",
|
|
1864
|
+
check(lines) {
|
|
1865
|
+
const re = /\bdark:[a-z[!]/g;
|
|
1866
|
+
const findings = [];
|
|
1867
|
+
lines.forEach((line, i) => {
|
|
1868
|
+
for (const m of line.matchAll(re)) {
|
|
1869
|
+
findings.push({
|
|
1870
|
+
ruleId: this.id,
|
|
1871
|
+
severity: this.severity,
|
|
1872
|
+
kind: "deterministic",
|
|
1873
|
+
message: "Manual `dark:` override. Dark mode resolves automatically via `light-dark()` semantic tokens.",
|
|
1874
|
+
fix: "Remove the `dark:` variant and rely on semantic tokens (`bg-surface-elevated` etc.), which already switch.",
|
|
1875
|
+
line: i + 1,
|
|
1876
|
+
match: m[0].slice(0, -1)
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
});
|
|
1880
|
+
return dedupeByLine(findings);
|
|
1881
|
+
}
|
|
1882
|
+
};
|
|
1883
|
+
var focusNotVisible = {
|
|
1884
|
+
id: "focus-not-visible",
|
|
1885
|
+
severity: "error",
|
|
1886
|
+
description: "Plain `focus:` ring instead of keyboard-only `focus-visible:`.",
|
|
1887
|
+
check(lines) {
|
|
1888
|
+
const re = /\bfocus:(?=[a-z[])/g;
|
|
1889
|
+
const findings = [];
|
|
1890
|
+
lines.forEach((line, i) => {
|
|
1891
|
+
for (const _ of line.matchAll(re)) {
|
|
1892
|
+
findings.push({
|
|
1893
|
+
ruleId: this.id,
|
|
1894
|
+
severity: this.severity,
|
|
1895
|
+
kind: "deterministic",
|
|
1896
|
+
message: "`focus:` shows a focus ring on mouse clicks too. Keyboard-only rings are the house style.",
|
|
1897
|
+
fix: "Use `focus-visible:` instead of `focus:`.",
|
|
1898
|
+
line: i + 1,
|
|
1899
|
+
match: "focus:"
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
return dedupeByLine(findings);
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
var hardcodedZIndex = {
|
|
1907
|
+
id: "hardcoded-z-index",
|
|
1908
|
+
severity: "error",
|
|
1909
|
+
description: "Hardcoded z-index instead of a `z-[var(--z-*)]` token.",
|
|
1910
|
+
check(lines) {
|
|
1911
|
+
const re = /\bz-(?:\d{1,4}|\[\d{1,4}\])(?![\w-])/g;
|
|
1912
|
+
const findings = [];
|
|
1913
|
+
lines.forEach((line, i) => {
|
|
1914
|
+
for (const m of line.matchAll(re)) {
|
|
1915
|
+
findings.push({
|
|
1916
|
+
ruleId: this.id,
|
|
1917
|
+
severity: this.severity,
|
|
1918
|
+
kind: "deterministic",
|
|
1919
|
+
message: `Hardcoded z-index \`${m[0]}\` collides with the layering scale and can sit behind/above the wrong overlay.`,
|
|
1920
|
+
fix: "Use a z-index token: `z-[var(--z-modal)]`, `z-[var(--z-dropdown)]`, `z-[var(--z-tooltip)]`, …",
|
|
1921
|
+
line: i + 1,
|
|
1922
|
+
match: m[0]
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
return dedupeByLine(findings);
|
|
1927
|
+
}
|
|
1928
|
+
};
|
|
1929
|
+
var dynamicClassInterpolation = {
|
|
1930
|
+
id: "dynamic-class-interpolation",
|
|
1931
|
+
severity: "error",
|
|
1932
|
+
description: "String-interpolated Tailwind class fragment (e.g. `gap-{x}`) — never compiled by Tailwind.",
|
|
1933
|
+
check(lines) {
|
|
1934
|
+
const re = new RegExp(`\\b(?:${DYNAMIC_UTILITY_ROOTS.join("|")})-(\\$\\{|\\{)`, "g");
|
|
1935
|
+
const findings = [];
|
|
1936
|
+
lines.forEach((line, i) => {
|
|
1937
|
+
for (const m of line.matchAll(re)) {
|
|
1938
|
+
findings.push({
|
|
1939
|
+
ruleId: this.id,
|
|
1940
|
+
severity: this.severity,
|
|
1941
|
+
kind: "deterministic",
|
|
1942
|
+
message: `Interpolated class fragment \`${m[0]}…\` — Tailwind only compiles static class names, so this utility is never generated.`,
|
|
1943
|
+
fix: "Switch the whole class string per state: `class={isHero ? 'gap-4' : 'gap-3'}` — keep each utility a complete literal.",
|
|
1944
|
+
line: i + 1,
|
|
1945
|
+
match: m[0]
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
});
|
|
1949
|
+
return dedupeByLine(findings);
|
|
1950
|
+
}
|
|
1951
|
+
};
|
|
1952
|
+
var tokenHallucination = {
|
|
1953
|
+
id: "token-hallucination",
|
|
1954
|
+
severity: "warning",
|
|
1955
|
+
description: "Colour utility referencing a non-existent semantic token (e.g. `bg-status-danger`).",
|
|
1956
|
+
check(lines, _raw, ctx) {
|
|
1957
|
+
const validCores = ctx?.validTokenCores ?? VALID_TOKEN_CORES;
|
|
1958
|
+
const prefixAlt = COLOR_PREFIXES.join("|");
|
|
1959
|
+
const re = new RegExp(`\\b(${prefixAlt})-([a-z][a-z0-9-]*)(?:\\/\\d{1,3})?\\b`, "g");
|
|
1960
|
+
const findings = [];
|
|
1961
|
+
lines.forEach((line, i) => {
|
|
1962
|
+
for (const m of line.matchAll(re)) {
|
|
1963
|
+
const core = m[2];
|
|
1964
|
+
if (!looksSemantic(core))
|
|
1965
|
+
continue;
|
|
1966
|
+
if (validCores.has(core))
|
|
1967
|
+
continue;
|
|
1968
|
+
findings.push({
|
|
1969
|
+
ruleId: this.id,
|
|
1970
|
+
severity: this.severity,
|
|
1971
|
+
kind: "deterministic",
|
|
1972
|
+
message: `\`${m[1]}-${core}\` is not a real token — likely hallucinated.`,
|
|
1973
|
+
fix: suggestForBadCore(core),
|
|
1974
|
+
line: i + 1,
|
|
1975
|
+
match: m[0]
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
});
|
|
1979
|
+
return dedupeByLine(findings);
|
|
1980
|
+
}
|
|
1981
|
+
};
|
|
1982
|
+
function looksSemantic(core) {
|
|
1983
|
+
if (/^text-(?:xs|sm|base|lg|\d?xl)$/.test(core))
|
|
1984
|
+
return false;
|
|
1985
|
+
if (isForeignVocab(core))
|
|
1986
|
+
return true;
|
|
1987
|
+
if (SEMANTIC_NAMESPACES.some((ns) => core.startsWith(ns)))
|
|
1988
|
+
return true;
|
|
1989
|
+
if (core.startsWith("status-"))
|
|
1990
|
+
return true;
|
|
1991
|
+
for (const intent of INTENT_PREFIXES) {
|
|
1992
|
+
if (core.startsWith(`${intent}-`))
|
|
1993
|
+
return true;
|
|
1994
|
+
}
|
|
1995
|
+
if (core.endsWith("-fg"))
|
|
1996
|
+
return true;
|
|
1997
|
+
return false;
|
|
1998
|
+
}
|
|
1999
|
+
function suggestForBadCore(core) {
|
|
2000
|
+
if (core === "foreground" || core.endsWith("-foreground") || core === "fg" || core.endsWith("-fg")) {
|
|
2001
|
+
return "Use `text-on-primary` / `text-on-surface` for foreground-on-intent text, or `text-text-primary`/`-secondary` for general text.";
|
|
2002
|
+
}
|
|
2003
|
+
if (isForeignVocab(core))
|
|
2004
|
+
return SHADCN_FIX;
|
|
2005
|
+
for (const [bad, hint] of Object.entries(KNOWN_BAD_NAMESPACES)) {
|
|
2006
|
+
if (bad.endsWith("-") ? core.startsWith(bad) : core.endsWith(bad))
|
|
2007
|
+
return hint;
|
|
2008
|
+
}
|
|
2009
|
+
if (core.startsWith("surface-"))
|
|
2010
|
+
return "Valid surfaces: surface-base/quiet/subtle/elevated/overlay/hover/active/selected/inverted.";
|
|
2011
|
+
if (core.startsWith("feedback-"))
|
|
2012
|
+
return "Valid feedback tokens: feedback-{info,success,warning,error}[-subtle].";
|
|
2013
|
+
const intent = INTENT_NAMES.find((n) => core.startsWith(`${n}-`));
|
|
2014
|
+
if (intent)
|
|
2015
|
+
return `Valid \`${intent}\` variants: ${intent}, ${intent}-hover, ${intent}-active, ${intent}-subtle, ${intent}-emphasis, or a scale step ${intent}-50…${intent}-950.`;
|
|
2016
|
+
return "Check `get_css_reference()` for the exact token name.";
|
|
2017
|
+
}
|
|
2018
|
+
var RULES = [
|
|
2019
|
+
rawTailwindColor,
|
|
2020
|
+
darkModeOverride,
|
|
2021
|
+
focusNotVisible,
|
|
2022
|
+
hardcodedZIndex,
|
|
2023
|
+
dynamicClassInterpolation,
|
|
2024
|
+
tokenHallucination,
|
|
2025
|
+
...MARKUP_RULES
|
|
2026
|
+
];
|
|
2027
|
+
|
|
2028
|
+
// ../design-engine/src/linter/linter.ts
|
|
2029
|
+
var SCORE_WEIGHTS = {
|
|
2030
|
+
error: 10,
|
|
2031
|
+
warning: 5,
|
|
2032
|
+
info: 2
|
|
2033
|
+
};
|
|
2034
|
+
var SLOP_WEIGHT = 10;
|
|
2035
|
+
function maskComments(code) {
|
|
2036
|
+
const blankKeepNewlines = (s) => s.replace(/[^\n]/g, " ");
|
|
2037
|
+
return code.replace(/<!--[\s\S]*?-->/g, blankKeepNewlines).replace(/\/\*[\s\S]*?\*\//g, blankKeepNewlines);
|
|
2038
|
+
}
|
|
2039
|
+
var SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
|
|
2040
|
+
function lintDesign(code, opts = {}) {
|
|
2041
|
+
const masked = maskComments(code);
|
|
2042
|
+
const lines = masked.split(`
|
|
2043
|
+
`);
|
|
2044
|
+
const ctx = { validTokenCores: resolveValidTokenCores(opts.extraTokens) };
|
|
2045
|
+
const findings = [];
|
|
2046
|
+
for (const rule of RULES) {
|
|
2047
|
+
findings.push(...rule.check(lines, masked, ctx));
|
|
2048
|
+
}
|
|
2049
|
+
if (!opts.skipHeuristics) {
|
|
2050
|
+
findings.push(...runHeuristics(masked));
|
|
2051
|
+
}
|
|
2052
|
+
findings.sort((a, b) => {
|
|
2053
|
+
const lineDiff = (a.line ?? Infinity) - (b.line ?? Infinity);
|
|
2054
|
+
if (lineDiff !== 0)
|
|
2055
|
+
return lineDiff;
|
|
2056
|
+
return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
|
2057
|
+
});
|
|
2058
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
2059
|
+
let correctnessDeduction = 0;
|
|
2060
|
+
let slopDeduction = 0;
|
|
2061
|
+
for (const f of findings) {
|
|
2062
|
+
counts[f.severity]++;
|
|
2063
|
+
if (f.kind === "heuristic")
|
|
2064
|
+
slopDeduction += SLOP_WEIGHT;
|
|
2065
|
+
else
|
|
2066
|
+
correctnessDeduction += SCORE_WEIGHTS[f.severity];
|
|
2067
|
+
}
|
|
2068
|
+
const scores = {
|
|
2069
|
+
correctness: Math.max(0, 100 - correctnessDeduction),
|
|
2070
|
+
slop: Math.max(0, 100 - slopDeduction)
|
|
2071
|
+
};
|
|
2072
|
+
return { findings, scores, counts, filename: opts.filename };
|
|
2073
|
+
}
|
|
2074
|
+
// src/cli/gate.ts
|
|
2075
|
+
function evaluateGate(reports, opts) {
|
|
2076
|
+
const totals = { error: 0, warning: 0, info: 0 };
|
|
2077
|
+
const slopBreaches = [];
|
|
2078
|
+
for (const r of reports) {
|
|
2079
|
+
totals.error += r.counts.error;
|
|
2080
|
+
totals.warning += r.counts.warning;
|
|
2081
|
+
totals.info += r.counts.info;
|
|
2082
|
+
if (opts.slopFloor !== null && r.scores.slop < opts.slopFloor) {
|
|
2083
|
+
slopBreaches.push({ label: r.filename ?? "<stdin>", slop: r.scores.slop });
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
const correctnessFailed = totals.error > 0 || opts.strict && totals.warning > 0;
|
|
2087
|
+
return {
|
|
2088
|
+
failed: correctnessFailed || slopBreaches.length > 0,
|
|
2089
|
+
correctnessFailed,
|
|
2090
|
+
totals,
|
|
2091
|
+
slopBreaches
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
function parseSlopFloor(raw) {
|
|
2095
|
+
if (raw === undefined)
|
|
2096
|
+
return null;
|
|
2097
|
+
if (typeof raw === "boolean")
|
|
2098
|
+
return "invalid";
|
|
2099
|
+
if (!/^\d{1,3}$/.test(raw.trim()))
|
|
2100
|
+
return "invalid";
|
|
2101
|
+
const n = Number(raw);
|
|
2102
|
+
if (n > 100)
|
|
2103
|
+
return "invalid";
|
|
2104
|
+
return n;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// src/cli/commands/hook.ts
|
|
2108
|
+
var HOOK_BLOCK = 2;
|
|
2109
|
+
async function readStdin() {
|
|
2110
|
+
const chunks = [];
|
|
2111
|
+
for await (const chunk of process.stdin)
|
|
2112
|
+
chunks.push(chunk);
|
|
2113
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
2114
|
+
}
|
|
2115
|
+
function editedPaths(event) {
|
|
2116
|
+
if (typeof event !== "object" || event === null)
|
|
2117
|
+
return [];
|
|
2118
|
+
const input = event.tool_input;
|
|
2119
|
+
if (typeof input !== "object" || input === null)
|
|
2120
|
+
return [];
|
|
2121
|
+
const fp = input.file_path;
|
|
2122
|
+
return typeof fp === "string" && fp.length > 0 ? [fp] : [];
|
|
2123
|
+
}
|
|
2124
|
+
async function runHook(_positionals, flags) {
|
|
2125
|
+
const slopFloor = parseSlopFloor(flags["slop-floor"]);
|
|
2126
|
+
if (slopFloor === "invalid") {
|
|
2127
|
+
printError("--slop-floor needs an integer between 0 and 100, e.g. --slop-floor 40");
|
|
2128
|
+
return EXIT.USAGE;
|
|
2129
|
+
}
|
|
2130
|
+
let event;
|
|
2131
|
+
try {
|
|
2132
|
+
event = JSON.parse(await readStdin());
|
|
2133
|
+
} catch {
|
|
2134
|
+
return EXIT.OK;
|
|
2135
|
+
}
|
|
2136
|
+
const paths = editedPaths(event).filter((p) => p.endsWith(".svelte"));
|
|
2137
|
+
if (paths.length === 0)
|
|
2138
|
+
return EXIT.OK;
|
|
2139
|
+
const manifestPath = resolveManifestPath(stringFlag(flags, "manifest"));
|
|
2140
|
+
const extraTokens = await readTokenOverrides(manifestPath);
|
|
2141
|
+
const strict = boolFlag(flags, "strict");
|
|
2142
|
+
const skipHeuristics = boolFlag(flags, "skip-heuristics");
|
|
2143
|
+
const reports = [];
|
|
2144
|
+
for (const p of paths) {
|
|
2145
|
+
let code;
|
|
2146
|
+
try {
|
|
2147
|
+
code = await readFile5(resolve4(p), "utf-8");
|
|
2148
|
+
} catch {
|
|
2149
|
+
continue;
|
|
2150
|
+
}
|
|
2151
|
+
reports.push(lintDesign(code, { filename: p, skipHeuristics, extraTokens }));
|
|
2152
|
+
}
|
|
2153
|
+
if (reports.length === 0)
|
|
2154
|
+
return EXIT.OK;
|
|
2155
|
+
const gate = evaluateGate(reports, { strict, slopFloor });
|
|
2156
|
+
if (!gate.failed)
|
|
2157
|
+
return EXIT.OK;
|
|
2158
|
+
const blocking = (r) => r.counts.error > 0 || strict && r.counts.warning > 0 || slopFloor !== null && r.scores.slop < slopFloor;
|
|
2159
|
+
for (const report of reports.filter(blocking))
|
|
2160
|
+
console.error(formatReport(report));
|
|
2161
|
+
if (gate.slopBreaches.length > 0) {
|
|
2162
|
+
console.error(`
|
|
2163
|
+
Below the slop floor (${slopFloor}): ` + gate.slopBreaches.map((b) => `${b.label} (${b.slop}/100)`).join(", "));
|
|
2164
|
+
}
|
|
2165
|
+
console.error(`
|
|
2166
|
+
Fix the issues above and re-save. — urbicon design gate`);
|
|
2167
|
+
return HOOK_BLOCK;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// src/cli/commands/init.ts
|
|
2171
|
+
import { mkdir, readFile as readFile7, writeFile as writeFile2 } from "node:fs/promises";
|
|
2172
|
+
import { dirname as dirname4, join as join2, relative as relative2, resolve as resolve6 } from "node:path";
|
|
2173
|
+
|
|
2174
|
+
// src/cli/package-root.ts
|
|
2175
|
+
import { readFile as readFile6 } from "node:fs/promises";
|
|
2176
|
+
import { dirname as dirname3, resolve as resolve5 } from "node:path";
|
|
2177
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
2178
|
+
async function findPackageRoot() {
|
|
2179
|
+
let dir = dirname3(fileURLToPath2(import.meta.url));
|
|
2180
|
+
for (let i = 0;i < 6; i++) {
|
|
2181
|
+
try {
|
|
2182
|
+
const pkg = JSON.parse(await readFile6(resolve5(dir, "package.json"), "utf-8"));
|
|
2183
|
+
if (pkg.name === "@urbicon-ui/design")
|
|
2184
|
+
return dir;
|
|
2185
|
+
} catch {}
|
|
2186
|
+
const parent = dirname3(dir);
|
|
2187
|
+
if (parent === dir)
|
|
2188
|
+
break;
|
|
2189
|
+
dir = parent;
|
|
2190
|
+
}
|
|
2191
|
+
return null;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// src/cli/commands/init.ts
|
|
2195
|
+
var BLOCK_START = "<!-- urbicon:start";
|
|
2196
|
+
var BLOCK_END = "<!-- urbicon:end -->";
|
|
2197
|
+
async function readTemplate(name) {
|
|
2198
|
+
const root = await findPackageRoot();
|
|
2199
|
+
if (!root)
|
|
2200
|
+
throw new Error("could not locate the @urbicon-ui/design package root");
|
|
2201
|
+
return readFile7(join2(root, "templates", name), "utf-8");
|
|
2202
|
+
}
|
|
2203
|
+
async function readOrNull(path) {
|
|
2204
|
+
try {
|
|
2205
|
+
return await readFile7(path, "utf-8");
|
|
2206
|
+
} catch {
|
|
2207
|
+
return null;
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
function upsertBlock(existing, block) {
|
|
2211
|
+
const startIdx = existing.indexOf(BLOCK_START);
|
|
2212
|
+
if (startIdx !== -1) {
|
|
2213
|
+
const endIdx = existing.indexOf(BLOCK_END, startIdx);
|
|
2214
|
+
if (endIdx === -1) {
|
|
2215
|
+
throw new Error(`found an unterminated \`${BLOCK_START}\` marker (no \`${BLOCK_END}\`) — remove the partial block and re-run`);
|
|
2216
|
+
}
|
|
2217
|
+
const content = existing.slice(0, startIdx) + block.trim() + existing.slice(endIdx + BLOCK_END.length);
|
|
2218
|
+
return { content, replaced: true };
|
|
2219
|
+
}
|
|
2220
|
+
const sep2 = existing.length === 0 ? "" : existing.endsWith(`
|
|
2221
|
+
|
|
2222
|
+
`) ? "" : existing.endsWith(`
|
|
2223
|
+
`) ? `
|
|
2224
|
+
` : `
|
|
2225
|
+
|
|
2226
|
+
`;
|
|
2227
|
+
return { content: `${existing}${sep2}${block.trim()}
|
|
2228
|
+
`, replaced: false };
|
|
2229
|
+
}
|
|
2230
|
+
async function mergeHook(settingsPath) {
|
|
2231
|
+
const existing = await readOrNull(settingsPath);
|
|
2232
|
+
let settings;
|
|
2233
|
+
try {
|
|
2234
|
+
settings = existing ? JSON.parse(existing) : {};
|
|
2235
|
+
} catch {
|
|
2236
|
+
throw new Error("invalid JSON — merge templates/claude-settings.json by hand");
|
|
2237
|
+
}
|
|
2238
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
|
|
2239
|
+
throw new Error("unexpected shape (not a JSON object) — merge templates/claude-settings.json by hand");
|
|
2240
|
+
}
|
|
2241
|
+
settings.hooks ??= {};
|
|
2242
|
+
settings.hooks.PostToolUse ??= [];
|
|
2243
|
+
if (JSON.stringify(settings.hooks.PostToolUse).includes("urbicon hook"))
|
|
2244
|
+
return "present";
|
|
2245
|
+
settings.hooks.PostToolUse.push({
|
|
2246
|
+
matcher: "Edit|MultiEdit|Write",
|
|
2247
|
+
hooks: [{ type: "command", command: "urbicon hook" }]
|
|
2248
|
+
});
|
|
2249
|
+
await mkdir(dirname4(settingsPath), { recursive: true });
|
|
2250
|
+
await writeFile2(settingsPath, `${JSON.stringify(settings, null, 2)}
|
|
2251
|
+
`, "utf-8");
|
|
2252
|
+
return "added";
|
|
2253
|
+
}
|
|
2254
|
+
async function runInit(_positionals, flags) {
|
|
2255
|
+
const cwd = process.cwd();
|
|
2256
|
+
const rel = (p) => relative2(cwd, p) || p;
|
|
2257
|
+
const done = [];
|
|
2258
|
+
const skipped = [];
|
|
2259
|
+
let block;
|
|
2260
|
+
try {
|
|
2261
|
+
block = await readTemplate("AGENTS.md");
|
|
2262
|
+
} catch (err) {
|
|
2263
|
+
printError(err.message);
|
|
2264
|
+
return EXIT.FAIL;
|
|
2265
|
+
}
|
|
2266
|
+
const agentsPath = resolve6(stringFlag(flags, "agents-file") ?? "AGENTS.md");
|
|
2267
|
+
const existingAgents = await readOrNull(agentsPath) ?? "";
|
|
2268
|
+
let upserted;
|
|
2269
|
+
try {
|
|
2270
|
+
upserted = upsertBlock(existingAgents, block);
|
|
2271
|
+
} catch (err) {
|
|
2272
|
+
printError(`${rel(agentsPath)}: ${err.message}`);
|
|
2273
|
+
return EXIT.FAIL;
|
|
2274
|
+
}
|
|
2275
|
+
await writeFile2(agentsPath, upserted.content, "utf-8");
|
|
2276
|
+
const verb = upserted.replaced ? "refreshed" : existingAgents ? "added" : "created";
|
|
2277
|
+
done.push(`${rel(agentsPath)} — ${verb} the Urbicon UI context block`);
|
|
2278
|
+
const manifestPath = resolveManifestPath(stringFlag(flags, "manifest"));
|
|
2279
|
+
if (await readOrNull(manifestPath)) {
|
|
2280
|
+
skipped.push(`${rel(manifestPath)} — already present (kept your intent)`);
|
|
2281
|
+
} else {
|
|
2282
|
+
await writeFile2(manifestPath, createManifestTemplate({}), "utf-8");
|
|
2283
|
+
done.push(`${rel(manifestPath)} — scaffolded`);
|
|
2284
|
+
}
|
|
2285
|
+
if (boolFlag(flags, "hook")) {
|
|
2286
|
+
const settingsPath = resolve6(".claude", "settings.json");
|
|
2287
|
+
try {
|
|
2288
|
+
const result = await mergeHook(settingsPath);
|
|
2289
|
+
(result === "added" ? done : skipped).push(`${rel(settingsPath)} — ${result === "added" ? "wired" : "already has"} the PostToolUse \`urbicon hook\``);
|
|
2290
|
+
} catch (err) {
|
|
2291
|
+
skipped.push(`${rel(settingsPath)} — skipped (${err.message})`);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
if (boolFlag(flags, "ci")) {
|
|
2295
|
+
const ciPath = resolve6(".github", "workflows", "design-gate.yml");
|
|
2296
|
+
if (await readOrNull(ciPath)) {
|
|
2297
|
+
skipped.push(`${rel(ciPath)} — already present`);
|
|
2298
|
+
} else {
|
|
2299
|
+
const ci = await readTemplate("ci-github.yml");
|
|
2300
|
+
await mkdir(dirname4(ciPath), { recursive: true });
|
|
2301
|
+
await writeFile2(ciPath, ci, "utf-8");
|
|
2302
|
+
done.push(`${rel(ciPath)} — wrote the design-gate workflow`);
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
console.log(`urbicon init — project wired into the design loop
|
|
2306
|
+
`);
|
|
2307
|
+
for (const d of done)
|
|
2308
|
+
console.log(` ✓ ${d}`);
|
|
2309
|
+
for (const s of skipped)
|
|
2310
|
+
console.log(` · ${s}`);
|
|
2311
|
+
console.log(`
|
|
2312
|
+
Next steps:`);
|
|
2313
|
+
console.log(" • Make sure your agent reads AGENTS.md (or paste the block into CLAUDE.md / .cursorrules).");
|
|
2314
|
+
console.log(" • Seed the design memory: `bunx urbicon verb adopt` (brownfield) or `onboard` (greenfield) — the guided intake.");
|
|
2315
|
+
if (!boolFlag(flags, "hook")) {
|
|
2316
|
+
console.log(" • Enforce at edit time: re-run with `--hook` to wire the PostToolUse gate.");
|
|
2317
|
+
}
|
|
2318
|
+
if (!boolFlag(flags, "ci")) {
|
|
2319
|
+
console.log(" • Enforce in CI: re-run with `--ci`, or add `bunx urbicon validate src/` to your pipeline.");
|
|
2320
|
+
}
|
|
2321
|
+
return EXIT.OK;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
// src/cli/commands/record-decision.ts
|
|
2325
|
+
var STATUSES = new Set(["accepted", "proposed", "superseded"]);
|
|
2326
|
+
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
2327
|
+
async function runRecordDecision(_positionals, flags) {
|
|
2328
|
+
const title = stringFlag(flags, "title");
|
|
2329
|
+
const decision = stringFlag(flags, "decision");
|
|
2330
|
+
if (!title || !decision) {
|
|
2331
|
+
printError("record-decision requires --title and --decision");
|
|
2332
|
+
return EXIT.USAGE;
|
|
2333
|
+
}
|
|
2334
|
+
const status = stringFlag(flags, "status") ?? "accepted";
|
|
2335
|
+
if (!STATUSES.has(status)) {
|
|
2336
|
+
printError(`invalid --status "${status}" (accepted | proposed | superseded)`);
|
|
2337
|
+
return EXIT.USAGE;
|
|
2338
|
+
}
|
|
2339
|
+
const date = stringFlag(flags, "date") ?? new Date().toISOString().slice(0, 10);
|
|
2340
|
+
if (!DATE_RE.test(date)) {
|
|
2341
|
+
printError(`invalid --date "${date}" (expected YYYY-MM-DD)`);
|
|
2342
|
+
return EXIT.USAGE;
|
|
2343
|
+
}
|
|
2344
|
+
const path = resolveManifestPath(stringFlag(flags, "manifest"));
|
|
2345
|
+
if (!path.endsWith(".md")) {
|
|
2346
|
+
printError(`refusing to write: "${path}" is not a .md file`);
|
|
2347
|
+
return EXIT.USAGE;
|
|
2348
|
+
}
|
|
2349
|
+
const { content, created } = await readOrCreateManifest(path);
|
|
2350
|
+
const updated = appendDecision(content, {
|
|
2351
|
+
date,
|
|
2352
|
+
title,
|
|
2353
|
+
status,
|
|
2354
|
+
decision,
|
|
2355
|
+
rationale: stringFlag(flags, "rationale")
|
|
2356
|
+
});
|
|
2357
|
+
try {
|
|
2358
|
+
await writeManifest(path, updated);
|
|
2359
|
+
} catch (err) {
|
|
2360
|
+
printError(`failed to write ${path}: ${err.message}`);
|
|
2361
|
+
return EXIT.FAIL;
|
|
2362
|
+
}
|
|
2363
|
+
const total = parseManifest(updated).decisions.length;
|
|
2364
|
+
console.log(`Recorded ADR "${title}" (${date}) in ${path}${created ? " (created the manifest)" : ""}. ` + `${total} decision(s) on record.`);
|
|
2365
|
+
return EXIT.OK;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
// src/cli/commands/sync-manifest.ts
|
|
2369
|
+
import { dirname as dirname5 } from "node:path";
|
|
2370
|
+
async function runSyncManifest(_positionals, flags) {
|
|
2371
|
+
const path = resolveManifestPath(stringFlag(flags, "manifest"));
|
|
2372
|
+
if (!path.endsWith(".md")) {
|
|
2373
|
+
printError(`refusing to write: "${path}" is not a .md file`);
|
|
2374
|
+
return EXIT.USAGE;
|
|
2375
|
+
}
|
|
2376
|
+
const src = resolveSourceDir(stringFlag(flags, "src"));
|
|
2377
|
+
const usages = await scanMarkers(src, dirname5(path));
|
|
2378
|
+
const { content, created } = await readOrCreateManifest(path);
|
|
2379
|
+
const updated = upsertUsagesSection(content, usages);
|
|
2380
|
+
try {
|
|
2381
|
+
await writeManifest(path, updated);
|
|
2382
|
+
} catch (err) {
|
|
2383
|
+
printError(`failed to write ${path}: ${err.message}`);
|
|
2384
|
+
return EXIT.FAIL;
|
|
2385
|
+
}
|
|
2386
|
+
const byPattern = new Map;
|
|
2387
|
+
for (const usage of usages)
|
|
2388
|
+
byPattern.set(usage.pattern, (byPattern.get(usage.pattern) ?? 0) + 1);
|
|
2389
|
+
if (boolFlag(flags, "json")) {
|
|
2390
|
+
console.log(JSON.stringify({ manifest: path, created, scanned: src, usages }, null, 2));
|
|
2391
|
+
return EXIT.OK;
|
|
2392
|
+
}
|
|
2393
|
+
let text = `Synced ${path}${created ? " (created it)" : ""} — scanned ${src}, ${usages.length} marker(s)`;
|
|
2394
|
+
if (byPattern.size > 0) {
|
|
2395
|
+
const summary = [...byPattern].sort((a, b) => a[0].localeCompare(b[0])).map(([pattern, n]) => `${pattern} (${n})`).join(", ");
|
|
2396
|
+
text += ` across ${byPattern.size} pattern(s): ${summary}.`;
|
|
2397
|
+
} else {
|
|
2398
|
+
text += '. No markers yet — add data-design-pattern="<name>" to pattern-following pages.';
|
|
2399
|
+
}
|
|
2400
|
+
console.log(text);
|
|
2401
|
+
return EXIT.OK;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
// src/cli/commands/validate.ts
|
|
2405
|
+
import { readdir as readdir2, readFile as readFile8, stat as stat2 } from "node:fs/promises";
|
|
2406
|
+
import { join as join3, relative as relative3, resolve as resolve7, sep as sep2 } from "node:path";
|
|
2407
|
+
var SKIP_DIRS2 = new Set([
|
|
2408
|
+
"node_modules",
|
|
2409
|
+
".svelte-kit",
|
|
2410
|
+
".git",
|
|
2411
|
+
"dist",
|
|
2412
|
+
"build",
|
|
2413
|
+
".next",
|
|
2414
|
+
".turbo",
|
|
2415
|
+
"coverage"
|
|
2416
|
+
]);
|
|
2417
|
+
var MAX_DEPTH2 = 24;
|
|
2418
|
+
function label(abs) {
|
|
2419
|
+
return relative3(process.cwd(), abs).split(sep2).join("/") || abs;
|
|
2420
|
+
}
|
|
2421
|
+
async function collectSvelte(dir, depth = 0) {
|
|
2422
|
+
if (depth > MAX_DEPTH2)
|
|
2423
|
+
return [];
|
|
2424
|
+
let entries;
|
|
2425
|
+
try {
|
|
2426
|
+
entries = await readdir2(dir, { withFileTypes: true });
|
|
2427
|
+
} catch {
|
|
2428
|
+
return [];
|
|
2429
|
+
}
|
|
2430
|
+
const files = [];
|
|
2431
|
+
for (const entry of entries) {
|
|
2432
|
+
if (entry.isDirectory()) {
|
|
2433
|
+
if (SKIP_DIRS2.has(entry.name) || entry.name.startsWith("."))
|
|
2434
|
+
continue;
|
|
2435
|
+
files.push(...await collectSvelte(join3(dir, entry.name), depth + 1));
|
|
2436
|
+
} else if (entry.isFile() && entry.name.endsWith(".svelte")) {
|
|
2437
|
+
files.push(join3(dir, entry.name));
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
return files;
|
|
2441
|
+
}
|
|
2442
|
+
async function readStdin2() {
|
|
2443
|
+
const chunks = [];
|
|
2444
|
+
for await (const chunk of process.stdin)
|
|
2445
|
+
chunks.push(chunk);
|
|
2446
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
2447
|
+
}
|
|
2448
|
+
async function gather(positionals) {
|
|
2449
|
+
if (positionals.length === 0 || positionals.length === 1 && positionals[0] === "-") {
|
|
2450
|
+
return [{ label: "<stdin>", code: await readStdin2() }];
|
|
2451
|
+
}
|
|
2452
|
+
const units = [];
|
|
2453
|
+
for (const p of positionals) {
|
|
2454
|
+
if (p === "-") {
|
|
2455
|
+
units.push({ label: "<stdin>", code: await readStdin2() });
|
|
2456
|
+
continue;
|
|
2457
|
+
}
|
|
2458
|
+
const abs = resolve7(p);
|
|
2459
|
+
let info;
|
|
2460
|
+
try {
|
|
2461
|
+
info = await stat2(abs);
|
|
2462
|
+
} catch {
|
|
2463
|
+
printError(`cannot read "${p}"`);
|
|
2464
|
+
return null;
|
|
2465
|
+
}
|
|
2466
|
+
if (info.isDirectory()) {
|
|
2467
|
+
for (const file of await collectSvelte(abs)) {
|
|
2468
|
+
units.push({ label: label(file), code: await readFile8(file, "utf-8") });
|
|
2469
|
+
}
|
|
2470
|
+
} else {
|
|
2471
|
+
units.push({ label: label(abs), code: await readFile8(abs, "utf-8") });
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
return units;
|
|
2475
|
+
}
|
|
2476
|
+
function buildHistoryEntry(reports) {
|
|
2477
|
+
const files = reports.length;
|
|
2478
|
+
const sum = (pick) => reports.reduce((a, r) => a + pick(r), 0);
|
|
2479
|
+
const mean = (pick) => files === 0 ? 100 : Math.round(sum(pick) / files);
|
|
2480
|
+
return {
|
|
2481
|
+
date: new Date().toISOString(),
|
|
2482
|
+
files,
|
|
2483
|
+
errors: sum((r) => r.counts.error),
|
|
2484
|
+
warnings: sum((r) => r.counts.warning),
|
|
2485
|
+
infos: sum((r) => r.counts.info),
|
|
2486
|
+
correctness: mean((r) => r.scores.correctness),
|
|
2487
|
+
slop: mean((r) => r.scores.slop)
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
async function runValidate(positionals, flags) {
|
|
2491
|
+
const asJson = boolFlag(flags, "json");
|
|
2492
|
+
const strict = boolFlag(flags, "strict");
|
|
2493
|
+
const skipHeuristics = boolFlag(flags, "skip-heuristics");
|
|
2494
|
+
const record = boolFlag(flags, "record");
|
|
2495
|
+
const slopFloor = parseSlopFloor(flags["slop-floor"]);
|
|
2496
|
+
if (slopFloor === "invalid") {
|
|
2497
|
+
printError("--slop-floor needs an integer between 0 and 100, e.g. --slop-floor 40");
|
|
2498
|
+
return EXIT.USAGE;
|
|
2499
|
+
}
|
|
2500
|
+
const manifestPath = resolveManifestPath(stringFlag(flags, "manifest"));
|
|
2501
|
+
const extraTokens = await readTokenOverrides(manifestPath);
|
|
2502
|
+
const units = await gather(positionals);
|
|
2503
|
+
if (units === null)
|
|
2504
|
+
return EXIT.USAGE;
|
|
2505
|
+
if (units.length === 0) {
|
|
2506
|
+
printError("no .svelte files found to validate");
|
|
2507
|
+
return EXIT.USAGE;
|
|
2508
|
+
}
|
|
2509
|
+
const reports = units.map((unit) => lintDesign(unit.code, { filename: unit.label, skipHeuristics, extraTokens }));
|
|
2510
|
+
const gate = evaluateGate(reports, { strict, slopFloor });
|
|
2511
|
+
const { totals, failed } = gate;
|
|
2512
|
+
if (record) {
|
|
2513
|
+
const line = serializeHistoryEntry(buildHistoryEntry(reports));
|
|
2514
|
+
try {
|
|
2515
|
+
await appendHistory(manifestPath, line);
|
|
2516
|
+
} catch (err) {
|
|
2517
|
+
printError(`failed to record history to ${resolveHistoryPath(manifestPath)}: ${err.message}`);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
if (asJson) {
|
|
2521
|
+
console.log(JSON.stringify({ ok: !failed, strict, slopFloor, extraTokens, results: reports }, null, 2));
|
|
2522
|
+
return failed ? EXIT.FAIL : EXIT.OK;
|
|
2523
|
+
}
|
|
2524
|
+
for (const report of reports)
|
|
2525
|
+
console.log(formatReport(report));
|
|
2526
|
+
if (reports.length > 1) {
|
|
2527
|
+
console.log(`
|
|
2528
|
+
${reports.length} file(s) · ${totals.error} error(s), ` + `${totals.warning} warning(s), ${totals.info} note(s)`);
|
|
2529
|
+
}
|
|
2530
|
+
if (extraTokens.length > 0) {
|
|
2531
|
+
console.log(`
|
|
2532
|
+
${extraTokens.length} project token override(s) applied from ${relative3(process.cwd(), manifestPath).split(sep2).join("/")}.`);
|
|
2533
|
+
}
|
|
2534
|
+
if (gate.slopBreaches.length > 0) {
|
|
2535
|
+
console.log(`
|
|
2536
|
+
${gate.slopBreaches.length} file(s) below the slop floor (${slopFloor}):`);
|
|
2537
|
+
for (const b of gate.slopBreaches)
|
|
2538
|
+
console.log(` · ${b.label} — slop ${b.slop}/100`);
|
|
2539
|
+
}
|
|
2540
|
+
if (failed) {
|
|
2541
|
+
const reason = gate.correctnessFailed ? strict ? "errors or warnings present (--strict)" : "fix the errors above" : "slop floor not met";
|
|
2542
|
+
console.log(`
|
|
2543
|
+
FAIL — ${reason}.`);
|
|
2544
|
+
}
|
|
2545
|
+
return failed ? EXIT.FAIL : EXIT.OK;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// src/cli/commands/verb.ts
|
|
2549
|
+
import { readdir as readdir3, readFile as readFile9 } from "node:fs/promises";
|
|
2550
|
+
import { resolve as resolve8 } from "node:path";
|
|
2551
|
+
var SAFE_VERB = /^[a-z][a-z0-9-]*$/;
|
|
2552
|
+
async function resolveVerbsDir() {
|
|
2553
|
+
const root = await findPackageRoot();
|
|
2554
|
+
return root ? resolve8(root, "skill", "verbs") : null;
|
|
2555
|
+
}
|
|
2556
|
+
function purposeOf(body) {
|
|
2557
|
+
const heading = body.split(`
|
|
2558
|
+
`, 1)[0] ?? "";
|
|
2559
|
+
const m = heading.match(/^#\s+[a-z-]+\s+[—-]\s+(.+)$/);
|
|
2560
|
+
return m ? m[1].trim() : "";
|
|
2561
|
+
}
|
|
2562
|
+
async function runVerbList(_positionals, _flags) {
|
|
2563
|
+
const dir = await resolveVerbsDir();
|
|
2564
|
+
if (!dir) {
|
|
2565
|
+
printError("could not locate the bundled skill — reinstall @urbicon-ui/design");
|
|
2566
|
+
return EXIT.FAIL;
|
|
2567
|
+
}
|
|
2568
|
+
let files;
|
|
2569
|
+
try {
|
|
2570
|
+
files = (await readdir3(dir)).filter((f) => f.endsWith(".md")).sort();
|
|
2571
|
+
} catch {
|
|
2572
|
+
printError(`no verb recipes found at ${dir}`);
|
|
2573
|
+
return EXIT.FAIL;
|
|
2574
|
+
}
|
|
2575
|
+
console.log("Design verbs — run `urbicon verb <name>` to print one:\n");
|
|
2576
|
+
for (const file of files) {
|
|
2577
|
+
const name = file.replace(/\.md$/, "");
|
|
2578
|
+
let purpose = "";
|
|
2579
|
+
try {
|
|
2580
|
+
purpose = purposeOf(await readFile9(resolve8(dir, file), "utf-8"));
|
|
2581
|
+
} catch {}
|
|
2582
|
+
console.log(` ${name.padEnd(10)} ${purpose}`);
|
|
2583
|
+
}
|
|
2584
|
+
return EXIT.OK;
|
|
2585
|
+
}
|
|
2586
|
+
async function runVerb(positionals, _flags) {
|
|
2587
|
+
const name = positionals[0];
|
|
2588
|
+
if (!name) {
|
|
2589
|
+
printError("verb requires a name, e.g. `urbicon verb compose` (list them with `urbicon verbs`)");
|
|
2590
|
+
return EXIT.USAGE;
|
|
2591
|
+
}
|
|
2592
|
+
if (!SAFE_VERB.test(name)) {
|
|
2593
|
+
printError(`invalid verb name "${name}"`);
|
|
2594
|
+
return EXIT.USAGE;
|
|
2595
|
+
}
|
|
2596
|
+
const dir = await resolveVerbsDir();
|
|
2597
|
+
if (!dir) {
|
|
2598
|
+
printError("could not locate the bundled skill — reinstall @urbicon-ui/design");
|
|
2599
|
+
return EXIT.FAIL;
|
|
2600
|
+
}
|
|
2601
|
+
try {
|
|
2602
|
+
console.log(await readFile9(resolve8(dir, `${name}.md`), "utf-8"));
|
|
2603
|
+
return EXIT.OK;
|
|
2604
|
+
} catch {
|
|
2605
|
+
printError(`unknown verb "${name}" — list the available verbs with \`urbicon verbs\``);
|
|
2606
|
+
return EXIT.USAGE;
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
// src/cli/index.ts
|
|
2611
|
+
var HELP = `urbicon — design validation & manifest tooling for Urbicon UI projects
|
|
2612
|
+
|
|
2613
|
+
Usage:
|
|
2614
|
+
urbicon <command> [options]
|
|
2615
|
+
|
|
2616
|
+
Commands:
|
|
2617
|
+
init Wire this project into the design loop: insert the AGENTS.md
|
|
2618
|
+
context block + scaffold design.manifest.md, then print next
|
|
2619
|
+
steps. Idempotent and non-destructive.
|
|
2620
|
+
--hook Also merge the PostToolUse gate into
|
|
2621
|
+
.claude/settings.json.
|
|
2622
|
+
--ci Also write .github/workflows/design-gate.yml.
|
|
2623
|
+
--agents-file <p> Target for the context block (default AGENTS.md).
|
|
2624
|
+
--manifest <path> Manifest path (default ./design.manifest.md).
|
|
2625
|
+
validate [paths...] Lint .svelte markup against the Urbicon UI design rules.
|
|
2626
|
+
Paths may be files, directories, or "-" (stdin).
|
|
2627
|
+
Reads ## Token Overrides from the manifest (if any) so your
|
|
2628
|
+
project's own tokens are not flagged as hallucinated.
|
|
2629
|
+
--json Machine-readable report ({ ok, slopFloor, results }).
|
|
2630
|
+
--strict Fail on warnings too, not just errors.
|
|
2631
|
+
--slop-floor <n> Also fail any file scoring below n/100 on the
|
|
2632
|
+
slop axis (0–100; off by default — slop is advisory).
|
|
2633
|
+
--skip-heuristics Deterministic rules only (no distribution notes).
|
|
2634
|
+
--record Append a drift entry to the sidecar history (CI).
|
|
2635
|
+
--manifest <path> Manifest for token overrides + history
|
|
2636
|
+
(default ./design.manifest.md).
|
|
2637
|
+
hook Editor-hook adapter: read a Claude Code PostToolUse event on
|
|
2638
|
+
stdin, validate the edited .svelte file, and exit 2 with the
|
|
2639
|
+
findings on stderr so the agent self-corrects. Takes the same
|
|
2640
|
+
gate flags as validate (--strict, --slop-floor, --manifest).
|
|
2641
|
+
Wire it via .claude/settings.json (see templates/).
|
|
2642
|
+
find [query] Discover components by fuzzy search over the version-pinned
|
|
2643
|
+
catalog (names, tags, descriptions). No query lists all.
|
|
2644
|
+
--tag <t> Filter by category tag (form, action, …).
|
|
2645
|
+
--limit <n> Max results for a query (default 10).
|
|
2646
|
+
--json Machine-readable catalog entries.
|
|
2647
|
+
get-component <slug> Print a component's API (its llm.txt) from the bundle.
|
|
2648
|
+
--section <s> overview | examples | variants | api | slots |
|
|
2649
|
+
full (default: full).
|
|
2650
|
+
context Print the project's design.manifest.md summary.
|
|
2651
|
+
--manifest <path> Manifest file (default ./design.manifest.md).
|
|
2652
|
+
--json Emit the parsed manifest as JSON.
|
|
2653
|
+
record-decision Append an ADR to the manifest.
|
|
2654
|
+
--title <t> (required) Short decision title.
|
|
2655
|
+
--decision <d> (required) What was decided.
|
|
2656
|
+
--rationale <r> Why — the trade-off.
|
|
2657
|
+
--status <s> accepted | proposed | superseded (default accepted).
|
|
2658
|
+
--date <date> Decision date, YYYY-MM-DD (default today).
|
|
2659
|
+
--manifest <path> Manifest file (default ./design.manifest.md).
|
|
2660
|
+
sync-manifest Re-index data-design-pattern markers into the manifest.
|
|
2661
|
+
--src <dir> Source tree to scan (default ./src).
|
|
2662
|
+
--manifest <path> Manifest file (default ./design.manifest.md).
|
|
2663
|
+
--json Emit the scan result as JSON.
|
|
2664
|
+
verbs List the design verbs (recipes over the design loop).
|
|
2665
|
+
verb <name> Print one verb recipe, e.g. "urbicon verb compose".
|
|
2666
|
+
help Show this help.
|
|
2667
|
+
|
|
2668
|
+
Exit codes:
|
|
2669
|
+
0 ok (clean, or only warnings/notes)
|
|
2670
|
+
1 failed — validate found errors (--strict: warnings too), or a command could not complete
|
|
2671
|
+
2 usage error — bad flags / unreadable input
|
|
2672
|
+
|
|
2673
|
+
Examples:
|
|
2674
|
+
urbicon validate src/ # CI: lint a whole tree
|
|
2675
|
+
urbicon validate src/ --slop-floor 40 --json # CI: gate correctness + slop
|
|
2676
|
+
urbicon validate App.svelte --strict # fail on warnings too
|
|
2677
|
+
cat Page.svelte | urbicon validate - # lint stdin
|
|
2678
|
+
urbicon record-decision --title "Tabs for settings" --decision "Use Tab over Sidebar"
|
|
2679
|
+
`;
|
|
2680
|
+
async function readVersion() {
|
|
2681
|
+
const root = await findPackageRoot();
|
|
2682
|
+
if (!root)
|
|
2683
|
+
return "unknown";
|
|
2684
|
+
try {
|
|
2685
|
+
const pkg = JSON.parse(await readFile10(resolve9(root, "package.json"), "utf-8"));
|
|
2686
|
+
return pkg.version ?? "unknown";
|
|
2687
|
+
} catch {
|
|
2688
|
+
return "unknown";
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
async function main(argv) {
|
|
2692
|
+
const { command, positionals, flags } = parseArgs(argv);
|
|
2693
|
+
if (flags.version === true || command === "version") {
|
|
2694
|
+
console.log(await readVersion());
|
|
2695
|
+
return EXIT.OK;
|
|
2696
|
+
}
|
|
2697
|
+
if (command === undefined || command === "help" || flags.help === true) {
|
|
2698
|
+
console.log(HELP);
|
|
2699
|
+
return EXIT.OK;
|
|
2700
|
+
}
|
|
2701
|
+
switch (command) {
|
|
2702
|
+
case "init":
|
|
2703
|
+
return runInit(positionals, flags);
|
|
2704
|
+
case "validate":
|
|
2705
|
+
return runValidate(positionals, flags);
|
|
2706
|
+
case "hook":
|
|
2707
|
+
return runHook(positionals, flags);
|
|
2708
|
+
case "find":
|
|
2709
|
+
return runFind(positionals, flags);
|
|
2710
|
+
case "get-component":
|
|
2711
|
+
return runGetComponent(positionals, flags);
|
|
2712
|
+
case "context":
|
|
2713
|
+
return runContext(positionals, flags);
|
|
2714
|
+
case "record-decision":
|
|
2715
|
+
return runRecordDecision(positionals, flags);
|
|
2716
|
+
case "sync-manifest":
|
|
2717
|
+
return runSyncManifest(positionals, flags);
|
|
2718
|
+
case "verbs":
|
|
2719
|
+
return runVerbList(positionals, flags);
|
|
2720
|
+
case "verb":
|
|
2721
|
+
return runVerb(positionals, flags);
|
|
2722
|
+
default:
|
|
2723
|
+
printError(`unknown command "${command}"`);
|
|
2724
|
+
console.log(`
|
|
2725
|
+
${HELP}`);
|
|
2726
|
+
return EXIT.USAGE;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
main(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
|
|
2730
|
+
printError(err instanceof Error ? err.message : String(err));
|
|
2731
|
+
process.exit(EXIT.FAIL);
|
|
2732
|
+
});
|