feat(v12.9): extraction quality pass — fix the core MVP

Eight features rode on top of the same extractor. This release fixes
four real defects in that extractor — visible across every downstream
output (grade, battle, remix, pack, theme-swap, brand, pair).

Fixed

  1. clusterColors() representative bug. Before, the first-encountered
     colour seeded each cluster, so a sparsely-used pale shade could
     become the canonical hex of a cluster that mostly held a vivid
     brand colour. The brand-book primary slot was reading lavender
     for Stripe instead of #533afd. Now: representative is the
     most-counted member.

  2. detectScale() missed common production base units. Before, only
     [2, 4, 6, 8] were tried, so Bootstrap-style base-5 and base-7/10/12
     sites returned base:null. Now: [2, 4, 5, 6, 7, 8, 10, 12, 16] with
     a small bonus for 4 and 8 to keep results stable for production
     defaults.

  3. Typography noise. Generic CSS stacks (sans-serif, monospace,
     system-ui, inherit, -apple-system, ...) and icon fonts (Material
     Icons, Font Awesome, Lucide, Tabler, ...) polluted the families
     list. Brand books had icon fonts documented as the body family.
     Now: an explicit filter at the source.

Added

  4. primary.confidence (0–1) on design.colors.primary. Computed from
     the brand-score gap between rank 1 and rank 2. Runaway leader →
     1.0; near-tie → 0.3. Downstream consumers can surface uncertainty
     warnings on low-confidence extractions.

  5. asList(v) helper exported from src/utils.js. Coerces anything-
     shaped input (array / object / comma-string / scalar) into a clean
     string array. Consolidates ad-hoc per-formatter defenses.

Verified live on stripe.com

  Before: primary slot showed a washed-out lavender; multiple icon
  fonts polluted families; spacing base often null.

  After: primary #533afd (count 899, confidence 0.59), families
  ['sohne-var'], spacing base correctly detected.

