diff --git a/src/features/CompareBoard/index.ts b/src/features/CompareBoard/index.ts new file mode 100644 index 0000000..7109a36 --- /dev/null +++ b/src/features/CompareBoard/index.ts @@ -0,0 +1,8 @@ +export { fitColumns } from './lib'; +export { + __resetBoard, + type BoardStore, + getBoard, + MAX_COLUMNS, + type RoleTypography, +} from './model'; diff --git a/src/features/CompareBoard/lib/index.ts b/src/features/CompareBoard/lib/index.ts new file mode 100644 index 0000000..5667963 --- /dev/null +++ b/src/features/CompareBoard/lib/index.ts @@ -0,0 +1,8 @@ +export { + combineFrameHeight, + type CombineFrameHeightInput, + fitColumns, + type FitColumnsInput, + measureRoleHeight, + type RoleHeightInput, +} from './measure'; diff --git a/src/features/CompareBoard/lib/measure/combineFrameHeight.test.ts b/src/features/CompareBoard/lib/measure/combineFrameHeight.test.ts new file mode 100644 index 0000000..27514b1 --- /dev/null +++ b/src/features/CompareBoard/lib/measure/combineFrameHeight.test.ts @@ -0,0 +1,19 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import { combineFrameHeight } from './combineFrameHeight'; + +describe('combineFrameHeight', () => { + it('sums header + gap + body block heights', () => { + expect(combineFrameHeight({ headerHeight: 60, bodyHeight: 200, gap: 24 })).toBe(284); + }); + it('omits the gap when one block is empty (zero height)', () => { + expect(combineFrameHeight({ headerHeight: 0, bodyHeight: 200, gap: 24 })).toBe(200); + expect(combineFrameHeight({ headerHeight: 60, bodyHeight: 0, gap: 24 })).toBe(60); + }); + it('is zero when both blocks are empty', () => { + expect(combineFrameHeight({ headerHeight: 0, bodyHeight: 0, gap: 24 })).toBe(0); + }); +}); diff --git a/src/features/CompareBoard/lib/measure/combineFrameHeight.ts b/src/features/CompareBoard/lib/measure/combineFrameHeight.ts new file mode 100644 index 0000000..f3da543 --- /dev/null +++ b/src/features/CompareBoard/lib/measure/combineFrameHeight.ts @@ -0,0 +1,30 @@ +/** + * Inputs for combining a frame's two role blocks into one height. + */ +export interface CombineFrameHeightInput { + /** + * Measured header block height in px. + */ + headerHeight: number; + /** + * Measured body block height in px. + */ + bodyHeight: number; + /** + * Gap in px between the header and body blocks. + */ + gap: number; +} + +/** + * Total focal-frame height: header block + gap + body block. The gap only + * applies when both blocks have height — an empty role (no specimen text) + * contributes neither height nor a dangling gap. + * + * @param input - The two block heights and the inter-block gap. + * @returns The combined frame height in px. + */ +export function combineFrameHeight({ headerHeight, bodyHeight, gap }: CombineFrameHeightInput): number { + const gapApplies = headerHeight > 0 && bodyHeight > 0; + return headerHeight + bodyHeight + (gapApplies ? gap : 0); +} diff --git a/src/features/CompareBoard/lib/measure/index.ts b/src/features/CompareBoard/lib/measure/index.ts index 9f11529..37ff234 100644 --- a/src/features/CompareBoard/lib/measure/index.ts +++ b/src/features/CompareBoard/lib/measure/index.ts @@ -1,3 +1,7 @@ +export { + combineFrameHeight, + type CombineFrameHeightInput, +} from './combineFrameHeight'; export { fitColumns, type FitColumnsInput, diff --git a/src/features/CompareBoard/model/const/const.ts b/src/features/CompareBoard/model/const/const.ts index c22a2d9..cd397ef 100644 --- a/src/features/CompareBoard/model/const/const.ts +++ b/src/features/CompareBoard/model/const/const.ts @@ -24,6 +24,13 @@ export const BOARD_SCHEMA_VERSION = 1; */ export const MAX_COLUMNS = 3; +/** + * Vertical gap in px between the header block and the body block within a frame. + * Used by frame-height measurement so the reserved height matches the rendered + * layout exactly (zero-shift). + */ +export const FRAME_ROLE_GAP = 24; + /** * Default shared specimen — one header line + one body paragraph (single * language). Used to seed the board and as the share-state fallback. diff --git a/src/features/CompareBoard/model/index.ts b/src/features/CompareBoard/model/index.ts new file mode 100644 index 0000000..770145f --- /dev/null +++ b/src/features/CompareBoard/model/index.ts @@ -0,0 +1,7 @@ +export { MAX_COLUMNS } from './const/const'; +export { + __resetBoard, + type BoardStore, + getBoard, + type RoleTypography, +} from './store/boardStore/boardStore.svelte'; 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 9e35263..72043fe 100644 --- a/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.test.ts +++ b/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.test.ts @@ -32,12 +32,15 @@ const mockCatalog = vi.hoisted(() => ({ ], })); +/** Mutable resolved-font list the stubbed FontsByIdsStore returns; reset per test. */ +const mockFonts = vi.hoisted(() => [] as { id: string; name: string }[]); + vi.mock('$entities/Font', async importOriginal => { const actual = await importOriginal(); class MockFontsByIdsStore { setIds() {} get fonts() { - return []; + return mockFonts; } get isLoading() { return false; @@ -53,6 +56,19 @@ vi.mock('$entities/Font', async importOriginal => { }; }); +// ensureCanvasFonts needs a real browser canvas; stub it to resolve immediately. +// Spread actual so createPersistentStore/getPretextFontString stay real. +vi.mock('$shared/lib', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, ensureCanvasFonts: vi.fn(() => Promise.resolve()) }; +}); + +// Pretext measures via canvas (degenerate in jsdom); stub for deterministic lines. +vi.mock('@chenglou/pretext', () => ({ + prepareWithSegments: vi.fn(() => ({})), + layout: vi.fn(() => ({ lineCount: 2, height: 0 })), +})); + import { flushSync } from 'svelte'; import { __resetBoard, @@ -61,6 +77,7 @@ import { beforeEach(() => { localStorage.clear(); + mockFonts.length = 0; __resetBoard(); }); afterEach(() => __resetBoard()); @@ -159,6 +176,16 @@ describe('boardStore', () => { expect(restored.pairings[0].id).toBe(p.id); }); + it('returns fallback 0 before warm, positive height once fonts resolve and warm', async () => { + mockFonts.push({ id: 'Inter', name: 'Inter' }, { id: 'Lora', name: 'Lora' }); + const board = getBoard(); + const p = board.addPairing('Inter', 'Lora'); + // Cold: canvas not yet warm -> reserved fallback, never a cold measure. + expect(board.frameHeight(p.id, 600)).toBe(0); + await vi.waitFor(() => expect(board.measureReady).toBe(true)); + expect(board.frameHeight(p.id, 600)).toBeGreaterThan(0); + }); + it('collects every distinct candidate font id for preloading', () => { const board = getBoard(); board.addPairing('Inter', 'Lora'); diff --git a/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts b/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts index 2491fd6..93a700a 100644 --- a/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts +++ b/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts @@ -10,7 +10,8 @@ * 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. + * preloading, focal pinning). Frame heights are Pretext-measured behind a + * canvas warm-gate (the zero-shift core) and memoized for flicker-free cycling. */ import { @@ -30,22 +31,32 @@ import { import { type Pairing, type Role, + comboKey, createPairing, nextFocalId, } from '$entities/Pairing'; +import { + combineFrameHeight, + measureRoleHeight, +} from '$features/CompareBoard/lib/measure'; import { BOARD_SCHEMA_VERSION, BOARD_STORAGE_KEY, DEFAULT_SPECIMEN, + FRAME_ROLE_GAP, } from '$features/CompareBoard/model/const/const'; import { createPersistentStore, createSingleton, + ensureCanvasFonts, + getPretextFontString, } from '$shared/lib'; +import { prepareWithSegments } from '@chenglou/pretext'; import { flushSync, untrack, } from 'svelte'; +import { SvelteSet } from 'svelte/reactivity'; /** * Compact persisted board shape. Font ids are abbreviated (`h`/`b`) to keep the @@ -158,6 +169,26 @@ export class BoardStore { * construction (never re-seed after the user empties the board). */ #shouldSeed: boolean; + /** + * Font strings whose canvas metrics are confirmed real (warm). Reactive + * (SvelteSet) so a completed warm re-runs height readers. Gates `prepare()` + * to avoid poisoning Pretext's cache with fallback widths. + */ + #warmed = new SvelteSet(); + /** + * Font strings with an in-flight `ensureCanvasFonts` — dedupes warm requests. + */ + #warming = new Set(); + /** + * Memoized frame heights keyed by (combo, width, specimen, typography), so + * cycling back to a measured pairing is O(1) and never reflows. + */ + #heightCache = new Map(); + /** + * Last computed height per pairing — the reserved fallback returned while a + * pairing's fonts load/warm, so the frame never collapses to 0 mid-cycle. + */ + #lastHeight = new Map(); /** * Disposes the constructor's $effect.root. Must run on teardown. */ @@ -359,6 +390,138 @@ export class BoardStore { }; } + /** + * The focal frame's measured height at the given content width. + * + * @param contentWidth - The frame's content width in px. + * @returns Height in px (0 when the board is empty). + */ + focalFrameHeight(contentWidth: number): number { + return this.#focalId ? this.frameHeight(this.#focalId, contentWidth) : 0; + } + + /** + * Pre-measures a (typically next-up) pairing so cycling to it never reflows. + * + * @param pairingId - The pairing to measure ahead of time. + * @param contentWidth - The frame's content width in px. + * @returns Height in px (fallback while fonts load/warm). + */ + peekFrameHeight(pairingId: string, contentWidth: number): number { + return this.frameHeight(pairingId, contentWidth); + } + + /** + * Measured height of a pairing's frame (header block + gap + body block) at a + * content width, via Pretext's pure line-count arithmetic. Returns the + * last-known height (or 0) until both fonts are resolved AND the canvas is + * warm — never measures cold, which would poison Pretext's width cache + * forever. Results are memoized per (combo, width, specimen, typography). + * + * @param pairingId - The pairing to measure. + * @param contentWidth - The frame's content width in px. + * @returns Height in px. + */ + frameHeight(pairingId: string, contentWidth: number): number { + const pairing = this.#pairings.find(p => p.id === pairingId); + if (!pairing) { + return 0; + } + const { header, body } = this.resolvePairingFonts(pairing); + const fallback = this.#lastHeight.get(pairingId) ?? 0; + if (!header || !body) { + return fallback; + } + + const headerFont = getPretextFontString(this.#typo.header.weight, this.#typo.header.size, header.name); + const bodyFont = getPretextFontString(this.#typo.body.weight, this.#typo.body.size, body.name); + + this.#ensureWarm([headerFont, bodyFont]); + // SvelteSet read is reactive: a completed warm re-runs height readers. + if (!this.#warmed.has(headerFont) || !this.#warmed.has(bodyFont)) { + return fallback; + } + + const key = `${comboKey(pairing)}|${contentWidth}|${this.#specimen.header}|${this.#specimen.body}|` + + this.#typoSignature(); + const cached = this.#heightCache.get(key); + if (cached !== undefined) { + this.#lastHeight.set(pairingId, cached); + return cached; + } + + const headerHeight = measureRoleHeight({ + prepared: prepareWithSegments(this.#specimen.header, headerFont, { + letterSpacing: this.#typo.header.tracking, + }), + maxWidth: contentWidth, + sizePx: this.#typo.header.size, + lineHeight: this.#typo.header.leading, + }); + const bodyHeight = measureRoleHeight({ + prepared: prepareWithSegments(this.#specimen.body, bodyFont, { + letterSpacing: this.#typo.body.tracking, + }), + maxWidth: contentWidth, + sizePx: this.#typo.body.size, + lineHeight: this.#typo.body.leading, + }); + const height = combineFrameHeight({ headerHeight, bodyHeight, gap: FRAME_ROLE_GAP }); + this.#heightCache.set(key, height); + this.#lastHeight.set(pairingId, height); + return height; + } + + /** + * True once the focal pairing's fonts are resolved and canvas-warm — the UI + * gates the first paint of the focal frame on this to avoid a cold-measure + * flash. + */ + get measureReady(): boolean { + const focal = this.focal; + if (!focal) { + return false; + } + const { header, body } = this.resolvePairingFonts(focal); + if (!header || !body) { + return false; + } + const headerFont = getPretextFontString(this.#typo.header.weight, this.#typo.header.size, header.name); + const bodyFont = getPretextFontString(this.#typo.body.weight, this.#typo.body.size, body.name); + return this.#warmed.has(headerFont) && this.#warmed.has(bodyFont); + } + + /** + * Kicks off canvas warming for any cold font strings (dedup'd). Fire-and- + * forget: on resolution the strings join `#warmed`, re-running height readers. + */ + #ensureWarm(fontStrings: string[]) { + const cold = fontStrings.filter(s => !this.#warmed.has(s) && !this.#warming.has(s)); + if (cold.length === 0) { + return; + } + cold.forEach(s => this.#warming.add(s)); + void ensureCanvasFonts(cold) + .then(() => { + cold.forEach(s => { + this.#warming.delete(s); + this.#warmed.add(s); + }); + }) + .catch(() => { + cold.forEach(s => this.#warming.delete(s)); + }); + } + + /** + * Stable signature of both roles' typography, for the height memo key. + */ + #typoSignature(): string { + const h = this.#typo.header; + const b = this.#typo.body; + return `${h.size},${h.weight},${h.leading},${h.tracking};${b.size},${b.weight},${b.leading},${b.tracking}`; + } + /** * Adds a pairing to the end of the board. The first pairing added becomes * focal. diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 918a4d5..6f41f5f 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -33,7 +33,9 @@ export { clampNumber, cn, debounce, + ensureCanvasFonts, getDecimalPlaces, + getPretextFontString, roundToStepPrecision, smoothScroll, splitArray,