@urbicon-ui/design-engine 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 +36 -0
- package/package.json +47 -0
- package/src/index.ts +23 -0
- package/src/linter/heuristics.ts +609 -0
- package/src/linter/index.ts +23 -0
- package/src/linter/linter.test.ts +509 -0
- package/src/linter/linter.ts +96 -0
- package/src/linter/markup-rules.test.ts +109 -0
- package/src/linter/markup-rules.ts +209 -0
- package/src/linter/markup.test.ts +139 -0
- package/src/linter/markup.ts +274 -0
- package/src/linter/rules.ts +354 -0
- package/src/linter/tokens.test.ts +111 -0
- package/src/linter/tokens.ts +230 -0
- package/src/linter/types.ts +119 -0
- package/src/manifest/history.test.ts +65 -0
- package/src/manifest/history.ts +57 -0
- package/src/manifest/index.ts +27 -0
- package/src/manifest/manifest.test.ts +338 -0
- package/src/manifest/manifest.ts +439 -0
- package/src/manifest/scan.test.ts +51 -0
- package/src/manifest/scan.ts +74 -0
- package/src/manifest/types.ts +98 -0
- package/src/rubric/index.ts +10 -0
- package/src/rubric/rubric.test.ts +43 -0
- package/src/rubric/rubric.ts +140 -0
- package/src/search/index.ts +12 -0
- package/src/search/match.ts +115 -0
- package/src/search/search.test.ts +195 -0
- package/src/search/section.ts +47 -0
- package/src/search/types.ts +44 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slop-floor — stage 2 of the validator (DESIGN-MCP-V2 §6). Unlike the
|
|
3
|
+
* deterministic correctness rules (which are Urbicon-specific: token whitelist,
|
|
4
|
+
* `dark:`/`focus:`), these are **system-agnostic** judgements about whether the
|
|
5
|
+
* markup *looks generic* — the faceless, default-everything output that reads as
|
|
6
|
+
* "an AI made this". They operationalise the Design-Quality guidance ("Color =
|
|
7
|
+
* meaning", "Spacing = hierarchy", "Vary visual weight", "Commit to a radius")
|
|
8
|
+
* plus the impeccable slop-floor signals (generic fonts, animated dimensions,
|
|
9
|
+
* grey-on-colour, touch targets, line length, heading jumps).
|
|
10
|
+
*
|
|
11
|
+
* All findings are `info`/`heuristic`, so they score on the **slop** axis (never
|
|
12
|
+
* the correctness gate) and are advisory: a judgement can have false positives,
|
|
13
|
+
* and these never block. Each heuristic fires **at most once** — it is one
|
|
14
|
+
* holistic verdict about the page, carrying the first occurrence's line plus a
|
|
15
|
+
* count, so a repeated sin costs one flat slop weight, not N.
|
|
16
|
+
*
|
|
17
|
+
* Thresholds are named constants so the eval-suite (§9) can tune them with data
|
|
18
|
+
* instead of guesswork.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Finding } from './types.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Chromatic intents only — `neutral` is structural greyscale, so neutral
|
|
25
|
+
* backgrounds (common and legitimate) must not count toward a decorative
|
|
26
|
+
* "rainbow". `info` is a distinct blue hue and does count.
|
|
27
|
+
*/
|
|
28
|
+
const CHROMATIC_INTENTS = ['primary', 'secondary', 'success', 'warning', 'danger', 'info'] as const;
|
|
29
|
+
|
|
30
|
+
/** Tunable thresholds. Kept together so evaluation can adjust them in one place. */
|
|
31
|
+
export const HEURISTIC_THRESHOLDS = {
|
|
32
|
+
/** Distinct intent hues used as backgrounds before it reads as a decorative "rainbow". */
|
|
33
|
+
rainbowIntentFamilies: 4,
|
|
34
|
+
/** Minimum spacing utilities before uniformity is judged (avoids nagging tiny snippets). */
|
|
35
|
+
minSpacingUtilities: 6,
|
|
36
|
+
/** Minimum Card instances before monotony is judged. */
|
|
37
|
+
minCards: 4,
|
|
38
|
+
/** Minimum structural surfaces before a missing radius strategy is worth a nudge. */
|
|
39
|
+
minSurfacesForRadius: 3,
|
|
40
|
+
/** Minimum explicit font-weight utilities before uniformity is judged. */
|
|
41
|
+
minFontWeights: 5,
|
|
42
|
+
/** `h-`/`size-` scale step (×4px) on an interactive element at/below which it is too small to tap (≤28px). */
|
|
43
|
+
touchTargetUnitCeil: 7,
|
|
44
|
+
/** Arbitrary `…-[Npx]` dimensions below this many px are hairlines/dividers, not magic numbers. */
|
|
45
|
+
hairlinePxFloor: 3
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// Shared helpers
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** All atoms inside class strings, lower-cased, comments already masked upstream. */
|
|
53
|
+
function collectAtoms(code: string): string[] {
|
|
54
|
+
const atoms: string[] = [];
|
|
55
|
+
// class="...", class={...}, slotClasses={{ base: '...' }} — grab quoted runs of utility-ish text.
|
|
56
|
+
const stringRe = /["'`]([^"'`]*?)["'`]/g;
|
|
57
|
+
for (const m of code.matchAll(stringRe)) {
|
|
58
|
+
const body = m[1]!;
|
|
59
|
+
if (!/[a-z]-/.test(body) && !/\b(flex|grid|block|hidden|relative|absolute)\b/.test(body))
|
|
60
|
+
continue;
|
|
61
|
+
for (const atom of body.split(/\s+/)) {
|
|
62
|
+
if (atom) atoms.push(atom);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return atoms;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A located occurrence of a slop pattern. */
|
|
69
|
+
interface Hit {
|
|
70
|
+
line: number;
|
|
71
|
+
match: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Collect every match of a global regex across lines, tagged with a 1-based line number. */
|
|
75
|
+
function collectHits(lines: string[], re: RegExp): Hit[] {
|
|
76
|
+
const hits: Hit[] = [];
|
|
77
|
+
lines.forEach((line, i) => {
|
|
78
|
+
for (const m of line.matchAll(re)) hits.push({ line: i + 1, match: m[0] });
|
|
79
|
+
});
|
|
80
|
+
return hits;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 1-based line number of a character offset in the full source. */
|
|
84
|
+
function lineOf(code: string, index: number): number {
|
|
85
|
+
let line = 1;
|
|
86
|
+
for (let i = 0; i < index && i < code.length; i++) {
|
|
87
|
+
if (code[i] === '\n') line++;
|
|
88
|
+
}
|
|
89
|
+
return line;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Each `class="…"` / `class={…}` value on a line (the raw utility string). */
|
|
93
|
+
function classValues(text: string): string[] {
|
|
94
|
+
const out: string[] = [];
|
|
95
|
+
for (const m of text.matchAll(/\bclass=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/g)) {
|
|
96
|
+
out.push(m[1] ?? m[2] ?? m[3] ?? '');
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reduce N occurrences to a single slop finding: the first occurrence anchors the
|
|
103
|
+
* line/match. `message` is a plain string, or a factory `(count, first) => string`
|
|
104
|
+
* for rules that fold the total count / first match into the text — the factory is
|
|
105
|
+
* called only when there is ≥1 hit, so it never reaches for `hits[0]` defensively.
|
|
106
|
+
* Empty hits → no finding.
|
|
107
|
+
*/
|
|
108
|
+
function slop(
|
|
109
|
+
ruleId: string,
|
|
110
|
+
hits: Hit[],
|
|
111
|
+
message: string | ((count: number, first: Hit) => string),
|
|
112
|
+
fix: string
|
|
113
|
+
): Finding[] {
|
|
114
|
+
if (hits.length === 0) return [];
|
|
115
|
+
const first = hits[0]!;
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
ruleId,
|
|
119
|
+
severity: 'info',
|
|
120
|
+
kind: 'heuristic',
|
|
121
|
+
message: typeof message === 'function' ? message(hits.length, first) : message,
|
|
122
|
+
fix,
|
|
123
|
+
line: first.line,
|
|
124
|
+
match: first.match
|
|
125
|
+
}
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
// Group 1 — Distribution heuristics (the original four + font-weight)
|
|
131
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/** "Color = meaning": flag a decorative rainbow of intent backgrounds. */
|
|
134
|
+
function checkIntentRainbow(atoms: string[]): Finding[] {
|
|
135
|
+
const families = new Set<string>();
|
|
136
|
+
const bgIntent = new RegExp(
|
|
137
|
+
`^bg-(${CHROMATIC_INTENTS.join('|')})(?:-(?:hover|active|subtle|emphasis|\\d{2,3}))?(?:\\/\\d{1,3})?$`
|
|
138
|
+
);
|
|
139
|
+
for (const atom of atoms) {
|
|
140
|
+
const m = atom.match(bgIntent);
|
|
141
|
+
if (m) families.add(m[1]!);
|
|
142
|
+
}
|
|
143
|
+
if (families.size >= HEURISTIC_THRESHOLDS.rainbowIntentFamilies) {
|
|
144
|
+
return [
|
|
145
|
+
{
|
|
146
|
+
ruleId: 'intent-rainbow',
|
|
147
|
+
severity: 'info',
|
|
148
|
+
kind: 'heuristic',
|
|
149
|
+
message: `${families.size} different intent hues used as backgrounds (${[...families].join(', ')}). Reads as decoration, not meaning.`,
|
|
150
|
+
fix: 'Let neutral surfaces dominate (80–90%). Reserve intent colour for genuine status/severity/action signals.'
|
|
151
|
+
}
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** "Spacing = hierarchy": flag a single uniform rhythm tier. */
|
|
158
|
+
function checkSpacingUniformity(atoms: string[]): Finding[] {
|
|
159
|
+
const values = new Set<string>();
|
|
160
|
+
let total = 0;
|
|
161
|
+
const spacingRe = /^(?:gap|gap-x|gap-y|space-x|space-y)-(\d+(?:\.\d+)?|px)$/;
|
|
162
|
+
for (const atom of atoms) {
|
|
163
|
+
const m = atom.match(spacingRe);
|
|
164
|
+
if (m) {
|
|
165
|
+
values.add(m[1]!);
|
|
166
|
+
total++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (total >= HEURISTIC_THRESHOLDS.minSpacingUtilities && values.size <= 1) {
|
|
170
|
+
return [
|
|
171
|
+
{
|
|
172
|
+
ruleId: 'spacing-uniform',
|
|
173
|
+
severity: 'info',
|
|
174
|
+
kind: 'heuristic',
|
|
175
|
+
message: `All ${total} spacing utilities use one value (\`${[...values][0] ?? '?'}\`). No tight-within vs generous-between rhythm.`,
|
|
176
|
+
fix: 'Use two tiers: tight (`gap-2`/`gap-3`) within related items, generous (`gap-8`/`gap-10`) between sections.'
|
|
177
|
+
}
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** "Vary visual weight": flag many identical Cards (same variant + padding). */
|
|
184
|
+
function checkCardMonotony(code: string): Finding[] {
|
|
185
|
+
const cardRe = /<Card\b([^>]*)>/g;
|
|
186
|
+
const signatures: string[] = [];
|
|
187
|
+
for (const m of code.matchAll(cardRe)) {
|
|
188
|
+
const attrs = m[1]!;
|
|
189
|
+
const variant = attrs.match(/\bvariant=(?:"([^"]*)"|'([^']*)'|\{['"]([^'"]*)['"]\})/);
|
|
190
|
+
const padding = attrs.match(/\bpadding=(?:"([^"]*)"|'([^']*)'|\{['"]([^'"]*)['"]\})/);
|
|
191
|
+
const v = variant ? (variant[1] ?? variant[2] ?? variant[3]) : 'default';
|
|
192
|
+
const p = padding ? (padding[1] ?? padding[2] ?? padding[3]) : 'default';
|
|
193
|
+
signatures.push(`${v}/${p}`);
|
|
194
|
+
}
|
|
195
|
+
if (signatures.length >= HEURISTIC_THRESHOLDS.minCards) {
|
|
196
|
+
const distinct = new Set(signatures);
|
|
197
|
+
if (distinct.size === 1) {
|
|
198
|
+
return [
|
|
199
|
+
{
|
|
200
|
+
ruleId: 'card-monotony',
|
|
201
|
+
severity: 'info',
|
|
202
|
+
kind: 'heuristic',
|
|
203
|
+
message: `All ${signatures.length} Cards share one look (\`${[...distinct][0]}\`). Visual weight does not vary.`,
|
|
204
|
+
fix: 'Differentiate: prominent content `variant="elevated"`/`padding="lg"`, secondary `variant="outlined"`/`padding="md"`.'
|
|
205
|
+
}
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** "Commit to a radius": nudge when structural surfaces exist but no radius override does. */
|
|
213
|
+
function checkRadiusStrategy(code: string, atoms: string[]): Finding[] {
|
|
214
|
+
// Only count genuine container surfaces. A bare `border`/`border-b` atom is far
|
|
215
|
+
// more often a table row, list divider, or input frame than a Card-like surface,
|
|
216
|
+
// so including it inflated the count into false positives (review finding #1).
|
|
217
|
+
const surfaceCount =
|
|
218
|
+
(code.match(/<Card\b/g)?.length ?? 0) +
|
|
219
|
+
(code.match(/<(?:Dialog|Drawer|Popover)\b/g)?.length ?? 0);
|
|
220
|
+
const hasRadiusOverride = atoms.some((a) => /^rounded(?:-|$)/.test(a));
|
|
221
|
+
if (surfaceCount >= HEURISTIC_THRESHOLDS.minSurfacesForRadius && !hasRadiusOverride) {
|
|
222
|
+
return [
|
|
223
|
+
{
|
|
224
|
+
ruleId: 'no-radius-strategy',
|
|
225
|
+
severity: 'info',
|
|
226
|
+
kind: 'heuristic',
|
|
227
|
+
message: 'Multiple surfaces but no explicit radius — relying solely on component defaults.',
|
|
228
|
+
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`.'
|
|
229
|
+
}
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** "Vary visual weight" (type axis): flag many explicit font-weights that never vary. */
|
|
236
|
+
function checkFontWeightUniformity(atoms: string[]): Finding[] {
|
|
237
|
+
const weightRe = /^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/;
|
|
238
|
+
const values = new Set<string>();
|
|
239
|
+
let total = 0;
|
|
240
|
+
for (const atom of atoms) {
|
|
241
|
+
const m = atom.match(weightRe);
|
|
242
|
+
if (m) {
|
|
243
|
+
values.add(m[1]!);
|
|
244
|
+
total++;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (total >= HEURISTIC_THRESHOLDS.minFontWeights && values.size === 1) {
|
|
248
|
+
return [
|
|
249
|
+
{
|
|
250
|
+
ruleId: 'font-weight-uniform',
|
|
251
|
+
severity: 'info',
|
|
252
|
+
kind: 'heuristic',
|
|
253
|
+
message: `All ${total} explicit font-weights are \`font-${[...values][0]}\`. No typographic hierarchy.`,
|
|
254
|
+
fix: 'Vary weight to rank content: headings `font-semibold`/`font-bold`, body `font-normal`, captions `font-medium text-text-tertiary`.'
|
|
255
|
+
}
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
262
|
+
// Group 2 — Token-system bypass & generic-default atoms (per-line regex scans)
|
|
263
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/** Generic system font stacks — the "no identity typeface" tell. */
|
|
266
|
+
const GENERIC_FONT_NAMES =
|
|
267
|
+
'arial|helvetica|system-ui|-apple-system|blinkmacsystemfont|segoe ui|segoe|roboto|times new roman|times|georgia|courier|verdana|tahoma|sans-serif|monospace';
|
|
268
|
+
const GENERIC_FONT_RE = new RegExp(
|
|
269
|
+
`(?:font-\\[[^\\]]*?(?:${GENERIC_FONT_NAMES})[^\\]]*?\\]|font-family\\s*:\\s*[^;"'}]*(?:${GENERIC_FONT_NAMES}))`,
|
|
270
|
+
'gi'
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
function checkGenericFont(lines: string[]): Finding[] {
|
|
274
|
+
return slop(
|
|
275
|
+
'generic-font',
|
|
276
|
+
collectHits(lines, GENERIC_FONT_RE),
|
|
277
|
+
'Hardcoded generic font stack (Arial/Helvetica/system-ui…). Defaults look like an unstyled draft, not a brand.',
|
|
278
|
+
'Use the design-system typeface via the `font-*` family tokens (e.g. `font-sans`/`font-display`) so type carries identity.'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Arbitrary colour value (`bg-[#…]`, `text-[rgb(…)]`) — bypasses the token system entirely. */
|
|
283
|
+
const ARBITRARY_COLOR_RE =
|
|
284
|
+
/\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;
|
|
285
|
+
|
|
286
|
+
function checkArbitraryColor(lines: string[]): Finding[] {
|
|
287
|
+
return slop(
|
|
288
|
+
'arbitrary-color',
|
|
289
|
+
collectHits(lines, ARBITRARY_COLOR_RE),
|
|
290
|
+
'Arbitrary colour literal in a utility — outside the token system, so no dark-mode adaptation, no theming, no cohesion.',
|
|
291
|
+
'Use a semantic token (`bg-surface-*`, `text-text-*`, intents) or `…-[var(--color-*)]` if you must reference a token by variable.'
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** `transition-all` — animates every property, including layout (janky), and over-animates. */
|
|
296
|
+
const TRANSITION_ALL_RE = /\btransition-all\b/g;
|
|
297
|
+
|
|
298
|
+
function checkTransitionAll(lines: string[]): Finding[] {
|
|
299
|
+
return slop(
|
|
300
|
+
'transition-all',
|
|
301
|
+
collectHits(lines, TRANSITION_ALL_RE),
|
|
302
|
+
'`transition-all` animates every property that changes — including layout — which is janky and rarely intended.',
|
|
303
|
+
'Transition only what changes: `transition-colors`, `transition-opacity`, `transition-transform`.'
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Transitioning a layout dimension — width/height/top/… cannot be GPU-composited,
|
|
309
|
+
* so it janks. The bracket body is captured in a single linear `[^\]]*` pass to the
|
|
310
|
+
* literal `]` (no overlapping quantifier → no quadratic backtracking on an
|
|
311
|
+
* unterminated `transition-[…`); the keyword is then tested on the small captured
|
|
312
|
+
* group.
|
|
313
|
+
*/
|
|
314
|
+
const ANIMATED_DIM_BRACKET_RE = /\btransition-\[([^\]]*)\]/g;
|
|
315
|
+
const ANIMATED_DIM_KEYWORD_RE = /\b(?:width|height|size|top|left|right|bottom|margin|padding)\b/;
|
|
316
|
+
|
|
317
|
+
function checkAnimatedDimensions(lines: string[]): Finding[] {
|
|
318
|
+
const hits: Hit[] = [];
|
|
319
|
+
lines.forEach((line, i) => {
|
|
320
|
+
for (const m of line.matchAll(ANIMATED_DIM_BRACKET_RE)) {
|
|
321
|
+
if (ANIMATED_DIM_KEYWORD_RE.test(m[1]!)) hits.push({ line: i + 1, match: m[0] });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
return slop(
|
|
325
|
+
'animated-dimensions',
|
|
326
|
+
hits,
|
|
327
|
+
'Transitioning a layout dimension (width/height/inset). These trigger layout on every frame and stutter.',
|
|
328
|
+
'Animate `transform` (`scale`/`translate`) or `opacity` instead — they composite on the GPU. For size, use a `grid-template` trick or accept an instant change.'
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Arbitrary px box-dimension (`w-[317px]`) — a magic number off the spacing scale.
|
|
334
|
+
* Box/spacing utilities only; font-size/leading/tracking are a separate typographic
|
|
335
|
+
* axis where small arbitrary px (`text-[10px]` micro-labels) is a common, legit choice.
|
|
336
|
+
*/
|
|
337
|
+
const MAGIC_DIM_RE =
|
|
338
|
+
/\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;
|
|
339
|
+
|
|
340
|
+
function checkMagicDimensions(lines: string[]): Finding[] {
|
|
341
|
+
const hits: Hit[] = [];
|
|
342
|
+
lines.forEach((line, i) => {
|
|
343
|
+
for (const m of line.matchAll(MAGIC_DIM_RE)) {
|
|
344
|
+
// Skip 1–2px values: those are hairlines/dividers, a legitimate use of arbitrary px.
|
|
345
|
+
if (Number.parseFloat(m[1]!) >= HEURISTIC_THRESHOLDS.hairlinePxFloor) {
|
|
346
|
+
hits.push({ line: i + 1, match: m[0] });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
return slop(
|
|
351
|
+
'magic-dimension',
|
|
352
|
+
hits,
|
|
353
|
+
(count, first) =>
|
|
354
|
+
`Arbitrary px dimension(s) off the spacing scale (${count} found, first \`${first.match}\`). Magic numbers drift from the rhythm.`,
|
|
355
|
+
'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.'
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* `!important` modifier — a specificity hack that signals fighting the system.
|
|
361
|
+
* Lookbehind on the class boundary (quote/space/brace) keeps the boundary char
|
|
362
|
+
* out of the match, and requires a `utility-` shape so JS negation (`!isOpen`)
|
|
363
|
+
* never matches.
|
|
364
|
+
*/
|
|
365
|
+
const IMPORTANT_RE = /(?<=[\s"'`])![a-z][a-z0-9]*-[a-z0-9[]/g;
|
|
366
|
+
|
|
367
|
+
function checkImportant(lines: string[]): Finding[] {
|
|
368
|
+
return slop(
|
|
369
|
+
'important-modifier',
|
|
370
|
+
collectHits(lines, IMPORTANT_RE),
|
|
371
|
+
(count, first) =>
|
|
372
|
+
`\`!important\` modifier(s) (${count} found, first \`${first.match}\`). Overriding the cascade by force is a smell, not a fix.`,
|
|
373
|
+
'Remove the `!` and resolve the specificity conflict at its source (ordering, the component’s own props, or `slotClasses`).'
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Inline `style="…"` that hardcodes a *paint or type* property — colour,
|
|
379
|
+
* background, border-colour, shadow, font. Those are exactly what the token system
|
|
380
|
+
* owns, so inlining them bypasses theming and dark-mode. Deliberately narrow:
|
|
381
|
+
* positioning/layout inline (`position`, `inset`, `transform`, `overflow`) is often
|
|
382
|
+
* functional (JS-driven overlays, SVG transforms) and not flagged, and interpolated
|
|
383
|
+
* runtime values (`width: {pct}%`) have no static utility equivalent.
|
|
384
|
+
*/
|
|
385
|
+
const INLINE_PAINT_RE =
|
|
386
|
+
/(?:^|;|\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;
|
|
387
|
+
|
|
388
|
+
function checkInlineStyle(lines: string[]): Finding[] {
|
|
389
|
+
const hits: Hit[] = [];
|
|
390
|
+
const styleRe = /\bstyle=(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g;
|
|
391
|
+
lines.forEach((line, i) => {
|
|
392
|
+
for (const m of line.matchAll(styleRe)) {
|
|
393
|
+
const body = m[1] ?? m[2] ?? m[3] ?? '';
|
|
394
|
+
// Any interpolation marks the *whole* declaration set dynamic and skips it.
|
|
395
|
+
// Trade-off: a mixed `color: red; width: {w}%` escapes the static `color` too —
|
|
396
|
+
// accepted, because suppressing the FP on dynamic styles matters more than that edge.
|
|
397
|
+
if (/\{[^}]*\}|\$\{/.test(body)) continue;
|
|
398
|
+
if (INLINE_PAINT_RE.test(body)) hits.push({ line: i + 1, match: 'style=…' });
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
return slop(
|
|
402
|
+
'inline-style',
|
|
403
|
+
hits,
|
|
404
|
+
(count) =>
|
|
405
|
+
`Inline \`style\` hardcodes colour/typography (${count} found). Bypasses the token system — no theming, no dark-mode adaptation.`,
|
|
406
|
+
'Use semantic utilities/tokens (`bg-surface-*`, `text-text-*`, `font-*`). Keep inline `style` for genuinely dynamic values (interpolated sizes/positions, CSS custom properties).'
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Gradient text (`bg-clip-text` + transparent fill) — a stock "hero" flourish. */
|
|
411
|
+
const GRADIENT_TEXT_RE = /\bbg-clip-text\b/g;
|
|
412
|
+
|
|
413
|
+
function checkGradientText(lines: string[]): Finding[] {
|
|
414
|
+
return slop(
|
|
415
|
+
'gradient-text',
|
|
416
|
+
collectHits(lines, GRADIENT_TEXT_RE),
|
|
417
|
+
'Gradient-clipped text (`bg-clip-text` + transparent fill) — a stock flourish that reads as generic and often fails contrast.',
|
|
418
|
+
'Prefer a solid `text-*` token. If you need emphasis, vary weight/size or use a single intent colour.'
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
423
|
+
// Group 3 — Contrast & content slop
|
|
424
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
/** A solid/emphasis chromatic intent background (not the light `-subtle` or scale tints). */
|
|
427
|
+
const INTENT_BG_RE = /\bbg-(?:primary|secondary|success|warning|danger|info)(?:-emphasis)?\b(?!-)/;
|
|
428
|
+
/** A muted/low-contrast text token (ours or a raw grey mid-step). */
|
|
429
|
+
const MUTED_TEXT_RE =
|
|
430
|
+
/\btext-(?:text-(?:tertiary|quaternary|disabled|secondary)|muted|neutral-[345]00|gray-[345]00|slate-[345]00|zinc-[345]00|stone-[345]00)\b/;
|
|
431
|
+
|
|
432
|
+
/** Grey-on-colour: muted text on a saturated intent surface — a recurring contrast failure. */
|
|
433
|
+
function checkGreyOnIntent(lines: string[]): Finding[] {
|
|
434
|
+
const hits: Hit[] = [];
|
|
435
|
+
lines.forEach((line, i) => {
|
|
436
|
+
for (const value of classValues(line)) {
|
|
437
|
+
if (INTENT_BG_RE.test(value) && MUTED_TEXT_RE.test(value)) {
|
|
438
|
+
hits.push({ line: i + 1, match: value.trim().slice(0, 40) });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
return slop(
|
|
443
|
+
'grey-on-intent',
|
|
444
|
+
hits,
|
|
445
|
+
'Muted/grey text on a saturated intent background. Low contrast and muddy — the colour stops carrying meaning.',
|
|
446
|
+
'On an intent surface use the on-colour token (`text-on-primary`/`text-on-dark`). Reserve muted greys for neutral surfaces.'
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Centred body copy: `text-center` on a `<p>` — hurts readability past one line.
|
|
452
|
+
* Scanned against the full source: the opening tag routinely wraps across lines
|
|
453
|
+
* under the repo's Prettier width, so a per-line scan would miss it. The class
|
|
454
|
+
* value is captured in a single linear pass (no overlapping `[^"]*` around the
|
|
455
|
+
* keyword → ReDoS-safe) and tested afterwards.
|
|
456
|
+
*/
|
|
457
|
+
const PARA_CLASS_RE = /<p\b[^>]*?\bclass=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/g;
|
|
458
|
+
|
|
459
|
+
function checkCenteredBodyText(code: string): Finding[] {
|
|
460
|
+
const hits: Hit[] = [];
|
|
461
|
+
for (const m of code.matchAll(PARA_CLASS_RE)) {
|
|
462
|
+
const cls = m[1] ?? m[2] ?? m[3] ?? '';
|
|
463
|
+
if (/\btext-center\b/.test(cls)) {
|
|
464
|
+
hits.push({ line: lineOf(code, m.index ?? 0), match: '<p … text-center>' });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return slop(
|
|
468
|
+
'centered-bodytext',
|
|
469
|
+
hits,
|
|
470
|
+
'Centred paragraph text. Ragged left edges make multi-line copy hard to scan.',
|
|
471
|
+
'Left-align body copy (`text-left`). Reserve `text-center` for short headings, single labels, or empty states.'
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Justified text — uneven word spacing and "rivers" hurt readability on the web. */
|
|
476
|
+
const JUSTIFIED_RE = /\btext-justify\b/g;
|
|
477
|
+
|
|
478
|
+
function checkJustifiedText(lines: string[]): Finding[] {
|
|
479
|
+
return slop(
|
|
480
|
+
'justified-text',
|
|
481
|
+
collectHits(lines, JUSTIFIED_RE),
|
|
482
|
+
'Justified text. Without hyphenation the browser stretches word spacing into uneven "rivers" that hurt readability.',
|
|
483
|
+
'Use `text-left` (`text-right` for RTL). Justification needs typographic control the web does not give by default.'
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Placeholder filler copy left in the markup — an unfinished tell. */
|
|
488
|
+
const PLACEHOLDER_RE =
|
|
489
|
+
/\b(?:lorem ipsum|dolor sit amet|consectetur adipiscing|sed do eiusmod|the quick brown fox)\b/gi;
|
|
490
|
+
|
|
491
|
+
function checkPlaceholderContent(lines: string[]): Finding[] {
|
|
492
|
+
return slop(
|
|
493
|
+
'placeholder-content',
|
|
494
|
+
collectHits(lines, PLACEHOLDER_RE),
|
|
495
|
+
'Lorem-ipsum / filler copy in the output. Placeholder text ships as "unfinished".',
|
|
496
|
+
'Replace with real, representative content — it changes layout, length, and tone decisions.'
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Emoji used where the icon system belongs. Three tiers:
|
|
502
|
+
* 1. Pictographic blocks (U+1F300–1FAFF: emoticons, symbols, transport) — always.
|
|
503
|
+
* 2. BMP symbols whose Unicode default presentation is *emoji* (✅❌✨⭐⭕❓❗➕… —
|
|
504
|
+
* render in colour with or without VS16, and LLMs emit them bare as status icons) — always.
|
|
505
|
+
* 3. The remaining misc-symbols / dingbats / arrows — only with an explicit
|
|
506
|
+
* emoji-presentation selector (U+FE0F), so a bare monochrome text glyph
|
|
507
|
+
* (`✓`, `⚠`, `→`, `★`) is not flagged, only its deliberate emoji form (`⚠️`).
|
|
508
|
+
* Excludes text symbols (©®™) and maths.
|
|
509
|
+
*/
|
|
510
|
+
const EMOJI_RE =
|
|
511
|
+
/[\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;
|
|
512
|
+
|
|
513
|
+
function checkEmojiAsIcon(lines: string[]): Finding[] {
|
|
514
|
+
return slop(
|
|
515
|
+
'emoji-as-icon',
|
|
516
|
+
collectHits(lines, EMOJI_RE),
|
|
517
|
+
'Emoji in the markup as iconography. They render inconsistently across platforms and clash with a real icon set.',
|
|
518
|
+
'Use the `Icon` component / a `*Icon` from the 315-icon set (`find_icons`) — consistent stroke, size, and theming.'
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
523
|
+
// Group 4 — Structural slop (need cross-element context)
|
|
524
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
/** Heading-level skip (e.g. `<h1>` → `<h3>`) — breaks the document outline and a11y. */
|
|
527
|
+
function checkHeadingSkip(code: string): Finding[] {
|
|
528
|
+
const headings: { level: number; index: number }[] = [];
|
|
529
|
+
for (const m of code.matchAll(/<h([1-6])\b/g)) {
|
|
530
|
+
headings.push({ level: Number(m[1]), index: m.index ?? 0 });
|
|
531
|
+
}
|
|
532
|
+
for (let i = 1; i < headings.length; i++) {
|
|
533
|
+
const prev = headings[i - 1]!;
|
|
534
|
+
const cur = headings[i]!;
|
|
535
|
+
// Going deeper by more than one level skips a rank. Going shallower is fine.
|
|
536
|
+
if (cur.level - prev.level >= 2) {
|
|
537
|
+
return [
|
|
538
|
+
{
|
|
539
|
+
ruleId: 'heading-skip',
|
|
540
|
+
severity: 'info',
|
|
541
|
+
kind: 'heuristic',
|
|
542
|
+
message: `Heading jumps from <h${prev.level}> to <h${cur.level}>, skipping a level. Breaks the outline and screen-reader navigation.`,
|
|
543
|
+
fix: `Use sequential ranks (<h${prev.level}> → <h${prev.level + 1}>). Style size with classes, not by picking a smaller heading tag.`,
|
|
544
|
+
line: lineOf(code, cur.index)
|
|
545
|
+
}
|
|
546
|
+
];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Interactive element pinned to a sub-touch-target height. Scanned against the
|
|
554
|
+
* full source so an opening tag wrapping across lines still matches. The
|
|
555
|
+
* `(?<![\w-])` boundary rejects `min-h-*`/`max-h-*`: a floor/cap is not a fixed
|
|
556
|
+
* sub-44 height (and `min-h-11` is the idiomatic fix this rule recommends), so
|
|
557
|
+
* matching the bare `h-` inside them would fire on correct code.
|
|
558
|
+
*/
|
|
559
|
+
const SMALL_TOUCH_RE = new RegExp(
|
|
560
|
+
`<(?:button|a)\\b[^>]*?(?<![\\w-])(?:h|size)-(?:[1-${HEURISTIC_THRESHOLDS.touchTargetUnitCeil}]|0\\.5|1\\.5|2\\.5|3\\.5)\\b`,
|
|
561
|
+
'g'
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
function checkTouchTarget(code: string): Finding[] {
|
|
565
|
+
const hits: Hit[] = [];
|
|
566
|
+
for (const m of code.matchAll(SMALL_TOUCH_RE)) {
|
|
567
|
+
hits.push({ line: lineOf(code, m.index ?? 0), match: m[0].replace(/\s+/g, ' ').slice(0, 40) });
|
|
568
|
+
}
|
|
569
|
+
return slop(
|
|
570
|
+
'touch-target-small',
|
|
571
|
+
hits,
|
|
572
|
+
'Interactive element with a fixed sub-44px height. Hard to tap; fails the 44×44 touch-target guideline.',
|
|
573
|
+
'Give tappable controls ≥ `h-11` (44px) or enough padding (`py-2.5`+). Keep tiny sizes for decorative icons only.'
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
/** Run all slop-floor heuristics over the (comment-masked) code. */
|
|
580
|
+
export function runHeuristics(code: string): Finding[] {
|
|
581
|
+
const atoms = collectAtoms(code);
|
|
582
|
+
const lines = code.split('\n');
|
|
583
|
+
return [
|
|
584
|
+
// Group 1 — distribution
|
|
585
|
+
...checkIntentRainbow(atoms),
|
|
586
|
+
...checkSpacingUniformity(atoms),
|
|
587
|
+
...checkCardMonotony(code),
|
|
588
|
+
...checkRadiusStrategy(code, atoms),
|
|
589
|
+
...checkFontWeightUniformity(atoms),
|
|
590
|
+
// Group 2 — token-bypass & generic defaults
|
|
591
|
+
...checkGenericFont(lines),
|
|
592
|
+
...checkArbitraryColor(lines),
|
|
593
|
+
...checkTransitionAll(lines),
|
|
594
|
+
...checkAnimatedDimensions(lines),
|
|
595
|
+
...checkMagicDimensions(lines),
|
|
596
|
+
...checkImportant(lines),
|
|
597
|
+
...checkInlineStyle(lines),
|
|
598
|
+
...checkGradientText(lines),
|
|
599
|
+
// Group 3 — contrast & content
|
|
600
|
+
...checkGreyOnIntent(lines),
|
|
601
|
+
...checkCenteredBodyText(code),
|
|
602
|
+
...checkJustifiedText(lines),
|
|
603
|
+
...checkPlaceholderContent(lines),
|
|
604
|
+
...checkEmojiAsIcon(lines),
|
|
605
|
+
// Group 4 — structural
|
|
606
|
+
...checkHeadingSkip(code),
|
|
607
|
+
...checkTouchTarget(code)
|
|
608
|
+
];
|
|
609
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API of the Urbicon UI design linter.
|
|
3
|
+
*
|
|
4
|
+
* The linter is the deterministic half of the "generate → validate → fix" loop
|
|
5
|
+
* (docs/DESIGN-MCP.md, Option B). It is consumed by the `validate_design` MCP
|
|
6
|
+
* tool and, programmatically, by the eval-suite (WP5) which scores generated
|
|
7
|
+
* pages without an LLM in the loop.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { HEURISTIC_THRESHOLDS } from './heuristics.js';
|
|
11
|
+
export { lintDesign, maskComments, SCORE_WEIGHTS, SLOP_WEIGHT } from './linter.js';
|
|
12
|
+
export { RULES } from './rules.js';
|
|
13
|
+
export { VALID_TOKEN_CORES } from './tokens.js';
|
|
14
|
+
export type {
|
|
15
|
+
Finding,
|
|
16
|
+
FindingKind,
|
|
17
|
+
LintContext,
|
|
18
|
+
LintOptions,
|
|
19
|
+
LintReport,
|
|
20
|
+
LintScores,
|
|
21
|
+
Rule,
|
|
22
|
+
Severity
|
|
23
|
+
} from './types.js';
|