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.
93 lines
5.6 KiB
JavaScript
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 },
|
|
]
|