designlang 12.1.0 → 12.3.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,379 @@
1
+ // designlang remix — render an extracted page-shape in a different design vocabulary.
2
+ //
3
+ // Inputs: the design object from extractDesignLanguage() (we read meta, voice,
4
+ // pageIntent, sectionRoles) + a vocabulary from src/vocabularies/.
5
+ // Output: a self-contained single HTML file. Same content, different language.
6
+
7
+ function esc(s) {
8
+ return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
9
+ }
10
+
11
+ function host(url) {
12
+ try { return new URL(url).hostname; } catch { return String(url || ''); }
13
+ }
14
+
15
+ function pickHeadings(design, count = 6) {
16
+ const fromVoice = (design.voice?.sampleHeadings || []).filter(Boolean);
17
+ const fromSections = (design.sectionRoles?.sections || [])
18
+ .map(s => s.heading || s.slots?.heading)
19
+ .filter(Boolean);
20
+ const merged = [...new Set([...fromVoice, ...fromSections])];
21
+ return merged.slice(0, count);
22
+ }
23
+
24
+ function pickCtas(design, count = 4) {
25
+ const verbs = (design.voice?.ctaVerbs || []).filter(Boolean);
26
+ const phrases = verbs.map(v => {
27
+ if (typeof v === 'string') return v;
28
+ return v.verb || v.text || v.phrase || '';
29
+ }).filter(Boolean);
30
+ if (phrases.length >= count) return phrases.slice(0, count);
31
+ // Pad with generic verbs informed by tone.
32
+ const tone = design.voice?.tone || 'neutral';
33
+ const fallback = tone === 'playful' ? ['Try it', 'Get started', 'See it', 'Play']
34
+ : tone === 'technical' ? ['Read docs', 'Get started', 'View API', 'Install']
35
+ : tone === 'formal' ? ['Begin', 'Continue', 'Learn more', 'Contact']
36
+ : ['Get started', 'Learn more', 'Sign up', 'See more'];
37
+ return [...phrases, ...fallback].slice(0, count);
38
+ }
39
+
40
+ // Map each section role to a vocabulary-styled markup block.
41
+ function renderSection(section, ctx) {
42
+ const { vocab, headings, ctas, design } = ctx;
43
+ const sectionHeading = section.heading || section.slots?.heading || headings.shift() || vocab.name;
44
+ const lede = section.slots?.lede || '';
45
+ const role = section.role;
46
+ const buttonCount = Math.max(1, section.buttonCount || 1);
47
+
48
+ switch (role) {
49
+ case 'nav':
50
+ case 'footer':
51
+ return ''; // rendered globally outside sections
52
+
53
+ case 'hero': {
54
+ const ctaSet = ctas.slice(0, Math.max(1, Math.min(2, buttonCount)));
55
+ return `
56
+ <section class="v-hero">
57
+ <p class="v-pill">${esc((design.pageIntent?.type || 'landing').toUpperCase())}</p>
58
+ <h1 class="v-display v-h1">${esc(sectionHeading)}</h1>
59
+ ${lede ? `<p class="v-lede">${esc(lede)}</p>` : ''}
60
+ <div class="v-cta-row">
61
+ ${ctaSet.map((c, i) => `<a href="#" class="v-cta${i > 0 ? ' v-cta-ghost' : ''}">${esc(c)}</a>`).join('')}
62
+ </div>
63
+ </section>`;
64
+ }
65
+
66
+ case 'feature-grid':
67
+ case 'bento': {
68
+ const items = headings.splice(0, 3);
69
+ while (items.length < 3) items.push('Feature');
70
+ return `
71
+ <section class="v-section">
72
+ <h2 class="v-display v-h2">${esc(sectionHeading)}</h2>
73
+ ${lede ? `<p class="v-lede">${esc(lede)}</p>` : ''}
74
+ <div class="v-grid v-grid-3">
75
+ ${items.map(t => `
76
+ <div class="v-card">
77
+ <div class="v-card-num">·</div>
78
+ <h3 class="v-display v-h3">${esc(t)}</h3>
79
+ <p class="v-body">${esc(squeeze(lede || sectionHeading, 90))}</p>
80
+ </div>`).join('')}
81
+ </div>
82
+ </section>`;
83
+ }
84
+
85
+ case 'stats': {
86
+ const numbers = ['10×', '99.9%', '< 50ms', '500K+'];
87
+ return `
88
+ <section class="v-section v-section-rule">
89
+ <h2 class="v-display v-h2">${esc(sectionHeading)}</h2>
90
+ <div class="v-grid v-grid-4">
91
+ ${numbers.map((n, i) => `
92
+ <div class="v-stat">
93
+ <div class="v-display v-stat-num">${esc(n)}</div>
94
+ <div class="v-pill">${esc((headings[i] || ['speed','uptime','latency','users'][i]).toUpperCase())}</div>
95
+ </div>`).join('')}
96
+ </div>
97
+ </section>`;
98
+ }
99
+
100
+ case 'testimonial': {
101
+ return `
102
+ <section class="v-section v-section-quiet">
103
+ <blockquote class="v-quote">
104
+ <p class="v-display v-quote-text">"${esc(lede || sectionHeading)}"</p>
105
+ <footer class="v-quote-attrib">— ${esc(headings.shift() || 'A satisfied user')}</footer>
106
+ </blockquote>
107
+ </section>`;
108
+ }
109
+
110
+ case 'pricing-table': {
111
+ const tiers = headings.splice(0, 3);
112
+ while (tiers.length < 3) tiers.push('Plan');
113
+ const prices = ['$0', '$29', '$99'];
114
+ return `
115
+ <section class="v-section">
116
+ <h2 class="v-display v-h2">${esc(sectionHeading)}</h2>
117
+ <div class="v-grid v-grid-3">
118
+ ${tiers.map((t, i) => `
119
+ <div class="v-card${i === 1 ? ' v-card-emphasis' : ''}">
120
+ <p class="v-pill">${esc(t.toUpperCase())}</p>
121
+ <div class="v-display v-price">${esc(prices[i])}</div>
122
+ <a href="#" class="v-cta">${esc(ctas[i] || 'Choose')}</a>
123
+ </div>`).join('')}
124
+ </div>
125
+ </section>`;
126
+ }
127
+
128
+ case 'faq': {
129
+ const qs = headings.splice(0, 4);
130
+ while (qs.length < 3) qs.push('A common question');
131
+ return `
132
+ <section class="v-section">
133
+ <h2 class="v-display v-h2">${esc(sectionHeading)}</h2>
134
+ <div class="v-faq">
135
+ ${qs.map(q => `
136
+ <details class="v-faq-item">
137
+ <summary class="v-faq-q">${esc(q)}</summary>
138
+ <p class="v-body">${esc(squeeze(lede || 'A short, useful answer in the voice of the original site.', 240))}</p>
139
+ </details>`).join('')}
140
+ </div>
141
+ </section>`;
142
+ }
143
+
144
+ case 'logo-wall': {
145
+ return `
146
+ <section class="v-section v-section-quiet">
147
+ <p class="v-pill v-pill-center">${esc(sectionHeading || 'Trusted by')}</p>
148
+ <div class="v-logos">
149
+ ${Array.from({ length: 6 }).map((_, i) => `<div class="v-logo">${esc((headings[i] || `BRAND ${i + 1}`).toUpperCase())}</div>`).join('')}
150
+ </div>
151
+ </section>`;
152
+ }
153
+
154
+ case 'steps': {
155
+ const steps = headings.splice(0, 3);
156
+ while (steps.length < 3) steps.push('Step');
157
+ return `
158
+ <section class="v-section">
159
+ <h2 class="v-display v-h2">${esc(sectionHeading)}</h2>
160
+ <ol class="v-steps">
161
+ ${steps.map((s, i) => `
162
+ <li class="v-step">
163
+ <span class="v-display v-step-num">${String(i + 1).padStart(2, '0')}</span>
164
+ <h3 class="v-display v-h3">${esc(s)}</h3>
165
+ <p class="v-body">${esc(squeeze(lede || s, 120))}</p>
166
+ </li>`).join('')}
167
+ </ol>
168
+ </section>`;
169
+ }
170
+
171
+ case 'cta': {
172
+ const ctaSet = ctas.slice(0, 2);
173
+ return `
174
+ <section class="v-section v-section-cta">
175
+ <h2 class="v-display v-h2">${esc(sectionHeading)}</h2>
176
+ ${lede ? `<p class="v-lede">${esc(lede)}</p>` : ''}
177
+ <div class="v-cta-row">
178
+ ${ctaSet.map((c, i) => `<a href="#" class="v-cta${i > 0 ? ' v-cta-ghost' : ''}">${esc(c)}</a>`).join('')}
179
+ </div>
180
+ </section>`;
181
+ }
182
+
183
+ default: {
184
+ return `
185
+ <section class="v-section">
186
+ <h2 class="v-display v-h2">${esc(sectionHeading)}</h2>
187
+ ${lede ? `<p class="v-body">${esc(lede)}</p>` : ''}
188
+ </section>`;
189
+ }
190
+ }
191
+ }
192
+
193
+ function squeeze(s, max) {
194
+ if (!s) return '';
195
+ s = String(s).replace(/\s+/g, ' ').trim();
196
+ if (s.length <= max) return s;
197
+ const cut = s.slice(0, max);
198
+ const lastSpace = cut.lastIndexOf(' ');
199
+ return (lastSpace > max * 0.7 ? cut.slice(0, lastSpace) : cut) + '…';
200
+ }
201
+
202
+ export function formatRemix(design, vocab, opts = {}) {
203
+ if (!design) throw new Error('remix: design is required');
204
+ if (!vocab || !vocab.tokens) throw new Error('remix: vocabulary is required');
205
+
206
+ const url = design.meta?.url || '';
207
+ const hostName = host(url);
208
+ const title = design.meta?.title || hostName;
209
+ const ctas = pickCtas(design, 6);
210
+
211
+ // Dedup adjacent sections that share the same heading. Real-world section
212
+ // walkers (especially on SPA-rendered marketing pages) often emit a hero
213
+ // wrapper + an inner hero with identical h1 — visually one block, but two
214
+ // entries in sectionRoles.sections.
215
+ const seenHeadings = new Set();
216
+ const sections = (design.sectionRoles?.sections || [])
217
+ .filter(s => s.role !== 'nav' && s.role !== 'footer')
218
+ .filter(s => {
219
+ const h = (s.heading || s.slots?.heading || '').trim().toLowerCase().slice(0, 80);
220
+ if (!h) return true; // keep heading-less sections (logo-walls, footers)
221
+ if (seenHeadings.has(h)) return false;
222
+ seenHeadings.add(h);
223
+ return true;
224
+ })
225
+ .slice(0, 8);
226
+
227
+ // Headings pool for sections that don't carry their own. Exclude any heading
228
+ // already claimed by a kept section so heading-less sections (cta bands,
229
+ // logo-walls) don't shift a heading that another section will also render.
230
+ const claimed = new Set(
231
+ sections
232
+ .map(s => (s.heading || s.slots?.heading || '').trim().toLowerCase())
233
+ .filter(Boolean),
234
+ );
235
+ const headings = pickHeadings(design, 16).filter(
236
+ h => !claimed.has(h.trim().toLowerCase()),
237
+ );
238
+
239
+ // If extraction surfaced no sections (rare, but happens for SPA-rendered
240
+ // pages), synthesize a hero + features + cta from voice + intent so the
241
+ // remix still produces a believable artifact.
242
+ if (sections.length === 0) {
243
+ sections.push(
244
+ { role: 'hero', heading: headings[0] || hostName, slots: { lede: design.pageIntent?.signals?.[0] }, buttonCount: 2 },
245
+ { role: 'feature-grid', heading: headings[1] || 'What it does', slots: {} },
246
+ { role: 'cta', heading: headings[2] || 'Get started', slots: {} },
247
+ );
248
+ }
249
+
250
+ const ctx = { vocab, headings: [...headings], ctas, design };
251
+ const sectionsHtml = sections.map(s => renderSection(s, ctx)).join('');
252
+
253
+ const t = vocab.tokens;
254
+ const fontImports = [vocab.fonts?.display?.import, vocab.fonts?.body?.import]
255
+ .filter(Boolean)
256
+ .filter((v, i, a) => a.indexOf(v) === i);
257
+
258
+ const ogTitle = `${hostName} · remixed as ${vocab.name.toLowerCase()}`;
259
+ const ogDesc = `${title} reimagined in the ${vocab.name} vocabulary by designlang.`;
260
+
261
+ return `<!doctype html>
262
+ <html lang="en">
263
+ <head>
264
+ <meta charset="utf-8">
265
+ <meta name="viewport" content="width=device-width,initial-scale=1">
266
+ <title>${esc(ogTitle)}</title>
267
+ <meta name="description" content="${esc(ogDesc)}">
268
+ <meta property="og:title" content="${esc(ogTitle)}">
269
+ <meta property="og:description" content="${esc(ogDesc)}">
270
+ <meta property="og:type" content="article">
271
+ <meta name="twitter:card" content="summary_large_image">
272
+ ${fontImports.map(href => `<link href="${esc(href)}" rel="stylesheet">`).join('')}
273
+ <style>
274
+ :root {
275
+ --paper: ${t.paper};
276
+ --ink: ${t.ink};
277
+ --ink-soft: ${t.inkSoft};
278
+ --accent: ${t.accent};
279
+ --rule: ${t.rule};
280
+ --radius: ${t.radius};
281
+ --radius-lg: ${t.radiusLg};
282
+ --shadow: ${t.shadow};
283
+ --shadow-sm: ${t.shadowSm};
284
+ --container: ${t.container};
285
+ --rhythm: ${t.rhythm};
286
+ }
287
+ * { box-sizing: border-box; }
288
+ html, body { margin: 0; padding: 0; }
289
+ ${vocab.css || ''}
290
+ .v-wrap { max-width: var(--container); margin: 0 auto; padding: 40px 32px 80px; }
291
+ @media (max-width: 640px) { .v-wrap { padding: 28px 20px 56px; } }
292
+ .v-topbar { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 18px; border-bottom: 1px solid var(--rule); margin-bottom: 64px; }
293
+ .v-topbar .v-brand { font-family: var(--vocab-display); font-size: 22px; }
294
+ .v-topbar .v-meta { font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-soft); }
295
+
296
+ /* — Hero — */
297
+ .v-hero { padding: 32px 0 80px; }
298
+ .v-h1 { font-size: clamp(40px, 7vw, 88px); margin: 18px 0 22px; }
299
+ .v-h2 { font-size: clamp(28px, 4vw, 48px); margin: 0 0 18px; }
300
+ .v-h3 { font-size: 20px; margin: 12px 0 8px; }
301
+ .v-lede { font-size: clamp(17px, 1.6vw, 22px); line-height: 1.5; max-width: 56ch; margin: 0 0 28px; color: var(--ink-soft); }
302
+ .v-body { font-size: 14px; line-height: var(--rhythm); margin: 0; color: var(--ink-soft); }
303
+ .v-cta-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
304
+ .v-cta-ghost { background: transparent !important; color: var(--ink) !important; border-color: var(--ink) !important; box-shadow: none !important; }
305
+
306
+ /* — Sections — */
307
+ section.v-section { padding: 64px 0; border-top: 1px solid var(--rule); }
308
+ section.v-section-quiet { background: rgba(0,0,0,0.015); border-radius: var(--radius-lg); padding: 56px 48px; margin: 32px 0; }
309
+ section.v-section-cta { text-align: center; padding: 96px 0; border-top: 1px solid var(--rule); }
310
+ section.v-section-cta .v-cta-row { justify-content: center; }
311
+ section.v-section-rule .v-grid { padding-top: 28px; border-top: 1px solid var(--rule); }
312
+
313
+ /* — Grids — */
314
+ .v-grid { display: grid; gap: 28px; margin-top: 28px; }
315
+ .v-grid-3 { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
316
+ .v-grid-4 { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
317
+ .v-card { padding: 24px; }
318
+ .v-card-num { font-family: var(--vocab-display); font-size: 32px; opacity: .35; margin-bottom: 8px; }
319
+ .v-card-emphasis { transform: translateY(-8px); }
320
+
321
+ /* — Stats — */
322
+ .v-stat { padding: 12px 0; }
323
+ .v-stat-num { font-size: clamp(36px, 4vw, 56px); line-height: 1; margin-bottom: 8px; }
324
+
325
+ /* — Quote — */
326
+ .v-quote { margin: 0; padding: 0; }
327
+ .v-quote-text { font-size: clamp(22px, 3vw, 38px); line-height: 1.25; margin: 0 0 24px; }
328
+ .v-quote-attrib { font-size: 14px; color: var(--ink-soft); }
329
+
330
+ /* — Pricing — */
331
+ .v-price { font-size: clamp(40px, 5vw, 72px); line-height: 1; margin: 14px 0 22px; }
332
+
333
+ /* — FAQ — */
334
+ .v-faq { margin-top: 28px; }
335
+ .v-faq-item { padding: 20px 0; border-top: 1px solid var(--rule); }
336
+ .v-faq-item[open] { padding-bottom: 24px; }
337
+ .v-faq-q { font-family: var(--vocab-display); font-size: 20px; cursor: pointer; list-style: none; display: flex; justify-content: space-between; align-items: center; }
338
+ .v-faq-q::after { content: '+'; font-size: 24px; color: var(--ink-soft); transition: transform .2s; }
339
+ .v-faq-item[open] .v-faq-q::after { transform: rotate(45deg); }
340
+ .v-faq-q::-webkit-details-marker { display: none; }
341
+
342
+ /* — Logos — */
343
+ .v-pill-center { display: inline-block; }
344
+ .v-logos { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 24px; margin-top: 28px; }
345
+ .v-logo { font-family: var(--vocab-display); font-weight: 700; opacity: .55; padding: 12px 0; text-align: center; letter-spacing: .04em; }
346
+
347
+ /* — Steps — */
348
+ .v-steps { list-style: none; padding: 0; margin: 32px 0 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 32px; counter-reset: step; }
349
+ .v-step { padding-top: 12px; border-top: 2px solid var(--ink); }
350
+ .v-step-num { font-size: 32px; opacity: .25; margin-bottom: 10px; display: block; }
351
+
352
+ /* — Footer — */
353
+ footer.v-footer { margin-top: 96px; padding-top: 32px; border-top: 1px solid var(--rule); display: flex; justify-content: space-between; align-items: end; flex-wrap: wrap; gap: 16px; font-size: 12px; }
354
+ footer.v-footer .v-sig { font-family: var(--vocab-display); font-size: 22px; }
355
+ footer.v-footer code { font-family: ${vocab.fonts?.body?.family ? `'${vocab.fonts.body.family}'` : 'ui-monospace'}, monospace; font-size: 11px; }
356
+ </style>
357
+ </head>
358
+ <body>
359
+ <main class="v-wrap">
360
+ <header class="v-topbar">
361
+ <div class="v-brand">${esc(hostName)}</div>
362
+ <div class="v-meta">remixed · ${esc(vocab.name)}</div>
363
+ </header>
364
+
365
+ ${sectionsHtml}
366
+
367
+ <footer class="v-footer">
368
+ <div>
369
+ <div class="v-sig">${esc(hostName)} <span class="v-mark">×</span> ${esc(vocab.name)}</div>
370
+ <div class="v-meta" style="margin-top:6px">${esc(title)}</div>
371
+ </div>
372
+ <div>
373
+ <code>npx designlang remix ${esc(hostName)} --as ${esc(opts.vocabId || '')}</code>
374
+ </div>
375
+ </footer>
376
+ </main>
377
+ </body>
378
+ </html>`;
379
+ }
@@ -1,4 +1,4 @@
1
- import { rgbToHex, rgbToHsl } from '../utils.js';
1
+ import { rgbToHsl } from '../utils.js';
2
2
 
