From 92ea7b9dc4e1d751bfc596c1ef2531395b9a795b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 24 Jun 2026 14:48:29 +0300 Subject: [PATCH] feat(CompareBoard): add board store with cycling, persistence, and typography seam --- .../boardStore/boardStore.svelte.test.ts | 122 +++++++ .../store/boardStore/boardStore.svelte.ts | 329 ++++++++++++++++++ 2 files changed, 451 insertions(+) create mode 100644 src/features/CompareBoard/model/store/boardStore/boardStore.svelte.test.ts create mode 100644 src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts diff --git a/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.test.ts b/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.test.ts new file mode 100644 index 0000000..1a488b6 --- /dev/null +++ b/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.test.ts @@ -0,0 +1,122 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { + __resetBoard, + getBoard, +} from './boardStore.svelte'; + +beforeEach(() => { + localStorage.clear(); + __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('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'); + }); +}); diff --git a/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts b/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts new file mode 100644 index 0000000..e9eb10d --- /dev/null +++ b/src/features/CompareBoard/model/store/boardStore/boardStore.svelte.ts @@ -0,0 +1,329 @@ +/** + * 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 + * resolution/loading (Task 14) and frame measurement (Task 16) layer on later. + */ + +import { + DEFAULT_FONT_SIZE, + DEFAULT_FONT_WEIGHT, + DEFAULT_LETTER_SPACING, + DEFAULT_LINE_HEIGHT, +} from '$entities/Font'; +import { + type Pairing, + type Role, + createPairing, + nextFocalId, +} from '$entities/Pairing'; +import { + BOARD_SCHEMA_VERSION, + BOARD_STORAGE_KEY, + DEFAULT_SPECIMEN, +} from '$features/CompareBoard/model/const/const'; +import { + createPersistentStore, + createSingleton, +} from '$shared/lib'; +import { flushSync } from 'svelte'; + +/** + * 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 (100–900). + */ + 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([]); + /** + * The focal pairing's id, or null when the board is empty. + */ + #focalId = $state(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(BOARD_STORAGE_KEY, emptyBoard()); + + 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 }; + } + + /** + * 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 } }; + } + + /** + * 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.#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;