@urbicon-ui/design-engine 6.2.0 → 6.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/design-engine",
3
- "version": "6.2.0",
3
+ "version": "6.3.3",
4
4
  "description": "Deterministic design engine for Urbicon UI — the zero-dependency design linter, manifest parser and quality rubric that power the design loop (validate · context · judge).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -507,3 +507,66 @@ describe('rule metadata', () => {
507
507
  expect(ids(lintDesign(code).findings)).toEqual(ids(lintDesign(code).findings));
508
508
  });
509
509
  });
510
+
511
+ describe('deep-internal-import', () => {
512
+ it('flags a deep import into a package component file', () => {
513
+ const code =
514
+ "<script>import Button from '@urbicon-ui/blocks/primitives/Button/Button.svelte';</script>";
515
+ expect(has(lintDesign(code).findings, 'deep-internal-import')).toBe(true);
516
+ });
517
+ it('flags an internal-directory path even without a file extension', () => {
518
+ const code = "<script>import { x } from '@urbicon-ui/blocks/icons/registry';</script>";
519
+ expect(has(lintDesign(code).findings, 'deep-internal-import')).toBe(true);
520
+ });
521
+ it('does NOT flag the package root or documented public subpaths', () => {
522
+ const code = [
523
+ "import { Button } from '@urbicon-ui/blocks';",
524
+ "import { addDays } from '@urbicon-ui/blocks/date';",
525
+ "import en from '@urbicon-ui/blocks/i18n/en';",
526
+ "import '@urbicon-ui/blocks/style/index.css';"
527
+ ].join('\n');
528
+ expect(has(lintDesign(code).findings, 'deep-internal-import')).toBe(false);
529
+ });
530
+ });
531
+
532
+ describe('hardcoded-motion', () => {
533
+ it('flags a hardcoded duration and a cubic-bezier easing', () => {
534
+ const code =
535
+ '<div class="transition-transform duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)]">';
536
+ expect(lintDesign(code).findings.filter((f) => f.ruleId === 'hardcoded-motion')).toHaveLength(
537
+ 2
538
+ );
539
+ });
540
+ it('flags a fractional-second duration', () => {
541
+ expect(has(lintDesign('<div class="duration-[0.2s]">').findings, 'hardcoded-motion')).toBe(
542
+ true
543
+ );
544
+ });
545
+ it('does NOT flag motion tokens or named eases', () => {
546
+ const code =
547
+ '<div class="duration-[var(--blocks-duration-fast)] ease-[var(--blocks-ease-smooth)] ease-out">';
548
+ expect(has(lintDesign(code).findings, 'hardcoded-motion')).toBe(false);
549
+ });
550
+ });
551
+
552
+ describe('token-hallucination — intent typos', () => {
553
+ it('flags a misspelled intent (`bg-primay` → `bg-primary`)', () => {
554
+ expect(has(lintDesign('<button class="bg-primay">').findings, 'token-hallucination')).toBe(
555
+ true
556
+ );
557
+ });
558
+ it('flags a misspelled intent on a text utility (`text-sucess`)', () => {
559
+ expect(has(lintDesign('<span class="text-sucess">').findings, 'token-hallucination')).toBe(
560
+ true
561
+ );
562
+ });
563
+ it('does NOT flag arbitrary, far-from-intent cores (`bg-brand`, `bg-cover`)', () => {
564
+ const { findings } = lintDesign('<div class="bg-brand text-on-brand bg-cover">');
565
+ expect(has(findings, 'token-hallucination')).toBe(false);
566
+ });
567
+ it('does NOT flag a valid intent', () => {
568
+ expect(
569
+ has(lintDesign('<div class="bg-primary text-success">').findings, 'token-hallucination')
570
+ ).toBe(false);
571
+ });
572
+ });
@@ -13,6 +13,7 @@ import {
13
13
  KNOWN_BAD_NAMESPACES,
14
14
  KNOWN_FOREIGN_CORES,
15
15
  SEMANTIC_NAMESPACES,
16
+ suggestIntentTypo,
16
17
  VALID_TOKEN_CORES
17
18
  } from './tokens.js';
18
19
  import type { Finding, Rule } from './types.js';
@@ -261,6 +262,94 @@ const dynamicClassInterpolation: Rule = {
261
262
  }
262
263
  };
263
264
 
