Setting the file. One moment.
Build Frame · PR To Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page — line 239
This file
Number 30.9
Position 9 of 32
Type JavaScript
Size 28 KB
Lines 589 scripts/ build-frame.mjs
JavaScript · 589 lines · 28 KB
14
// the brand ink (darkest/ink-named), the canvas-role key takes the brand
15 // canvas (lightest), and every other color is repainted with the nearest
16 // brand accent's hue+saturation while KEEPING its own lightness, so tint
17 // families (sun / sun-soft / haze) stay a family. Empty brand colors → the
18 // preset palette is kept (it is already a complete, good design).
19 // fonts — the preset's display family → the brand display font, its body family →
20 // the brand body font, wherever they appear. Empty brand fonts → kept.
21
22 import {
23 copyFileSync,
24 existsSync,
25 mkdirSync,
26 readdirSync,
27 readFileSync,
28 writeFileSync,
29 } from "node:fs" ;
30 import { dirname, join, resolve } from "node:path" ;
31 import { fileURLToPath } from "node:url" ;
32 import {
33 brandRolesFromStats,
34 chroma,
35 lum,
36 parseColors,
37 parseFonts,
38 pickAccent,
39 semanticColors,
40 STATUS_ROLE_KEY,
41 UA_DEFAULT_COLORS,
42 } from "./lib/tokens.mjs" ;
43
44 import { stageCapturedFonts } from "./lib/captured-fonts.mjs" ;
45
46 const __dirname = dirname ( fileURLToPath ( import . meta .url));
47 const argv = process.argv. slice ( 2 );
48 const flag = ( name , def ) => {
49 const i = argv. indexOf ( `--${ name }` );
50 return i >= 0 && i + 1 < argv. length ? argv[i + 1 ] : def;
51 };
52 const die = ( m ) => {
53 console. error ( `✗ build-frame: ${ m }` );
54 process. exit ( 1 );
55 };
56
57 const presetName = flag ( "preset" , null );
58 const hyperframesDir = resolve ( flag ( "hyperframes" , "." ));
59 const presetDir = resolve (
60 flag ( "preset-dir" , join (__dirname, "../../hyperframes-creative/frame-presets" )),
61 );
62 const tokensPath = resolve ( flag ( "tokens" , join (hyperframesDir, "capture/extracted/tokens.json" )));
63
64 if ( ! presetName) die ( "--preset <name> is required" );
65 const presetFrame = join (presetDir, presetName, "FRAME.md" );
66 if ( ! existsSync (presetFrame)) {
67 const avail = existsSync (presetDir)
68 ? readdirSync (presetDir, { withFileTypes: true })
69 . filter (( d ) => d. isDirectory ())
70 . map (( d ) => d.name)
71 : [];
72 die (
73 `no FRAME.md for preset "${ presetName }" under ${ presetDir } \n available: ${ avail . join ( ", " ) }` ,
74 );
75 }
76
77 // ── HSL helpers (recolor = brand hue+sat, original lightness) ──────────────────
78 function hexToHsl ( hex ) {
79 const m = / ^ # ? ( [0-9a-fA-F] {6} ) $ / . exec ( String (hex). trim ());
80 if ( ! m) return null ;
81 const n = parseInt (m[ 1 ], 16 );
82 const r = ((n >> 16 ) & 255 ) / 255 ,
83 g = ((n >> 8 ) & 255 ) / 255 ,
84 b = (n & 255 ) / 255 ;
85 const max = Math. max (r, g, b),
86 min = Math. min (r, g, b),
87 d = max - min;
88 let h = 0 ;
89 const l = (max + min) / 2 ;
90 const s = d === 0 ? 0 : l > 0.5 ? d / ( 2 - max - min) : d / (max + min);
91 if (d !== 0 ) {
92 h = max === r ? (g - b) / d + (g < b ? 6 : 0 ) : max === g ? (b - r) / d + 2 : (r - g) / d + 4 ;
93 h *= 60 ;
94 }
95 return { h, s, l };
96 }
97 function hslToHex ( h , s , l ) {
98 h = (((h % 360 ) + 360 ) % 360 ) / 360 ;
99 const hue = ( p , q , t ) => {
100 t = (t + 1 ) % 1 ;
101 if (t < 1 / 6 ) return p + (q - p) * 6 * t;
102 if (t < 1 / 2 ) return q;
103 if (t < 2 / 3 ) return p + (q - p) * ( 2 / 3 - t) * 6 ;
104 return p;
105 };
106 let r, g, b;
107 if (s === 0 ) {
108 r = g = b = l;
109 } else {
110 const q = l < 0.5 ? l * ( 1 + s) : l + s - l * s;
111 const p = 2 * l - q;
112 r = hue (p, q, h + 1 / 3 );
113 g = hue (p, q, h);
114 b = hue (p, q, h - 1 / 3 );
115 }
116 const to = ( x ) =>
117 Math. round (x * 255 )
118 . toString ( 16 )
119 . padStart ( 2 , "0" )
120 . toUpperCase ();
121 return `#${ to ( r ) }${ to ( g ) }${ to ( b ) }` ;
122 }
123 const hueDist = ( a , b ) => {
124 const d = Math. abs (a - b) % 360 ;
125 return d > 180 ? 360 - d : d;
126 };
127 function hexToRgb ( hex ) {
128 const m = / ^ # ? ( [0-9a-fA-F] {6} ) $ / . exec ( String (hex). trim ());
129 if ( ! m) return null ;
130 const n = parseInt (m[ 1 ], 16 );
131 return [(n >> 16 ) & 255 , (n >> 8 ) & 255 , n & 255 ];
132 }
133 const rgbToHsl = ( r , g , b ) =>
134 hexToHsl ( "#" + [r, g, b]. map (( x ) => Math. round (x). toString ( 16 ). padStart ( 2 , "0" )). join ( "" ));
135 // Repaint a chromatic rgba()/rgb() tint with the brand accent's RGB, keeping its alpha.
136 // A near-neutral rgb (shadow / scrim overlay) is left untouched; a non-rgba string → null.
137 function remapRgbaToAccent ( val , brAccent , brAccent2 , prAccentHsl , prAccent2Hsl ) {
138 const m = / ^ rgba ? \( \s * ( [\d.] + ) [\s,] + ( [\d.] + ) [\s,] + ( [\d.] + ) \s * (?: [,/]\s * ( [\d.] + % ? )) ? \s * \) $ / i . exec (
139 String (val). trim (),
140 );
141 if ( ! m) return null ;
142 const r = + m[ 1 ],
143 g = + m[ 2 ],
144 b = + m[ 3 ],
145 a = m[ 4 ];
146 if (Math. max (r, g, b) - Math. min (r, g, b) < 16 ) return null ; // neutral overlay — keep as-is
147 const src = rgbToHsl (r, g, b);
148 const useSecond =
149 brAccent2 &&
150 prAccentHsl &&
151 prAccent2Hsl &&
152 src &&
153 hueDist (src.h, prAccent2Hsl.h) < hueDist (src.h, prAccentHsl.h);
154 const t = hexToRgb (useSecond ? brAccent2 : brAccent);
155 if ( ! t) return null ;
156 return a !== undefined
157 ? `rgba(${ t [ 0 ] }, ${ t [ 1 ] }, ${ t [ 2 ] }, ${ a })`
158 : `rgb(${ t [ 0 ] }, ${ t [ 1 ] }, ${ t [ 2 ] })` ;
159 }
160
161 // ── brand tokens ──────────────────────────────────────────────────────────────
162 let brandColors = [];
163 let brandFonts = [];
164 let brandFontWeights = []; // weights the brand text font actually ships (tokens fonts[].weights)
165 let brandColorStats = []; // rich per-color usage stats (areaBg / interactiveBg / textCount …)
166 // Icon/glyph fonts capture surfaces as "fonts" — they are never the brand text face
167 // (webflow-icons, Font Awesome, icomoon …) and must not become display/body or contribute weights.
168 const ICON_FONT_PATTERN =
169 /(?: ^| [\s_-] )icons ? (?: [\s_-] |$ ) | icomoon | font \s * - ? awesome | glyphicons ?| material \s * icons | feather \s * icons | (?:icon | glyph) . * font | font . * (?:icon | glyph) |^ vidaxlfont $ / i ;
170 const isIconFont = ( name ) => ICON_FONT_PATTERN . test ( String (name));
171 if ( existsSync (tokensPath)) {
172 try {
173 const t = JSON . parse ( readFileSync (tokensPath, "utf8" ));
174 brandColors = (t.colors ?? [])
175 . map (( c ) => ( typeof c === "string" ? c : (c?.hex ?? c?.value ?? "" )))
176 . map (( c ) => String (c). trim ())
177 . filter (( c ) => / ^ # ? [0-9a-fA-F] {6}$ / . test (c))
178 . map (( c ) => (c. startsWith ( "#" ) ? c : `#${ c }` ));
179 brandFonts = (t.fonts ?? [])
180 . map (( f ) => ( typeof f === "string" ? f : (f?.family ?? f?.name ?? "" )))
181 . map (( f ) => String (f). split ( "," )[ 0 ]. replace ( / ['"] / g , "" ). trim ())
182 . filter (Boolean)
183 . filter (( f ) => ! isIconFont (f));
184 // Union of the (non-icon) brand fonts' available weights — used to clamp the preset's
185 // type ramp so a font shipping only 400/500 never faux-bolds a 600/700 heading.
186 brandFontWeights = [
187 ...new Set (
188 (t.fonts ?? [])
189 . filter (( f ) => f && typeof f === "object" && ! isIconFont (f.family ?? f.name ?? "" ))
190 . flatMap (( f ) => (Array. isArray (f.weights) ? f.weights : []))
191 . map (( w ) => parseInt (w, 10 ))
192 . filter (( w ) => Number. isFinite (w)),
193 ),
194 ]. sort (( a , b ) => a - b);
195 brandColorStats = Array. isArray (t.colorStats) ? t.colorStats : [];
196 } catch (e) {
197 die ( `tokens.json parse: ${ e . message }` );
198 }
199 }
200
201 let md = readFileSync (presetFrame, "utf8" );
202 const presetColors = parseColors (md);
203 const summary = [];
204
205 // ── color remix ───────────────────────────────────────────────────────────────
206 if (brandColors. length && presetColors. length ) {
207 const pr = semanticColors (presetColors);
208 // Brand roles: prefer the function-based reading of capture colorStats (canvas =
209 // largest background, accent = top interactive bg, ink = dominant contrasting text).
210 // Fall back to the legacy luminance/chroma heuristic only when stats are absent —
211 // but pick the accent via pickAccent either way so a UA-default link color never wins.
212 const br =
213 brandRolesFromStats (brandColorStats, brandColors) ??
214 (() => {
215 // strip UA-default link colors so a stray <a> color can't become ink/canvas/accent
216 const clean = brandColors. filter (( h ) => ! UA_DEFAULT_COLORS . has (h. toUpperCase ()));
217 const s = semanticColors (clean. map (( h , i ) => [ `c${ i }` , h]));
218 return {
219 ink: s.ink,
220 canvas: s.canvas,
221 accent: pickAccent (brandColorStats, clean, [s.ink, s.canvas]) ?? s.accent,
222 accent2: s.accent2,
223 };
224 })();
225 if ( ! br.accent) die ( "accent 选取失败:品牌色里没有可用的强调色" );
226 if ( chroma (br.accent) <= 40 ) {
227 console. warn (
228 ` ⚠ accent ${ br . accent } 彩度很低 (${ chroma ( br . accent ) }) — 确认这是品牌色而非中性/默认色` ,
229 );
230 }
231 // Map by LUMINANCE POLARITY. The preset's darker value takes the brand's darker value and
232 // the lighter takes the lighter — UNLESS the brand's GROUND polarity differs from the
233 // preset's. Every shipped preset is light-ground; a dark-mode brand (Linear, Vercel,
234 // Raycast…) has its canvas darker than its ink (colorStats already resolved the real
235 // ground as the largest-area background). On a polarity MISMATCH we INVERT the mapping so a
236 // light preset becomes the dark brand (canvas↔ink swap) instead of forcing the brand onto
237 // an off-brand light video; neutral/tint lightness is then mirrored (L→1−L) so the whole
238 // palette flips to the brand's ground. Same-polarity (the common case) is unchanged.
239 const darker = ( a , b ) => (( lum (a) ?? 0 ) <= ( lum (b) ?? 0 ) ? a : b);
240 const prDark = darker (pr.ink, pr.canvas);
241 const prLight = prDark === pr.ink ? pr.canvas : pr.ink;
242 const brDark = darker (br.ink, br.canvas);
243 const brLight = brDark === br.ink ? br.canvas : br.ink;
244 const presetGroundDark = ( lum (pr.canvas) ?? 255 ) < ( lum (pr.ink) ?? 0 );
245 const brandGroundDark = ( lum (br.canvas) ?? 255 ) < ( lum (br.ink) ?? 0 );
246 const invert = presetGroundDark !== brandGroundDark;
247 const mapDark = invert ? brLight : brDark; // preset's dark value → this brand value
248 const mapLight = invert ? brDark : brLight; // preset's light value → this brand value
249 const flipL = ( l ) => (invert ? 1 - l : l); // mirror tint/neutral lightness when flipping
250 const prAccentHsl = hexToHsl (pr.accent);
251 const prAccent2Hsl = hexToHsl (pr.accent2);
252 const newByKey = new Map ();
253 for ( const [ key , val ] of presetColors) {
254 const ph = hexToHsl (val);
255 let next;
256 if (val === prDark) next = mapDark;
257 else if (val === prLight) next = mapLight;
258 else if ( STATUS_ROLE_KEY . test (key))
259 // semantic status colors (green/red …) — the HUE carries the meaning; never repaint.
260 // MUST precede the accent checks: a preset's red "negative" is often its 2nd-most-chromatic
261 // color and would otherwise be claimed as accent2 and recolored to the brand hue.
262 next = val;
263 else if (val === pr.accent)
264 next = br.accent; // primary accent → the EXACT brand color
265 else if (pr.accent2 !== pr.accent && val === pr.accent2)
266 next = br.accent2; // exact 2nd accent
267 else if ( ! ph) {
268 // rgba()/rgb() tint → repaint its rgb with the brand accent, keep alpha (a neutral
269 // overlay is kept). A non-color non-hex value (var(), named) falls through unchanged.
270 next = remapRgbaToAccent (val, br.accent, br.accent2, prAccentHsl, prAccent2Hsl) ?? val;
271 } else if ( chroma (val) < 16 ) {
272 // NEUTRAL source (grey text-ladder, hairline borders) → keep it NEUTRAL. Apply at most a
273 // whisper of the brand hue (sat ≤ 0.06); never the accent's full saturation — that is what
274 // turned the grey ladder into saturated blue.
275 const bh = hexToHsl (br.accent);
276 next = bh ? hslToHex (bh.h, Math. min (ph.s, 0.06 ), flipL (ph.l)) : val;
277 } else {
278 // chromatic tint → repaint with the nearest brand accent's hue+sat, keep THIS color's
279 // lightness so tint families stay families.
280 const useSecond =
281 pr.accent !== pr.accent2 &&
282 prAccentHsl &&
283 prAccent2Hsl &&
284 hueDist (ph.h, prAccent2Hsl.h) < hueDist (ph.h, prAccentHsl.h);
285 const bh = hexToHsl (useSecond ? br.accent2 : br.accent);
286 next = bh ? hslToHex (bh.h, bh.s, flipL (ph.l)) : val;
287 }
288 if (next !== val) newByKey. set (key, next);
289 }
290 // rewrite only the value of each colors: line; everything else byte-identical.
291 let inBlock = false ;
292 md = md
293 . split ( / \r ? \n / )
294 . map (( line ) => {
295 if ( / ^ colors: \s *$ / . test (line)) {
296 inBlock = true ;
297 return line;
298 }
299 if (inBlock && / ^ \S / . test (line)) inBlock = false ;
300 if ( ! inBlock) return line;
301 const m = line. match (
302 / ^ ( \s + )( [\w-] + ): \s * (?:" [ ^ "] * " | ' [ ^ '] * ' | # [0-9a-fA-F] {3,8}| rgba ? \( [ ^ )] * \) | [ ^ #\n] *? )( \s + # . * ) ?$ / ,
303 );
304 if (m && newByKey. has (m[ 2 ])) return `${ m [ 1 ] }${ m [ 2 ] }: "${ newByKey . get ( m [ 2 ]) }"${ m [ 3 ] ?? ""}` ;
305 return line;
306 })
307 . join ( " \n " );
308 summary. push (
309 `colors: ${ invert ? "INVERTED (dark-mode brand on light preset) · " : ""}dark ${ prDark }→${ mapDark }, light ${ prLight }→${ mapLight }, accent ${ pr . accent }→${ br . accent }` +
310 ` (${ newByKey . size }/${ presetColors . length } keys repainted${ brandColorStats . length ? ", via colorStats" : ""})` ,
311 );
312 } else {
313 summary. push (
314 brandColors. length
315 ? "colors: preset has no parseable colors — kept"
316 : "colors: no brand colors — preset palette kept" ,
317 );
318 }
319
320 // ── font remix ────────────────────────────────────────────────────────────────
321 if (brandFonts. length ) {
322 const pf = parseFonts (md);
323 const strip = ( q ) => (q ? q. replace ( / ^ " | " $ / g , "" ) : null );
324 const pDisplay = strip (pf.display);
325 const pBody = strip (pf.body);
326 const pMono = strip (pf.mono);
327 // A monospace brand face is for code / labels / chrome — never the reading display or body.
328 // Split the brand fonts: the primary NON-mono family carries display AND body (the common
329 // single-sans case, e.g. Inter for everything), and a captured mono (Berkeley Mono,
330 // JetBrains Mono…) is routed onto the preset's mono role instead of turning the body
331 // monospace. (Distinct display/body brands still resolve to a clean sans; hand-tune the
332 // display in frame.md if a separate display face is wanted.)
333 const isMonoFont = ( n ) =>
334 /(?: ^| [\s_-] )mono(?: [\s_-] |$ ) | monospace | consol | courier | menlo | monaco | jetbrains | berkeley | space \s * mono | ibm \s * plex \s * mono | sf \s * mono | roboto \s * mono | source \s * code | fira \s * code | geist \s * mono | dm \s * mono/ i . test (
335 String (n),
336 );
337 const nonMono = brandFonts. filter (( f ) => ! isMonoFont (f));
338 const monoFonts = brandFonts. filter (isMonoFont);
339 const bDisplay = nonMono[ 0 ] ?? brandFonts[ 0 ];
340 const bBody = nonMono[ 0 ] ?? brandFonts[ 0 ];
341 const bMono = monoFonts[ 0 ] ?? null ;
342 const escRe = ( s ) => s. replace ( / [.*+?^${}()|[ \]\\ ] / g , " \\ $&" );
343 // Replace the preset family as a WHOLE WORD/PHRASE everywhere — frontmatter values,
344 // component strings like "Space Grotesk 600", AND prose — case-sensitive with word
345 // boundaries so a single-word family ("Inter") can never corrupt a substring
346 // ("interactive"). Quote-exact replace alone missed names baked into longer strings + prose.
347 const swapFamily = ( from , to ) => {
348 if (from && to && from !== to) md = md. replace ( new RegExp ( ` \\ b${ escRe ( from ) } \\ b` , "g" ), to);
349 };
350 swapFamily (pDisplay, bDisplay);
351 if (pBody !== pDisplay) swapFamily (pBody, bBody);
352 // route the brand mono onto the preset's mono role (only if the preset has a DISTINCT mono
353 // family — never collapse body/display into mono)
354 if (bMono && pMono && pMono !== pBody && pMono !== pDisplay) swapFamily (pMono, bMono);
355 summary. push (
356 `fonts: display ${ pDisplay }→${ bDisplay }, body ${ pBody }→${ bBody }` +
357 (bMono && pMono && pMono !== pBody && pMono !== pDisplay ? `, mono ${ pMono }→${ bMono }` : "" ),
358 );
359 } else {
360 summary. push ( "fonts: no brand fonts — preset fonts kept" );
361 }
362
363 // ── stage preset-owned offline font faces ────────────────────────────────────
364 // PR ingestion has no captured brand fonts. Presets that own a type system must
365 // therefore carry their own licensed files instead of depending on a first-run
366 // Google Fonts fetch or a renderer-only embedding path that Studio workers cannot see.
367 const presetFontsDir = join (presetDir, presetName, "fonts" );
368 if ( existsSync (presetFontsDir)) {
369 const fontSpecs = [
370 [ "EB Garamond" , "EBGaramond" , 400 ],
371 [ "EB Garamond" , "EBGaramond" , 700 ],
372 [ "Inter" , "Inter" , 400 ],
373 [ "Inter" , "Inter" , 700 ],
374 [ "JetBrains Mono" , "JetBrainsMono" , 400 ],
375 [ "JetBrains Mono" , "JetBrainsMono" , 700 ],
376 ];
377 const outDir = join (hyperframesDir, "assets/fonts" );
378 const faces = [];
379 for ( const [ family , stem , weight ] of fontSpecs) {
380 const file = `${ stem }-${ weight }.woff2` ;
381 const source = join (presetFontsDir, file);
382 if ( ! existsSync (source)) die ( `preset font is missing: ${ source }` );
383 mkdirSync (outDir, { recursive: true });
384 copyFileSync (source, join (outDir, file));
385 faces. push (
386 `@font-face{font-family:"${ family }";font-weight:${ weight };font-style:normal;font-display:block;src:url("assets/fonts/${ file }") format("woff2");}` ,
387 );
388 }
389 md +=
390 ` \n\n ## Font loading (preset-owned, offline) \n\n ` +
391 `These licensed faces are staged in \` assets/fonts/ \` . Paste this block inside every frame template; do not link Google Fonts: \n\n ` +
392 "```html \n <style> \n " +
393 faces. join ( " \n " ) +
394 " \n </style> \n ``` \n " ;
395 summary. push ( `fonts: staged ${ fontSpecs . length } preset face(s) for offline preview/render` );
396 }
397
398 // ── cap type weights to the brand font's available faces ──────────────────────
399 // The remix swaps the font FAMILY but keeps the preset's weights; a brand font that ships
400 // only e.g. 400/500 would faux-bold every 600/700 heading. Clamp each `typography:` weight
401 // to the NEAREST weight the brand font actually provides (tokens.json fonts[].weights).
402 if (brandFonts. length && brandFontWeights. length ) {
403 const avail = brandFontWeights;
404 const nearest = ( n ) =>
405 avail. reduce (( best , w ) => {
406 const dw = Math. abs (w - n),
407 db = Math. abs (best - n);
408 return dw < db || (dw === db && w > best) ? w : best;
409 }, avail[ 0 ]);
410 let capped = 0 ;
411 const cap = ( num ) => {
412 const n = parseInt (num, 10 );
413 if (avail. includes (n)) return String (n);
414 const c = nearest (n);
415 if (c !== n) capped ++ ;
416 return String (c);
417 };
418 let inType = false ;
419 md = md
420 . split ( / \r ? \n / )
421 . map (( line ) => {
422 if ( / ^ typography: \s *$ / . test (line)) {
423 inType = true ;
424 return line;
425 }
426 if (inType && / ^ \S / . test (line)) inType = false ;
427 let out = line;
428 // (a) structured `weight: NNN` in the typography ramp
429 if (inType) out = out. replace ( /( \b weight: \s * )( \d {3} ) \b / g , ( m , pfx , num ) => pfx + cap (num));
430 // (b) a weight baked into a quoted `typography:` component value, e.g.
431 // cta-button → typography: "Basier Square 600" (NNN not followed by a unit like px)
432 out = out. replace (
433 /(typography: \s * " [ ^ "] *?\b )( \d {3} ) \b (?! [a-z%] )/ gi ,
434 ( m , pfx , num ) => pfx + cap (num),
435 );
436 return out;
437 })
438 . join ( " \n " );
439 if (capped)
440 summary. push ( `fonts: capped ${ capped } type weight(s) to brand faces {${ avail . join ( ", " ) }}` );
441 }
442
443 // ── brand-adaptation note ─────────────────────────────────────────────────────
444 // The remix fixes the NORMATIVE frontmatter, but the preset's PROSE still carries its
445 // original weight ranges / color-names. Prepend a short "frontmatter is truth" header so a
446 // reader (or frame worker) interprets any lingering preset prose THROUGH the brand values —
447 // instead of fragile per-sentence prose surgery.
448 if (brandFonts. length || (brandColors. length && presetColors. length )) {
449 const bD = brandFonts[ 0 ];
450 const bB = brandFonts[ 1 ] ?? brandFonts[ 0 ];
451 const note =
452 `## Brand adaptation (READ FIRST — the frontmatter is the source of truth) \n\n ` +
453 `This is the **${ presetName }** preset remixed onto the captured brand. The YAML frontmatter above ` +
454 `(colors · typography · components) is **normative and already correct — use it verbatim.** The prose ` +
455 `below is the ORIGINAL preset's intent; read it THROUGH the frontmatter: \n\n ` +
456 (brandFonts. length
457 ? `- **Fonts** — already set to **${ bD }** (display) / **${ bB }** (body); ignore any preset font name lingering in prose. \n `
458 : "" ) +
459 (brandFontWeights. length
460 ? `- **Weights** — the brand font ships \` {${ brandFontWeights . join ( ", " ) }} \` only; every weight is clamped to these — ignore higher preset weights (e.g. 600/700) in prose. \n `
461 : "" ) +
462 `- **Colors** — use the frontmatter hex; preset color NAMES in prose (e.g. "cobalt", "cream") mean the remapped brand values. \n ` ;
463 if ( / ^ # . *$ / m . test (md)) md = md. replace ( / ^ # . *$ / m , ( m ) => `${ m } \n\n ${ note }` );
464 else md = `${ note } \n ${ md }` ;
465 summary. push ( "brand-adaptation note prepended" );
466 }
467
468 // ── stage brand font files + emit @font-face ──────────────────────────────────
469 // A brand font is rarely a Google font, so renaming the family in frame.md is not enough:
470 // nothing loads the actual face. If the capture downloaded font files, copy them to
471 // assets/fonts/ under CLEAN, face-named names (so captions.mjs' family-prefix matcher
472 // finds them too) and append a ready-to-paste, ROOT-RELATIVE @font-face block to frame.md.
473 //
474 // The staged NAME is a contract, not cosmetics: captions.mjs derives each face's weight and
475 // style back out of it. So the name has to carry every axis that distinguishes one face from
476 // another, and the dedup key has to be the whole face. Naming on weight alone made Google's
477 // two-file Newsreader download (upright + italic, both scoring "Regular") collide on one
478 // slot: the italic sorts first, took the name, the upright was never staged, and the block
479 // below then asserted font-style:normal over italic bytes.
480 if (brandFonts. length ) {
481 const norm = ( s ) =>
482 String (s)
483 . toLowerCase ()
484 . replace ( / [ ^ a-z0-9] / g , "" );
485 const extOf = ( f ) => (f. match ( / \. (woff2 | woff | ttf | otf) $ / i )?.[ 1 ] ?? "" ). toLowerCase ();
486 const FMT = { woff2: "woff2" , woff: "woff" , ttf: "truetype" , otf: "opentype" };
487 const weightInfo = ( name ) => {
488 const s = name. toLowerCase ();
489 // A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource
490 // names every face that way and carries no weight WORD at all, so word-only parsing
491 // scored a whole family "Regular" and staged exactly one of its faces.
492 //
493 // A weight token must not be buried inside a longer run: this reads capture files,
494 // which are commonly hash-named, and "Newsreader-a1b200c3.woff2" is not a 200-weight
495 // face. Hence a non-digit before (which also stops "2100" reading as 100) and no
496 // alphanumeric after. "Roboto900.ttf" still parses.
497 const numeric = /(?: ^| [ ^ 0-9] )( [1-9] 00)(?! [0-9a-z] )/ . exec (s);
498 if (numeric) return { n: Number (numeric[ 1 ]), w: numeric[ 1 ] };
499 if ( /black | heavy | ultra | extrabold/ . test (s)) return { n: 800 , w: "ExtraBold" };
500 if ( /semibold | demibold/ . test (s)) return { n: 600 , w: "SemiBold" };
501 if ( /bold/ . test (s)) return { n: 700 , w: "Bold" };
502 if ( /medium/ . test (s)) return { n: 500 , w: "Medium" };
503 if ( /light | thin/ . test (s)) return { n: 300 , w: "Light" };
504 return { n: 400 , w: "Regular" };
505 };
506 const styleOf = ( name ) => ( /italic | oblique/ i . test (name) ? "italic" : "normal" );
507 const fams = [ ...new Set (brandFonts)];
508 const srcDirs = [
509 join (hyperframesDir, "capture/assets/fonts" ),
510 join (hyperframesDir, "assets/fonts" ),
511 ]. filter (( d ) => existsSync (d));
512 const files = [];
513 for ( const d of srcDirs)
514 for ( const f of readdirSync (d). sort ()) if ( extOf (f)) files. push ({ d, f });
515 // Single family → all font files belong to it (the common captured case, hash-named files
516 // included). Multiple families → assign each file to the longest family key its name contains.
517 const ranked = [ ... fams]. sort (( a , b ) => norm (b). length - norm (a). length );
518 const famOf = ( f ) =>
519 fams. length === 1 ? fams[ 0 ] : ranked. find (( x ) => norm (f). includes ( norm (x)));
520 const outDir = join (hyperframesDir, "assets/fonts" );
521 const captured = stageCapturedFonts (hyperframesDir, fams);
522 const faces = [ ... captured.faces];
523 const stagedNames = new Set (captured.files);
524 for ( const { d , f } of files) {
525 const fam = famOf (f);
526 if ( ! fam || captured.families. has (fam. toLowerCase ())) continue ;
527 const { n , w } = weightInfo (f);
528 const style = styleOf (f);
529 const clean = `${ fam . replace ( / [ ^ A-Za-z0-9] / g , "" ) }-${ w }${ style === "italic" ? "-Italic" : ""}.${ extOf ( f ) }` ;
530 if (stagedNames. has (clean)) continue ;
531 mkdirSync (outDir, { recursive: true });
532 if ( ! existsSync ( join (outDir, clean))) copyFileSync ( join (d, f), join (outDir, clean));
533 stagedNames. add (clean);
534 faces. push (
535 `@font-face{font-family:"${ fam }";font-weight:${ n };font-style:${ style };font-display:block;src:url("assets/fonts/${ clean }") format("${ FMT [ extOf ( f )] }");}` ,
536 );
537 }
538 if (faces. length ) {
539 md +=
540 ` \n\n ## Font loading (auto-generated) \n\n ` +
541 `The brand font ships as local files in \` assets/fonts/ \` — do NOT link Google Fonts for it. ` +
542 `Paste this \` <style> \` into every frame's \` <head> \` / \` <template> \` (captions use the same files) ` +
543 `so \` font-family \` resolves in preview, snapshot, and render alike: \n\n ` +
544 "```html \n <style> \n " +
545 faces. join ( " \n " ) +
546 " \n </style> \n ``` \n " ;
547 summary. push (
548 `fonts: staged ${ stagedNames . size } face(s) → assets/fonts/ + @font-face in frame.md` ,
549 );
550 }
551 }
552
553 // ── write frame.md ────────────────────────────────────────────────────────────
554 const framePath = join (hyperframesDir, "frame.md" );
555 writeFileSync (framePath, md);
556
557 // ── copy caption-skin.html ────────────────────────────────────────────────────
558 const presetSkin = join (presetDir, presetName, "caption-skin.html" );
559 let skinCopied = false ;
560 if ( existsSync (presetSkin)) {
561 const skinDir = join (hyperframesDir, ".hyperframes" );
562 mkdirSync (skinDir, { recursive: true });
563 copyFileSync (presetSkin, join (skinDir, "caption-skin.html" ));
564 skinCopied = true ;
565 }
566
567 // ── self-validate ─────────────────────────────────────────────────────────────
568 const outColors = parseColors (md);
569 if (outColors. length !== presetColors. length ) {
570 die ( `color keys changed (${ presetColors . length }→${ outColors . length }) — keys must be preserved` );
571 }
572 const outRoles = semanticColors (outColors);
573 const li = lum (outRoles.ink),
574 lc = lum (outRoles.canvas);
575 // ink (type) and canvas (ground) must differ enough to READ — in EITHER direction. A
576 // light-mode spec has ink darker than canvas; a dark-mode spec (the polarity flip above)
577 // the reverse. Assert luminance SEPARATION, not a fixed polarity.
578 if (li != null && lc != null && Math. abs (li - lc) < 40 ) {
579 die (
580 `ink (${ outRoles . ink }, lum ${ li . toFixed ( 0 ) }) and canvas (${ outRoles . canvas }, lum ${ lc . toFixed ( 0 ) }) lack contrast — bad brand mapping` ,
581 );
582 }
583
584 console. log ( `✓ build-frame: ${ presetName } → ${ framePath }` );
585 for ( const s of summary) console. log ( ` ${ s }` );
586 console. log (
587 ` .hyperframes/caption-skin.html: ${ skinCopied ? "copied" : "preset ships none — captions will use the default pill"}` ,
588 );
589 console. log ( ` self-check: keys preserved, ink/canvas contrast ok ✓` );