17 Commits

Author SHA1 Message Date
Ilia Mashkov 5ace4aee07 feat(Board): add CandidateCard mini switcher 2026-06-24 15:33:33 +03:00
Ilia Mashkov f3a2a6a7bd feat(Board): add keyboard and swipe focal cycling 2026-06-24 15:31:38 +03:00
Ilia Mashkov 118c588859 feat(Board): add constant-size FocalFrame 2026-06-24 15:29:52 +03:00
Ilia Mashkov 59097ca9ad feat(Board): add always-editable RoleField specimen input 2026-06-24 15:27:20 +03:00
Ilia Mashkov 738ed3b4ed feat(CompareBoard): measure focal frame height and expose slice public API 2026-06-24 15:23:35 +03:00
Ilia Mashkov 132d1327f5 feat(CompareBoard): orchestrate font preloading, pinning, and default seeding 2026-06-24 14:59:17 +03:00
Ilia Mashkov 92ea7b9dc4 feat(CompareBoard): add board store with cycling, persistence, and typography seam 2026-06-24 14:48:29 +03:00
Ilia Mashkov e55e713517 feat(CompareBoard): add board constants and storage schema 2026-06-24 13:49:24 +03:00
Ilia Mashkov f49180e83d feat(CompareBoard): add fitColumns honest column gating 2026-06-24 13:49:24 +03:00
Ilia Mashkov 2c3d88c81f feat(CompareBoard): add measureRoleHeight via Pretext line count 2026-06-24 13:49:23 +03:00
Ilia Mashkov 0e9288c295 feat(shared): add ensureCanvasFonts canvas-warm helper 2026-06-24 13:49:13 +03:00
Ilia Mashkov dbd48b287d feat(shared): add getPretextFontString formatter 2026-06-24 13:49:02 +03:00
Ilia Mashkov f29e0b0c7c feat(Pairing): add nextFocalId cycle math and expose slice API 2026-06-24 13:48:50 +03:00
Ilia Mashkov 91bb046339 feat(Pairing): add createPairing id factory 2026-06-24 13:21:30 +03:00
Ilia Mashkov f680fe01ea feat(Pairing): add comboKey natural-key derivation 2026-06-24 13:21:29 +03:00
Ilia Mashkov d37d01e6d8 feat(Pairing): add Pairing and Role types 2026-06-24 13:21:29 +03:00
ilia c78b8e032e Merge pull request 'Feature/adaptive crossfade window' (#50) from feature/adaptive-crossfade-window into main
Workflow / build (push) Successful in 1m15s
Workflow / e2e (push) Successful in 1m4s
Workflow / publish (push) Successful in 14s
Reviewed-on: #50
2026-06-06 06:05:08 +00:00
41 changed files with 2014 additions and 0 deletions
@@ -0,0 +1,20 @@
import {
describe,
expect,
it,
} from 'vitest';
import { comboKey } from './comboKey';
describe('comboKey', () => {
it('derives a key from the two font ids', () => {
expect(comboKey({ id: 'x', headerFontId: 'Inter', bodyFontId: 'Lora' })).toBe('Inter|Lora');
});
it('ignores the surrogate id (content not identity)', () => {
const a = comboKey({ id: 'a', headerFontId: 'Inter', bodyFontId: 'Lora' });
const b = comboKey({ id: 'b', headerFontId: 'Inter', bodyFontId: 'Lora' });
expect(a).toBe(b);
});
it('is order-sensitive on role', () => {
expect(comboKey({ id: 'x', headerFontId: 'Lora', bodyFontId: 'Inter' })).toBe('Lora|Inter');
});
});
@@ -0,0 +1,13 @@
import type { Pairing } from '../types';
/**
* Natural key describing a Pairing's current fonts (not its identity).
* Used for URL share-encoding and "is this combo already on the board" checks.
* Recomputed on swap; two cards may share a comboKey but never an id.
*
* @param pairing - The pairing whose fonts form the key (its `id` is ignored).
* @returns The `headerFontId|bodyFontId` key.
*/
export function comboKey(pairing: Pairing): string {
return `${pairing.headerFontId}|${pairing.bodyFontId}`;
}
@@ -0,0 +1,22 @@
import {
describe,
expect,
it,
} from 'vitest';
import { createPairing } from './createPairing';
describe('createPairing', () => {
it('builds a pairing from two font ids', () => {
const p = createPairing('Inter', 'Lora');
expect(p.headerFontId).toBe('Inter');
expect(p.bodyFontId).toBe('Lora');
});
it('generates a unique id each call (duplicates stay distinct)', () => {
const a = createPairing('Inter', 'Lora');
const b = createPairing('Inter', 'Lora');
expect(a.id).not.toBe(b.id);
});
it('accepts an explicit id for rehydration', () => {
expect(createPairing('Inter', 'Lora', 'fixed-id').id).toBe('fixed-id');
});
});
@@ -0,0 +1,15 @@
import type { Pairing } from '../types';
/**
* Creates a Pairing with a fresh surrogate id (or a supplied one when
* rehydrating from storage). The id is identity, never content — two pairings
* with the same fonts are still distinct cards.
*
* @param headerFontId - Font entity id for the header role.
* @param bodyFontId - Font entity id for the body role.
* @param id - Explicit id for rehydration; defaults to a fresh UUID.
* @returns The new Pairing.
*/
export function createPairing(headerFontId: string, bodyFontId: string, id: string = crypto.randomUUID()): Pairing {
return { id, headerFontId, bodyFontId };
}
+3
View File
@@ -0,0 +1,3 @@
export { comboKey } from './comboKey/comboKey';
export { createPairing } from './createPairing/createPairing';
export { nextFocalId } from './nextFocalId/nextFocalId';
@@ -0,0 +1,32 @@
import {
describe,
expect,
it,
} from 'vitest';
import { nextFocalId } from './nextFocalId';
const ids = ['a', 'b', 'c'];
describe('nextFocalId', () => {
it('steps forward', () => {
expect(nextFocalId(ids, 'a', 1)).toBe('b');
});
it('steps backward', () => {
expect(nextFocalId(ids, 'b', -1)).toBe('a');
});
it('wraps forward at the end', () => {
expect(nextFocalId(ids, 'c', 1)).toBe('a');
});
it('wraps backward at the start', () => {
expect(nextFocalId(ids, 'a', -1)).toBe('c');
});
it('returns the only id when list has one', () => {
expect(nextFocalId(['solo'], 'solo', 1)).toBe('solo');
});
it('returns current when focal id is absent', () => {
expect(nextFocalId(ids, 'missing', 1)).toBe('missing');
});
it('returns null for an empty list', () => {
expect(nextFocalId([], 'x', 1)).toBeNull();
});
});
@@ -0,0 +1,21 @@
/**
* The id one step from `currentId` in board order, wrapping at both ends.
*
* @param orderedIds - Pairing ids in board order.
* @param currentId - The currently focal id to step from.
* @param direction - +1 for next, -1 for previous.
* @returns The neighbouring id (wrapped), `currentId` unchanged if it isn't in
* the list, or null for an empty list.
*/
export function nextFocalId(orderedIds: string[], currentId: string, direction: 1 | -1): string | null {
if (orderedIds.length === 0) {
return null;
}
const i = orderedIds.indexOf(currentId);
if (i === -1) {
return currentId;
}
const len = orderedIds.length;
const next = (i + direction + len) % len;
return orderedIds[next];
}
@@ -0,0 +1,4 @@
export type {
Pairing,
Role,
} from './pairing';
@@ -0,0 +1,26 @@
/**
* A slot within a Pairing that a font fills.
*/
export type Role = 'header' | 'body';
/**
* The atomic unit of comparison: a header font + a body font.
* Carries a surrogate `id` (stable for the card's life, never tracks content)
* and the two font ids it pairs. Text and typography are global to the Board,
* not stored here.
*/
export interface Pairing {
/**
* Surrogate key generated at creation, stable for the card's life.
* Distinguishes duplicates with identical fonts. Focal/cycling key on this.
*/
id: string;
/**
* Font entity id filling the header role.
*/
headerFontId: string;
/**
* Font entity id filling the body role.
*/
bodyFontId: string;
}
+9
View File
@@ -0,0 +1,9 @@
export {
comboKey,
createPairing,
nextFocalId,
} from './domain';
export type {
Pairing,
Role,
} from './model/types';
@@ -0,0 +1,4 @@
export type {
Pairing,
Role,
} from './pairing';
@@ -0,0 +1,9 @@
/**
* Re-export of the Pairing identity types. The source of truth lives in
* `domain/types` so the pure domain segment can reference them without importing
* `model` (FSD+ domain isolation: ui -> model -> domain, never back).
*/
export type {
Pairing,
Role,
} from '../../domain/types';
+9
View File
@@ -0,0 +1,9 @@
export { fitColumns } from './lib';
export {
__resetBoard,
type BoardStore,
FRAME_ROLE_GAP,
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);
}
@@ -0,0 +1,28 @@
import {
describe,
expect,
it,
} from 'vitest';
import { fitColumns } from './fitColumns';
describe('fitColumns', () => {
it('packs as many honest columns as fit, gap-aware', () => {
// each needs 600, gap 40, available 1280 -> 1 col=600, 2 cols=1240, 3=1880
expect(fitColumns({ naturalWidth: 600, available: 1280, gap: 40, maxColumns: 3 })).toBe(2);
});
it('never exceeds maxColumns even with room', () => {
expect(fitColumns({ naturalWidth: 100, available: 5000, gap: 20, maxColumns: 3 })).toBe(3);
});
it('never returns less than 1', () => {
expect(fitColumns({ naturalWidth: 9000, available: 300, gap: 20, maxColumns: 3 })).toBe(1);
});
it('fits a column at the exact boundary (inclusive)', () => {
// 2 cols: 2*600 + 1*40 = 1240 == available -> fits
expect(fitColumns({ naturalWidth: 600, available: 1240, gap: 40, maxColumns: 3 })).toBe(2);
// one px short -> only 1
expect(fitColumns({ naturalWidth: 600, available: 1239, gap: 40, maxColumns: 3 })).toBe(1);
});
it('respects a maxColumns of 1 even with unlimited room', () => {
expect(fitColumns({ naturalWidth: 100, available: 5000, gap: 20, maxColumns: 1 })).toBe(1);
});
});
@@ -0,0 +1,41 @@
/**
* Inputs for column gating.
*/
export interface FitColumnsInput {
/**
* The widest pairing's Pretext natural (shrink-wrap) width in px.
*/
naturalWidth: number;
/**
* Total available width in px for the columns row.
*/
available: number;
/**
* Gap in px between columns.
*/
gap: number;
/**
* Hard cap on columns that still preserve an honest measure (23).
*/
maxColumns: number;
}
/**
* How many equal honest columns fit. Uses the real per-pairing required width
* (Pretext shrink-wrap) — the 4575ch rule is only a fallback bound elsewhere.
* `n` columns occupy `n*naturalWidth + (n-1)*gap`. Clamped to [1, maxColumns].
*
* @param input - Natural width, available width, gap, and column cap.
* @returns The number of columns that fit, in [1, maxColumns].
*/
export function fitColumns({ naturalWidth, available, gap, maxColumns }: FitColumnsInput): number {
let fit = 1;
for (let n = 2; n <= maxColumns; n++) {
if (n * naturalWidth + (n - 1) * gap <= available) {
fit = n;
} else {
break;
}
}
return fit;
}
@@ -0,0 +1,12 @@
export {
combineFrameHeight,
type CombineFrameHeightInput,
} from './combineFrameHeight';
export {
fitColumns,
type FitColumnsInput,
} from './fitColumns';
export {
measureRoleHeight,
type RoleHeightInput,
} from './measureFrameHeight';
@@ -0,0 +1,32 @@
import {
describe,
expect,
it,
vi,
} from 'vitest';
import { measureRoleHeight } from './measureFrameHeight';
describe('measureRoleHeight', () => {
it('multiplies pretext line count by sizePx*lineHeight', () => {
const layout = vi.fn().mockReturnValue({ lineCount: 3, height: 0 });
const prepared = {} as never;
// 3 lines * 20px * 1.5 = 90
expect(measureRoleHeight({ prepared, maxWidth: 600, sizePx: 20, lineHeight: 1.5 }, layout)).toBe(90);
});
it('passes width and pixel line-height into pretext layout', () => {
const layout = vi.fn().mockReturnValue({ lineCount: 1, height: 0 });
measureRoleHeight({ prepared: {} as never, maxWidth: 600, sizePx: 16, lineHeight: 1.25 }, layout);
expect(layout).toHaveBeenCalledWith(expect.anything(), 600, 16 * 1.25);
});
it('returns 0 when the text lays out to zero lines (empty specimen)', () => {
const layout = vi.fn().mockReturnValue({ lineCount: 0, height: 0 });
expect(measureRoleHeight({ prepared: {} as never, maxWidth: 600, sizePx: 16, lineHeight: 1.5 }, layout))
.toBe(0);
});
it('handles fractional sizes and line-heights without rounding', () => {
const layout = vi.fn().mockReturnValue({ lineCount: 2, height: 0 });
// 2 * 15.5 * 1.4 = 43.4
expect(measureRoleHeight({ prepared: {} as never, maxWidth: 320, sizePx: 15.5, lineHeight: 1.4 }, layout))
.toBeCloseTo(43.4);
});
});
@@ -0,0 +1,46 @@
import {
type PreparedText,
layout as pretextLayout,
} from '@chenglou/pretext';
/**
* Inputs for measuring one role block's rendered height.
*/
export interface RoleHeightInput {
/**
* Pretext-prepared specimen text for this role+font.
*/
prepared: PreparedText;
/**
* Available width in px (the focal frame's content width).
*/
maxWidth: number;
/**
* Resolved font-size in px.
*/
sizePx: number;
/**
* Unitless line-height multiplier.
*/
lineHeight: number;
}
/**
* Height in px of a role's text block at the given width, from Pretext's
* pure-arithmetic line count.
*
* Height is `lineCount * sizePx * lineHeight` rather than Pretext's own
* `height` so it tracks the CSS box model exactly (line-height as a multiple of
* font-size), keeping measurement and render in lockstep — the zero-shift
* invariant.
*
* @param input - Prepared text plus width and resolved type metrics.
* @param layout - Pretext layout fn; injectable for unit tests, defaults to
* `@chenglou/pretext`'s `layout`.
* @returns The block height in px.
*/
export function measureRoleHeight(input: RoleHeightInput, layout = pretextLayout): number {
const { prepared, maxWidth, sizePx, lineHeight } = input;
const { lineCount } = layout(prepared, maxWidth, sizePx * lineHeight);
return lineCount * sizePx * lineHeight;
}
@@ -0,0 +1,42 @@
/**
* localStorage key for the persisted board (pairings + focal + specimen).
*/
export const BOARD_STORAGE_KEY = 'glyphdiff:board';
/**
* Per-role typography storage key — header AdjustTypography instance.
*/
export const HEADER_TYPO_KEY = 'glyphdiff:typo:header';
/**
* Per-role typography storage key — body AdjustTypography instance.
*/
export const BODY_TYPO_KEY = 'glyphdiff:typo:body';
/**
* Schema version stamped into persisted board state (gates future
* migrations / the URL share-state codec).
*/
export const BOARD_SCHEMA_VERSION = 1;
/**
* Hard cap on side-by-side columns that still preserve an honest measure.
*/
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.
*/
export const DEFAULT_SPECIMEN = {
header: 'The Art of Harmonious Type',
body:
'Good typography is invisible. It guides the eye without calling attention to itself, balancing rhythm, contrast, and proportion so the reader forgets there is a typeface at all and simply reads.',
};
+10
View File
@@ -0,0 +1,10 @@
export {
FRAME_ROLE_GAP,
MAX_COLUMNS,
} from './const/const';
export {
__resetBoard,
type BoardStore,
getBoard,
type RoleTypography,
} from './store/boardStore/boardStore.svelte';
@@ -0,0 +1,223 @@
import {
afterEach,
beforeEach,
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' },
],
}));
/** 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<typeof import('$entities/Font')>();
class MockFontsByIdsStore {
setIds() {}
get fonts() {
return mockFonts;
}
get isLoading() {
return false;
}
destroy() {}
}
return {
...actual,
FontsByIdsStore: MockFontsByIdsStore,
getFontLifecycleManager: () => mockLifecycle,
getFontCatalog: () => mockCatalog,
getFontUrl: () => 'https://example.com/font.woff2',
};
});
// 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 {
__resetBoard,
getBoard,
} from './boardStore.svelte';
beforeEach(() => {
localStorage.clear();
mockFonts.length = 0;
__resetBoard();
});
afterEach(() => __resetBoard());
describe('boardStore', () => {
it('starts empty with no focal', () => {
const board = getBoard();
expect(board.pairings).toEqual([]);
expect(board.focalId).toBeNull();
});
it('adds a pairing and makes the first one focal', () => {
const board = getBoard();
const p = board.addPairing('Inter', 'Lora');
expect(board.pairings).toHaveLength(1);
expect(board.focalId).toBe(p.id);
expect(board.focal).toEqual(p);
});
it('cycles focal forward with wrap', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
const b = board.addPairing('Roboto', 'Merriweather');
board.setFocal(a.id);
board.cycle(1);
expect(board.focalId).toBe(b.id);
board.cycle(1);
expect(board.focalId).toBe(a.id);
});
it('cycles focal backward with wrap', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
const b = board.addPairing('Roboto', 'Merriweather');
board.setFocal(a.id);
board.cycle(-1);
expect(board.focalId).toBe(b.id);
});
it('empties the board and clears focal when the last pairing is removed', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
board.removePairing(a.id);
expect(board.pairings).toEqual([]);
expect(board.focalId).toBeNull();
});
it('duplicates a pairing as a distinct card next to the source', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
const dup = board.duplicate(a.id);
expect(dup.id).not.toBe(a.id);
expect(dup.headerFontId).toBe('Inter');
expect(board.pairings[1].id).toBe(dup.id);
});
it('swaps one role on the focal pairing', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
board.swapFont(a.id, 'body', 'Merriweather');
expect(board.focal?.bodyFontId).toBe('Merriweather');
});
it('rewrites the shared specimen (global, not per-pairing)', () => {
const board = getBoard();
board.addPairing('Inter', 'Lora');
board.setSpecimen('header', 'New Header');
expect(board.specimen.header).toBe('New Header');
});
it('keeps a focal when the focal pairing is removed', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
const b = board.addPairing('Roboto', 'Merriweather');
board.setFocal(a.id);
board.removePairing(a.id);
expect(board.pairings).toHaveLength(1);
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('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');
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);
expect(board.typo.header.weight).toBeGreaterThan(0);
expect(board.typo.body.leading).toBeGreaterThan(0);
});
it('sets one role typography independently via setTypo', () => {
const board = getBoard();
board.setTypo('header', { size: 64, weight: 700, leading: 1.1, tracking: -0.02 });
expect(board.typo.header.size).toBe(64);
expect(board.typo.header.weight).toBe(700);
// body untouched
expect(board.typo.body.size).not.toBe(64);
});
it('persists and rehydrates pairings, focal, and specimen', () => {
const board = getBoard();
const a = board.addPairing('Inter', 'Lora');
board.setSpecimen('body', 'Persisted body');
__resetBoard();
const restored = getBoard();
expect(restored.pairings).toHaveLength(1);
expect(restored.pairings[0].id).toBe(a.id);
expect(restored.focalId).toBe(a.id);
expect(restored.specimen.body).toBe('Persisted body');
});
});
@@ -0,0 +1,657 @@
/**
* CompareBoard store the board singleton.
*
* Owns the comparison board's business state: the ordered list of Pairings, the
* single focal pairing, and the board-global specimen text (header + body).
* Persists to localStorage as a compact, URL-encoding-friendly blob.
*
* 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 metadata is resolved + preloaded via the Font entity (candidate
* preloading, focal pinning). Frame heights are Pretext-measured behind a
* canvas warm-gate (the zero-shift core) and memoized for flicker-free cycling.
*/
import {
DEFAULT_FONT_SIZE,
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,
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
* blob small and URL-encoding-friendly; specimen text is in localStorage but is
* intentionally excluded from any future URL share.
*/
interface PersistedBoard {
/**
* Schema version (gates migrations / the future URL codec).
*/
v: number;
/**
* Pairings in board order: surrogate id + the two font ids.
*/
pairings: { id: string; h: string; b: string }[];
/**
* The focal pairing's id, or null when the board is empty.
*/
focalId: string | null;
/**
* Board-global specimen text.
*/
specimen: { header: string; body: string };
}
const emptyBoard = (): PersistedBoard => ({
v: BOARD_SCHEMA_VERSION,
pairings: [],
focalId: null,
specimen: { ...DEFAULT_SPECIMEN },
});
/**
* Plain per-role typography values the board renders and measures with. Mirrors
* the four axes an `AdjustTypography` store exposes, but as a framework-free
* value shape the board owns the inversion seam (`widgets/Board` pushes the
* concrete store's values in via `setTypo`). Not persisted here: the
* AdjustTypography stores own typography persistence.
*/
export interface RoleTypography {
/**
* Font size in px (honest, absolute no responsive multiplier).
*/
size: number;
/**
* Numeric font weight (100900).
*/
weight: number;
/**
* Unitless line-height multiplier.
*/
leading: number;
/**
* Letter spacing in px.
*/
tracking: number;
}
const defaultRoleTypography = (): RoleTypography => ({
size: DEFAULT_FONT_SIZE,
weight: DEFAULT_FONT_WEIGHT,
leading: DEFAULT_LINE_HEIGHT,
tracking: DEFAULT_LETTER_SPACING,
});
/**
* Singleton board store. Pairings live as a reassigned `$state` array (ordered
* cycling needs index order); mutations reassign so Svelte tracks them and
* persist synchronously through the persistent store.
*/
export class BoardStore {
/**
* Ordered pairings on the board.
*/
#pairings = $state<Pairing[]>([]);
/**
* The focal pairing's id, or null when the board is empty.
*/
#focalId = $state<string | null>(null);
/**
* Board-global specimen text shared by every pairing.
*/
#specimen = $state<{ header: string; body: string }>({ ...DEFAULT_SPECIMEN });
/**
* Per-role typography, fed in by the widget from the AdjustTypography stores
* (dependency-inversion seam). Read by font-loading and frame measurement.
*/
#typo = $state<{ header: RoleTypography; body: RoleTypography }>({
header: defaultRoleTypography(),
body: defaultRoleTypography(),
});
/**
* 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;
/**
* 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.
*/
#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;
}
/**
* Writes current state back to the persistent store. The persistent store's
* own effect flushes to localStorage; `destroy()` forces that flush so
* synchronous rehydration (and test teardown) never loses a write.
*/
#persist() {
this.#storage.value = {
v: BOARD_SCHEMA_VERSION,
pairings: this.#pairings.map(p => ({ id: p.id, h: p.headerFontId, b: p.bodyFontId })),
focalId: this.#focalId,
specimen: { ...this.#specimen },
};
}
/**
* All pairings in board order (reactive).
*/
get pairings(): readonly Pairing[] {
return this.#pairings;
}
/**
* The focal pairing's id, or null when empty (reactive).
*/
get focalId(): string | null {
return this.#focalId;
}
/**
* The focal pairing, or undefined when empty (reactive).
*/
get focal(): Pairing | undefined {
return this.#pairings.find(p => p.id === this.#focalId);
}
/**
* Board-global specimen text (reactive).
*/
get specimen(): { header: string; body: string } {
return this.#specimen;
}
/**
* Per-role typography values (reactive). Fed by the widget via `setTypo`.
*/
get typo(): { header: RoleTypography; body: RoleTypography } {
return this.#typo;
}
/**
* Replaces one role's typography values. Called by `widgets/Board` whenever
* the corresponding AdjustTypography store changes (the inversion seam).
*
* @param role - Which role's typography to set.
* @param values - The new typography values for that role.
*/
setTypo(role: Role, values: RoleTypography) {
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),
};
}
/**
* 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.
*
* @param headerFontId - Font id for the header role.
* @param bodyFontId - Font id for the body role.
* @returns The created pairing.
*/
addPairing(headerFontId: string, bodyFontId: string): Pairing {
const pairing = createPairing(headerFontId, bodyFontId);
this.#pairings = [...this.#pairings, pairing];
if (this.#focalId === null) {
this.#focalId = pairing.id;
}
this.#persist();
return pairing;
}
/**
* Clones a pairing as a distinct card inserted directly after the source, and
* makes the clone focal so the user can immediately swap one side.
*
* @param id - Source pairing id.
* @returns The new pairing.
*/
duplicate(id: string): Pairing {
const index = this.#pairings.findIndex(p => p.id === id);
const source = this.#pairings[index];
const dup = createPairing(source.headerFontId, source.bodyFontId);
this.#pairings = [
...this.#pairings.slice(0, index + 1),
dup,
...this.#pairings.slice(index + 1),
];
this.#focalId = dup.id;
this.#persist();
return dup;
}
/**
* Removes a pairing. If the removed pairing was focal, focal moves to a
* neighbour so exactly one focal always exists on a non-empty board.
*
* @param id - Pairing id to remove.
*/
removePairing(id: string) {
let nextFocal = this.#focalId;
if (this.#focalId === id) {
// Pick a neighbour from the still-full ordered list; if the only
// candidate is the one being removed, the board becomes empty.
const candidate = nextFocalId(this.#pairings.map(p => p.id), id, 1);
nextFocal = candidate === id ? null : candidate;
}
this.#pairings = this.#pairings.filter(p => p.id !== id);
this.#focalId = nextFocal;
this.#persist();
}
/**
* Sets the focal pairing.
*
* @param id - Pairing id to focus.
*/
setFocal(id: string) {
this.#focalId = id;
this.#persist();
}
/**
* Steps focal one pairing in board order, wrapping at both ends.
*
* @param direction - +1 for next, -1 for previous.
*/
cycle(direction: 1 | -1) {
if (this.#focalId === null) {
return;
}
const next = nextFocalId(this.#pairings.map(p => p.id), this.#focalId, direction);
if (next !== null) {
this.#focalId = next;
this.#persist();
}
}
/**
* Swaps the font filling one role of a pairing.
*
* @param id - Pairing id.
* @param role - Which role to swap.
* @param fontId - New font id for that role.
*/
swapFont(id: string, role: Role, fontId: string) {
this.#pairings = this.#pairings.map(p => {
if (p.id !== id) {
return p;
}
return role === 'header' ? { ...p, headerFontId: fontId } : { ...p, bodyFontId: fontId };
});
this.#persist();
}
/**
* Rewrites the board-global specimen for a role.
*
* @param role - Which role's text to set.
* @param text - New specimen text.
*/
setSpecimen(role: Role, text: string) {
this.#specimen = { ...this.#specimen, [role]: text };
this.#persist();
}
/**
* Flushes the pending persist write, then disposes the persistent store.
* Call on teardown.
*/
destroy() {
flushSync();
this.#disposeEffects();
this.#fontsByIds.destroy();
this.#storage.destroy();
}
}
const board = createSingleton(
() => new BoardStore(),
instance => instance.destroy(),
);
export const getBoard = board.get;
// test-only reset, so specs don't share live state or persisted blobs
export const __resetBoard = board.reset;
+2
View File
@@ -33,7 +33,9 @@ export {
clampNumber,
cn,
debounce,
ensureCanvasFonts,
getDecimalPlaces,
getPretextFontString,
roundToStepPrecision,
smoothScroll,
splitArray,
@@ -0,0 +1,71 @@
/**
* Ensures a set of fonts is usable in a `<canvas>` measurement context.
*
* `document.fonts.load()` resolves once the FontFace bytes are fetched and
* parsed, but Chrome lazily registers fonts with the canvas measurement engine
* after that `measureText` keeps returning a fallback width for some frames
* even though `document.fonts.check()` reports the font as loaded.
*
* Pretext caches measurements per font string forever, so a single fallback
* measurement during initial mount permanently poisons the cache and the
* rendered text drifts visibly from its measured box. This helper polls canvas
* measurement until each font reports a width that differs from the "unknown
* font family" fallback, guaranteeing the next `measureText` call sees the real
* glyph metrics.
*
* ponytail: deliberate copy of widgets/ComparisonView/lib's version ADR-0002
* keeps the shelved morph tool untouched, so we don't move its util. The poll
* logic is the proven fix for Pretext's fallback-width cache poisoning; copying
* it is cheaper than refactoring frozen code.
*
* @param fontStrings - Pretext/canvas font strings (`weight sizepx "family"`) to warm.
*/
import { getPretextFontString } from '../getPretextFontString/getPretextFontString';
const PROBE_TEXT = 'mmmmmmmmmm';
const MAX_WAIT_MS = 1000;
const DEFAULT_PROBE_SIZE_PX = 16;
// Family unlikely to exist in any system — gives canvas's "unknown font" fallback width.
const FALLBACK_PROBE_FAMILY = '__glyphdiff_no_such_font_42__';
export async function ensureCanvasFonts(fontStrings: string[]): Promise<void> {
await Promise.all(fontStrings.map(f => document.fonts.load(f)));
// Pretext uses OffscreenCanvas when available; DOM canvas has separate font
// registration timing, so we MUST poll using the same canvas type pretext does.
const ctx = typeof OffscreenCanvas !== 'undefined'
? new OffscreenCanvas(1, 1).getContext('2d')
: document.createElement('canvas').getContext('2d');
if (!ctx) {
return;
}
// Measure each font's "unknown font" fallback width (different per browser, per OS).
// Canvas uses this same fallback for any font family it can't resolve, so when the
// requested font finally registers, measureText will return a non-fallback width.
const fallbackWidths = new Map<string, number>();
for (const font of fontStrings) {
const sizeMatch = font.match(/(\d+(?:\.\d+)?)px/);
const sizePx = sizeMatch ? parseFloat(sizeMatch[1]) : DEFAULT_PROBE_SIZE_PX;
ctx.font = getPretextFontString(400, sizePx, FALLBACK_PROBE_FAMILY);
fallbackWidths.set(font, ctx.measureText(PROBE_TEXT).width);
}
const deadline = performance.now() + MAX_WAIT_MS;
const pending = new Set(fontStrings);
while (pending.size > 0 && performance.now() < deadline) {
for (const font of Array.from(pending)) {
ctx.font = font;
const w = ctx.measureText(PROBE_TEXT).width;
if (Math.abs(w - fallbackWidths.get(font)!) > 0.5) {
pending.delete(font);
}
}
if (pending.size === 0) {
break;
}
// Sequential by design: poll once per animation frame until fonts register.
// eslint-disable-next-line no-await-in-loop
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
}
}
@@ -0,0 +1,15 @@
import {
describe,
expect,
it,
} from 'vitest';
import { getPretextFontString } from './getPretextFontString';
describe('getPretextFontString', () => {
it('formats weight, px size and quoted family for pretext/canvas', () => {
expect(getPretextFontString(400, 48, 'Inter')).toBe('400 48px "Inter"');
});
it('preserves fractional sizes and quotes multi-word family names', () => {
expect(getPretextFontString(700, 12.5, 'PT Serif')).toBe('700 12.5px "PT Serif"');
});
});
@@ -0,0 +1,16 @@
/**
* Formats a font config into the string `@chenglou/pretext` and the Canvas 2D
* `font` property both expect: `weight sizepx "family"`.
*
* ponytail: deliberate copy of widgets/ComparisonView/lib's version ADR-0002
* keeps the shelved morph tool untouched, so we don't move its util. Three lines
* is cheaper to duplicate than to refactor frozen code.
*
* @param weight - Numeric font weight (e.g. 400).
* @param sizePx - Font size in pixels.
* @param fontName - The font family name.
* @returns A formatted font string: `weight sizepx "fontName"`.
*/
export function getPretextFontString(weight: number, sizePx: number, fontName: string): string {
return `${weight} ${sizePx}px "${fontName}"`;
}
+2
View File
@@ -17,7 +17,9 @@ export {
export { clampNumber } from './clampNumber/clampNumber';
export { cn } from './cn';
export { debounce } from './debounce/debounce';
export { ensureCanvasFonts } from './ensureCanvasFonts/ensureCanvasFonts';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { getPretextFontString } from './getPretextFontString/getPretextFontString';
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
export { smoothScroll } from './smoothScroll/smoothScroll';
@@ -0,0 +1,23 @@
import {
describe,
expect,
it,
} from 'vitest';
import { swipeDirection } from './cycleGestures';
describe('swipeDirection', () => {
it('maps a leftward swipe past threshold to next', () => {
expect(swipeDirection(-80, 50)).toBe(1);
});
it('maps a rightward swipe past threshold to previous', () => {
expect(swipeDirection(80, 50)).toBe(-1);
});
it('ignores sub-threshold movement', () => {
expect(swipeDirection(20, 50)).toBe(0);
expect(swipeDirection(-20, 50)).toBe(0);
});
it('treats the exact threshold as a swipe (inclusive)', () => {
expect(swipeDirection(-50, 50)).toBe(1);
expect(swipeDirection(50, 50)).toBe(-1);
});
});
@@ -0,0 +1,18 @@
/**
* Maps a horizontal swipe delta to a cycle direction. A leftward swipe (negative
* dx) advances to the next pairing (+1); a rightward swipe (positive dx) goes to
* the previous (-1). Movement below the threshold is ignored (0).
*
* @param dx - Horizontal travel in px (end minus start).
* @param threshold - Minimum absolute travel in px to count as a swipe.
* @returns +1 (next), -1 (previous), or 0 (no cycle).
*/
export function swipeDirection(dx: number, threshold: number): -1 | 0 | 1 {
if (dx <= -threshold) {
return 1;
}
if (dx >= threshold) {
return -1;
}
return 0;
}
+60
View File
@@ -0,0 +1,60 @@
<!--
Component: Board
Shell of the pairing board widget. Reads the board singleton, renders the
focal frame, and wires focal cycling (keyboard arrows + touch swipe). Cycling
swaps the focal in place (no remount) so the reserved frame height holds — the
zero-shift invariant. Rail / sidebar / side-by-side compose in here later.
-->
<script lang="ts">
import { getBoard } from '$features/CompareBoard';
import { swipeDirection } from '../../lib/cycleGestures/cycleGestures';
import FocalFrame from '../FocalFrame/FocalFrame.svelte';
const board = getBoard();
/**
* Minimum horizontal travel (px) to register a swipe as a cycle.
*/
const SWIPE_THRESHOLD = 50;
// Arrow-key cycling, suppressed while a specimen field is being edited.
$effect(() => {
function onKeydown(event: KeyboardEvent) {
const active = document.activeElement;
if (active instanceof HTMLElement && active.isContentEditable) {
return;
}
if (event.key === 'ArrowRight') {
board.cycle(1);
} else if (event.key === 'ArrowLeft') {
board.cycle(-1);
}
}
window.addEventListener('keydown', onKeydown);
return () => window.removeEventListener('keydown', onKeydown);
});
let touchStartX = 0;
function onTouchStart(event: TouchEvent) {
touchStartX = event.touches[0]?.clientX ?? 0;
}
function onTouchEnd(event: TouchEvent) {
const endX = event.changedTouches[0]?.clientX ?? touchStartX;
const direction = swipeDirection(endX - touchStartX, SWIPE_THRESHOLD);
if (direction !== 0) {
board.cycle(direction);
}
}
</script>
<div
class="w-full"
role="group"
aria-label="Pairing board — swipe or use arrow keys to cycle"
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
<FocalFrame />
</div>
@@ -0,0 +1,36 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import CandidateCard from './CandidateCard.svelte';
const { Story } = defineMeta({
title: 'Widgets/Board/CandidateCard',
component: CandidateCard,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Compact rail switcher for one pairing: the two font names in their own fonts. Click makes the pairing focal (aria-current). Not an evaluation surface — no real-length specimen.',
},
story: { inline: false },
},
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story
name="Default"
args={{
pairing: { id: 'demo-1', headerFontId: 'Playfair Display', bodyFontId: 'Source Sans Pro' },
}}
>
{#snippet template(args: ComponentProps<typeof CandidateCard>)}
<div style="max-width: 220px;">
<CandidateCard {...args} />
</div>
{/snippet}
</Story>
@@ -0,0 +1,62 @@
<!--
Component: CandidateCard
Compact switcher for one pairing in the rail — NOT an evaluation surface. Shows
the two font names rendered in their own fonts at a small decorative size
(clamp/cqi is fine here: chrome, not the honest-measure specimen). Click makes
the pairing focal. Container-query driven so the same card works anywhere.
-->
<script lang="ts">
import {
FontApplicator,
type UnifiedFont,
getFontLifecycleManager,
} from '$entities/Font';
import type {
Pairing,
Role,
} from '$entities/Pairing';
import { getBoard } from '$features/CompareBoard';
interface Props {
/**
* The pairing this card switches to.
*/
pairing: Pairing;
}
let { pairing }: Props = $props();
const board = getBoard();
const lifecycle = getFontLifecycleManager();
const isFocal = $derived(board.focalId === pairing.id);
const fonts = $derived(board.resolvePairingFonts(pairing));
</script>
<button
type="button"
class="
@container flex w-full flex-col gap-1 rounded-lg border p-3 text-left transition-colors
aria-current:border-indigo-500 aria-current:bg-indigo-50
border-slate-200 hover:border-slate-300
"
aria-current={isFocal ? 'true' : undefined}
onclick={() => board.setFocal(pairing.id)}
>
{@render name('header', fonts.header?.name ?? pairing.headerFontId, fonts.header)}
{@render name('body', fonts.body?.name ?? pairing.bodyFontId, fonts.body)}
</button>
{#snippet name(role: Role, label: string, font: UnifiedFont | undefined)}
{@const size = role === 'header' ? 'clamp(0.9rem, 5cqi, 1.25rem)' : 'clamp(0.75rem, 4cqi, 1rem)'}
{#if font}
<FontApplicator
{font}
status={lifecycle.getFontStatus(font.id, board.typo[role].weight, font.features?.isVariable)}
>
<span class="block truncate" style:font-size={size}>{label}</span>
</FontApplicator>
{:else}
<span class="block truncate text-slate-500" style:font-size={size}>{label}</span>
{/if}
{/snippet}
@@ -0,0 +1,26 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import FocalFrame from './FocalFrame.svelte';
const { Story } = defineMeta({
title: 'Widgets/Board/FocalFrame',
component: FocalFrame,
parameters: {
docs: {
description: {
component:
"The constant-size focal pairing (header over body, each in its own font). Height is reserved from the board store's Pretext measurement before paint. Reads the board singleton, which self-seeds a curated pairing from the catalog.",
},
story: { inline: false },
},
},
});
</script>
<Story name="Default">
{#snippet template()}
<div style="max-width: 900px; margin: 2rem auto; padding: 0 1rem;">
<FocalFrame />
</div>
{/snippet}
</Story>
@@ -0,0 +1,74 @@
<!--
Component: FocalFrame
The constant-size focal pairing: header RoleField over body RoleField, each in
its own font. The frame's height is reserved from the board's Pretext-measured
`focalFrameHeight` BEFORE content paints — this is the zero-shift mechanism, so
cycling candidates of equal typography never reflows. Sizes are absolute px
(honest measure), never cqi/clamp.
-->
<script lang="ts">
import {
FontApplicator,
type UnifiedFont,
getFontLifecycleManager,
} from '$entities/Font';
import type { Role } from '$entities/Pairing';
import {
FRAME_ROLE_GAP,
getBoard,
} from '$features/CompareBoard';
import RoleField from '../RoleField/RoleField.svelte';
const board = getBoard();
const lifecycle = getFontLifecycleManager();
let frameWidth = $state(0);
const focal = $derived(board.focal);
const fonts = $derived(focal ? board.resolvePairingFonts(focal) : { header: undefined, body: undefined });
// Reserve the measured height up front; 0 (unmeasured) leaves the frame to grow
// naturally until the warm measurement lands.
const reservedHeight = $derived(board.focalFrameHeight(frameWidth));
</script>
<div
class="flex w-full flex-col"
style:gap="{FRAME_ROLE_GAP}px"
style:min-height={reservedHeight > 0 ? `${reservedHeight}px` : undefined}
bind:clientWidth={frameWidth}
>
{#if focal}
{@render roleBlock('header', fonts.header)}
{@render roleBlock('body', fonts.body)}
{/if}
</div>
{#snippet roleBlock(role: Role, font: UnifiedFont | undefined)}
{@const typo = board.typo[role]}
{#if font}
<FontApplicator {font} status={lifecycle.getFontStatus(font.id, typo.weight, font.features?.isVariable)}>
<RoleField
{role}
text={board.specimen[role]}
fontName={font.name}
size={typo.size}
weight={typo.weight}
leading={typo.leading}
tracking={typo.tracking}
oncommit={text => board.setSpecimen(role, text)}
/>
</FontApplicator>
{:else}
<!-- Font not yet resolved: render in system font so the field stays live. -->
<RoleField
{role}
text={board.specimen[role]}
fontName="system-ui"
size={typo.size}
weight={typo.weight}
leading={typo.leading}
tracking={typo.tracking}
oncommit={text => board.setSpecimen(role, text)}
/>
{/if}
{/snippet}
@@ -0,0 +1,83 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import RoleField from './RoleField.svelte';
const { Story } = defineMeta({
title: 'Widgets/Board/RoleField',
component: RoleField,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Always-editable plain-text specimen field for one role. Uncontrolled while focused (no caret jump), commits on blur. Header blocks Enter (single-line); body allows line breaks. Paste inserts plain text only.',
},
story: { inline: false },
},
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
let headerText = $state('The Art of Harmonious Type');
let bodyText = $state(
'Good typography is invisible. It guides the eye without calling attention to itself, balancing rhythm, contrast, and proportion.',
);
let emptyText = $state('');
</script>
<Story
name="Header (single-line)"
args={{
role: 'header',
text: headerText,
fontName: 'Georgia',
size: 48,
weight: 700,
leading: 1.1,
tracking: 0,
oncommit: t => (headerText = t),
}}
>
{#snippet template(args: ComponentProps<typeof RoleField>)}
<RoleField {...args} />
{/snippet}
</Story>
<Story
name="Body (multi-line)"
args={{
role: 'body',
text: bodyText,
fontName: 'Georgia',
size: 18,
weight: 400,
leading: 1.5,
tracking: 0,
oncommit: t => (bodyText = t),
}}
>
{#snippet template(args: ComponentProps<typeof RoleField>)}
<RoleField {...args} />
{/snippet}
</Story>
<Story
name="Empty (placeholder)"
args={{
role: 'body',
text: emptyText,
fontName: 'Georgia',
size: 18,
weight: 400,
leading: 1.5,
tracking: 0,
oncommit: t => (emptyText = t),
}}
>
{#snippet template(args: ComponentProps<typeof RoleField>)}
<RoleField {...args} />
{/snippet}
</Story>
@@ -0,0 +1,127 @@
<!--
Component: RoleField
Always-live plain-text specimen field for one role (header/body). Edits stay
uncontrolled while the field is focused (the prop never writes back over the
caret), and commit to the board on blur. The editable node is wrapped so any
frame transition animates the wrapper, never the node being typed in.
-->
<script lang="ts">
import type { Role } from '$entities/Pairing';
interface Props {
/**
* Which role this field edits — drives Enter behaviour and the placeholder.
*/
role: Role;
/**
* Current committed specimen text for the role.
*/
text: string;
/**
* Font family the specimen renders in.
*/
fontName: string;
/**
* Font size in px.
*/
size: number;
/**
* Numeric font weight.
*/
weight: number;
/**
* Unitless line-height multiplier.
*/
leading: number;
/**
* Letter spacing in px.
*/
tracking: number;
/**
* Called with the field's text when it commits (on blur).
*/
oncommit: (text: string) => void;
/**
* Extra CSS classes for the wrapper.
*/
class?: string;
}
let {
role,
text,
fontName,
size,
weight,
leading,
tracking,
oncommit,
class: className = '',
}: Props = $props();
let element = $state<HTMLDivElement>();
let focused = $state(false);
const placeholder = $derived(role === 'header' ? 'Header text…' : 'Body text…');
/**
* Sync the prop into the DOM only while unfocused. External updates (cycling,
* reset, another field's commit) must never move the caret mid-edit, so we skip
* the write whenever the field has focus. innerText keeps the content plain.
*/
$effect(() => {
const next = text;
if (element && !focused && element.innerText !== next) {
element.innerText = next;
}
});
function handleBlur() {
focused = false;
if (element) {
oncommit(element.innerText);
}
}
function handlePaste(event: ClipboardEvent) {
// Strip formatting: insert the clipboard's plain text only.
event.preventDefault();
const plain = event.clipboardData?.getData('text/plain') ?? '';
document.execCommand('insertText', false, plain);
}
function handleKeydown(event: KeyboardEvent) {
// Header is single-line: Enter commits (blur) instead of inserting a break.
if (role === 'header' && event.key === 'Enter') {
event.preventDefault();
element?.blur();
}
}
</script>
<div class={className}>
<div
bind:this={element}
contenteditable="plaintext-only"
spellcheck="false"
role="textbox"
tabindex="0"
aria-label="{role} specimen"
data-placeholder={placeholder}
onfocus={() => (focused = true)}
onblur={handleBlur}
onpaste={handlePaste}
onkeydown={handleKeydown}
class="
w-full min-h-[1.4em] outline-none whitespace-pre-wrap break-words
empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400
focus:outline-none
"
style:font-family={`"${fontName}"`}
style:font-size="{size}px"
style:font-weight={weight}
style:line-height={leading}
style:letter-spacing="{tracking}px"
>
</div>
</div>
@@ -0,0 +1,51 @@
import {
fireEvent,
render,
screen,
} from '@testing-library/svelte';
import { tick } from 'svelte';
import {
describe,
expect,
it,
vi,
} from 'vitest';
import RoleField from './RoleField.svelte';
const baseProps = { fontName: 'Georgia', size: 24, weight: 400, leading: 1.4, tracking: 0 };
describe('RoleField', () => {
it('renders the initial text', async () => {
render(RoleField, { props: { role: 'header', text: 'Hello', oncommit: () => {}, ...baseProps } });
await tick();
expect(screen.getByRole('textbox').textContent).toBe('Hello');
});
it('commits the field text on blur (not on input)', async () => {
const oncommit = vi.fn();
render(RoleField, { props: { role: 'body', text: 'Start', oncommit, ...baseProps } });
await tick();
const field = screen.getByRole('textbox');
field.textContent = 'Edited';
await fireEvent.blur(field);
expect(oncommit).toHaveBeenCalledWith('Edited');
});
it('prevents Enter on the header role (single-line)', async () => {
render(RoleField, { props: { role: 'header', text: 'Title', oncommit: () => {}, ...baseProps } });
await tick();
const field = screen.getByRole('textbox');
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true });
field.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
it('allows Enter on the body role (multi-line)', async () => {
render(RoleField, { props: { role: 'body', text: 'Para', oncommit: () => {}, ...baseProps } });
await tick();
const field = screen.getByRole('textbox');
const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true, bubbles: true });
field.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
});
+13
View File
@@ -19,6 +19,19 @@ Element.prototype.animate = vi.fn().mockReturnValue({
// jsdom lacks SVG geometry methods
(SVGElement.prototype as any).getTotalLength = vi.fn(() => 0);
// jsdom lacks innerText; back it with textContent so contenteditable specs work.
if (!Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText')) {
Object.defineProperty(HTMLElement.prototype, 'innerText', {
configurable: true,
get(this: HTMLElement) {
return this.textContent ?? '';
},
set(this: HTMLElement, value: string) {
this.textContent = value;
},
});
}
// Robust localStorage mock for jsdom environment
const localStorageMock = (() => {
let store: Record<string, string> = {};