feat(CompareBoard): orchestrate font preloading, pinning, and default seeding

This commit is contained in:
Ilia Mashkov
2026-06-24 14:59:17 +03:00
parent 92ea7b9dc4
commit 132d1327f5
2 changed files with 242 additions and 3 deletions
@@ -4,7 +4,56 @@ import {
describe, describe,
expect, expect,
it, it,
vi,
} from 'vitest'; } 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<typeof import('$entities/Font')>();
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 { import {
__resetBoard, __resetBoard,
getBoard, getBoard,
@@ -92,6 +141,31 @@ describe('boardStore', () => {
expect(board.focalId).toBe(b.id); 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', () => { it('exposes default per-role typography', () => {
const board = getBoard(); const board = getBoard();
expect(board.typo.header.size).toBeGreaterThan(0); expect(board.typo.header.size).toBeGreaterThan(0);
@@ -7,8 +7,10 @@
* *
* Typography is NOT owned here as an AdjustTypography store (features can't * Typography is NOT owned here as an AdjustTypography store (features can't
* import sibling features). Instead the board holds plain per-role typography * import sibling features). Instead the board holds plain per-role typography
* values, fed in via `setTypo` by `widgets/Board` (dependency inversion). Font * values, fed in via `setTypo` by `widgets/Board` (dependency inversion).
* resolution/loading (Task 14) and frame measurement (Task 16) layer on later. *
* Font metadata is resolved + preloaded via the Font entity (candidate
* preloading, focal pinning). Frame measurement (Task 16) layers on later.
*/ */
import { import {
@@ -16,6 +18,14 @@ import {
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
DEFAULT_LETTER_SPACING, DEFAULT_LETTER_SPACING,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
type FontCatalogStore,
type FontLifecycleManager,
type FontLoadRequestConfig,
FontsByIdsStore,
type UnifiedFont,
getFontCatalog,
getFontLifecycleManager,
getFontUrl,
} from '$entities/Font'; } from '$entities/Font';
import { import {
type Pairing, type Pairing,
@@ -32,7 +42,10 @@ import {
createPersistentStore, createPersistentStore,
createSingleton, createSingleton,
} from '$shared/lib'; } 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 * 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. * localStorage-backed mirror of the board blob.
*/ */
#storage = createPersistentStore<PersistedBoard>(BOARD_STORAGE_KEY, emptyBoard()); #storage = createPersistentStore<PersistedBoard>(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() { constructor() {
const stored = this.#storage.value; const stored = this.#storage.value;
this.#pairings = stored.pairings.map(p => createPairing(p.h, p.b, p.id)); this.#pairings = stored.pairings.map(p => createPairing(p.h, p.b, p.id));
this.#focalId = stored.focalId; this.#focalId = stored.focalId;
this.#specimen = { ...stored.specimen }; 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<string>();
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 } }; 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<string>();
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 * Adds a pairing to the end of the board. The first pairing added becomes
* focal. * focal.
@@ -314,6 +477,8 @@ export class BoardStore {
*/ */
destroy() { destroy() {
flushSync(); flushSync();
this.#disposeEffects();
this.#fontsByIds.destroy();
this.#storage.destroy(); this.#storage.destroy();
} }
} }