doxa-maps/library/composables/useDocumentFont.js
doxa-maps-88655c70 1b7db04a5b Vendor Poppins: the body face every line of text on these pages uses
Poppins is DX-life's --font-family-base. Ours was NOT rendering it, and a
computed-style diff could not see that: both sides merely NAME "Poppins" first
in font-family, so a first-family comparison matched and the region page
reported 68 of 68 properties identical while every line of body text was a
system fallback.

The glyph-width oracle is what caught it — the same string at the same size
measured 334.58px here against 317px on their page, 5.5% wider, i.e. a
different face. After vendoring: 317 vs 317, exact. Bebas stays 279 vs 279.
document.fonts.check() is not usable for this; it returned true for Oswald and
Brandon Grotesque, which were never vendored, and false for Bebas on DX-life's
own page.

COPIED, NOT RECREATED, and deliberately narrow: weights 400/500/600/700 only —
the four the regions surface actually asks for, measured across their _globals,
_cards, _buttons and _filters (medium x10, regular x4, bold x2, plus the 600 in
the prayer-coverage legend). Subsets latin + latin-ext only. Google's own
unicode-ranges (css2 v24) reproduced verbatim so the subset split behaves as it
does on their site. 44 KB; assets/fonts is now 0.1 MB of a 129.8 MB tree.

useDocumentFont gained per-face weights, because a real family is several files
where Bebas was one. A face without its own weight falls back to the call's
single weight, so the existing Bebas caller is unchanged.

PLACEMENT, per the zone rule: the composable stays in library/ — it has two
consumers already (both regions profiles) and nothing about it is specific to
these pages. The fonts sit in public/assets/fonts beside Bebas, self-hosted,
never a CDN.

BLOCKED ON A DRIVER DECISION: Brandon Grotesque (--font-family-buttons, every
.button) is COMMERCIAL — Google Fonts returns HTTP 400 for it, so it cannot be
vendored from there. Measured 134.34px against their 128px on FULL PROFILE.
Either license it, substitute a close free face and document the deviation, or
supply the file if Doxa already holds a licence. Recorded on
PORT-REGIONS-TYPEFACES-AND-SVG-ASSETS.

Region page still 68/68 vs live, country page 28/28, every gate green.
2026-09-24 02:05:56 +00:00

93 lines
5.6 KiB
JavaScript

/**
* useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page.
*
* WHY THIS IS NOT useShadowStyles. @font-face is DOCUMENT-SCOPED: a face declared inside a shadow
* root is ignored by every engine, so a custom element cannot register its own webfont the way it
* registers its own CSS. The face has to go in the host document; only then can shadow CSS refer
* to the family by name. That asymmetry is the whole reason this file exists next to
* useShadowStyles rather than inside it.
*
* WHY A WEBFONT AT ALL (measured 2026-09-24). The regions plugin is an exact clone of DX-life's
* /regions pages, whose headings are Bebas Neue. We cannot @font-face from their origin — their
* font file is served without CORS headers, so a cross-origin face silently fails and every
* heading falls back to a condensed system stack. That substitution was the ONLY difference left
* between our region dashboard and theirs: 7 computed properties, of which 3 were the family
* itself and 4 were knock-ons (an uppercase hack to imitate Bebas's caps, and two font-sizes
* stepped down because a wider fallback face overflowed their card widths). Vendoring the font
* collapses all 7 and DELETES those workarounds rather than adding more.
*
* SELF-HOSTED, NEVER A CDN — the same rule public/assets/vendor/tabulator follows, and the
* reason the files live under public/assets/fonts with their licence beside them.
*
* IDEMPOTENT per page: keyed by family name on document.head, so N elements composing on one page
* register one face. Safe to call from setup(); no-ops server-side and in any context without a
* document.
*/
/**
* @param {string} family the family name shadow CSS will ask for, e.g. 'Bebas Neue'
* @param {Array} faces [{ src, unicodeRange? }] — src RELATIVE to assetsBase
* @param {string} assetsBase where the shared asset tree is served from (profileConfig.assetsBase)
* @param {object} [opts] { weight = '400', style = 'normal', display = 'swap' }
*/
export function useDocumentFont(family, faces, assetsBase, opts = {}) {
if (typeof document === 'undefined' || !family || !faces?.length) return
const key = 'doxa-font-' + String(family).toLowerCase().replace(/[^a-z0-9]+/g, '-')
if (document.getElementById(key)) return
const base = String(assetsBase || './assets').replace(/\/+$/, '')
const weight = opts.weight || '400'
const style = opts.style || 'normal'
const display = opts.display || 'swap'
const css = faces.map((f) => {
// Per-face weight, because a real family is several files: Poppins ships 400/500/600/700 and
// each weight is its own woff2 per subset. A face without its own weight falls back to the
// call's single weight, so the one-weight callers (Bebas) are unchanged.
const w = f.weight || weight
// The unicode-range values are Google's own (css2 API), carried through verbatim so the
// subset split behaves as it does on their site rather than pulling the larger file always.
const range = f.unicodeRange ? ` unicode-range: ${f.unicodeRange};\n` : ''
return `@font-face {\n font-family: '${family}';\n font-style: ${style};\n` +
` font-weight: ${w};\n font-display: ${display};\n` +
` src: url('${base}/${String(f.src).replace(/^\/+/, '')}') format('woff2');\n${range}}`
}).join('\n')
const el = document.createElement('style')
el.id = key
el.setAttribute('data-doxa-font', family)
el.textContent = css
document.head.appendChild(el)
}
// Bebas Neue, as DX-life serves it: two subsets, Google's own ranges (css2 v16).
export const BEBAS_NEUE_FACES = [
{
src: 'fonts/bebas-neue/bebas-neue-latin.woff2',
unicodeRange: 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, ' +
'U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, ' +
'U+FEFF, U+FFFD',
},
{
src: 'fonts/bebas-neue/bebas-neue-latin-ext.woff2',
unicodeRange: 'U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, ' +
'U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, ' +
'U+2113, U+2C60-2C7F, U+A720-A7FF',
},
]
// Poppins — DX-life's --font-family-base, the body face of every page these components clone.
// Only the four weights the regions surface asks for (measured), latin + latin-ext only, 44 KB.
// Google's own unicode-ranges (css2 v24) so the subset split behaves as it does on their site.
const POPPINS_LATIN = 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD'
const POPPINS_LATIN_EXT = 'U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF'
export const POPPINS_FACES = [
{ weight: '400', src: 'fonts/poppins/poppins-400-latin.woff2', unicodeRange: POPPINS_LATIN },
{ weight: '400', src: 'fonts/poppins/poppins-400-latin-ext.woff2', unicodeRange: POPPINS_LATIN_EXT },
{ weight: '500', src: 'fonts/poppins/poppins-500-latin.woff2', unicodeRange: POPPINS_LATIN },
{ weight: '500', src: 'fonts/poppins/poppins-500-latin-ext.woff2', unicodeRange: POPPINS_LATIN_EXT },
{ weight: '600', src: 'fonts/poppins/poppins-600-latin.woff2', unicodeRange: POPPINS_LATIN },
{ weight: '600', src: 'fonts/poppins/poppins-600-latin-ext.woff2', unicodeRange: POPPINS_LATIN_EXT },
{ weight: '700', src: 'fonts/poppins/poppins-700-latin.woff2', unicodeRange: POPPINS_LATIN },
{ weight: '700', src: 'fonts/poppins/poppins-700-latin-ext.woff2', unicodeRange: POPPINS_LATIN_EXT },
]