Setting the file. One moment.
Build Frame · Faceless Explainer · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page — line 239
This file
Number 15.9
Position 9 of 26
Type JavaScript
Size 26 KB
Lines 554 scripts/ build-frame.mjs
JavaScript · 554 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 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 // ── cap type weights to the brand font's available faces ──────────────────────
364 // The remix swaps the font FAMILY but keeps the preset's weights; a brand font that ships
365 // only e.g. 400/500 would faux-bold every 600/700 heading. Clamp each `typography:` weight
366 // to the NEAREST weight the brand font actually provides (tokens.json fonts[].weights).
367 if (brandFonts. length && brandFontWeights. length ) {
368 const avail = brandFontWeights;
369 const nearest = ( n ) =>
370 avail. reduce (( best , w ) => {
371 const dw = Math. abs (w - n),
372 db = Math. abs (best - n);
373 return dw < db || (dw === db && w > best) ? w : best;
374 }, avail[ 0 ]);
375 let capped = 0 ;
376 const cap = ( num ) => {
377 const n = parseInt (num, 10 );
378 if (avail. includes (n)) return String (n);
379 const c = nearest (n);
380 if (c !== n) capped ++ ;
381 return String (c);
382 };
383 let inType = false ;
384 md = md
385 . split ( / \r ? \n / )
386 . map (( line ) => {
387 if ( / ^ typography: \s *$ / . test (line)) {
388 inType = true ;
389 return line;
390 }
391 if (inType && / ^ \S / . test (line)) inType = false ;
392 let out = line;
393 // (a) structured `weight: NNN` in the typography ramp
394 if (inType) out = out. replace ( /( \b weight: \s * )( \d {3} ) \b / g , ( m , pfx , num ) => pfx + cap (num));
395 // (b) a weight baked into a quoted `typography:` component value, e.g.
396 // cta-button → typography: "Basier Square 600" (NNN not followed by a unit like px)
397 out = out. replace (
398 /(typography: \s * " [ ^ "] *?\b )( \d {3} ) \b (?! [a-z%] )/ gi ,
399 ( m , pfx , num ) => pfx + cap (num),
400 );
401 return out;
402 })
403 . join ( " \n " );
404 if (capped)
405 summary. push ( `fonts: capped ${ capped } type weight(s) to brand faces {${ avail . join ( ", " ) }}` );
406 }
407
408 // ── brand-adaptation note ─────────────────────────────────────────────────────
409 // The remix fixes the NORMATIVE frontmatter, but the preset's PROSE still carries its
410 // original weight ranges / color-names. Prepend a short "frontmatter is truth" header so a
411 // reader (or frame worker) interprets any lingering preset prose THROUGH the brand values —
412 // instead of fragile per-sentence prose surgery.
413 if (brandFonts. length || (brandColors. length && presetColors. length )) {
414 const bD = brandFonts[ 0 ];
415 const bB = brandFonts[ 1 ] ?? brandFonts[ 0 ];
416 const note =
417 `## Brand adaptation (READ FIRST — the frontmatter is the source of truth) \n\n ` +
418 `This is the **${ presetName }** preset remixed onto the captured brand. The YAML frontmatter above ` +
419 `(colors · typography · components) is **normative and already correct — use it verbatim.** The prose ` +
420 `below is the ORIGINAL preset's intent; read it THROUGH the frontmatter: \n\n ` +
421 (brandFonts. length
422 ? `- **Fonts** — already set to **${ bD }** (display) / **${ bB }** (body); ignore any preset font name lingering in prose. \n `
423 : "" ) +
424 (brandFontWeights. length
425 ? `- **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 `
426 : "" ) +
427 `- **Colors** — use the frontmatter hex; preset color NAMES in prose (e.g. "cobalt", "cream") mean the remapped brand values. \n ` ;
428 if ( / ^ # . *$ / m . test (md)) md = md. replace ( / ^ # . *$ / m , ( m ) => `${ m } \n\n ${ note }` );
429 else md = `${ note } \n ${ md }` ;
430 summary. push ( "brand-adaptation note prepended" );
431 }
432
433 // ── stage brand font files + emit @font-face ──────────────────────────────────
434 // A brand font is rarely a Google font, so renaming the family in frame.md is not enough:
435 // nothing loads the actual face. If the capture downloaded font files, copy them to
436 // assets/fonts/ under CLEAN, face-named names (so captions.mjs' family-prefix matcher
437 // finds them too) and append a ready-to-paste, ROOT-RELATIVE @font-face block to frame.md.
438 //
439 // The staged NAME is a contract, not cosmetics: captions.mjs derives each face's weight and
440 // style back out of it. So the name has to carry every axis that distinguishes one face from
441 // another, and the dedup key has to be the whole face. Naming on weight alone made Google's
442 // two-file Newsreader download (upright + italic, both scoring "Regular") collide on one
443 // slot: the italic sorts first, took the name, the upright was never staged, and the block
444 // below then asserted font-style:normal over italic bytes.
445 if (brandFonts. length ) {
446 const norm = ( s ) =>
447 String (s)
448 . toLowerCase ()
449 . replace ( / [ ^ a-z0-9] / g , "" );
450 const extOf = ( f ) => (f. match ( / \. (woff2 | woff | ttf | otf) $ / i )?.[ 1 ] ?? "" ). toLowerCase ();
451 const FMT = { woff2: "woff2" , woff: "woff" , ttf: "truetype" , otf: "opentype" };
452 const weightInfo = ( name ) => {
453 const s = name. toLowerCase ();
454 // A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource
455 // names every face that way and carries no weight WORD at all, so word-only parsing
456 // scored a whole family "Regular" and staged exactly one of its faces.
457 //
458 // A weight token must not be buried inside a longer run: this reads capture files,
459 // which are commonly hash-named, and "Newsreader-a1b200c3.woff2" is not a 200-weight
460 // face. Hence a non-digit before (which also stops "2100" reading as 100) and no
461 // alphanumeric after. "Roboto900.ttf" still parses.
462 const numeric = /(?: ^| [ ^ 0-9] )( [1-9] 00)(?! [0-9a-z] )/ . exec (s);
463 if (numeric) return { n: Number (numeric[ 1 ]), w: numeric[ 1 ] };
464 if ( /black | heavy | ultra | extrabold/ . test (s)) return { n: 800 , w: "ExtraBold" };
465 if ( /semibold | demibold/ . test (s)) return { n: 600 , w: "SemiBold" };
466 if ( /bold/ . test (s)) return { n: 700 , w: "Bold" };
467 if ( /medium/ . test (s)) return { n: 500 , w: "Medium" };
468 if ( /light | thin/ . test (s)) return { n: 300 , w: "Light" };
469 return { n: 400 , w: "Regular" };
470 };
471 const styleOf = ( name ) => ( /italic | oblique/ i . test (name) ? "italic" : "normal" );
472 const fams = [ ...new Set (brandFonts)];
473 const srcDirs = [
474 join (hyperframesDir, "capture/assets/fonts" ),
475 join (hyperframesDir, "assets/fonts" ),
476 ]. filter (( d ) => existsSync (d));
477 const files = [];
478 for ( const d of srcDirs)
479 for ( const f of readdirSync (d). sort ()) if ( extOf (f)) files. push ({ d, f });
480 // Single family → all font files belong to it (the common captured case, hash-named files
481 // included). Multiple families → assign each file to the longest family key its name contains.
482 const ranked = [ ... fams]. sort (( a , b ) => norm (b). length - norm (a). length );
483 const famOf = ( f ) =>
484 fams. length === 1 ? fams[ 0 ] : ranked. find (( x ) => norm (f). includes ( norm (x)));
485 const outDir = join (hyperframesDir, "assets/fonts" );
486 const captured = stageCapturedFonts (hyperframesDir, fams);
487 const faces = [ ... captured.faces];
488 const stagedNames = new Set (captured.files);
489 for ( const { d , f } of files) {
490 const fam = famOf (f);
491 if ( ! fam || captured.families. has (fam. toLowerCase ())) continue ;
492 const { n , w } = weightInfo (f);
493 const style = styleOf (f);
494 const clean = `${ fam . replace ( / [ ^ A-Za-z0-9] / g , "" ) }-${ w }${ style === "italic" ? "-Italic" : ""}.${ extOf ( f ) }` ;
495 if (stagedNames. has (clean)) continue ;
496 mkdirSync (outDir, { recursive: true });
497 if ( ! existsSync ( join (outDir, clean))) copyFileSync ( join (d, f), join (outDir, clean));
498 stagedNames. add (clean);
499 faces. push (
500 `@font-face{font-family:"${ fam }";font-weight:${ n };font-style:${ style };font-display:block;src:url("assets/fonts/${ clean }") format("${ FMT [ extOf ( f )] }");}` ,
501 );
502 }
503 if (faces. length ) {
504 md +=
505 ` \n\n ## Font loading (auto-generated) \n\n ` +
506 `The brand font ships as local files in \` assets/fonts/ \` — do NOT link Google Fonts for it. ` +
507 `Paste this \` <style> \` into every frame's \` <head> \` / \` <template> \` (captions use the same files) ` +
508 `so \` font-family \` resolves in preview, snapshot, and render alike: \n\n ` +
509 "```html \n <style> \n " +
510 faces. join ( " \n " ) +
511 " \n </style> \n ``` \n " ;
512 summary. push (
513 `fonts: staged ${ stagedNames . size } face(s) → assets/fonts/ + @font-face in frame.md` ,
514 );
515 }
516 }
517
518 // ── write frame.md ────────────────────────────────────────────────────────────
519 const framePath = join (hyperframesDir, "frame.md" );
520 writeFileSync (framePath, md);
521
522 // ── copy caption-skin.html ────────────────────────────────────────────────────
523 const presetSkin = join (presetDir, presetName, "caption-skin.html" );
524 let skinCopied = false ;
525 if ( existsSync (presetSkin)) {
526 const skinDir = join (hyperframesDir, ".hyperframes" );
527 mkdirSync (skinDir, { recursive: true });
528 copyFileSync (presetSkin, join (skinDir, "caption-skin.html" ));
529 skinCopied = true ;
530 }
531
532 // ── self-validate ─────────────────────────────────────────────────────────────
533 const outColors = parseColors (md);
534 if (outColors. length !== presetColors. length ) {
535 die ( `color keys changed (${ presetColors . length }→${ outColors . length }) — keys must be preserved` );
536 }
537 const outRoles = semanticColors (outColors);
538 const li = lum (outRoles.ink),
539 lc = lum (outRoles.canvas);
540 // ink (type) and canvas (ground) must differ enough to READ — in EITHER direction. A
541 // light-mode spec has ink darker than canvas; a dark-mode spec (the polarity flip above)
542 // the reverse. Assert luminance SEPARATION, not a fixed polarity.
543 if (li != null && lc != null && Math. abs (li - lc) < 40 ) {
544 die (
545 `ink (${ outRoles . ink }, lum ${ li . toFixed ( 0 ) }) and canvas (${ outRoles . canvas }, lum ${ lc . toFixed ( 0 ) }) lack contrast — bad brand mapping` ,
546 );
547 }
548
549 console. log ( `✓ build-frame: ${ presetName } → ${ framePath }` );
550 for ( const s of summary) console. log ( ` ${ s }` );
551 console. log (
552 ` .hyperframes/caption-skin.html: ${ skinCopied ? "copied" : "preset ships none — captions will use the default pill"}` ,
553 );
554 console. log ( ` self-check: keys preserved, ink/canvas contrast ok ✓` );