designlang 8.0.0 → 10.0.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.
@@ -0,0 +1,172 @@
1
+ // Classify a crawled URL into a canonical page type. Heuristic-only by default
2
+ // (zero deps, deterministic). Returns { type, confidence, signals, alternates }.
3
+ // Consumers can fall back to the optional --smart LLM pass when confidence is low.
4
+
5
+ const TYPES = [
6
+ 'landing', 'pricing', 'docs', 'blog', 'blog-post',
7
+ 'product', 'about', 'dashboard', 'auth', 'legal', 'unknown',
8
+ ];
9
+
10
+ const URL_RULES = [
11
+ { re: /\/pricing(\/|$)/i, type: 'pricing', weight: 0.9 },
12
+ { re: /\/plans?(\/|$)/i, type: 'pricing', weight: 0.75 },
13
+ { re: /\/docs?(\/|$)|\/documentation|\/guide/i, type: 'docs', weight: 0.9 },
14
+ { re: /\/api-reference|\/reference(\/|$)/i, type: 'docs', weight: 0.85 },
15
+ { re: /\/blog(\/[\w-]+)+/i, type: 'blog-post', weight: 0.9 },
16
+ { re: /\/blog(\/|$)/i, type: 'blog', weight: 0.85 },
17
+ { re: /\/changelog(\/|$)/i, type: 'blog', weight: 0.6 },
18
+ { re: /\/about(\/|$)|\/company|\/team/i, type: 'about', weight: 0.85 },
19
+ { re: /\/product(\/|$)|\/products\//i, type: 'product', weight: 0.75 },
20
+ { re: /\/features?(\/|$)|\/solutions?(\/|$)/i, type: 'product', weight: 0.7 },
21
+ { re: /\/login|\/signin|\/sign-in|\/signup|\/sign-up|\/register/i, type: 'auth', weight: 0.95 },
22
+ { re: /\/terms|\/privacy|\/legal|\/cookie-policy/i, type: 'legal', weight: 0.95 },
23
+ { re: /\/app(\/|$)|\/dashboard|\/console|\/admin/i, type: 'dashboard', weight: 0.8 },
24
+ ];
25
+
26
+ const PRICING_TEXT = /(\$\s?\d|€\s?\d|£\s?\d|₹\s?\d)|\b(per\s?(month|user|seat)|\/mo\b|\/month|\/year|\/yr|billed (annually|monthly)|free (forever|plan|tier)|start (free|trial))\b/i;
27
+ const DOCS_TEXT = /\b(installation|getting started|api reference|parameters|return value|npm install|yarn add|pnpm add|`npx |import \{|quickstart)\b/i;
28
+ const BLOG_POST_TEXT = /\b(by\s+[A-Z][a-z]+\s+[A-Z][a-z]+|posted on|published (on|in)|min read|\d+\s+min read)\b/i;
29
+ const LEGAL_TEXT = /\b(privacy policy|terms of service|terms of use|cookie policy|data protection|gdpr|ccpa|effective date|last updated)\b/i;
30
+ const AUTH_TEXT = /\b(sign in|log in|create (an )?account|forgot password|email address|password)\b/i;
31
+
32
+ function rootPath(url) {
33
+ try {
34
+ const u = new URL(url);
35
+ return (u.pathname || '/').replace(/\/+$/, '') || '/';
36
+ } catch { return '/'; }
37
+ }
38
+
39
+ function countFormFields(sections) {
40
+ // sections text doesn't include input tags, so we approximate via headings + text.
41
+ return sections.reduce((n, s) => n + (s.buttonCount || 0), 0);
42
+ }
43
+
44
+ function detectDocsLayout(sections) {
45
+ // Docs pages often have a sidebar nav + long-form article.
46
+ const hasSidebar = sections.some(s =>
47
+ (s.tag === 'aside') || /sidebar|toc|nav-?docs|left-?nav/i.test(s.className || '')
48
+ );
49
+ const longArticle = sections.find(s => s.tag === 'main' || s.tag === 'section');
50
+ const articleLen = longArticle ? (longArticle.text || '').length : 0;
51
+ return { hasSidebar, articleLen };
52
+ }
53
+
54
+ function detectPricingLayout(sections) {
55
+ // 2-4 similarly-sized cards with currency signals = pricing table.
56
+ for (const s of sections) {
57
+ if (!PRICING_TEXT.test(s.text || '')) continue;
58
+ if ((s.cardCount || 0) >= 2 && (s.cardCount || 0) <= 6) return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ export function extractPageIntent(rawData = {}, opts = {}) {
64
+ const url = opts.url || rawData.url || '';
65
+ const path = rootPath(url);
66
+ const title = (opts.title || rawData.title || '').toLowerCase();
67
+ const sections = (rawData.light && rawData.light.sections) || rawData.sections || [];
68
+ const metas = ((rawData.light && rawData.light.stack && rawData.light.stack.metas) || []).map(m => ({
69
+ name: (m.name || '').toLowerCase(),
70
+ content: (m.content || ''),
71
+ }));
72
+ const description = (metas.find(m => m.name === 'description') || {}).content || '';
73
+ const ogType = (metas.find(m => m.name === 'og:type') || {}).content || '';
74
+
75
+ const scores = Object.fromEntries(TYPES.map(t => [t, 0]));
76
+ const signals = [];
77
+
78
+ // 1) URL rules (strongest signal).
79
+ for (const rule of URL_RULES) {
80
+ if (rule.re.test(path)) {
81
+ scores[rule.type] += rule.weight;
82
+ signals.push({ kind: 'url', type: rule.type, weight: rule.weight });
83
+ }
84
+ }
85
+
86
+ // 2) og:type.
87
+ if (ogType === 'article') {
88
+ scores['blog-post'] += 0.6;
89
+ signals.push({ kind: 'meta', type: 'blog-post', weight: 0.6, detail: 'og:type=article' });
90
+ }
91
+
92
+ // 3) Title keywords.
93
+ if (/\bpricing\b|\bplans?\b/.test(title)) { scores.pricing += 0.4; signals.push({ kind: 'title', type: 'pricing', weight: 0.4 }); }
94
+ if (/\bdocs?\b|documentation|guide/.test(title)) { scores.docs += 0.4; signals.push({ kind: 'title', type: 'docs', weight: 0.4 }); }
95
+ if (/\bblog\b/.test(title)) { scores.blog += 0.3; signals.push({ kind: 'title', type: 'blog', weight: 0.3 }); }
96
+ if (/\bprivacy|\bterms\b/.test(title)) { scores.legal += 0.5; signals.push({ kind: 'title', type: 'legal', weight: 0.5 }); }
97
+ if (/\bsign.?in|\blog.?in|\bsign.?up\b/.test(title)) { scores.auth += 0.5; signals.push({ kind: 'title', type: 'auth', weight: 0.5 }); }
98
+
99
+ // 4) DOM text signals.
100
+ const bigText = sections.map(s => s.text || '').join('\n').slice(0, 8000);
101
+ if (PRICING_TEXT.test(bigText) && detectPricingLayout(sections)) {
102
+ scores.pricing += 0.6;
103
+ signals.push({ kind: 'dom', type: 'pricing', weight: 0.6, detail: 'currency+card-grid' });
104
+ }
105
+ if (DOCS_TEXT.test(bigText)) {
106
+ const { hasSidebar, articleLen } = detectDocsLayout(sections);
107
+ const w = 0.3 + (hasSidebar ? 0.25 : 0) + (articleLen > 1500 ? 0.15 : 0);
108
+ scores.docs += w;
109
+ signals.push({ kind: 'dom', type: 'docs', weight: w, detail: `sidebar=${hasSidebar} article=${articleLen}` });
110
+ }
111
+ if (BLOG_POST_TEXT.test(bigText)) {
112
+ scores['blog-post'] += 0.35;
113
+ signals.push({ kind: 'dom', type: 'blog-post', weight: 0.35, detail: 'byline|min-read' });
114
+ }
115
+ if (LEGAL_TEXT.test(bigText)) {
116
+ scores.legal += 0.4;
117
+ signals.push({ kind: 'dom', type: 'legal', weight: 0.4 });
118
+ }
119
+ if (AUTH_TEXT.test(bigText) && countFormFields(sections) < 8 && bigText.length < 3000) {
120
+ scores.auth += 0.35;
121
+ signals.push({ kind: 'dom', type: 'auth', weight: 0.35, detail: 'auth-form-shape' });
122
+ }
123
+
124
+ // 5) Path="/" fallback → landing (weak prior).
125
+ if (path === '/' || path === '') {
126
+ scores.landing += 0.45;
127
+ signals.push({ kind: 'url', type: 'landing', weight: 0.45, detail: 'root-path' });
128
+ }
129
+
130
+ // 6) Generic "has hero + features + cta" shape → landing.
131
+ const roles = new Set();
132
+ for (const s of sections) {
133
+ const blob = `${s.className || ''} ${s.id || ''}`.toLowerCase();
134
+ if (/hero/.test(blob)) roles.add('hero');
135
+ if ((s.cardCount || 0) >= 3) roles.add('features');
136
+ if (/cta|get-?started/.test(blob)) roles.add('cta');
137
+ if (s.tag === 'footer') roles.add('footer');
138
+ }
139
+ if (roles.has('hero') && (roles.has('features') || roles.has('cta'))) {
140
+ scores.landing += 0.3;
141
+ signals.push({ kind: 'shape', type: 'landing', weight: 0.3, detail: [...roles].join('+') });
142
+ }
143
+
144
+ // Pick winner.
145
+ const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
146
+ const [winType, winScore] = ranked[0];
147
+ const [, secondScore] = ranked[1] || ['unknown', 0];
148
+ const margin = winScore - secondScore;
149
+ let confidence = 0;
150
+ if (winScore > 0) {
151
+ confidence = Math.min(1, winScore * 0.6 + margin * 0.4);
152
+ }
153
+ const type = winScore === 0 ? 'unknown' : winType;
154
+
155
+ const alternates = ranked
156
+ .filter(([, s]) => s > 0 && s !== winScore)
157
+ .slice(0, 3)
158
+ .map(([t, s]) => ({ type: t, score: Number(s.toFixed(3)) }));
159
+
160
+ return {
161
+ type,
162
+ confidence: Number(confidence.toFixed(3)),
163
+ path,
164
+ title: opts.title || rawData.title || '',
165
+ description: description.slice(0, 200),
166
+ signals: signals.slice(0, 20),
167
+ alternates,
168
+ needsSmart: confidence < 0.6,
169
+ };
170
+ }
171
+
172
+ export const PAGE_TYPES = TYPES;
@@ -0,0 +1,135 @@
1
+ // Attach richer semantic roles to the sections that semantic-regions.js already
2
+ // found. v9 labels were coarse (hero/features/pricing/testimonials/cta/footer/nav);
3
+ // v10 adds logo-wall, stats, faq, comparison, steps, gallery, bento, blog-grid.
4
+ // Input: enriched sections (from crawler) + existing `regions` array. Output: a
5
+ // parallel array with { role, subrole, confidence, slots }.
6
+
7
+ const CTA_RE = /\b(get started|sign ?up|try (free|it|now)|start (free|trial|now)|book a demo|request demo|contact sales|talk to sales|learn more|watch demo)\b/i;
8
+ const STATS_RE = /\b(\d[\d,.]*[+%]|\d+x\b|\$\d+[MBK]\b)/i;
9
+ const FAQ_RE = /\b(frequently asked|faq|common questions|questions & answers)\b/i;
10
+ const STEPS_RE = /\b(step\s?\d|how it works|\d\s*\.\s+[A-Z])/i;
11
+ const COMPARE_RE = /\b(vs\.?|compared to|free vs|basic vs|enterprise vs)\b/i;
12
+ const TESTIMONIAL_RE = /(".{20,}"|".{20,}"|—\s?[A-Z][a-z]+\s+[A-Z][a-z]+|ceo|founder|head of)/i;
13
+ const PRICING_RE = /(\$\s?\d|€\s?\d|£\s?\d|per\s?(month|user|seat)|\/mo\b|\/month|billed)/i;
14
+
15
+ function blob(s) {
16
+ return `${s.className || ''} ${s.id || ''}`.toLowerCase();
17
+ }
18
+
19
+ function detectLogoWall(s) {
20
+ // Logo walls: many small images in one row, minimal text.
21
+ // The enriched sections don't carry image counts directly — we proxy via
22
+ // cardCount with very short text.
23
+ if ((s.cardCount || 0) >= 5 && (s.text || '').length < 300 &&
24
+ /(trusted by|used by|customers|as seen in|logos?|partners|clients)/i.test(s.text || '' + ' ' + blob(s))) {
25
+ return true;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ function detectBento(s) {
31
+ const b = blob(s);
32
+ if (/bento/.test(b)) return true;
33
+ // Bento hint: 4-8 cards with irregular sizes — we can't measure layout grid
34
+ // here, so fall back to class name + cardCount heuristic.
35
+ return false;
36
+ }
37
+
38
+ function classifyRole(s, existingRole, pageType) {
39
+ const text = (s.text || '').slice(0, 2000);
40
+ const b = blob(s);
41
+ const headings = s.headings || [];
42
+ const h1 = (headings[0] || '').toLowerCase();
43
+
44
+ // Landmarks come first.
45
+ if (s.tag === 'footer') return { role: 'footer', confidence: 0.95 };
46
+ if (s.tag === 'nav' || /^nav|header-nav|top-?bar/.test(b)) return { role: 'nav', confidence: 0.9 };
47
+
48
+ // Strong class-based hints.
49
+ if (/logo-?(wall|cloud|grid|strip)|trusted-?by/.test(b) || detectLogoWall(s)) {
50
+ return { role: 'logo-wall', confidence: 0.85 };
51
+ }
52
+ if (/bento/.test(b) || detectBento(s)) return { role: 'bento', subrole: 'features', confidence: 0.75 };
53
+ if (/gallery|carousel|slider/.test(b)) return { role: 'gallery', confidence: 0.7 };
54
+ if (/stat(s|istic)|metric|number/.test(b) && STATS_RE.test(text)) return { role: 'stats', confidence: 0.85 };
55
+ if (FAQ_RE.test(text) || /\bfaq\b/.test(b)) return { role: 'faq', confidence: 0.85 };
56
+ if (STEPS_RE.test(text) && (s.cardCount || 0) >= 3) return { role: 'steps', confidence: 0.75 };
57
+ if (COMPARE_RE.test(text) && (s.cardCount || 0) >= 2) return { role: 'comparison', confidence: 0.7 };
58
+ if (PRICING_RE.test(text) && (s.cardCount || 0) >= 2) return { role: 'pricing-table', confidence: 0.9 };
59
+
60
+ // Testimonials vs hero vs features — existing classifier handled most of these;
61
+ // re-run with tighter signals.
62
+ if (TESTIMONIAL_RE.test(text) || /testimonial|review|quote/.test(b)) {
63
+ return { role: 'testimonial', confidence: 0.8 };
64
+ }
65
+ if (/hero/.test(b) || (headings.length === 1 && (s.buttonCount || 0) >= 1 && s.bounds && s.bounds.h > 300 && s.bounds.y < 400)) {
66
+ return { role: 'hero', confidence: 0.85 };
67
+ }
68
+ if ((s.cardCount || 0) >= 3 && (/(feature|benefit|what you get|why )/i.test(text) || /feature|grid/.test(b))) {
69
+ return { role: 'feature-grid', confidence: 0.8 };
70
+ }
71
+ if (CTA_RE.test(text) && (s.buttonCount || 0) >= 1 && text.length < 600) {
72
+ return { role: 'cta', confidence: 0.75 };
73
+ }
74
+ if (pageType === 'blog' && (s.cardCount || 0) >= 3) {
75
+ return { role: 'blog-grid', confidence: 0.75 };
76
+ }
77
+
78
+ // Fall back to the coarse existing label if we have one.
79
+ if (existingRole && existingRole !== 'content') {
80
+ return { role: existingRole, confidence: 0.4 };
81
+ }
82
+ return { role: 'content', confidence: 0.3 };
83
+ }
84
+
85
+ function extractSlots(s) {
86
+ // Lightweight slot detection from headings + button count. Real DOM-walking
87
+ // slot extraction happens in component-anatomy.js — here we just record
88
+ // rendered copy so prompts can quote the actual words.
89
+ const slots = {};
90
+ if ((s.headings || []).length) slots.heading = s.headings[0];
91
+ if ((s.headings || []).length > 1) slots.subheadings = s.headings.slice(1);
92
+ if (s.buttonCount > 0) slots.ctaCount = s.buttonCount;
93
+ const text = s.text || '';
94
+ const firstPara = text.split(/\n{2,}/)[0];
95
+ if (firstPara && firstPara.length < 400 && firstPara !== slots.heading) {
96
+ slots.lede = firstPara.trim();
97
+ }
98
+ return slots;
99
+ }
100
+
101
+ export function extractSectionRoles(sections = [], regions = [], pageIntent = null) {
102
+ const pageType = pageIntent && pageIntent.type;
103
+ const labeled = sections.map((s, i) => {
104
+ const existing = regions[i] ? regions[i].role : null;
105
+ const classified = classifyRole(s, existing, pageType);
106
+ return {
107
+ index: i,
108
+ tag: s.tag,
109
+ role: classified.role,
110
+ subrole: classified.subrole || null,
111
+ confidence: Number((classified.confidence || 0).toFixed(3)),
112
+ heading: (s.headings && s.headings[0]) || null,
113
+ bounds: s.bounds,
114
+ buttonCount: s.buttonCount || 0,
115
+ cardCount: s.cardCount || 0,
116
+ slots: extractSlots(s),
117
+ needsSmart: (classified.confidence || 0) < 0.5,
118
+ };
119
+ });
120
+
121
+ // Reading-order + emphasis summary.
122
+ const byRole = {};
123
+ for (const r of labeled) {
124
+ byRole[r.role] = (byRole[r.role] || 0) + 1;
125
+ }
126
+
127
+ return {
128
+ sections: labeled,
129
+ counts: byRole,
130
+ readingOrder: labeled
131
+ .filter(r => r.bounds)
132
+ .sort((a, b) => (a.bounds?.y || 0) - (b.bounds?.y || 0))
133
+ .map(r => r.role),
134
+ };
135
+ }
@@ -0,0 +1,96 @@
1
+ // Brand voice extraction — microcopy patterns from CTA verbs, headings, and section copy.
2
+ // Feeds LLMs the *tone*, not just the paint.
3
+
4
+ const IMPERATIVE_VERBS = new Set([
5
+ 'get', 'start', 'try', 'build', 'create', 'ship', 'deploy', 'launch', 'learn', 'read',
6
+ 'buy', 'shop', 'explore', 'discover', 'join', 'sign', 'subscribe', 'book', 'download',
7
+ 'install', 'contact', 'talk', 'request', 'view', 'see', 'watch', 'read', 'meet',
8
+ ]);
9
+
10
+ const FRIENDLY_MARKERS = /\b(we|our|us|you|your|let's|hey|welcome|thanks|love|yay|awesome)\b/i;
11
+ const FORMAL_MARKERS = /\b(enterprise|platform|solution|infrastructure|comprehensive|leverage|empower|facilitate|utilize)\b/i;
12
+ const TECHNICAL_MARKERS = /\b(api|sdk|webhook|latency|throughput|cli|runtime|schema|endpoint|token|typescript|kubernetes)\b/i;
13
+ const PLAYFUL_MARKERS = /[!?]{1,2}$|\b(magic|wow|boom|zap|rocket|sparkle|fire)\b|[✨🚀🔥⚡💫]/i;
14
+
15
+ function words(str) {
16
+ return (str || '').toLowerCase().match(/[a-z']+/g) || [];
17
+ }
18
+
19
+ function firstVerb(text) {
20
+ const w = words(text);
21
+ for (const word of w) {
22
+ if (IMPERATIVE_VERBS.has(word)) return word;
23
+ }
24
+ return w[0] || '';
25
+ }
26
+
27
+ function scoreTone(texts) {
28
+ const joined = texts.join(' ');
29
+ return {
30
+ friendly: (joined.match(FRIENDLY_MARKERS) || []).length,
31
+ formal: (joined.match(FORMAL_MARKERS) || []).length,
32
+ technical: (joined.match(TECHNICAL_MARKERS) || []).length,
33
+ playful: (joined.match(PLAYFUL_MARKERS) || []).length,
34
+ };
35
+ }
36
+
37
+ function pickTone(scores) {
38
+ const entries = Object.entries(scores);
39
+ const total = entries.reduce((s, [, n]) => s + n, 0);
40
+ if (total === 0) return 'neutral';
41
+ const [top] = entries.sort((a, b) => b[1] - a[1]);
42
+ return top[1] / total > 0.4 ? top[0] : 'neutral';
43
+ }
44
+
45
+ function topN(arr, n = 10) {
46
+ const counts = {};
47
+ for (const v of arr) if (v) counts[v] = (counts[v] || 0) + 1;
48
+ return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, n).map(([value, count]) => ({ value, count }));
49
+ }
50
+
51
+ function avgLen(arr) {
52
+ if (!arr.length) return 0;
53
+ return Math.round(arr.reduce((s, v) => s + v.length, 0) / arr.length);
54
+ }
55
+
56
+ export function extractVoice({ componentCandidates = [], sections = [] } = {}) {
57
+ const buttons = componentCandidates.filter(c => c.kind === 'button' || c.kind === 'link').map(c => c.text).filter(Boolean);
58
+ const headings = sections.flatMap(s => s.headings || []).filter(Boolean);
59
+ const sectionTexts = sections.map(s => s.text || '').filter(Boolean);
60
+
61
+ const ctaVerbs = buttons.map(firstVerb).filter(Boolean);
62
+ const buttonPatterns = topN(buttons.map(b => b.toLowerCase().trim()), 15);
63
+ const ctaTopVerbs = topN(ctaVerbs, 10);
64
+
65
+ const tone = pickTone(scoreTone([...buttons, ...headings, ...sectionTexts]));
66
+ const personPronoun = /\byou\b/i.test(headings.join(' ') + sectionTexts.join(' '))
67
+ ? (/\bwe\b/i.test(headings.join(' ')) ? 'we→you' : 'you-only')
68
+ : (/\bwe\b/i.test(headings.join(' ')) ? 'we-only' : 'third-person');
69
+
70
+ const headingStyle = (() => {
71
+ if (!headings.length) return 'unknown';
72
+ const titleCase = headings.filter(h => /^[A-Z]/.test(h) && /\s[A-Z]/.test(h)).length;
73
+ const sentenceCase = headings.filter(h => /^[A-Z]/.test(h) && !/\s[A-Z]/.test(h)).length;
74
+ const allLower = headings.filter(h => h === h.toLowerCase()).length;
75
+ if (allLower > headings.length / 2) return 'all-lowercase';
76
+ if (titleCase > sentenceCase) return 'Title Case';
77
+ return 'Sentence case';
78
+ })();
79
+
80
+ const avgHeadingWords = avgLen(headings.map(h => words(h))) / Math.max(avgLen(headings) || 1, 1) * (avgLen(headings) || 1);
81
+ const headingLengthClass = avgHeadingWords <= 4 ? 'tight' : avgHeadingWords <= 8 ? 'balanced' : 'verbose';
82
+
83
+ return {
84
+ tone,
85
+ pronoun: personPronoun,
86
+ headingStyle,
87
+ headingLengthClass,
88
+ ctaVerbs: ctaTopVerbs,
89
+ buttonPatterns,
90
+ sampleHeadings: headings.slice(0, 10),
91
+ stats: {
92
+ buttons: buttons.length,
93
+ headings: headings.length,
94
+ },
95
+ };
96
+ }
@@ -623,6 +623,185 @@ export function formatMarkdown(design) {
623
623
  }
624
624
  }
625
625
 
626
+ // ── Motion Language (v9) ──
627
+ if (design.motion && (design.motion.durations?.length || design.motion.keyframes?.length)) {
628
+ lines.push('## Motion Language');
629
+ lines.push('');
630
+ lines.push(`**Feel:** ${design.motion.feel} · **Scroll-linked:** ${design.motion.scrollLinked?.present ? 'yes' : 'no'}`);
631
+ lines.push('');
632
+ if (design.motion.durations?.length) {
633
+ lines.push('### Duration Tokens');
634
+ lines.push('');
635
+ lines.push('| name | value | ms |');
636
+ lines.push('|---|---|---|');
637
+ for (const d of design.motion.durations) lines.push(`| \`${d.name}\` | \`${d.css}\` | ${d.ms} |`);
638
+ lines.push('');
639
+ }
640
+ if (design.motion.easings?.length) {
641
+ lines.push('### Easing Families');
642
+ lines.push('');
643
+ const byFamily = {};
644
+ for (const e of design.motion.easings) (byFamily[e.family] ||= []).push(e);
645
+ for (const [family, list] of Object.entries(byFamily)) {
646
+ lines.push(`- **${family}** (${list.reduce((s, e) => s + (e.count || 0), 0)} uses) — \`${list.map(e => e.raw).slice(0, 3).join('`, `')}\``);
647
+ }
648
+ lines.push('');
649
+ }
650
+ if (design.motion.springs?.length) {
651
+ lines.push('### Spring / Overshoot Easings');
652
+ lines.push('');
653
+ for (const s of design.motion.springs) lines.push(`- \`${s.raw}\``);
654
+ lines.push('');
655
+ }
656
+ const usedKf = (design.motion.keyframes || []).filter(k => k.used);
657
+ if (usedKf.length) {
658
+ lines.push('### Keyframes In Use');
659
+ lines.push('');
660
+ lines.push('| name | kind | properties | uses |');
661
+ lines.push('|---|---|---|---|');
662
+ for (const k of usedKf.slice(0, 20)) lines.push(`| \`${k.name}\` | ${k.kind} | ${k.propertiesAnimated.slice(0, 4).join(', ')} | ${k.usageCount} |`);
663
+ lines.push('');
664
+ }
665
+ }
666
+
667
+ // ── Component Anatomy (v9) ──
668
+ if ((design.componentAnatomy || []).length) {
669
+ lines.push('## Component Anatomy');
670
+ lines.push('');
671
+ for (const a of design.componentAnatomy.slice(0, 6)) {
672
+ lines.push(`### ${a.kind} — ${a.totalInstances} instance${a.totalInstances === 1 ? '' : 's'}`);
673
+ lines.push('');
674
+ const slots = Object.entries(a.slots).filter(([, v]) => v).map(([k]) => k);
675
+ if (slots.length) lines.push(`**Slots:** ${slots.join(', ')}`);
676
+ if (a.props.variant.length) lines.push(`**Variants:** ${a.props.variant.join(' · ')}`);
677
+ if (a.props.size.length) lines.push(`**Sizes:** ${a.props.size.join(' · ')}`);
678
+ lines.push('');
679
+ if (a.variants.length > 1) {
680
+ lines.push('| variant | count | sample label |');
681
+ lines.push('|---|---|---|');
682
+ for (const v of a.variants.slice(0, 8)) lines.push(`| ${v.name} | ${v.count} | ${(v.sampleText[0] || '').slice(0, 40)} |`);
683
+ lines.push('');
684
+ }
685
+ }
686
+ }
687
+
688
+ // ── Brand Voice (v9) ──
689
+ if (design.voice && (design.voice.ctaVerbs?.length || design.voice.sampleHeadings?.length)) {
690
+ lines.push('## Brand Voice');
691
+ lines.push('');
692
+ lines.push(`**Tone:** ${design.voice.tone} · **Pronoun:** ${design.voice.pronoun} · **Headings:** ${design.voice.headingStyle} (${design.voice.headingLengthClass})`);
693
+ lines.push('');
694
+ if (design.voice.ctaVerbs?.length) {
695
+ lines.push('### Top CTA Verbs');
696
+ lines.push('');
697
+ for (const v of design.voice.ctaVerbs.slice(0, 8)) lines.push(`- **${v.value}** (${v.count})`);
698
+ lines.push('');
699
+ }
700
+ if (design.voice.buttonPatterns?.length) {
701
+ lines.push('### Button Copy Patterns');
702
+ lines.push('');
703
+ for (const p of design.voice.buttonPatterns.slice(0, 10)) lines.push(`- "${p.value}" (${p.count}×)`);
704
+ lines.push('');
705
+ }
706
+ if (design.voice.sampleHeadings?.length) {
707
+ lines.push('### Sample Headings');
708
+ lines.push('');
709
+ for (const h of design.voice.sampleHeadings) lines.push(`> ${h}`);
710
+ lines.push('');
711
+ }
712
+ }
713
+
714
+ // ── v10: Page Intent ──
715
+ if (design.pageIntent && design.pageIntent.type) {
716
+ lines.push('## Page Intent');
717
+ lines.push('');
718
+ lines.push(`**Type:** \`${design.pageIntent.type}\` (confidence ${design.pageIntent.confidence})`);
719
+ if (design.pageIntent.description) lines.push(`**Description:** ${design.pageIntent.description}`);
720
+ if (design.pageIntent.alternates?.length) {
721
+ lines.push('');
722
+ lines.push('Alternates: ' + design.pageIntent.alternates.map(a => `${a.type} (${a.score})`).join(', '));
723
+ }
724
+ lines.push('');
725
+ }
726
+
727
+ // ── v10: Section Roles ──
728
+ if (design.sectionRoles && design.sectionRoles.sections?.length) {
729
+ lines.push('## Section Roles');
730
+ lines.push('');
731
+ lines.push('Reading order (top→bottom): ' + (design.sectionRoles.readingOrder || []).join(' → '));
732
+ lines.push('');
733
+ lines.push('| # | Role | Heading | Confidence |');
734
+ lines.push('|---|------|---------|------------|');
735
+ for (const s of design.sectionRoles.sections.slice(0, 20)) {
736
+ const h = (s.heading || '').replace(/\|/g, '\\|').slice(0, 80);
737
+ lines.push(`| ${s.index} | ${s.role}${s.subrole ? ` · ${s.subrole}` : ''} | ${h || '—'} | ${s.confidence} |`);
738
+ }
739
+ lines.push('');
740
+ }
741
+
742
+ // ── v10: Material Language ──
743
+ if (design.materialLanguage && design.materialLanguage.label) {
744
+ lines.push('## Material Language');
745
+ lines.push('');
746
+ lines.push(`**Label:** \`${design.materialLanguage.label}\` (confidence ${design.materialLanguage.confidence})`);
747
+ const m = design.materialLanguage.metrics || {};
748
+ lines.push('');
749
+ lines.push('| Metric | Value |');
750
+ lines.push('|--------|-------|');
751
+ if (m.saturation != null) lines.push(`| Avg saturation | ${m.saturation} |`);
752
+ if (m.shadowProfile) lines.push(`| Shadow profile | ${m.shadowProfile} |`);
753
+ if (m.avgShadowBlur != null) lines.push(`| Avg shadow blur | ${m.avgShadowBlur}px |`);
754
+ if (m.maxRadius != null) lines.push(`| Max radius | ${m.maxRadius}px |`);
755
+ if (m.hasBackdropBlur != null) lines.push(`| backdrop-filter in use | ${m.hasBackdropBlur ? 'yes' : 'no'} |`);
756
+ if (m.gradientCount != null) lines.push(`| Gradients | ${m.gradientCount} |`);
757
+ lines.push('');
758
+ }
759
+
760
+ // ── v10: Imagery Style ──
761
+ if (design.imageryStyle && design.imageryStyle.label && design.imageryStyle.label !== 'none') {
762
+ lines.push('## Imagery Style');
763
+ lines.push('');
764
+ lines.push(`**Label:** \`${design.imageryStyle.label}\` (confidence ${design.imageryStyle.confidence})`);
765
+ const c = design.imageryStyle.counts || {};
766
+ lines.push(`**Counts:** total ${c.total || 0}, svg ${c.svg || 0}, icon ${c.icon || 0}, screenshot-like ${c.screenshot || 0}, photo-like ${c.photoLike || 0}`);
767
+ if (design.imageryStyle.dominantAspect) lines.push(`**Dominant aspect:** ${design.imageryStyle.dominantAspect}`);
768
+ if (design.imageryStyle.radiusProfile) lines.push(`**Radius profile on images:** ${design.imageryStyle.radiusProfile}`);
769
+ lines.push('');
770
+ }
771
+
772
+ // ── v10: Component Library ──
773
+ if (design.componentLibrary && design.componentLibrary.library && design.componentLibrary.library !== 'unknown') {
774
+ lines.push('## Component Library');
775
+ lines.push('');
776
+ lines.push(`**Detected:** \`${design.componentLibrary.library}\` (confidence ${design.componentLibrary.confidence})`);
777
+ if ((design.componentLibrary.evidence || []).length) {
778
+ lines.push('');
779
+ lines.push('Evidence:');
780
+ for (const e of design.componentLibrary.evidence) lines.push(`- ${e}`);
781
+ }
782
+ if ((design.componentLibrary.alternates || []).length) {
783
+ lines.push('');
784
+ lines.push('Also considered: ' + design.componentLibrary.alternates.map(a => `${a.id} (${a.score})`).join(', '));
785
+ }
786
+ lines.push('');
787
+ }
788
+
789
+ // ── v10: Multi-Page Map ──
790
+ if (design.multiPage && Array.isArray(design.multiPage.pages) && design.multiPage.pages.length) {
791
+ lines.push('## Multi-Page Map');
792
+ lines.push('');
793
+ lines.push('| Page Type | URL | Status |');
794
+ lines.push('|-----------|-----|--------|');
795
+ for (const p of design.multiPage.pages) {
796
+ lines.push(`| ${p.type || '—'} | ${p.url} | ${p.error ? 'error' : 'ok'} |`);
797
+ }
798
+ lines.push('');
799
+ if (design.multiPage.consistency?.shared?.colors?.length) {
800
+ lines.push(`**Shared colors across pages:** ${design.multiPage.consistency.shared.colors.slice(0, 10).map(c => `\`${c}\``).join(', ')}`);
801
+ lines.push('');
802
+ }
803
+ }
804
+
626
805
  // ── Quick Start ──
627
806
  lines.push('## Quick Start');
628
807
  lines.push('');
@@ -0,0 +1,22 @@
1
+ // Emits a DTCG-flavored motion tokens JSON.
2
+ export function formatMotionTokens(motion) {
3
+ if (!motion) return '{}';
4
+ const out = {
5
+ $description: 'Motion tokens extracted by designlang',
6
+ duration: {},
7
+ easing: {},
8
+ spring: {},
9
+ };
10
+ for (const d of motion.durations || []) {
11
+ out.duration[d.name] = { $value: d.css, $type: 'duration', ms: d.ms };
12
+ }
13
+ for (const e of motion.easings || []) {
14
+ const slug = e.family + (e.raw.includes('cubic-bezier') ? `-${Math.abs(e.raw.split('').reduce((a, c) => a + c.charCodeAt(0), 0)) % 1000}` : '');
15
+ out.easing[slug] = { $value: e.raw, $type: 'cubicBezier', family: e.family };
16
+ }
17
+ (motion.springs || []).forEach((s, i) => {
18
+ out.spring[`spring-${i + 1}`] = { $value: s.raw, $type: 'cubicBezier', overshoot: true };
19
+ });
20
+ out.$meta = { feel: motion.feel, scrollLinked: !!motion.scrollLinked?.present };
21
+ return JSON.stringify(out, null, 2);
22
+ }