diff --git a/.changeset/data-fonts.md b/.changeset/data-fonts.md new file mode 100644 index 0000000..55b26ca --- /dev/null +++ b/.changeset/data-fonts.md @@ -0,0 +1,13 @@ +--- +'@vosjs/core': minor +'@vosjs/elements': minor +--- + +Data-carried webfonts. `data.fonts` accepts the same `{family, url, +weight?, style?}` entries as `config.fonts`, registered through one +dedup'd registrar: boot faces (both sources) are awaited before first +render (capped, fail-open); faces arriving via `setData` load lazily and +re-raster text elements when they land, so the real face replaces the +fallback without a recompile — font swaps become pure data edits. New +element-system API: `rerasterAll(elementMap)` plus per-instance +`refreshRaster()` (re-draw with unchanged values). diff --git a/packages/core/src/__tests__/fonts.test.ts b/packages/core/src/__tests__/fonts.test.ts index ffcb7dc..e47893c 100644 --- a/packages/core/src/__tests__/fonts.test.ts +++ b/packages/core/src/__tests__/fonts.test.ts @@ -45,9 +45,23 @@ describe('config.fonts compilation', () => { ) }) - it('emits nothing without a fonts block', () => { + it('emits the registrar even without a fonts block (data.fonts must work)', () => { const output = compileVosConfig(base) - expect(output).not.toContain('FontFace') + expect(output).toContain('const fontFaceDecls = []') + expect(output).toContain('__vosRegisterFonts(__vosData.fonts)') + }) + + it('boot awaits BOTH config and data faces; setData re-registers + re-rasters', () => { + const output = compileVosConfig({ ...base, fonts: [LEXEND] }) + // one dedup'd registrar feeds both sources at boot + expect(output).toContain('__vosRegisterFonts(fontFaceDecls)') + expect(output).toContain('__vosRegisterFonts(__vosData.fonts)') + expect(output).toContain('__vosFontSeen') + // setData: lazy registration whose completion re-rasters text elements, + // so a late-landing face replaces the fallback that painted first + const setDataBlock = output.slice(output.indexOf('setData:')) + expect(setDataBlock).toContain('__vosRegisterFonts(__vosData.fonts') + expect(setDataBlock).toContain('rerasterAll(elements)') }) it('schema keeps the fonts block (nothing stripped)', () => { diff --git a/packages/core/src/compiler/compileVosConfig.ts b/packages/core/src/compiler/compileVosConfig.ts index daa8159..6e90add 100644 --- a/packages/core/src/compiler/compileVosConfig.ts +++ b/packages/core/src/compiler/compileVosConfig.ts @@ -327,6 +327,14 @@ export const initVos = async (container, deps) => { // do not retroactively change — that is a program (T3) edit handled by warm LOAD. setData: (next) => { __vosData = Object.freeze(next ?? {}); + // Data-carried webfonts (font knobs): register new faces lazily; when + // one lands, re-raster text elements so the real face replaces the + // fallback that painted in the meantime. + __vosRegisterFonts(__vosData.fonts, () => { + if (window.__vos__ && window.__vos__.elements && window.__vos__.elements.rerasterAll) { + window.__vos__.elements.rerasterAll(elements); + } + }); // Re-resolve {$data}-bound element props (re-raster in place, no re-init). if (window.__vos__ && window.__vos__.elements && window.__vos__.elements.updateData) { window.__vos__.elements.updateData(elements, __vosData); diff --git a/packages/core/src/compiler/generators/generateFontsSetup.ts b/packages/core/src/compiler/generators/generateFontsSetup.ts index 2fbbee3..dbce0d7 100644 --- a/packages/core/src/compiler/generators/generateFontsSetup.ts +++ b/packages/core/src/compiler/generators/generateFontsSetup.ts @@ -1,36 +1,65 @@ /** - * Generate webfont registration for `config.fonts`. + * Generate webfont registration for `config.fonts` AND data-carried faces. * - * Declared faces register via the FontFace API and are AWAITED before scene - * setup and element rendering, so canvas text (elements, user setup code) - * rasterizes with the real face — in preview and in every capture path, - * including per-chunk fresh pages. Capped and fail-open: a dead URL degrades - * to fallback stacks rather than hanging the page. + * Two sources feed one dedup'd registrar: + * - `config.fonts` — compile-time declarations (unchanged contract). + * - `data.fonts` — the same `{family, url, weight?, style?}` shape carried + * in the DATA object, so hosts can register faces without a recompile + * (remix font knobs: swapping a family is a pure data edit). + * + * Boot faces (both sources) are AWAITED before scene setup and element + * rendering, so canvas text rasterizes with the real face — in preview and + * in every capture path, including per-chunk fresh pages. Capped and + * fail-open: a dead URL degrades to fallback stacks rather than hanging the + * page. Faces arriving via setData load lazily; the module's setData hooks + * their completion to re-raster text elements, so the real face replaces + * the fallback as it lands. + * + * Always emitted — data fonts must work on configs that declare none. */ export function generateFontsSetup(config: { fonts?: unknown }): string { const fonts = config.fonts - if (!Array.isArray(fonts) || fonts.length === 0) return '' - const fontsJson = JSON.stringify(fonts, null, 2).replace(/\n/g, '\n ') + const fontsJson = + Array.isArray(fonts) && fonts.length > 0 + ? JSON.stringify(fonts, null, 2).replace(/\n/g, '\n ') + : '[]' return ` - // Webfonts: register declared faces and await them (capped, fail-open) + // Webfonts: one dedup'd registrar for config-declared and data-carried + // faces (data.fonts — font knobs register faces at runtime, no recompile). + const __vosFontSeen = new Set(); + const __vosRegisterFonts = (list, onLoaded) => { + if (typeof document === 'undefined' || typeof FontFace === 'undefined') return []; + if (!Array.isArray(list)) return []; + const loads = []; + for (const f of list) { + if (!f || typeof f.family !== 'string' || !f.family || typeof f.url !== 'string' || !f.url) continue; + const key = f.family + '|' + (f.weight != null ? f.weight : 'normal') + '|' + (f.style || 'normal') + '|' + f.url; + if (__vosFontSeen.has(key)) continue; + __vosFontSeen.add(key); + try { + const face = new FontFace(f.family, 'url(' + f.url + ')', { + weight: f.weight != null ? String(f.weight) : 'normal', + style: f.style || 'normal', + }); + document.fonts.add(face); + loads.push(face.load().then(() => { if (onLoaded) onLoaded(f); }).catch(() => {})); + } catch (e) {} + } + return loads; + }; const fontFaceDecls = ${fontsJson}; - if (typeof document !== 'undefined' && typeof FontFace !== 'undefined') { - await Promise.race([ - Promise.all(fontFaceDecls.map((f) => { - try { - const face = new FontFace(f.family, 'url(' + f.url + ')', { - weight: f.weight != null ? String(f.weight) : 'normal', - style: f.style || 'normal', - }); - document.fonts.add(face); - return face.load().catch(() => {}); - } catch (e) { - return Promise.resolve(); - } - })), - new Promise((resolve) => setTimeout(resolve, 4000)), - ]); + { + const bootFontLoads = [ + ...__vosRegisterFonts(fontFaceDecls), + ...__vosRegisterFonts(__vosData.fonts), + ]; + if (bootFontLoads.length) { + await Promise.race([ + Promise.all(bootFontLoads), + new Promise((resolve) => setTimeout(resolve, 4000)), + ]); + } } ` } diff --git a/packages/core/src/types/elements.ts b/packages/core/src/types/elements.ts index 8b0b358..e5e71fb 100644 --- a/packages/core/src/types/elements.ts +++ b/packages/core/src/types/elements.ts @@ -300,6 +300,11 @@ export interface ElementInstance { * compiled module's setData). Returns true when a change was picked up. */ updateData?: (data: Record | null | undefined) => boolean + /** + * Re-raster with unchanged values — the late-webfont hook (a data-carried + * face landing after first paint re-draws over the fallback stack). + */ + refreshRaster?: () => boolean /** Re-rasterize canvas-backed textures for a new output resolution */ updateResolution?: (resolution: unknown) => boolean /** Remove element from scene */ diff --git a/packages/elements/src/__tests__/dataBinding.test.ts b/packages/elements/src/__tests__/dataBinding.test.ts index 425153c..efc78da 100644 --- a/packages/elements/src/__tests__/dataBinding.test.ts +++ b/packages/elements/src/__tests__/dataBinding.test.ts @@ -142,6 +142,24 @@ describe('bound elements through renderElements', () => { expect(inst.updateData({ headline: 'Hello!', ink: '#ff0000' })).toBe(false) }) + it('refreshRaster re-draws with unchanged values (the late-webfont hook)', async () => { + const elements = await renderElements( + [{ id: 't', type: 'text', content: 'Hi', position: 'center' }], + scenes(), + RESOLUTION, + THREE, + ) + const inst = elements.get('t')! + const mesh = inst.mesh as THREE.Mesh + const mapBefore = (mesh.material as THREE.MeshBasicMaterial).map + expect(inst.refreshRaster()).toBe(true) + await flushMicrotasks() + // same values, fresh raster: texture swapped, mesh identity stable + expect(inst.mesh).toBe(mesh) + expect((mesh.material as THREE.MeshBasicMaterial).map).not.toBe(mapBefore) + expect(inst.config.content).toBe('Hi') + }) + it('unbound elements ignore updateData', async () => { const elements = await renderElements( [{ id: 't', type: 'text', content: 'Static', position: 'center' }], diff --git a/packages/elements/src/index.ts b/packages/elements/src/index.ts index 44dd64c..8d9e0b6 100644 --- a/packages/elements/src/index.ts +++ b/packages/elements/src/index.ts @@ -25,6 +25,12 @@ export interface VosElements { elementMap: Map, data: Record | null | undefined, ) => boolean + /** + * Re-raster every canvas-backed text element with UNCHANGED values — the + * late-webfont hook: a face registered after first paint (data.fonts via + * setData) re-draws over the fallback stack once it lands. + */ + rerasterAll: (elementMap: Map) => boolean } /** @@ -62,6 +68,13 @@ export function createVosElements(THREE: typeof THREE_NS): VosElements { }) return changed }, + rerasterAll: (elementMap: Map) => { + let changed = false + elementMap.forEach((instance) => { + if (instance.refreshRaster?.()) changed = true + }) + return changed + }, } } diff --git a/packages/elements/src/renderElements.ts b/packages/elements/src/renderElements.ts index 1df5ac2..b68b044 100644 --- a/packages/elements/src/renderElements.ts +++ b/packages/elements/src/renderElements.ts @@ -327,6 +327,19 @@ export async function renderElements( console.warn('[vos] setContent on split text requires a reload') } }, + // Force a re-raster with UNCHANGED values — the hook for late-landing + // webfonts (a face registered after this element painted with the + // fallback stack). Rides the same coalescing queue; split text skips + // (per-unit meshes re-raster only on resolution/structure changes). + refreshRaster: () => { + if (!textRerender) return false + pendingRaster = pendingRaster || {} + if (!rasterScheduled) { + rasterScheduled = true + void Promise.resolve().then(flushRaster) + } + return true + }, // Re-resolve {$data}-bound props against fresh data (host setData). // Routed through the raster queue so bursts coalesce with prop // writes; split text is boot-only (per-unit meshes are structural).