feat(CompareBoard): orchestrate font preloading, pinning, and default seeding
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user