designlang 12.8.0 → 12.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  "name": "designlang",
10
10
  "source": "./",
11
11
  "description": "Eight slash commands wrapping the designlang CLI: /extract (full design language \u2192 DTCG, Tailwind, Figma), /grade (shareable HTML report card + SVG badge), /battle (head-to-head graded comparison), /remix (restyle in 6 vocabularies \u2014 brutalist, swiss, art-deco, cyberpunk, soft-ui, editorial), /pack (one downloadable design-system bundle), /theme-swap (OKLCH-correct recolour around a new brand primary), /brand (full editorial brand-guidelines book \u2014 13 chapters, hand-off-ready), /pair (fuse two designs across configurable axes \u2014 colours from one site, typography from another).",
12
- "version": "12.8.0",
12
+ "version": "12.10.0",
13
13
  "author": {
14
14
  "name": "Manavarya Singh"
15
15
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "designlang",
3
3
  "description": "Extract any website's design language and ship it. Eight slash commands \u2014 /extract, /grade, /battle, /remix, /pack, /theme-swap, /brand, /pair \u2014 wrap the designlang CLI to pull DTCG tokens, Tailwind/shadcn/Figma vars, motion + voice, generate shareable graded report cards, head-to-head battle pages, six-vocabulary remixes, downloadable design-system bundles, OKLCH-correct theme recolouring, full editorial brand-guidelines books, and design crossovers between two sites.",
4
- "version": "12.8.0",
4
+ "version": "12.10.0",
5
5
  "author": {
6
6
  "name": "Manavarya Singh",
7
7
  "url": "https://github.com/Manavarya09"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,101 @@
1
1
  # Changelog
2
2
 
3
+ ## [12.10.0] — 2026-05-12
4
+
5
+ **Small ship: \`designlang stats\` + low-confidence warning in grade cards.**
6
+
7
+ Two tight quality-of-life additions on top of the v12.9 extraction pass.
8
+
9
+ ### Added
10
+
11
+ - **\`designlang stats <url>\`** — one-screen summary to stdout. Grade, primary, fonts, spacing base, WCAG, stack, library, tone, material, intent — all in ~15 lines. No files written. Use \`-j\` / \`--as-json\` for machine-readable output (CI, scripting).
12
+
13
+ \`\`\`
14
+ Grade B · 87/100
15
+ Primary #533afd ×899 59% conf
16
+ Fonts sohne-var
17
+ Type scale 14 sizes
18
+ Spacing base 2px · 13 steps
19
+ Shape 5 radii · 6 shadows
20
+ Colours 31 tokens
21
+ WCAG 79%
22
+ Stack next · unknown
23
+ Material skeuomorphic
24
+ Tone neutral
25
+ Intent landing
26
+ \`\`\`
27
+
28
+ - **Low-confidence primary warning in \`grade.html\`.** Surfaces the v12.9
29
+ \`primary.confidence\` field when it drops under 0.5 — a soft, amber
30
+ callout above the dimensions grid that tells the reader the brand
31
+ colour is a near-tie pick rather than a runaway leader. Renders as
32
+ ordinary inline note, not an error. Stays out of the way when
33
+ confidence is high (the common case).
34
+
35
+ ### Why \`stats\`?
36
+
37
+ Every other command writes files. There was no one-line "what's this
38
+ site made of" path for scripting, CI summaries, or a quick sanity
39
+ check. \`stats\` fills that gap without bloat — it's the read-only,
40
+ zero-side-effect entry point.
41
+
42
+ 2 new tests (low-confidence note present + absent paths). 398/398 total.
43
+
44
+ ## [12.9.0] — 2026-05-11
45
+
46
+ **Extraction quality pass — the core MVP, fixed.**
47
+
48
+ Eight features rode on top of the same extractor. This release fixes
49
+ four real defects in that extractor — visible across grade, battle,
50
+ remix, pack, theme-swap, brand, and pair without anyone having to
51
+ re-run the downstream code.
52
+
53
+ ### Fixed
54
+
55
+ - **Cluster representative bug** in \`clusterColors()\`. Before: the first-
56
+ encountered colour seeded each cluster, so a sparsely-used pale shade
57
+ could become the canonical hex for a cluster that mostly held a vivid
58
+ brand colour. The brand-book primary slot was reading lavender for
59
+ Stripe instead of \`#533afd\`. Fixed: representative is now the
60
+ most-counted member of the cluster.
61
+ - **Spacing base detection** missed common production scales. Before:
62
+ only \`[2, 4, 6, 8]\` were tried as base candidates, so Bootstrap-style
63
+ base-5 sites and base-7/10/12 sites returned \`base: null\`. Fixed:
64
+ expanded to \`[2, 4, 5, 6, 7, 8, 10, 12, 16]\` with a small bonus for
65
+ 4 and 8 to keep results stable for the production-default sites.
66
+ - **Typography noise**. Before: generic CSS stacks (\`sans-serif\`,
67
+ \`monospace\`, \`system-ui\`, \`inherit\`), OS UI fonts (\`-apple-system\`),
68
+ and icon fonts (Material Icons, Font Awesome, Lucide, Tabler, etc.)
69
+ polluted the \`families\` list, making the brand book mistakenly
70
+ document an icon font as the brand's body family. Fixed: explicit
71
+ generic + icon-family filter at the source.
72
+
73
+ ### Added
74
+
75
+ - **\`primary.confidence\`** (0–1) on \`design.colors.primary\`. Computed
76
+ from the score gap between rank 1 and rank 2 brand candidates — a
77
+ runaway leader scores 1.0; a near-tie scores 0.3. Downstream
78
+ consumers (brand book, grade, theme-swap) can surface uncertainty
79
+ warnings on low-confidence extractions.
80
+ - **\`asList(v)\`** helper exported from \`src/utils.js\`. Coerces
81
+ anything-shaped input (array / object / comma-string / scalar) into
82
+ a clean string array. Consolidates the per-formatter ad-hoc
83
+ defenses (brand-book, pair, pack all had their own copies).
84
+
85
+ ### Why
86
+
87
+ Verified live on \`stripe.com\`:
88
+
89
+ - Pre-v12.9: primary slot showed a lavender shade, multiple icon-font
90
+ entries in families, spacing base often \`null\`.
91
+ - v12.9: primary \`#533afd\` (count 899, confidence 0.59), \`families:
92
+ ['sohne-var']\`, spacing base detected correctly.
93
+
94
+ No new dependencies, no schema breaks, no public-API changes. 396/396
95
+ tests pass (6 new — base-5 + base-6 detectScale, cluster representative
96
+ correctness, generic-family filter, icon-family filter, asList shape
97
+ coercion).
98
+
3
99
  ## [12.8.0] — 2026-05-10
4
100
 
5
101
  **Pair — fuse two extracted designs into a single hybrid identity.**
@@ -944,6 +944,104 @@ program
944
944
  }
945
945
  });
946
946
 
947
+ // ── Stats command — fast stdout summary, no files written ──
948
+ program
949
+ .command('stats <url>')
950
+ .description('Print a concise one-screen summary to stdout — grade, primary, fonts, spacing, voice. No files written.')
951
+ .option('-j, --as-json', 'emit machine-readable JSON to stdout instead of pretty text')
952
+ .action(async (url, opts) => {
953
+ if (!url.startsWith('http')) url = `https://${url}`;
954
+ validateUrl(url);
955
+
956
+ // Quiet path for --as-json: no spinner / chrome noise, just data on stdout.
957
+ // (`--json` is already a global program flag; `--as-json` avoids the clash.)
958
+ const wantJson = !!opts.asJson;
959
+ const spinner = wantJson ? null : ora(`Reading ${url}...`).start();
960
+ try {
961
+ const design = await extractDesignLanguage(url);
962
+ const s = design.score || {};
963
+ const primary = design.colors?.primary;
964
+ const families = (design.typography?.families || []).map(f => f?.name || f).filter(Boolean);
965
+ const summary = {
966
+ url,
967
+ title: design.meta?.title,
968
+ grade: s.grade ?? null,
969
+ score: s.overall ?? null,
970
+ primary: primary
971
+ ? { hex: primary.hex, count: primary.count, confidence: primary.confidence ?? null }
972
+ : null,
973
+ families: families.slice(0, 3),
974
+ fontFamilyCount: families.length,
975
+ typeScale: (design.typography?.scale || []).length,
976
+ spacingBase: design.spacing?.base ?? null,
977
+ spacingScale: (design.spacing?.scale || []).length,
978
+ radii: (design.borders?.radii || []).length,
979
+ shadows: (design.shadows?.values || []).length,
980
+ colors: (design.colors?.all || []).length,
981
+ wcag: design.accessibility?.score ?? null,
982
+ material: design.materialLanguage?.label,
983
+ library: design.componentLibrary?.library,
984
+ tone: design.voice?.tone,
985
+ stack: design.stack?.framework,
986
+ intent: design.pageIntent?.type,
987
+ };
988
+
989
+ if (wantJson) {
990
+ if (spinner) spinner.stop();
991
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
992
+ return;
993
+ }
994
+
995
+ spinner.stop();
996
+ const gradeColor =
997
+ summary.grade === 'A' ? chalk.green
998
+ : summary.grade === 'B' ? chalk.cyan
999
+ : summary.grade === 'C' ? chalk.yellow
1000
+ : summary.grade === 'D' ? chalk.magenta
1001
+ : chalk.red;
1002
+ const confTag = primary && primary.confidence != null
1003
+ ? (primary.confidence < 0.5
1004
+ ? chalk.yellow(`~${Math.round(primary.confidence * 100)}% conf`)
1005
+ : chalk.gray(`${Math.round(primary.confidence * 100)}% conf`))
1006
+ : '';
1007
+ const line = (label, value) =>
1008
+ ` ${chalk.gray(label.padEnd(12))} ${value}`;
1009
+
1010
+ console.log('');
1011
+ console.log(` ${chalk.bold(url)}`);
1012
+ if (summary.title) console.log(` ${chalk.gray(summary.title)}`);
1013
+ console.log('');
1014
+ console.log(line('Grade', `${gradeColor.bold(summary.grade || '—')} ${chalk.gray('·')} ${chalk.bold(String(summary.score ?? '—') + '/100')}`));
1015
+ if (primary) {
1016
+ console.log(line('Primary', `${chalk.bold(primary.hex)} ${chalk.gray('×' + primary.count)} ${confTag}`));
1017
+ } else {
1018
+ console.log(line('Primary', chalk.gray('—')));
1019
+ }
1020
+ if (families.length) {
1021
+ const head = families[0];
1022
+ const body = families[1] || head;
1023
+ const extra = families.length > 2 ? chalk.gray(` +${families.length - 2}`) : '';
1024
+ console.log(line('Fonts', `${head}${body && body !== head ? chalk.gray(' / ') + body : ''}${extra}`));
1025
+ } else {
1026
+ console.log(line('Fonts', chalk.gray('—')));
1027
+ }
1028
+ console.log(line('Type scale', `${summary.typeScale} sizes`));
1029
+ console.log(line('Spacing', `${summary.spacingBase ? `base ${summary.spacingBase}px` : 'no base'} · ${summary.spacingScale} steps`));
1030
+ console.log(line('Shape', `${summary.radii} radii · ${summary.shadows} shadows`));
1031
+ console.log(line('Colours', `${summary.colors} tokens`));
1032
+ console.log(line('WCAG', summary.wcag != null ? `${summary.wcag}%` : chalk.gray('—')));
1033
+ console.log(line('Stack', [summary.stack, summary.library].filter(Boolean).join(' · ') || chalk.gray('—')));
1034
+ console.log(line('Material', summary.material || chalk.gray('—')));
1035
+ console.log(line('Tone', summary.tone || chalk.gray('—')));
1036
+ console.log(line('Intent', summary.intent || chalk.gray('—')));
1037
+ console.log('');
1038
+ } catch (err) {
1039
+ if (spinner) spinner.fail('Stats failed');
1040
+ console.error(chalk.red(`\n ${err.message}\n`));
1041
+ process.exit(1);
1042
+ }
1043
+ });
1044
+
947
1045
  // ── Grade command — shareable HTML report card ─────────────
