mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(skills): declare the caption brand font's style axis, not just its weight (#3300)
A font filename encodes more than a weight, but only the weight was ever read out of it, so two faces of one family collapsed onto a single slot. Google Fonts ships Newsreader as Newsreader-Italic-VariableFont_opsz,wght.ttf and Newsreader-VariableFont_opsz,wght.ttf. The italic sorts first, both scored 400, so the italic claimed the family's only 400 slot, the upright was dropped as a duplicate, and the surviving face was declared with no font-style at all. @font-face is deliberately global — the composition CSS scoper exempts it, since a face declaration cannot be scoped — so mounting captions re-pointed the whole document's Newsreader at the italic file and every sibling composition rendered in italics. Faces now carry a font-style descriptor and dedupe on weight AND style. The same fix had to land in build-frame.mjs, which renames captured fonts BEFORE captions.mjs sees them. It dropped the style token while renaming, so an italic file arrived as "Newsreader-Regular.ttf" and was then asserted upright — leaving the global normal slot pointing at italic bytes even once brandFontFaces understood styles. The staged filename is a contract: it must carry every axis that distinguishes one face from another, and the dedup key must be the whole face. Second axis, same misparse: weight parsing matched WORDS only, so a Fontsource capture (inter-latin-500-normal.woff2) scored a whole family 400 and shipped one of its faces. A numeric axis in the filename now wins over the word heuristic, anchored so it is not read out of the middle of a hash-named capture file — a non-digit before it and no alphanumeric after, which keeps both the 4-digit guard and separator-free names like Roboto900.ttf. Tests pin both ends of the contract: a round-trip asserting the names build-frame stages map back to the right weight+style through the real brandFontFaces, plus a source check that no copy reverts to a weight-only name or a hardcoded font-style:normal. captions.mjs also gains a parity pin across the three workflows that ship it. Not addressed: a VariableFont file is still declared at a single font-weight rather than its range, so weights it could interpolate are still synthesized.
This commit is contained in:
@@ -302,6 +302,17 @@ function brandFontFaces(framePath, hyperframesDir) {
|
||||
].filter((d) => existsSync(d.abs));
|
||||
const weightOf = (n) => {
|
||||
const s = n.toLowerCase();
|
||||
// A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource
|
||||
// names every face this way ("inter-latin-500-normal.woff2") and carries no weight
|
||||
// WORD at all, so word-only parsing collapsed a whole family onto 400 and shipped
|
||||
// exactly one of its faces.
|
||||
//
|
||||
// A weight token must not be buried inside a longer run: capture/assets/fonts holds
|
||||
// hash-named files, and "Newsreader-a1b200c3.woff2" is not a 200-weight face. Hence a
|
||||
// non-digit before (which also stops "2100" reading as 100) and no alphanumeric after.
|
||||
// "Roboto900.ttf" still parses — requiring separators on both sides would have lost it.
|
||||
const numeric = /(?:^|[^0-9])([1-9]00)(?![0-9a-z])/.exec(s);
|
||||
if (numeric) return Number(numeric[1]);
|
||||
if (/black|heavy|ultra|extrabold/.test(s)) return 800;
|
||||
if (/semibold|demibold/.test(s)) return 600; // before /bold/ — "demibold" contains "bold"
|
||||
if (/bold/.test(s)) return 700;
|
||||
@@ -309,6 +320,14 @@ function brandFontFaces(framePath, hyperframesDir) {
|
||||
if (/light|thin/.test(s)) return 300;
|
||||
return 400; // book / regular / roman
|
||||
};
|
||||
// Weight is not the only axis in a filename. Google Fonts ships Newsreader as
|
||||
// "Newsreader-Italic-VariableFont_opsz,wght.ttf" + "Newsreader-VariableFont_opsz,wght.ttf",
|
||||
// and the italic sorts first — so without a style axis the italic file claimed the
|
||||
// family's ONLY 400 slot, the upright file was dropped as a duplicate, and the face
|
||||
// was declared with no `font-style`. @font-face is deliberately global (the composition
|
||||
// CSS scoper exempts it, and it has to be), so the whole document then rendered that
|
||||
// family in italics — captions italicizing every sibling composition.
|
||||
const styleOf = (n) => (/italic|oblique/i.test(n) ? "italic" : "normal");
|
||||
const fmtOf = (f) =>
|
||||
/\.woff2$/i.test(f)
|
||||
? "woff2"
|
||||
@@ -345,12 +364,13 @@ function brandFontFaces(framePath, hyperframesDir) {
|
||||
if (claimed.has(f)) continue; // a more specific family already took this file
|
||||
if (!norm(f.replace(/\.(woff2|woff|ttf|otf)$/i, "")).startsWith(key)) continue;
|
||||
const w = weightOf(f);
|
||||
const dedup = `${fam}-${w}`;
|
||||
if (seen.has(dedup)) continue; // one src per weight; assets/fonts wins over capture
|
||||
const style = styleOf(f);
|
||||
const dedup = `${fam}-${w}-${style}`;
|
||||
if (seen.has(dedup)) continue; // one src per face; assets/fonts wins over capture
|
||||
seen.add(dedup);
|
||||
claimed.add(f);
|
||||
faces.push(
|
||||
` @font-face { font-family: '${fam}'; src: url('${d.rel}/${f}') format('${fmtOf(f)}'); font-weight: ${w}; font-display: block; }`,
|
||||
` @font-face { font-family: '${fam}'; src: url('${d.rel}/${f}') format('${fmtOf(f)}'); font-weight: ${w}; font-style: ${style}; font-display: block; }`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -373,6 +393,8 @@ function brandFontFaces(framePath, hyperframesDir) {
|
||||
return faces.join("\n");
|
||||
}
|
||||
|
||||
export { brandFontFaces }; // exported as a seam for unit testing
|
||||
|
||||
// frame.md colors:/typography: → a :root token block, mapped to the fixed semantic
|
||||
// vocab every preset skin references. Robust to per-preset key names: colors are
|
||||
// matched by name, then by luminance. Brand-token overlay (Step 2) flows through
|
||||
|
||||
Reference in New Issue
Block a user