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