3
3
  function generateColorScale(hex, parsed) {
4
4
  const { h, s } = parsed.hsl ?? rgbToHsl(parsed.rgb);
@@ -1,5 +1,3 @@
1
- import { rgbToHsl } from '../utils.js';
2
-
3
1
  export function formatVueTheme(design) {
4
2
  const { colors, typography, spacing, borders, shadows } = design;
5
3
  const lines = [];
package/src/history.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Historical tracking — save and compare design snapshots over time
2
2
 
3
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
3
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import { homedir } from 'os';
6
6
 
package/src/index.js CHANGED
@@ -26,7 +26,7 @@ import { extractWideGamut } from './extractors/wide-gamut.js';
26
26
  import { extractTokenSources } from './extractors/token-sources.js';
27
27
  import { extractInteractionStates } from './extractors/interaction-states.js';
28
28
  import { extractMotion } from './extractors/motion.js';
29
- import { extractComponentAnatomy, formatAnatomyStubs } from './extractors/component-anatomy.js';
29
+ import { extractComponentAnatomy } from './extractors/component-anatomy.js';
30
30
  import { extractVoice } from './extractors/voice.js';
31
31
  import { extractPageIntent } from './extractors/page-intent.js';
32
32
  import { extractSectionRoles } from './extractors/section-roles.js';
@@ -39,7 +39,6 @@ import { extractBackgroundPatterns } from './extractors/background-patterns.js';
39
39
  import { extractStackIntel } from './extractors/stack-intel.js';
40
40
  import { extractFormStates } from './extractors/form-states.js';
41
41
  import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
42
- import { formatMotionTokens } from './formatters/motion-tokens.js';
43
42
 
44
43
  function safeExtract(fn, ...args) {
45
44
  try { return fn(...args); } catch { return null; }
package/src/studio.js CHANGED
@@ -302,9 +302,9 @@ export async function runStudio(opts) {
302
302
  res.end('not found');
303
303
  return;
304
304
  }
305
- // Race-safe read — single try/catch instead of exists→stat→read chain.
305
+ // Race-free read — let readFileSync surface ENOENT / EISDIR / EACCES
306
+ // in one syscall instead of a stat→read pair (which would TOCTOU).
306
307
  try {
307
- if (!statSync(filePath).isFile()) throw new Error('not a file');
308
308
  const body = readFileSync(filePath);
309
309
  const ext = extname(filePath).toLowerCase();
310
310
  res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream' });
package/src/sync.js CHANGED
@@ -5,17 +5,28 @@ import { formatTokens } from './formatters/tokens.js';
5
5
  import { formatTailwind } from './formatters/tailwind.js';
6
6
  import { formatCssVars } from './formatters/css-vars.js';
7
7
  import { saveSnapshot, getHistory } from './history.js';
8
- import { writeFileSync, readFileSync, statSync } from 'fs';
8
+ import { openSync, closeSync, ftruncateSync, writeSync } from 'fs';
9
9
  import { join } from 'path';
10
10
 
11
- // Race-safe "update only if file exists" — statSync inside try/catch
12
- // closes the toctou window vs. existsSync→writeFileSync.
11
+ // Race-free "update only if file exists" — open with 'r+' atomically
12
+ // requires an existing file (throws ENOENT otherwise) and gives us a
13
+ // write-capable descriptor in one syscall, eliminating the toctou window
14
+ // that statSync→writeFileSync would have. Truncate then write through the
15
+ // same fd so no other process can sneak between check and write.
13
16
  function updateIfExists(path, content) {
17
+ let fd;
14
18
  try {
15
- if (!statSync(path).isFile()) return false;
16
- writeFileSync(path, content, 'utf-8');
19
+ fd = openSync(path, 'r+');
20
+ ftruncateSync(fd, 0);
21
+ writeSync(fd, content, 0, 'utf-8');
17
22
  return true;
18
- } catch { return false; }
23
+ } catch {
24
+ return false;
25
+ } finally {
26
+ if (fd !== undefined) {
27
+ try { closeSync(fd); } catch { /* best-effort close */ }
28
+ }
29
+ }
19
30
  }
20
31
 
21
32
  export async function syncDesign(url, options = {}) {
@@ -7,7 +7,6 @@ import { extractDesignLanguage } from './index.js';
7
7
  import { diffDesigns } from './diff.js';
8
8
  import { nameFromUrl } from './utils.js';
9
9
  import { statSync, existsSync, readFileSync } from 'fs';
10
- import { basename } from 'path';
11
10
 
12
11
  function fileKb(p) {
13
12
  try { return Math.round(statSync(p).size / 1024); } catch { return 0; }
@@ -0,0 +1,79 @@
1
+ // Art Deco — geometry, gold, ornament, vertical typography.
2
+ // References: Chrysler Building, 1920s Vogue covers, Gatsby-era posters.
3
+
4
+ export const artDeco = {
5
+ name: 'Art Deco',
6
+ blurb: 'Gold on ink, geometric ornament, vertical type.',
7
+ fonts: {
8
+ display: { family: 'Playfair Display', weights: [400, 700, 900], import: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&display=swap' },
9
+ body: { family: 'Cormorant Garamond', weights: [400, 500, 700], import: 'https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;700&display=swap' },
10
+ },
11
+ tokens: {
12
+ paper: '#0d1117',
13
+ ink: '#e8d4a0',
14
+ inkSoft: '#a89968',
15
+ accent: '#d4af37',
16
+ rule: '#a89968',
17
+ radius: '0px',
18
+ radiusLg: '2px',
19
+ shadow: 'none',
20
+ shadowSm: 'none',
21
+ spacingUnit: 8,
22
+ container: '1080px',
23
+ rhythm: 1.55,
24
+ },
25
+ css: `
26
+ :root {
27
+ --vocab-display: 'Playfair Display', 'Times New Roman', serif;
28
+ --vocab-body: 'Cormorant Garamond', 'Garamond', serif;
29
+ }
30
+ body {
31
+ background: var(--paper);
32
+ color: var(--ink);
33
+ font-family: var(--vocab-body);
34
+ font-size: 18px;
35
+ line-height: 1.6;
36
+ background-image:
37
+ linear-gradient(135deg, rgba(212,175,55,0.04) 0%, transparent 40%),
38
+ radial-gradient(ellipse at top, rgba(232,212,160,0.06) 0%, transparent 70%);
39
+ }
40
+ .v-display, h1, h2, h3 {
41
+ font-family: var(--vocab-display);
42
+ font-weight: 900;
43
+ letter-spacing: 0.005em;
44
+ line-height: 1.0;
45
+ color: var(--accent);
46
+ }
47
+ .v-card { border: 1px solid var(--rule); padding: 28px; position: relative; }
48
+ .v-card::before, .v-card::after {
49
+ content: '';
50
+ position: absolute; width: 12px; height: 12px;
51
+ border: 1px solid var(--accent);
52
+ }
53
+ .v-card::before { top: -1px; left: -1px; border-right: 0; border-bottom: 0; }
54
+ .v-card::after { bottom: -1px; right: -1px; border-left: 0; border-top: 0; }
55
+ .v-rule {
56
+ border: 0;
57
+ height: 1px;
58
+ background: linear-gradient(90deg, transparent, var(--accent) 20%, var(--accent) 80%, transparent);
59
+ margin: 32px auto;
60
+ max-width: 200px;
61
+ }
62
+ .v-cta {
63
+ background: var(--paper);
64
+ color: var(--accent);
65
+ border: 1.5px solid var(--accent);
66
+ padding: 14px 32px;
67
+ font-family: var(--vocab-display);
68
+ font-weight: 700;
69
+ letter-spacing: 0.18em;
70
+ text-transform: uppercase;
71
+ font-size: 12px;
72
+ transition: background .2s;
73
+ }
74
+ .v-cta:hover { background: var(--accent); color: var(--paper); }
75
+ .v-pill { font-family: var(--vocab-body); font-style: italic; color: var(--accent); letter-spacing: 0.04em; }
76
+ .v-mark { color: var(--accent); font-style: italic; }
77
+ a { color: var(--accent); text-decoration: none; border-bottom: 1px solid currentColor; padding-bottom: 1px; }
78
+ `,
79
+ };
@@ -0,0 +1,72 @@
1
+ // Brutalist — exposed structure, hard edges, raw type, single accent.
2
+ // References: David Carson's Ray Gun, early Bloomberg.com, Balenciaga,
3
+ // brutalistwebsites.com archive.
4
+
5
+ export const brutalist = {
6
+ name: 'Brutalist',
7
+ blurb: 'Hard edges, mono type, single screaming accent.',
8
+ fonts: {
9
+ display: { family: 'Space Grotesk', weights: [500, 700], import: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&display=swap' },
10
+ body: { family: 'IBM Plex Mono', weights: [400, 500, 700], import: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap' },
11
+ },
12
+ tokens: {
13
+ paper: '#f4f1ea',
14
+ ink: '#0a0a0a',
15
+ inkSoft: '#3a3a3a',
16
+ accent: '#ff4800',
17
+ rule: '#0a0a0a',
18
+ radius: '0px',
19
+ radiusLg: '0px',
20
+ shadow: '6px 6px 0 #0a0a0a',
21
+ shadowSm: '3px 3px 0 #0a0a0a',
22
+ spacingUnit: 8,
23
+ container: '1100px',
24
+ rhythm: 1.45,
25
+ },
26
+ // Signature CSS — applied alongside the per-instance vars below.
27
+ // Use --vocab-* prefix so tokens compose without colliding with the page shape.
28
+ css: `
29
+ :root {
30
+ --vocab-display: 'Space Grotesk', 'Helvetica Neue', sans-serif;
31
+ --vocab-body: 'IBM Plex Mono', ui-monospace, monospace;
32
+ }
33
+ body {
34
+ background: var(--paper);
35
+ color: var(--ink);
36
+ font-family: var(--vocab-body);
37
+ font-size: 15px;
38
+ line-height: 1.55;
39
+ text-transform: uppercase;
40
+ letter-spacing: 0.02em;
41
+ }
42
+ .v-display, h1, h2, h3 {
43
+ font-family: var(--vocab-display);
44
+ font-weight: 700;
45
+ letter-spacing: -0.02em;
46
+ text-transform: none;
47
+ line-height: 0.95;
48
+ }
49
+ .v-card { border: 2px solid var(--ink); background: var(--paper); box-shadow: var(--shadow); }
50
+ .v-rule { border-top: 2px solid var(--ink); }
51
+ .v-cta {
52
+ background: var(--accent);
53
+ color: var(--ink);
54
+ border: 2px solid var(--ink);
55
+ box-shadow: var(--shadow-sm);
56
+ padding: 14px 22px;
57
+ font-family: var(--vocab-display);
58
+ font-weight: 700;
59
+ text-transform: uppercase;
60
+ letter-spacing: 0.04em;
61
+ transition: transform .08s, box-shadow .08s;
62
+ }
63
+ .v-cta:hover { transform: translate(-2px, -2px); box-shadow: 8px 8px 0 var(--ink); }
64
+ .v-pill { display: inline-block; padding: 4px 10px; border: 1.5px solid var(--ink); background: var(--paper); }
65
+ .v-mark { background: var(--accent); padding: 0 4px; }
66
+ .v-noise {
67
+ background-image: repeating-linear-gradient(45deg, transparent 0 6px, rgba(0,0,0,0.04) 6px 7px);
68
+ }
69
+ a { color: var(--ink); text-decoration: none; border-bottom: 2px solid var(--accent); }
70
+ a:hover { background: var(--accent); }
71
+ `,
72
+ };