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