──────────────────────────────────────────────────────────────────
customizable-workspace
──────────────────────────────────────────────────────────────────
Two issues caught in the audit pass:
1. **Corrupt persisted spans broke the grid layout.**
localStorage entries from older versions (or hand-edited debug
sessions) could contain span=NaN / 0 / -3 / 99. These flowed
straight into `grid-column: span <bad>` which silently broke
the whole row. The visual symptom was a tile rendering at zero
width or pushing every sibling off-screen.
Fix: `reconcile()` now clamps every span (including newly
appended tiles' defaultSpan) to the legal `[1, 4]` range via
`sanitiseSpan()`.
2. **Re-reconcile effect could loop when callers forget to memoise.**
The `useEffect([tiles, hydrated])` always called `setLayout`
with a fresh `{ entries: [...] }` object reference, even when
the content was identical. If `tiles` itself was a fresh
reference per parent render (e.g. `tiles=[{...}]` inline),
every render \u2192 setLayout \u2192 save effect \u2192 (no loop because
tiles ref same), but constant unnecessary writes to
localStorage.
Fix: added `sameLayout(a, b)` structural-equality check;
setLayout now short-circuits to the previous state when the
reconciled output is identical.
Tests: 10 \u2192 11
reconcile \u00b7 sanitises corrupt spans (NaN/0/negative/>4) \u2192 clamp
──────────────────────────────────────────────────────────────────
generative-theme
──────────────────────────────────────────────────────────────────
Cosmetic but worth fixing: the `rose` palette regex included
`warm` as a keyword, but the `citrus` palette \u2014 listed earlier in
the PALETTES table \u2014 also matched `warm`. Since first-match wins,
`warm` was unreachable in rose and the entry was misleading.
Dropped `warm` from the rose regex. Citrus retains it (was always
where it routed in practice). All 18 existing tests still pass.
161 lines
5.2 KiB
TypeScript
161 lines
5.2 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type {
|
|
LayoutEntry,
|
|
LayoutPersistence,
|
|
LayoutSpec,
|
|
TileSpan,
|
|
TileSpec,
|
|
} from './types.js';
|
|
import { localStoragePersistence } from './persistence.js';
|
|
|
|
export interface UseWorkspaceLayoutOptions {
|
|
/** Persistence key (route-scoped). Should be product + view unique. */
|
|
storageKey: string;
|
|
/** Override the persistence layer — default is `localStorage`. */
|
|
persistence?: LayoutPersistence;
|
|
}
|
|
|
|
export interface UseWorkspaceLayoutHelpers {
|
|
layout: LayoutSpec;
|
|
/** Move a tile from `fromIndex` → `toIndex` (clamped). */
|
|
move: (fromIndex: number, toIndex: number) => void;
|
|
/** Set a tile's span. */
|
|
resize: (id: string, span: TileSpan) => void;
|
|
/** Reset to the default layout (one entry per tile, defaultSpan or 2). */
|
|
reset: () => void;
|
|
/** True once the persistence load has resolved. */
|
|
hydrated: boolean;
|
|
}
|
|
|
|
/**
|
|
* `useWorkspaceLayout` — derives the live layout from a tile registry +
|
|
* persistence layer. Order + spans are persisted on every change.
|
|
*
|
|
* The hook is `tiles`-driven: pass the canonical tile registry and the
|
|
* hook returns the current ordered+sized layout. Tiles absent from
|
|
* persisted layout are appended in registry order; persisted entries
|
|
* referencing removed tiles are dropped (defensive against schema
|
|
* drift between releases).
|
|
*/
|
|
export function useWorkspaceLayout(
|
|
tiles: TileSpec[],
|
|
options: UseWorkspaceLayoutOptions,
|
|
): UseWorkspaceLayoutHelpers {
|
|
const persistence = options.persistence ?? localStoragePersistence;
|
|
const persistenceRef = useRef(persistence);
|
|
persistenceRef.current = persistence;
|
|
|
|
const defaults: LayoutSpec = useMemo(
|
|
() => ({
|
|
entries: tiles.map((t) => ({ id: t.id, span: t.defaultSpan ?? 2 })),
|
|
}),
|
|
[tiles],
|
|
);
|
|
|
|
const [layout, setLayout] = useState<LayoutSpec>(defaults);
|
|
const [hydrated, setHydrated] = useState(false);
|
|
|
|
// One-shot load on mount.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const loaded = await persistenceRef.current.load(options.storageKey);
|
|
if (cancelled) return;
|
|
if (loaded) {
|
|
setLayout(reconcile(loaded, tiles));
|
|
}
|
|
} finally {
|
|
if (!cancelled) setHydrated(true);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [options.storageKey]);
|
|
|
|
// Re-reconcile whenever the tile registry changes (add/remove/reorder).
|
|
// Short-circuit identical layouts to avoid setLayout → save effect →
|
|
// re-render loop when callers forget to memoise `tiles`.
|
|
useEffect(() => {
|
|
if (!hydrated) return;
|
|
setLayout((cur) => {
|
|
const next = reconcile(cur, tiles);
|
|
return sameLayout(cur, next) ? cur : next;
|
|
});
|
|
}, [tiles, hydrated]);
|
|
|
|
// Persist on every change (after hydration so initial load doesn't write back).
|
|
useEffect(() => {
|
|
if (!hydrated) return;
|
|
void persistenceRef.current.save(options.storageKey, layout);
|
|
}, [layout, options.storageKey, hydrated]);
|
|
|
|
const move = useCallback((from: number, to: number) => {
|
|
setLayout((cur) => {
|
|
const len = cur.entries.length;
|
|
if (from === to || from < 0 || from >= len) return cur;
|
|
const clampedTo = Math.max(0, Math.min(len - 1, to));
|
|
const next = cur.entries.slice();
|
|
const [moved] = next.splice(from, 1);
|
|
if (!moved) return cur;
|
|
next.splice(clampedTo, 0, moved);
|
|
return { entries: next };
|
|
});
|
|
}, []);
|
|
|
|
const resize = useCallback((id: string, span: TileSpan) => {
|
|
setLayout((cur) => ({
|
|
entries: cur.entries.map((e) => (e.id === id ? { ...e, span } : e)),
|
|
}));
|
|
}, []);
|
|
|
|
const reset = useCallback(() => {
|
|
setLayout(defaults);
|
|
}, [defaults]);
|
|
|
|
return { layout, move, resize, reset, hydrated };
|
|
}
|
|
|
|
/**
|
|
* Drop entries that no longer exist; append new tiles in registry order
|
|
* with their default span. Pure function (used by tests).
|
|
*
|
|
* Also sanitises spans — a corrupt persisted entry with span=NaN /
|
|
* 0 / negative / 100 used to render "grid-column: span NaN" which
|
|
* silently broke the layout; we now clamp to `[1, 4]`.
|
|
*/
|
|
export function reconcile(
|
|
layout: LayoutSpec,
|
|
tiles: TileSpec[],
|
|
): LayoutSpec {
|
|
const knownIds = new Set(tiles.map((t) => t.id));
|
|
const surviving: LayoutEntry[] = layout.entries
|
|
.filter((e) => knownIds.has(e.id))
|
|
.map((e) => ({ id: e.id, span: sanitiseSpan(e.span) }));
|
|
const seen = new Set(surviving.map((e) => e.id));
|
|
for (const t of tiles) {
|
|
if (!seen.has(t.id)) {
|
|
surviving.push({ id: t.id, span: sanitiseSpan(t.defaultSpan ?? 2) });
|
|
}
|
|
}
|
|
return { entries: surviving };
|
|
}
|
|
|
|
function sanitiseSpan(span: unknown): TileSpan {
|
|
const n = typeof span === 'number' && Number.isFinite(span) ? span : 2;
|
|
return Math.max(1, Math.min(4, Math.round(n))) as TileSpan;
|
|
}
|
|
|
|
/** Cheap structural equality between two layouts. */
|
|
function sameLayout(a: LayoutSpec, b: LayoutSpec): boolean {
|
|
if (a.entries.length !== b.entries.length) return false;
|
|
for (let i = 0; i < a.entries.length; i++) {
|
|
const ae = a.entries[i]!;
|
|
const be = b.entries[i]!;
|
|
if (ae.id !== be.id || ae.span !== be.span) return false;
|
|
}
|
|
return true;
|
|
}
|