feat(CompareBoard): add board store with cycling, persistence, and typography seam
This commit is contained in:
@@ -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 (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<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;
|
||||
Reference in New Issue
Block a user