948
1046
  program
949
1047
  .command('grade <url>')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "12.8.0",
3
+ "version": "12.10.0",
4
4
  "description": "Extract the complete design language from any website and ship it \u2014 clone to a working Next.js starter, guard tokens with a CI drift bot, or browse everything in a local studio. Outputs W3C DTCG tokens, motion tokens, typed anatomy stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -106,8 +106,29 @@ export function extractColors(computedStyles) {
106
106
  return pct < 0.05 && c.members.some(m => m.contexts.has('background'));
107
107
  }) || ranked.find(c => c !== primary && c !== secondary) || null;
108
108
 
109
+ // Primary detection confidence — useful signal for downstream consumers
110
+ // that may want to warn the user when extraction is uncertain (e.g. a
111
+ // monochrome site where there's no clear brand colour). We compute it
112
+ // from the score gap between rank 1 and rank 2: a runaway leader is
113
+ // confident, a near-tie is not.
114
+ let primaryConfidence = null;
115
+ if (primary) {
116
+ const top = brandScore(primary);
117
+ const next = ranked[1] ? brandScore(ranked[1]) : 0;
118
+ if (top <= 0) {
119
+ primaryConfidence = 0;
120
+ } else if (next <= 0) {
121
+ primaryConfidence = primary.interactiveBg > 0 ? 1 : 0.6;
122
+ } else {
123
+ const gap = (top - next) / top;
124
+ // Anchor: gap >= 0.5 → 1.0 (runaway). gap 0 → 0.3 (near-tie).
125
+ primaryConfidence = Math.max(0.3, Math.min(1, 0.3 + gap * 1.4));
126
+ }
127
+ primaryConfidence = Math.round(primaryConfidence * 100) / 100;
128
+ }
129
+
109
130
  return {
110
- primary: primary ? { hex: primary.hex, rgb: primary.representative, hsl: rgbToHsl(primary.representative), count: primary.count } : null,
131
+ primary: primary ? { hex: primary.hex, rgb: primary.representative, hsl: rgbToHsl(primary.representative), count: primary.count, confidence: primaryConfidence } : null,
111
132
  secondary: secondary ? { hex: secondary.hex, rgb: secondary.representative, hsl: rgbToHsl(secondary.representative), count: secondary.count } : null,
112
133
  accent: accent ? { hex: accent.hex, rgb: accent.representative, hsl: rgbToHsl(accent.representative), count: accent.count } : null,
113
134
  neutrals: neutrals.map(c => ({ hex: c.hex, rgb: c.representative, hsl: rgbToHsl(c.representative), count: c.count })),
@@ -1,14 +1,49 @@
1
1
  import { parseCSSValue } from '../utils.js';
2
2
 
3
+ // Filter set for fonts that aren't part of the site's brand typography:
4
+ // generic CSS fallbacks, OS UI stacks, icon fonts, and inherited "no
5
+ // declaration" values. These slipped into families[] before and polluted
6
+ // the brand book + grade summary.
7
+ const GENERIC_FAMILIES = new Set([
8
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
9
+ 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
10
+ 'inherit', 'initial', 'unset', 'revert', 'auto', '-apple-system',
11
+ 'blinkmacsystemfont', 'apple-system',
12
+ ]);
13
+ const ICON_FAMILY_RE = /^(material[-\s]?icons|font\s?awesome|fa-?solid|fa-?regular|fa-?brands|ionicons|glyphicons|bootstrap-icons|remixicon|feather|tabler-icons|lucide)/i;
14
+
15
+ function normaliseFamily(raw) {
16
+ if (!raw) return null;
17
+ // Strip quotes + take the first stack member (sites declare e.g.
18
+ // `"Inter", "Helvetica Neue", sans-serif` — only the first is the
19
+ // *intended* family).
20
+ const first = String(raw).replace(/["']/g, '').split(',')[0].trim();
21
+ if (!first) return null;
22
+ return first;
23
+ }
24
+
25
+ function isMeaningfulFamily(name) {
26
+ if (!name) return false;
27
+ const lower = name.toLowerCase();
28
+ if (GENERIC_FAMILIES.has(lower)) return false;
29
+ if (ICON_FAMILY_RE.test(name)) return false;
30
+ // Single-character or all-symbol names are extraction noise.
31
+ if (name.length < 2) return false;
32
+ if (!/[a-z]/i.test(name)) return false;
33
+ return true;
34
+ }
35
+
3
36
  export function extractTypography(computedStyles) {
4
37
  const familyCount = new Map();
5
38
  const sizeEntries = [];
6
39
  const weightCount = new Map();
7
40
 
8
41
  for (const el of computedStyles) {
9
- // Font families
10
- const family = el.fontFamily?.replace(/["']/g, '').split(',')[0]?.trim();
11
- if (family) familyCount.set(family, (familyCount.get(family) || 0) + 1);
42
+ // Font families — normalised first-of-stack, with noise filtered out.
43
+ const family = normaliseFamily(el.fontFamily);
44
+ if (family && isMeaningfulFamily(family)) {
45
+ familyCount.set(family, (familyCount.get(family) || 0) + 1);
46
+ }
12
47
 
13
48
  // Font sizes
14
49
  const sizeVal = parseCSSValue(el.fontSize);
@@ -124,6 +124,16 @@ export function formatGrade(design, opts = {}) {
124
124
  const ogTitle = `${host} — Grade ${s.grade}`;
125
125
  const ogDesc = `Design system audit by designlang. ${s.overall}/100 across 8 dimensions.`;
126
126
 
127
+ // Surface a low-confidence warning when the primary detection was uncertain
128
+ // (e.g. monochrome site, near-tie between top brand candidates). The field
129
+ // arrived in v12.9 — this card now exposes it so readers don't take the
130
+ // primary at face value when it's a soft pick.
131
+ const primaryConfidence = design.colors?.primary?.confidence;
132
+ const lowConfidence = typeof primaryConfidence === 'number' && primaryConfidence < 0.5;
133
+ const confidenceNote = lowConfidence
134
+ ? `<p class="confidence-note">Primary detection was low-confidence (${Math.round(primaryConfidence * 100)}%). The brand colour may be a soft pick — review the palette below.</p>`
135
+ : '';
136
+
127
137
  const dims = DIMENSIONS
128
138
  .filter(([k]) => s.scores[k] !== undefined)
129
139
  .map(([k, label, blurb]) => {
@@ -225,6 +235,20 @@ ${headHref ? `<link href="${esc(headHref)}" rel="stylesheet">` : ''}
225
235
  section > h2 { font-family: var(--display); font-weight: 400; font-size: 32px; margin: 0 0 8px; letter-spacing: -.005em; }
226
236
  section > h2 + .lead { color: var(--ink-soft); margin: 0 0 36px; max-width: 60ch; }
227
237
 
238
+ /* Low-confidence primary callout — only rendered when v12.9's
239
+ primary.confidence drops under 0.5. Soft warning, not an error. */
240
+ .confidence-note {
241
+ margin: -16px 0 32px;
242
+ padding: 12px 16px;
243
+ background: rgba(212, 145, 0, .08);
244
+ border-left: 2px solid #d49100;
245
+ border-radius: 0 4px 4px 0;
246
+ font-size: 14px;
247
+ color: var(--ink-soft);
248
+ max-width: 60ch;
249
+ }
250
+ [data-theme="dark"] .confidence-note { background: rgba(212, 145, 0, .14); }
251
+
228
252
  /* — Dimensions grid — */
229
253
  .dims { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px 48px; }
230
254
  @media (max-width: 640px) { .dims { grid-template-columns: 1fr; gap: 28px; } }
@@ -313,6 +337,7 @@ ${headHref ? `<link href="${esc(headHref)}" rel="stylesheet">` : ''}
313
337
  <section>
314
338
  <h2>Eight dimensions, scored.</h2>
315
339
  <p class="lead">Each dimension is graded against calibrated thresholds drawn from production design systems (Stripe, Linear, Vercel, GitHub, Apple). The number is the headline; the prose underneath is what to do next.</p>
340
+ ${confidenceNote}
316
341
  <div class="dims">${dims}</div>
317
342
  </section>
318
343
 
package/src/utils.js CHANGED
@@ -188,6 +188,20 @@ export function clusterColors(colors, threshold = 15) {
188
188
  clusters.push({ representative: color.parsed, hex: color.hex, members: [color], count: color.count });
189
189
  }
190
190
  }
191
+ // The first encountered colour seeded each cluster, but that's order-of-
192
+ // iteration accident, not signal. Re-pick the representative as the
193
+ // most-used member of the cluster so downstream consumers (primary
194
+ // detection, palette display, brand book) get the dominant shade.
195
+ for (const cluster of clusters) {
196
+ if (cluster.members.length > 1) {
197
+ const dominant = cluster.members.reduce(
198
+ (best, m) => (m.count > best.count ? m : best),
199
+ cluster.members[0],
200
+ );
201
+ cluster.representative = dominant.parsed;
202
+ cluster.hex = dominant.hex;
203
+ }
204
+ }
191
205
  return clusters.sort((a, b) => b.count - a.count);
192
206
  }
193
207
 
@@ -235,13 +249,33 @@ export function nameFromUrl(url) {
235
249
 
236
250
  export function detectScale(values) {
237
251
  if (values.length < 3) return { base: null, scale: values };
238
- const candidates = [2, 4, 6, 8];
252
+ // Expanded candidate set. Real production palettes use 4/8 (Tailwind +
253
+ // Material), 5 (Bootstrap), 6 (some Apple specs), 7 (rare), 10/12/16
254
+ // (looser systems). 2 stays as a fallback for icon/component-level
255
+ // numbers. We give 4 and 8 a small head-start because they win >70%
256
+ // of the time and were the previous-only choices — keeps results
257
+ // stable for sites that worked before.
258
+ const candidates = [2, 4, 5, 6, 7, 8, 10, 12, 16];
259
+ const bonus = { 4: 0.04, 8: 0.04 };
239
260
  let bestBase = null;
240
261
  let bestScore = 0;
241
262
  for (const base of candidates) {
242
- const score = values.filter(v => v > 0 && v % base === 0).length / values.length;
263
+ const fit = values.filter(v => v > 0 && v % base === 0).length / values.length;
264
+ const score = fit + (bonus[base] || 0);
243
265
  if (score > bestScore) { bestScore = score; bestBase = base; }
244
266
  }
245
267
  if (bestScore >= 0.6) return { base: bestBase, scale: values };
246
268
  return { base: null, scale: values };
247
269
  }
270
+
271
+ // Shared "always-list" coercer for downstream consumers (formatters,
272
+ // pack, brand-book). Different extractors return slot/prop/variant
273
+ // fields as arrays, objects, or comma-separated strings depending on
274
+ // what was detected; this normalises without losing data.
275
+ export function asList(v) {
276
+ if (v == null) return [];
277
+ if (Array.isArray(v)) return v.filter(x => x != null);
278
+ if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
279
+ if (typeof v === 'object') return Object.keys(v).filter(k => v[k] !== false && v[k] !== null);
280
+ return [String(v)];
281
+ }