feat(CompareBoard): orchestrate font preloading, pinning, and default seeding
This commit is contained in:
@@ -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<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 {
|
||||
__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);
|
||||
|
||||
@@ -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<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() {
|
||||
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<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 } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* focal.
|
||||
@@ -314,6 +477,8 @@ export class BoardStore {
|
||||
*/
|
||||
destroy() {
|
||||
flushSync();
|
||||
this.#disposeEffects();
|
||||
this.#fontsByIds.destroy();
|
||||
this.#storage.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user