265
+ /** Subpath segments that are internal to an `@urbicon-ui` package — never a public export. */
266
+ const INTERNAL_SUBPATH_SEGMENTS = new Set([
267
+ 'primitives',
268
+ 'components',
269
+ 'lib',
270
+ 'dist',
271
+ 'src',
272
+ 'icons'
273
+ ]);
274
+
275
+ /**
276
+ * A deep import into an `@urbicon-ui` package: either a concrete component/module
277
+ * file (`…/Button.svelte`, `…/foo.js`) or a path through an internal directory
278
+ * (`primitives/`, `components/`, …). The documented public subpaths — `./date`,
279
+ * `./style/*.css`, `./i18n/en` — are flat, extensionless-or-CSS, and stay allowed.
280
+ */
281
+ function isDeepInternalSubpath(subpath: string): boolean {
282
+ if (/\.svelte(\.[jt]s)?$|\.[jt]s$/.test(subpath)) return true;
283
+ return subpath.split('/').some((seg) => INTERNAL_SUBPATH_SEGMENTS.has(seg));
284
+ }
285
+
286
+ const deepInternalImport: Rule = {
287
+ id: 'deep-internal-import',
288
+ severity: 'error',
289
+ description: 'Deep/internal import into an `@urbicon-ui` package instead of its public root.',
290
+ check(lines) {
291
+ // The module specifier of any import / export-from / @import / dynamic import.
292
+ const re = /['"](@urbicon-ui\/[a-z-]+)\/([^'"]+)['"]/g;
293
+ const findings: Finding[] = [];
294
+ lines.forEach((line, i) => {
295
+ for (const m of line.matchAll(re)) {
296
+ const pkg = m[1]!;
297
+ const subpath = m[2]!;
298
+ if (!isDeepInternalSubpath(subpath)) continue;
299
+ findings.push({
300
+ ruleId: this.id,
301
+ severity: this.severity,
302
+ kind: 'deterministic',
303
+ message: `Deep import \`${pkg}/${subpath}\` reaches into ${pkg}'s internals — they can move between releases.`,
304
+ fix: `Import from the package root: \`import { … } from '${pkg}'\`.`,
305
+ line: i + 1,
306
+ match: `${pkg}/${subpath}`
307
+ });
308
+ }
309
+ });
310
+ return dedupeByLine(findings);
311
+ }
312
+ };
313
+
314
+ const hardcodedMotion: Rule = {
315
+ id: 'hardcoded-motion',
316
+ severity: 'error',
317
+ description:
318
+ 'Hardcoded transition duration or `cubic-bezier()` easing instead of a motion token.',
319
+ check(lines) {
320
+ // `duration-[250ms]` / `duration-[0.2s]` — but not `duration-[var(--blocks-duration-fast)]`.
321
+ const duration = /\bduration-\[\d+(?:\.\d+)?m?s\]/g;
322
+ // `ease-[cubic-bezier(…)]` — but not `ease-[var(--blocks-ease-smooth)]` or named `ease-out`.
323
+ const easing = /\bease-\[cubic-bezier\([^\]]*\)\]/g;
324
+ const findings: Finding[] = [];
325
+ lines.forEach((line, i) => {
326
+ for (const m of line.matchAll(duration)) {
327
+ findings.push({
328
+ ruleId: this.id,
329
+ severity: this.severity,
330
+ kind: 'deterministic',
331
+ message: `Hardcoded transition duration \`${m[0]}\` bypasses the motion scale (no global speed / reduced-motion control).`,
332
+ fix: 'Use a duration token: `duration-[var(--blocks-duration-fast)]` / `-normal` / `-slow`.',
333
+ line: i + 1,
334
+ match: m[0]
335
+ });
336
+ }
337
+ for (const m of line.matchAll(easing)) {
338
+ findings.push({
339
+ ruleId: this.id,
340
+ severity: this.severity,
341
+ kind: 'deterministic',
342
+ message: `Hardcoded \`cubic-bezier()\` easing \`${m[0]}\` bypasses the motion system's easing tokens.`,
343
+ fix: 'Use an easing token: `ease-[var(--blocks-ease-smooth)]` / `-snappy` / `-gentle`, or a named Tailwind ease (`ease-out`).',
344
+ line: i + 1,
345
+ match: m[0]
346
+ });
347
+ }
348
+ });
349
+ return dedupeByLine(findings);
350
+ }
351
+ };
352
+
264
353
  /**
265
354
  * Token hallucination: a colour utility whose core *looks* like an Urbicon UI
266
355
  * semantic token (right namespace / intent prefix) but is not in the validated
@@ -284,7 +373,25 @@ const tokenHallucination: Rule = {
284
373
  lines.forEach((line, i) => {
285
374
  for (const m of line.matchAll(re)) {
286
375
  const core = m[2]!;
287
- if (!looksSemantic(core)) continue;
376
+ if (!looksSemantic(core)) {
377
+ // Not in our vocabulary — but a bare core one edit from an intent is almost
378
+ // certainly a misspelling (`bg-primay` → `bg-primary`), where the namespace
379
+ // heuristic alone would let it pass silently. Arbitrary cores (`bg-cover`,
380
+ // `bg-brand`) are far from every intent and stay unflagged.
381
+ const intended = suggestIntentTypo(core);
382
+ if (intended) {
383
+ findings.push({
384
+ ruleId: this.id,
385
+ severity: this.severity,
386
+ kind: 'deterministic',
387
+ message: `\`${m[1]}-${core}\` looks like a typo of \`${m[1]}-${intended}\`.`,
388
+ fix: `Did you mean \`${m[1]}-${intended}\`? Valid intents: ${INTENT_NAMES.join(', ')}.`,
389
+ line: i + 1,
390
+ match: m[0]
391
+ });
392
+ }
393
+ continue;
394
+ }
288
395
  if (validCores.has(core)) continue;
289
396
 
290
397
  findings.push({
@@ -348,6 +455,8 @@ export const RULES: Rule[] = [
348
455
  darkModeOverride,
349
456
  focusNotVisible,
350
457
  hardcodedZIndex,
458
+ hardcodedMotion,
459
+ deepInternalImport,
351
460
  dynamicClassInterpolation,
352
461
  tokenHallucination,
353
462
  ...MARKUP_RULES
@@ -2,7 +2,12 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { describe, expect, it } from 'vitest';
5
- import { resolveValidTokenCores, VALID_TOKEN_CORES } from './tokens.js';
5
+ import {
6
+ isSingleEditApart,
7
+ resolveValidTokenCores,
8
+ suggestIntentTypo,
9
+ VALID_TOKEN_CORES
10
+ } from './tokens.js';
6
11
 
7
12
  /**
8
13
  * Drift guard: the hardcoded {@link VALID_TOKEN_CORES} is the design-engine's
@@ -109,3 +114,31 @@ describe('resolveValidTokenCores', () => {
109
114
  expect(VALID_TOKEN_CORES.has('surface-brand')).toBe(false);
110
115
  });
111
116
  });
117
+
118
+ describe('isSingleEditApart', () => {
119
+ it('detects single substitutions, insertions, deletions, and adjacent transpositions', () => {
120
+ expect(isSingleEditApart('primay', 'primary')).toBe(true); // insertion
121
+ expect(isSingleEditApart('primaryy', 'primary')).toBe(true); // deletion
122
+ expect(isSingleEditApart('prinary', 'primary')).toBe(true); // substitution
123
+ expect(isSingleEditApart('priamry', 'primary')).toBe(true); // transposition
124
+ });
125
+ it('rejects identical strings and edits of distance ≥ 2', () => {
126
+ expect(isSingleEditApart('primary', 'primary')).toBe(false);
127
+ expect(isSingleEditApart('brand', 'primary')).toBe(false);
128
+ expect(isSingleEditApart('prmiarx', 'primary')).toBe(false); // two edits
129
+ });
130
+ });
131
+
132
+ describe('suggestIntentTypo', () => {
133
+ it('maps a one-edit misspelling to the intended intent', () => {
134
+ expect(suggestIntentTypo('primay')).toBe('primary');
135
+ expect(suggestIntentTypo('sucess')).toBe('success');
136
+ expect(suggestIntentTypo('wraning')).toBe('warning'); // transposition
137
+ });
138
+ it('returns null for exact intents, hyphenated cores, and unrelated words', () => {
139
+ expect(suggestIntentTypo('primary')).toBeNull(); // exact intent, not a typo
140
+ expect(suggestIntentTypo('primary-subtle')).toBeNull(); // hyphenated — left to whitelist
141
+ expect(suggestIntentTypo('brand')).toBeNull(); // far from every intent
142
+ expect(suggestIntentTypo('cover')).toBeNull();
143
+ });
144
+ });
@@ -115,6 +115,9 @@ const INTERACTIVE_CORES = [
115
115
  /** Chart series tokens → `text-chart-1` … `text-chart-6`. */
