From 132d1327f5eac2095b6cf6e157853ff4284c9dde Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 24 Jun 2026 14:59:17 +0300 Subject: [PATCH] feat(CompareBoard): orchestrate font preloading, pinning, and default seeding --- .../boardStore/boardStore.svelte.test.ts | 74 ++++++++ .../store/boardStore/boardStore.svelte.ts | 171 +++++++++++++++++- 2 files changed, 242 insertions(+), 3 deletions(-) diff --git a/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.test.ts b/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.test.ts index 1a488b6..9e35263 100644 --- a/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.test.ts +++ b/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.test.ts @@ -4,7 +4,56 @@ import { describe, expect, it, + vi, } from 'vitest'; + +/** + * Font orchestration is exercised in e2e (needs a real browser/queryClient). + * Here we stub the entity's font stores so the board's pure logic stays testable + * off the network — only `candidateFontIds` derivation is asserted at this level. + */ +const mockLifecycle = vi.hoisted(() => ({ + touch: vi.fn(), + pin: vi.fn(), + unpin: vi.fn(), +})); + +/** + * Catalog stub with four fonts so the seeding effect has material to pair. + * Seeding only fires when storage is empty AND nothing has been added yet, so + * the empty/add tests (which never flush before asserting) are unaffected. + */ +const mockCatalog = vi.hoisted(() => ({ + fonts: [ + { id: 'c0', name: 'C0' }, + { id: 'c1', name: 'C1' }, + { id: 'c2', name: 'C2' }, + { id: 'c3', name: 'C3' }, + ], +})); + +vi.mock('$entities/Font', async importOriginal => { + const actual = await importOriginal(); + class MockFontsByIdsStore { + setIds() {} + get fonts() { + return []; + } + get isLoading() { + return false; + } + destroy() {} + } + return { + ...actual, + FontsByIdsStore: MockFontsByIdsStore, + getFontLifecycleManager: () => mockLifecycle, + getFontCatalog: () => mockCatalog, + getFontUrl: () => 'https://example.com/font.woff2', + }; +}); + +import { flushSync } from 'svelte'; import { __resetBoard, getBoard, @@ -92,6 +141,31 @@ describe('boardStore', () => { expect(board.focalId).toBe(b.id); }); + it('seeds curated pairings from the catalog when storage is empty', () => { + const board = getBoard(); + flushSync(); // let the seed effect run + expect(board.pairings.length).toBeGreaterThan(0); + expect(board.focalId).not.toBeNull(); + }); + + it('does not seed when storage already has pairings', () => { + // pre-seed storage so a fresh board rehydrates instead of seeding + const first = getBoard(); + const p = first.addPairing('Inter', 'Lora'); + __resetBoard(); + const restored = getBoard(); + flushSync(); + expect(restored.pairings).toHaveLength(1); + expect(restored.pairings[0].id).toBe(p.id); + }); + + it('collects every distinct candidate font id for preloading', () => { + const board = getBoard(); + board.addPairing('Inter', 'Lora'); + board.addPairing('Inter', 'Merriweather'); // Inter deduped + expect(new Set(board.candidateFontIds)).toEqual(new Set(['Inter', 'Lora', 'Merriweather'])); + }); + it('exposes default per-role typography', () => { const board = getBoard(); expect(board.typo.header.size).toBeGreaterThan(0); diff --git a/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts b/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts index e9eb10d..2491fd6 100644 --- a/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts +++ b/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts @@ -7,8 +7,10 @@ * * Typography is NOT owned here as an AdjustTypography store (features can't * import sibling features). Instead the board holds plain per-role typography - * values, fed in via `setTypo` by `widgets/Board` (dependency inversion). Font - * resolution/loading (Task 14) and frame measurement (Task 16) layer on later. + * values, fed in via `setTypo` by `widgets/Board` (dependency inversion). + * + * Font metadata is resolved + preloaded via the Font entity (candidate + * preloading, focal pinning). Frame measurement (Task 16) layers on later. */ import { @@ -16,6 +18,14 @@ import { DEFAULT_FONT_WEIGHT, DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, + type FontCatalogStore, + type FontLifecycleManager, + type FontLoadRequestConfig, + FontsByIdsStore, + type UnifiedFont, + getFontCatalog, + getFontLifecycleManager, + getFontUrl, } from '$entities/Font'; import { type Pairing, @@ -32,7 +42,10 @@ import { createPersistentStore, createSingleton, } from '$shared/lib'; -import { flushSync } from 'svelte'; +import { + flushSync, + untrack, +} from 'svelte'; /** * Compact persisted board shape. Font ids are abbreviated (`h`/`b`) to keep the @@ -128,12 +141,126 @@ export class BoardStore { * localStorage-backed mirror of the board blob. */ #storage = createPersistentStore(BOARD_STORAGE_KEY, emptyBoard()); + /** + * Batch font-metadata resolver, kept in sync with `candidateFontIds`. + */ + #fontsByIds: FontsByIdsStore; + /** + * Font load/cache/eviction manager; pinned to keep on-screen fonts resident. + */ + #lifecycle: FontLifecycleManager; + /** + * Paginated font catalog — source of fonts for default seeding. + */ + #fontCatalog: FontCatalogStore; + /** + * One-shot guard: only seed a default board when storage was empty at + * construction (never re-seed after the user empties the board). + */ + #shouldSeed: boolean; + /** + * Disposes the constructor's $effect.root. Must run on teardown. + */ + #disposeEffects: () => void; constructor() { const stored = this.#storage.value; this.#pairings = stored.pairings.map(p => createPairing(p.h, p.b, p.id)); this.#focalId = stored.focalId; this.#specimen = { ...stored.specimen }; + this.#shouldSeed = stored.pairings.length === 0; + + this.#lifecycle = getFontLifecycleManager(); + this.#fontCatalog = getFontCatalog(); + this.#fontsByIds = new FontsByIdsStore(this.candidateFontIds); + + this.#disposeEffects = $effect.root(() => { + // Seed a curated default board the first time the catalog is ready and + // storage was empty — so the screen is never blank on first visit. + $effect(() => { + if (!this.#shouldSeed || this.#pairings.length > 0) { + return; + } + const fonts = this.#fontCatalog.fonts; + if (fonts.length < 2) { + return; + } + untrack(() => { + this.#shouldSeed = false; + const count = Math.min(4, Math.floor(fonts.length / 2)); + for (let i = 0; i < count; i++) { + this.addPairing(fonts[i * 2].id, fonts[i * 2 + 1].id); + } + }); + }); + + // Keep the batch query's id set in sync with the board's candidates. + $effect(() => { + this.#fontsByIds.setIds(this.candidateFontIds); + }); + + // Preload every candidate font at its role weight (brief §Performance). + $effect(() => { + const configs = this.#candidateConfigs(); + if (configs.length > 0) { + this.#lifecycle.touch(configs); + } + }); + + // Pin the focal pairing's fonts so eviction never drops on-screen + // glyphs; unpin on focal/weight change via the cleanup return. + $effect(() => { + const focal = this.focal; + if (!focal) { + return; + } + const headerWeight = this.#typo.header.weight; + const bodyWeight = this.#typo.body.weight; + const header = this.fontById(focal.headerFontId); + const body = this.fontById(focal.bodyFontId); + if (header) { + this.#lifecycle.pin(header.id, headerWeight, header.features?.isVariable); + } + if (body) { + this.#lifecycle.pin(body.id, bodyWeight, body.features?.isVariable); + } + return () => { + if (header) { + this.#lifecycle.unpin(header.id, headerWeight, header.features?.isVariable); + } + if (body) { + this.#lifecycle.unpin(body.id, bodyWeight, body.features?.isVariable); + } + }; + }); + }); + } + + /** + * Builds dedup'd font-load configs for every resolvable candidate font at its + * role weight (header fonts at header weight, body fonts at body weight). + * Unresolved fonts (metadata not yet fetched) are skipped. + */ + #candidateConfigs(): FontLoadRequestConfig[] { + const configs: FontLoadRequestConfig[] = []; + const seen = new Set(); + const add = (fontId: string, weight: number) => { + const font = this.fontById(fontId); + if (!font) { + return; + } + const url = getFontUrl(font, weight); + if (!url || seen.has(url)) { + return; + } + seen.add(url); + configs.push({ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable }); + }; + for (const pairing of this.#pairings) { + add(pairing.headerFontId, this.#typo.header.weight); + add(pairing.bodyFontId, this.#typo.body.weight); + } + return configs; } /** @@ -196,6 +323,42 @@ export class BoardStore { this.#typo = { ...this.#typo, [role]: { ...values } }; } + /** + * Every distinct font id referenced by any pairing (header or body). The + * preload set — kept in sync with the batch font resolver. + */ + get candidateFontIds(): string[] { + const ids = new Set(); + for (const pairing of this.#pairings) { + ids.add(pairing.headerFontId); + ids.add(pairing.bodyFontId); + } + return [...ids]; + } + + /** + * Resolves a font id to its loaded metadata, or undefined if not yet fetched. + * + * @param id - Font entity id. + * @returns The font metadata, or undefined while loading. + */ + fontById(id: string): UnifiedFont | undefined { + return this.#fontsByIds.fonts.find(f => f.id === id); + } + + /** + * Resolves both fonts of a pairing for the UI. + * + * @param pairing - The pairing to resolve. + * @returns Header and body font metadata (each undefined while loading). + */ + resolvePairingFonts(pairing: Pairing): { header?: UnifiedFont; body?: UnifiedFont } { + return { + header: this.fontById(pairing.headerFontId), + body: this.fontById(pairing.bodyFontId), + }; + } + /** * Adds a pairing to the end of the board. The first pairing added becomes * focal. @@ -314,6 +477,8 @@ export class BoardStore { */ destroy() { flushSync(); + this.#disposeEffects(); + this.#fontsByIds.destroy(); this.#storage.destroy(); } }