Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/data-fonts.md
Original file line number Diff line number Diff line change
@@ -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).
18 changes: 16 additions & 2 deletions packages/core/src/__tests__/fonts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/compiler/compileVosConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
79 changes: 54 additions & 25 deletions packages/core/src/compiler/generators/generateFontsSetup.ts
Original file line number Diff line number Diff line change
@@ -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)),
]);
}
}
`
}
5 changes: 5 additions & 0 deletions packages/core/src/types/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@ export interface ElementInstance {
* compiled module's setData). Returns true when a change was picked up.
*/
updateData?: (data: Record<string, unknown> | 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 */
Expand Down
18 changes: 18 additions & 0 deletions packages/elements/src/__tests__/dataBinding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
Expand Down
13 changes: 13 additions & 0 deletions packages/elements/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export interface VosElements {
elementMap: Map<string, any>,
data: Record<string, unknown> | 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<string, any>) => boolean
}

/**
Expand Down Expand Up @@ -62,6 +68,13 @@ export function createVosElements(THREE: typeof THREE_NS): VosElements {
})
return changed
},
rerasterAll: (elementMap: Map<string, any>) => {
let changed = false
elementMap.forEach((instance) => {
if (instance.refreshRaster?.()) changed = true
})
return changed
},
}
}

Expand Down
13 changes: 13 additions & 0 deletions packages/elements/src/renderElements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading