feat(CompareBoard): measure focal frame height and expose slice public API

This commit is contained in:
Ilia Mashkov
2026-06-24 15:23:35 +03:00
parent 132d1327f5
commit 738ed3b4ed
10 changed files with 277 additions and 2 deletions
+8
View File
@@ -0,0 +1,8 @@
export { fitColumns } from './lib';
export {
__resetBoard,
type BoardStore,
getBoard,
MAX_COLUMNS,
type RoleTypography,
} from './model';
+8
View File
@@ -0,0 +1,8 @@
export {
combineFrameHeight,
type CombineFrameHeightInput,
fitColumns,
type FitColumnsInput,
measureRoleHeight,
type RoleHeightInput,
} from './measure';
@@ -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);
});
});
@@ -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);
}
@@ -1,3 +1,7 @@
export {
combineFrameHeight,
type CombineFrameHeightInput,
} from './combineFrameHeight';
export { export {
fitColumns, fitColumns,
type FitColumnsInput, type FitColumnsInput,
@@ -24,6 +24,13 @@ export const BOARD_SCHEMA_VERSION = 1;
*/ */
export const MAX_COLUMNS = 3; 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 * Default shared specimen — one header line + one body paragraph (single
* language). Used to seed the board and as the share-state fallback. * language). Used to seed the board and as the share-state fallback.
+7
View File
@@ -0,0 +1,7 @@
export { MAX_COLUMNS } from './const/const';
export {
__resetBoard,
type BoardStore,
getBoard,
type RoleTypography,
} from './store/boardStore/boardStore.svelte';
@@ -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 => { vi.mock('$entities/Font', async importOriginal => {
const actual = await importOriginal<typeof import('$entities/Font')>(); const actual = await importOriginal<typeof import('$entities/Font')>();
class MockFontsByIdsStore { class MockFontsByIdsStore {
setIds() {} setIds() {}
get fonts() { get fonts() {
return []; return mockFonts;
} }
get isLoading() { get isLoading() {
return false; 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<typeof import('$shared/lib')>();
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 { flushSync } from 'svelte';
import { import {
__resetBoard, __resetBoard,
@@ -61,6 +77,7 @@ import {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
mockFonts.length = 0;
__resetBoard(); __resetBoard();
}); });
afterEach(() => __resetBoard()); afterEach(() => __resetBoard());
@@ -159,6 +176,16 @@ describe('boardStore', () => {
expect(restored.pairings[0].id).toBe(p.id); 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', () => { it('collects every distinct candidate font id for preloading', () => {
const board = getBoard(); const board = getBoard();
board.addPairing('Inter', 'Lora'); board.addPairing('Inter', 'Lora');
@@ -10,7 +10,8 @@
* values, fed in via `setTypo` by `widgets/Board` (dependency inversion). * values, fed in via `setTypo` by `widgets/Board` (dependency inversion).
* *
* Font metadata is resolved + preloaded via the Font entity (candidate * 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 { import {
@@ -30,22 +31,32 @@ import {
import { import {
type Pairing, type Pairing,
type Role, type Role,
comboKey,
createPairing, createPairing,
nextFocalId, nextFocalId,
} from '$entities/Pairing'; } from '$entities/Pairing';
import {
combineFrameHeight,
measureRoleHeight,
} from '$features/CompareBoard/lib/measure';
import { import {
BOARD_SCHEMA_VERSION, BOARD_SCHEMA_VERSION,
BOARD_STORAGE_KEY, BOARD_STORAGE_KEY,
DEFAULT_SPECIMEN, DEFAULT_SPECIMEN,
FRAME_ROLE_GAP,
} from '$features/CompareBoard/model/const/const'; } from '$features/CompareBoard/model/const/const';
import { import {
createPersistentStore, createPersistentStore,
createSingleton, createSingleton,
ensureCanvasFonts,
getPretextFontString,
} from '$shared/lib'; } from '$shared/lib';
import { prepareWithSegments } from '@chenglou/pretext';
import { import {
flushSync, flushSync,
untrack, untrack,
} from 'svelte'; } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
/** /**
* 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
@@ -158,6 +169,26 @@ export class BoardStore {
* construction (never re-seed after the user empties the board). * construction (never re-seed after the user empties the board).
*/ */
#shouldSeed: boolean; #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<string>();
/**
* Font strings with an in-flight `ensureCanvasFonts` — dedupes warm requests.
*/
#warming = new Set<string>();
/**
* 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<string, number>();
/**
* 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<string, number>();
/** /**
* Disposes the constructor's $effect.root. Must run on teardown. * 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 * Adds a pairing to the end of the board. The first pairing added becomes
* focal. * focal.
+2
View File
@@ -33,7 +33,9 @@ export {
clampNumber, clampNumber,
cn, cn,
debounce, debounce,
ensureCanvasFonts,
getDecimalPlaces, getDecimalPlaces,
getPretextFontString,
roundToStepPrecision, roundToStepPrecision,
smoothScroll, smoothScroll,
splitArray, splitArray,