feat(CompareBoard): add board store with cycling, persistence, and typography seam

This commit is contained in:
Ilia Mashkov
2026-06-24 14:48:29 +03:00
parent e55e713517
commit 92ea7b9dc4
2 changed files with 451 additions and 0 deletions
@@ -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');
});
});
@@ -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 (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());
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;