designlang 12.8.0 → 12.9.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +55 -0
- package/package.json +1 -1
- package/src/extractors/colors.js +22 -1
- package/src/extractors/typography.js +38 -3
- package/src/utils.js +36 -2
|
@@ -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.
|
|
12
|
+
"version": "12.9.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.
|
|
4
|
+
"version": "12.9.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Manavarya Singh",
|
|
7
7
|
"url": "https://github.com/Manavarya09"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [12.9.0] — 2026-05-11
|
|
4
|
+
|
|
5
|
+
**Extraction quality pass — the core MVP, fixed.**
|
|
6
|
+
|
|
7
|
+
Eight features rode on top of the same extractor. This release fixes
|
|
8
|
+
four real defects in that extractor — visible across grade, battle,
|
|
9
|
+
remix, pack, theme-swap, brand, and pair without anyone having to
|
|
10
|
+
re-run the downstream code.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Cluster representative bug** in \`clusterColors()\`. Before: the first-
|
|
15
|
+
encountered colour seeded each cluster, so a sparsely-used pale shade
|
|
16
|
+
could become the canonical hex for a cluster that mostly held a vivid
|
|
17
|
+
brand colour. The brand-book primary slot was reading lavender for
|
|
18
|
+
Stripe instead of \`#533afd\`. Fixed: representative is now the
|
|
19
|
+
most-counted member of the cluster.
|
|
20
|
+
- **Spacing base detection** missed common production scales. Before:
|
|
21
|
+
only \`[2, 4, 6, 8]\` were tried as base candidates, so Bootstrap-style
|
|
22
|
+
base-5 sites and base-7/10/12 sites returned \`base: null\`. Fixed:
|
|
23
|
+
expanded to \`[2, 4, 5, 6, 7, 8, 10, 12, 16]\` with a small bonus for
|
|
24
|
+
4 and 8 to keep results stable for the production-default sites.
|
|
25
|
+
- **Typography noise**. Before: generic CSS stacks (\`sans-serif\`,
|
|
26
|
+
\`monospace\`, \`system-ui\`, \`inherit\`), OS UI fonts (\`-apple-system\`),
|
|
27
|
+
and icon fonts (Material Icons, Font Awesome, Lucide, Tabler, etc.)
|
|
28
|
+
polluted the \`families\` list, making the brand book mistakenly
|
|
29
|
+
document an icon font as the brand's body family. Fixed: explicit
|
|
30
|
+
generic + icon-family filter at the source.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **\`primary.confidence\`** (0–1) on \`design.colors.primary\`. Computed
|
|
35
|
+
from the score gap between rank 1 and rank 2 brand candidates — a
|
|
36
|
+
runaway leader scores 1.0; a near-tie scores 0.3. Downstream
|
|
37
|
+
consumers (brand book, grade, theme-swap) can surface uncertainty
|
|
38
|
+
warnings on low-confidence extractions.
|
|
39
|
+
- **\`asList(v)\`** helper exported from \`src/utils.js\`. Coerces
|
|
40
|
+
anything-shaped input (array / object / comma-string / scalar) into
|
|
41
|
+
a clean string array. Consolidates the per-formatter ad-hoc
|
|
42
|
+
defenses (brand-book, pair, pack all had their own copies).
|
|
43
|
+
|
|
44
|
+
### Why
|
|
45
|
+
|
|
46
|
+
Verified live on \`stripe.com\`:
|
|
47
|
+
|
|
48
|
+
- Pre-v12.9: primary slot showed a lavender shade, multiple icon-font
|
|
49
|
+
entries in families, spacing base often \`null\`.
|
|
50
|
+
- v12.9: primary \`#533afd\` (count 899, confidence 0.59), \`families:
|
|
51
|
+
['sohne-var']\`, spacing base detected correctly.
|
|
52
|
+
|
|
53
|
+
No new dependencies, no schema breaks, no public-API changes. 396/396
|
|
54
|
+
tests pass (6 new — base-5 + base-6 detectScale, cluster representative
|
|
55
|
+
correctness, generic-family filter, icon-family filter, asList shape
|
|
56
|
+
coercion).
|
|
57
|
+
|
|
3
58
|
## [12.8.0] — 2026-05-10
|
|
4
59
|
|
|
5
60
|
**Pair — fuse two extracted designs into a single hybrid identity.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.9.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": {
|
package/src/extractors/colors.js
CHANGED
|
@@ -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
|
|
11
|
-
if (family
|
|
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);
|
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
|
-
|
|
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
|
|
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
|
+
}
|