116
116
  const CHART_CORES = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5', 'chart-6'] as const;
117
117
 
118
+ /** Skeleton loading tokens → `bg-skeleton-shimmer` (the shimmer overlay sweep). */
119
+ const SKELETON_CORES = ['skeleton-shimmer'] as const;
120
+
118
121
  function buildIntentCores(): string[] {
119
122
  const cores: string[] = [];
120
123
  for (const intent of INTENT_NAMES) {
@@ -138,7 +141,8 @@ export const VALID_TOKEN_CORES: ReadonlySet<string> = new Set([
138
141
  ...WARM_NEUTRAL_STEPS.map((s) => `warm-neutral-${s}`),
139
142
  ...FEEDBACK_CORES,
140
143
  ...INTERACTIVE_CORES,
141
- ...CHART_CORES
144
+ ...CHART_CORES,
145
+ ...SKELETON_CORES
142
146
  ]);
143
147
 
144
148
  /**
@@ -227,4 +231,62 @@ export const KNOWN_BAD_NAMESPACES: Record<string, string> = {
227
231
  '-fg': 'Use `text-on-primary` / `text-on-surface` for foreground-on-intent text.'
228
232
  };
229
233
 
234
+ /**
235
+ * Whether `a` and `b` differ by exactly one typo: a single substitution,
236
+ * insertion, deletion, or adjacent transposition (Optimal String Alignment
237
+ * distance 1). Deliberately stricter than Levenshtein ≤ 2 — it catches the common
238
+ * typo classes (`primay`→`primary`, `sucess`→`success`, `priamry`→`primary`)
239
+ * while keeping unrelated words (`brand`, `cover`, `accent`) clearly apart, so
240
+ * the typo check never fires on a legitimately different utility.
241
+ */
242
+ export function isSingleEditApart(a: string, b: string): boolean {
243
+ if (a === b) return false;
244
+ if (a.length === b.length) {
245
+ let diffs = 0;
246
+ let at = -1;
247
+ for (let i = 0; i < a.length; i++) {
248
+ if (a[i] !== b[i]) {
249
+ diffs++;
250
+ if (at === -1) at = i;
251
+ }
252
+ }
253
+ if (diffs === 1) return true; // one substitution
254
+ // adjacent transposition: two diffs that are a swapped neighbouring pair
255
+ return diffs === 2 && at >= 0 && a[at] === b[at + 1] && a[at + 1] === b[at];
256
+ }
257
+ if (Math.abs(a.length - b.length) !== 1) return false;
258
+ // one insertion/deletion: the shorter is the longer with a single char removed
259
+ const [short, long] = a.length < b.length ? [a, b] : [b, a];
260
+ let i = 0;
261
+ let j = 0;
262
+ let skipped = false;
263
+ while (i < short.length && j < long.length) {
264
+ if (short[i] === long[j]) {
265
+ i++;
266
+ j++;
267
+ } else if (!skipped) {
268
+ skipped = true;
269
+ j++;
270
+ } else {
271
+ return false;
272
+ }
273
+ }
274
+ return true;
275
+ }
276
+
277
+ /**
278
+ * If `core` is a likely typo of an intent name — a single bare word one edit away
279
+ * from `primary`/`secondary`/… but not an exact intent — return the intended
280
+ * intent, else `null`. Hyphenated cores (`primary-subtle`, `success-foo`) are left
281
+ * to the namespace/whitelist checks; only the bare intent word is typo-matched.
282
+ */
283
+ export function suggestIntentTypo(core: string): string | null {
284
+ if (core.includes('-')) return null;
285
+ if ((INTENT_NAMES as readonly string[]).includes(core)) return null;
286
+ for (const intent of INTENT_NAMES) {
287
+ if (isSingleEditApart(core, intent)) return intent;
288
+ }
289
+ return null;
290
+ }
291
+
230
292
  export { INTENT_NAMES, INTENT_VARIANTS, SCALE_STEPS };
@@ -172,6 +172,25 @@ describe('parseManifest — product intent', () => {
172
172
  expect(parseManifest(md).intent.references).toEqual(['Linear', 'Stripe']);
173
173
  });
174
174
 
175
+ it('joins a soft-wrapped Audience value instead of truncating at the first line', () => {
176
+ const md =
177
+ '## Product Intent\n\n**Audience:** Homeowners with rooftop solar and a battery — non-experts who glance\nat production, consumption and savings.\n';
178
+ expect(parseManifest(md).intent.audience).toBe(
179
+ 'Homeowners with rooftop solar and a battery — non-experts who glance at production, consumption and savings.'
180
+ );
181
+ });
182
+
183
+ it('keeps the continuation items of a soft-wrapped inline reference list', () => {
184
+ const md = '## Product Intent\n\n**References:** Linear, Stripe, Notion,\nFigma, Things\n';
185
+ expect(parseManifest(md).intent.references).toEqual([
186
+ 'Linear',
187
+ 'Stripe',
188
+ 'Notion',
189
+ 'Figma',
190
+ 'Things'
191
+ ]);
192
+ });
193
+
175
194
  it('returns an empty intent when the section is absent', () => {
176
195
  const intent = parseManifest('# Bare\n\nsome prose\n').intent;
177
196
  expect(intent).toEqual({ voice: [], references: [], antiReferences: [] });
@@ -106,35 +106,62 @@ function parseIntent(body: string): ProductIntent {
106
106
  if (section === null) return emptyIntent();
107
107
  const lines = section.split('\n');
108
108
 
109
+ // A line that opens a different labelled field, or a heading — either terminates
110
+ // the value currently being gathered.
111
+ const isFieldOrHeading = (l: string): boolean => /^\*\*[^*]+:\*\*/.test(l) || /^#{1,6}\s/.test(l);
112
+
113
+ // `**Label:** value`, joining soft-wrapped continuation lines into one value —
114
+ // a Markdown line-wrap inside the value (common for a sentence-long Audience) is
115
+ // a continuation, not a truncation point. Stops at the first blank line, the next
116
+ // labelled field, or a heading.
109
117
  const inlineField = (label: string): string | undefined => {
110
- const re = new RegExp(`^\\*\\*${label}:\\*\\*\\s*(.+)$`);
111
- for (const l of lines) {
112
- const m = l.match(re);
113
- if (m) return m[1]!.trim();
118
+ const labelRe = new RegExp(`^\\*\\*${label}:\\*\\*\\s*(.*)$`);
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const m = lines[i]!.match(labelRe);
121
+ if (!m) continue;
122
+ const parts = m[1]!.trim() ? [m[1]!.trim()] : [];
123
+ for (let j = i + 1; j < lines.length; j++) {
124
+ const l = lines[j]!;
125
+ if (l.trim() === '' || isFieldOrHeading(l)) break;
126
+ parts.push(l.trim());
127
+ }
128
+ const value = parts.join(' ').trim();
129
+ return value === '' ? undefined : value;
114
130
  }
115
131
  return undefined;
116
132
  };
117
133
 
118
- // Items under a `**Label:**`: an inline comma list on the label line and/or the
119
- // bullet lines that follow it, up to the next labelled field. Blank/prose lines
120
- // in between are skipped, not treated as terminators.
134
+ // Items under a `**Label:**`: an inline comma list on the label line (and its
135
+ // soft-wrapped continuation lines) and/or the bullet lines that follow it, up to
136
+ // the next labelled field. Blank/prose lines are skipped, not terminators.
121
137
  const listField = (label: string): string[] => {
122
138
  const items: string[] = [];
123
139
  const labelRe = new RegExp(`^\\*\\*${label}:\\*\\*\\s*(.*)$`);
124
140
  let capturing = false;
141
+ let inInlineRun = false; // still gathering the soft-wrapped inline comma value
125
142
  for (const l of lines) {
126
143
  if (!capturing) {
127
144
  const m = l.match(labelRe);
128
145
  if (m) {
129
146
  capturing = true;
147
+ inInlineRun = true;
130
148
  const inline = m[1]!.trim();
131
149
  if (inline) items.push(...splitList(inline));
132
150
  }
133
151
  continue;
134
152
  }
135
- if (/^\*\*[^*]+:\*\*/.test(l)) break; // next labelled field ends this one
153
+ if (isFieldOrHeading(l)) break; // next labelled field / heading ends this one
136
154
  const bullet = l.match(/^\s*[-*]\s+(.+)$/);
137
- if (bullet) items.push(bullet[1]!.trim());
155
+ if (bullet) {
156
+ items.push(bullet[1]!.trim());
157
+ inInlineRun = false; // bullets started — later loose lines aren't inline-list continuation
158
+ continue;
159
+ }
160
+ if (l.trim() === '') {
161
+ inInlineRun = false; // blank closes the inline run (bullets may still follow)
162
+ continue;
163
+ }
164
+ if (inInlineRun) items.push(...splitList(l.trim())); // soft-wrapped continuation of the inline list
138
165
  }
139
166
  return items;
140
167
  };