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:
Miguel Ángel
2026-08-17 21:51:52 -04:00
committed by GitHub
parent f8a1e2d315
commit a41da86517
10 changed files with 657 additions and 27 deletions
@@ -429,8 +429,15 @@ if (brandFonts.length || (brandColors.length && presetColors.length)) {
// ── stage brand font files + emit @font-face ──────────────────────────────────
// A brand font is rarely a Google font, so renaming the family in frame.md is not enough:
// nothing loads the actual face. If the capture downloaded font files, copy them to
// assets/fonts/ under CLEAN, weight-named names (so captions.mjs' family-prefix matcher
// assets/fonts/ under CLEAN, face-named names (so captions.mjs' family-prefix matcher
// finds them too) and append a ready-to-paste, ROOT-RELATIVE @font-face block to frame.md.
//
// The staged NAME is a contract, not cosmetics: captions.mjs derives each face's weight and
// style back out of it. So the name has to carry every axis that distinguishes one face from
// another, and the dedup key has to be the whole face. Naming on weight alone made Google's
// two-file Newsreader download (upright + italic, both scoring "Regular") collide on one
// slot: the italic sorts first, took the name, the upright was never staged, and the block
// below then asserted font-style:normal over italic bytes.
if (brandFonts.length) {
const norm = (s) =>
String(s)
@@ -440,6 +447,16 @@ if (brandFonts.length) {
const FMT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" };
const weightInfo = (name) => {
const s = name.toLowerCase();
// A numeric axis is the font's own answer, so it beats the word heuristic. Fontsource
// names every face that way and carries no weight WORD at all, so word-only parsing
// scored a whole family "Regular" and staged exactly one of its faces.
//
// A weight token must not be buried inside a longer run: this reads capture files,
// which are commonly hash-named, 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.
const numeric = /(?:^|[^0-9])([1-9]00)(?![0-9a-z])/.exec(s);
if (numeric) return { n: Number(numeric[1]), w: numeric[1] };
if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" };
if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" };
if (/bold/.test(s)) return { n: 700, w: "Bold" };
@@ -447,6 +464,7 @@ if (brandFonts.length) {
if (/light|thin/.test(s)) return { n: 300, w: "Light" };
return { n: 400, w: "Regular" };
};
const styleOf = (name) => (/italic|oblique/i.test(name) ? "italic" : "normal");
const fams = [...new Set(brandFonts)];
const srcDirs = [
join(hyperframesDir, "capture/assets/fonts"),
@@ -467,13 +485,14 @@ if (brandFonts.length) {
const fam = famOf(f);
if (!fam) continue;
const { n, w } = weightInfo(f);
const clean = `${fam.replace(/[^A-Za-z0-9]/g, "")}-${w}.${extOf(f)}`;
const style = styleOf(f);
const clean = `${fam.replace(/[^A-Za-z0-9]/g, "")}-${w}${style === "italic" ? "-Italic" : ""}.${extOf(f)}`;
if (stagedNames.has(clean)) continue;
mkdirSync(outDir, { recursive: true });
if (!existsSync(join(outDir, clean))) copyFileSync(join(d, f), join(outDir, clean));
stagedNames.add(clean);
faces.push(
`@font-face{font-family:"${fam}";font-weight:${n};font-style:normal;font-display:block;src:url("assets/fonts/${clean}") format("${FMT[extOf(f)]}");}`,
`@font-face{font-family:"${fam}";font-weight:${n};font-style:${style};font-display:block;src:url("assets/fonts/${clean}") format("${FMT[extOf(f)]}");}`,
);
}
if (faces.length) {