No new dependencies, no schema breaks, no public-API changes.
396/396 tests pass (6 new).
This commit is contained in:
Manav Arya Singh
2026-05-12 13:31:28 +04:00
parent fb3437232a
commit 95a18d7211
9 changed files with 221 additions and 9 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"name": "designlang",
"source": "./",
"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).",
"version": "12.8.0",
"version": "12.9.0",
"author": {
"name": "Manavarya Singh"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "designlang",
"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.",
"version": "12.8.0",
"version": "12.9.0",
"author": {
"name": "Manavarya Singh",
"url": "https://github.com/Manavarya09"
+55
View File
@@ -1,5 +1,60 @@
# Changelog
## [12.9.0] — 2026-05-11
**Extraction quality pass — the core MVP, fixed.**
Eight features rode on top of the same extractor. This release fixes
four real defects in that extractor — visible across grade, battle,
remix, pack, theme-swap, brand, and pair without anyone having to
re-run the downstream code.
### Fixed
- **Cluster representative bug** in \`clusterColors()\`. Before: the first-
encountered colour seeded each cluster, so a sparsely-used pale shade
could become the canonical hex for a cluster that mostly held a vivid
brand colour. The brand-book primary slot was reading lavender for
Stripe instead of \`#533afd\`. Fixed: representative is now the
most-counted member of the cluster.
- **Spacing base detection** missed common production scales. Before:
only \`[2, 4, 6, 8]\` were tried as base candidates, so Bootstrap-style
base-5 sites and base-7/10/12 sites returned \`base: null\`. Fixed:
expanded to \`[2, 4, 5, 6, 7, 8, 10, 12, 16]\` with a small bonus for
4 and 8 to keep results stable for the production-default sites.
- **Typography noise**. Before: generic CSS stacks (\`sans-serif\`,
\`monospace\`, \`system-ui\`, \`inherit\`), OS UI fonts (\`-apple-system\`),
and icon fonts (Material Icons, Font Awesome, Lucide, Tabler, etc.)
polluted the \`families\` list, making the brand book mistakenly
document an icon font as the brand's body family. Fixed: explicit
generic + icon-family filter at the source.
### Added
- **\`primary.confidence\`** (01) on \`design.colors.primary\`. Computed
from the score gap between rank 1 and rank 2 brand candidates — a
runaway leader scores 1.0; a near-tie scores 0.3. Downstream
consumers (brand book, grade, theme-swap) can surface uncertainty
warnings on low-confidence extractions.
- **\`asList(v)\`** helper exported from \`src/utils.js\`. Coerces
anything-shaped input (array / object / comma-string / scalar) into
a clean string array. Consolidates the per-formatter ad-hoc
defenses (brand-book, pair, pack all had their own copies).
### Why
Verified live on \`stripe.com\`:
- Pre-v12.9: primary slot showed a lavender shade, multiple icon-font
entries in families, spacing base often \`null\`.
- v12.9: primary \`#533afd\` (count 899, confidence 0.59), \`families:
['sohne-var']\`, spacing base detected correctly.
No new dependencies, no schema breaks, no public-API changes. 396/396
tests pass (6 new — base-5 + base-6 detectScale, cluster representative
correctness, generic-family filter, icon-family filter, asList shape
coercion).
## [12.8.0] — 2026-05-10
**Pair — fuse two extracted designs into a single hybrid identity.**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "designlang",
"version": "12.8.0",
"version": "12.9.0",
"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.",
"type": "module",
"bin": {
+22 -1
View File
@@ -106,8 +106,29 @@ export function extractColors(computedStyles) {
return pct < 0.05 && c.members.some(m => m.contexts.has('background'));
}) || ranked.find(c => c !== primary && c !== secondary) || null;
// Primary detection confidence — useful signal for downstream consumers
// that may want to warn the user when extraction is uncertain (e.g. a
// monochrome site where there's no clear brand colour). We compute it
// from the score gap between rank 1 and rank 2: a runaway leader is
// confident, a near-tie is not.
let primaryConfidence = null;
if (primary) {
const top = brandScore(primary);
const next = ranked[1] ? brandScore(ranked[1]) : 0;
if (top <= 0) {
primaryConfidence = 0;
} else if (next <= 0) {
primaryConfidence = primary.interactiveBg > 0 ? 1 : 0.6;
} else {
const gap = (top - next) / top;
// Anchor: gap >= 0.5 → 1.0 (runaway). gap 0 → 0.3 (near-tie).
primaryConfidence = Math.max(0.3, Math.min(1, 0.3 + gap * 1.4));
}
primaryConfidence = Math.round(primaryConfidence * 100) / 100;
}
return {
primary: primary ? { hex: primary.hex, rgb: primary.representative, hsl: rgbToHsl(primary.representative), count: primary.count } : null,
primary: primary ? { hex: primary.hex, rgb: primary.representative, hsl: rgbToHsl(primary.representative), count: primary.count, confidence: primaryConfidence } : null,
secondary: secondary ? { hex: secondary.hex, rgb: secondary.representative, hsl: rgbToHsl(secondary.representative), count: secondary.count } : null,
accent: accent ? { hex: accent.hex, rgb: accent.representative, hsl: rgbToHsl(accent.representative), count: accent.count } : null,
neutrals: neutrals.map(c => ({ hex: c.hex, rgb: c.representative, hsl: rgbToHsl(c.representative), count: c.count })),
+38 -3
View File
@@ -1,14 +1,49 @@
import { parseCSSValue } from '../utils.js';
// Filter set for fonts that aren't part of the site's brand typography:
// generic CSS fallbacks, OS UI stacks, icon fonts, and inherited "no
// declaration" values. These slipped into families[] before and polluted
// the brand book + grade summary.
const GENERIC_FAMILIES = new Set([
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
'inherit', 'initial', 'unset', 'revert', 'auto', '-apple-system',
'blinkmacsystemfont', 'apple-system',
]);
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;
function normaliseFamily(raw) {
if (!raw) return null;
// Strip quotes + take the first stack member (sites declare e.g.
// `"Inter", "Helvetica Neue", sans-serif` — only the first is the
// *intended* family).
const first = String(raw).replace(/["']/g, '').split(',')[0].trim();
if (!first) return null;
return first;
}
function isMeaningfulFamily(name) {
if (!name) return false;
const lower = name.toLowerCase();
if (GENERIC_FAMILIES.has(lower)) return false;
if (ICON_FAMILY_RE.test(name)) return false;
// Single-character or all-symbol names are extraction noise.
if (name.length < 2) return false;
if (!/[a-z]/i.test(name)) return false;
return true;
}
export function extractTypography(computedStyles) {
const familyCount = new Map();
const sizeEntries = [];
const weightCount = new Map();
for (const el of computedStyles) {
// Font families
const family = el.fontFamily?.replace(/["']/g, '').split(',')[0]?.trim();
if (family) familyCount.set(family, (familyCount.get(family) || 0) + 1);
// Font families — normalised first-of-stack, with noise filtered out.
const family = normaliseFamily(el.fontFamily);
if (family && isMeaningfulFamily(family)) {
familyCount.set(family, (familyCount.get(family) || 0) + 1);
}
// Font sizes
const sizeVal = parseCSSValue(el.fontSize);
+36 -2
View File
@@ -188,6 +188,20 @@ export function clusterColors(colors, threshold = 15) {
clusters.push({ representative: color.parsed, hex: color.hex, members: [color], count: color.count });
}
}
// The first encountered colour seeded each cluster, but that's order-of-
// iteration accident, not signal. Re-pick the representative as the
// most-used member of the cluster so downstream consumers (primary
// detection, palette display, brand book) get the dominant shade.
for (const cluster of clusters) {
if (cluster.members.length > 1) {
const dominant = cluster.members.reduce(
(best, m) => (m.count > best.count ? m : best),
cluster.members[0],
);
cluster.representative = dominant.parsed;
cluster.hex = dominant.hex;
}
}
return clusters.sort((a, b) => b.count - a.count);
}
@@ -235,13 +249,33 @@ export function nameFromUrl(url) {
export function detectScale(values) {
if (values.length < 3) return { base: null, scale: values };
const candidates = [2, 4, 6, 8];
// Expanded candidate set. Real production palettes use 4/8 (Tailwind +
// Material), 5 (Bootstrap), 6 (some Apple specs), 7 (rare), 10/12/16
// (looser systems). 2 stays as a fallback for icon/component-level
// numbers. We give 4 and 8 a small head-start because they win >70%
// of the time and were the previous-only choices — keeps results
// stable for sites that worked before.
const candidates = [2, 4, 5, 6, 7, 8, 10, 12, 16];
const bonus = { 4: 0.04, 8: 0.04 };
let bestBase = null;
let bestScore = 0;
for (const base of candidates) {
const score = values.filter(v => v > 0 && v % base === 0).length / values.length;
const fit = values.filter(v => v > 0 && v % base === 0).length / values.length;
const score = fit + (bonus[base] || 0);
if (score > bestScore) { bestScore = score; bestBase = base; }
}
if (bestScore >= 0.6) return { base: bestBase, scale: values };
return { base: null, scale: values };
}
// Shared "always-list" coercer for downstream consumers (formatters,
// pack, brand-book). Different extractors return slot/prop/variant
// fields as arrays, objects, or comma-separated strings depending on
// what was detected; this normalises without losing data.
export function asList(v) {
if (v == null) return [];
if (Array.isArray(v)) return v.filter(x => x != null);
if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
if (typeof v === 'object') return Object.keys(v).filter(k => v[k] !== false && v[k] !== null);
return [String(v)];
}
+30
View File
@@ -196,6 +196,36 @@ describe('extractTypography', () => {
assert.ok(typo.weights.some(w => w.weight === '400'));
assert.ok(typo.weights.some(w => w.weight === '700'));
});
it('drops generic CSS family names from the families list', () => {
// Stacks declaring only generic fallbacks should not produce any
// entry in families[] — they aren't part of the site's brand.
const noisy = [
makeEl({ tag: 'p', fontFamily: 'sans-serif', fontSize: '16px' }),
makeEl({ tag: 'span', fontFamily: 'system-ui', fontSize: '14px' }),
makeEl({ tag: 'code', fontFamily: 'monospace', fontSize: '12px' }),
makeEl({ tag: 'h1', fontFamily: 'inherit', fontSize: '32px' }),
makeEl({ tag: 'h2', fontFamily: '"Inter", sans-serif', fontSize: '24px' }),
];
const typo = extractTypography(noisy);
const names = typo.families.map(f => f.name);
assert.ok(!names.some(n => /^sans-serif|monospace|system-ui|inherit$/i.test(n)),
`families must not contain generic stacks, got: ${names.join(', ')}`);
assert.ok(names.includes('Inter'), 'real families must still come through');
});
it('drops icon-font families (Material Icons, Font Awesome, etc.)', () => {
const iconHeavy = [
makeEl({ tag: 'i', fontFamily: '"Material Icons", sans-serif', fontSize: '24px' }),
makeEl({ tag: 'i', fontFamily: '"Font Awesome 6 Free"', fontSize: '20px' }),
makeEl({ tag: 'span', fontFamily: 'lucide', fontSize: '16px' }),
makeEl({ tag: 'p', fontFamily: '"Inter", sans-serif', fontSize: '16px' }),
];
const typo = extractTypography(iconHeavy);
const names = typo.families.map(f => f.name);
assert.ok(!names.some(n => /Material Icons|Font Awesome|lucide/i.test(n)),
`families must not contain icon fonts, got: ${names.join(', ')}`);
});
});
// ── extractSpacing ──────────────────────────────────────────────
+37
View File
@@ -239,6 +239,22 @@ describe('clusterColors', () => {
const clusters = clusterColors(colors, 15);
assert.ok(clusters[0].count >= clusters[clusters.length - 1].count);
});
it('uses the most-used member as the cluster representative (not first-encountered)', () => {
// The lavender (#f0f0ff) was encountered FIRST but is only used 2 times;
// the deep purple (#5544ff) joins its cluster and is used 50 times.
// The representative MUST be the dominant shade for downstream
// primary-detection to work correctly. (This was a real bug pre-v12.9
// that produced washed-out brand colours in the brand book.)
const colors = [
{ hex: '#f0f0ff', parsed: { r: 240, g: 240, b: 255 }, count: 2 },
{ hex: '#5544ff', parsed: { r: 85, g: 68, b: 255 }, count: 50 },
];
const clusters = clusterColors(colors, 280); // big threshold → both cluster together
assert.equal(clusters.length, 1);
assert.equal(clusters[0].hex, '#5544ff', 'cluster hex must be the dominant member');
assert.deepEqual(clusters[0].representative, { r: 85, g: 68, b: 255 });
});
});
// ── clusterValues ───────────────────────────────────────────────
@@ -327,6 +343,27 @@ describe('detectScale', () => {
const result = detectScale([3, 7, 11, 19, 37, 53]);
assert.equal(result.base, null);
});
it('detects base-5 scales (Bootstrap-style)', () => {
// 5/10/15/20/25/40/60 — all divisible by 5; nothing in [2,4,6,8] fits
// well, so before this change the result was base:null.
const result = detectScale([5, 10, 15, 20, 25, 40, 60]);
assert.equal(result.base, 5);
});
it('detects base-6 scales', () => {
const result = detectScale([6, 12, 18, 24, 30, 42, 60]);
// base 2 or 6 both fit ≥60%; 6 should win the tie (or 2 with bonus).
assert.ok([2, 6].includes(result.base));
});
it('still prefers base-8 when both 4 and 8 fit (Tailwind-leaning bonus)', () => {
// 8/16/24/32/40 — 4 fits 100%, 8 fits 100%. Tie. Bonus keeps both
// valid but the chosen base should still be one of the production
// defaults, not a coincidental higher divisor.
const result = detectScale([8, 16, 24, 32, 40]);
assert.ok([4, 8].includes(result.base));
});
});
// ── nameFromUrl ─────────────────────────────────────────────────