──────────────────────────────────────────────────────────────────
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.
263 lines
8.6 KiB
TypeScript
263 lines
8.6 KiB
TypeScript
import { useCallback, useState, type CSSProperties, type DragEvent } from 'react';
|
|
import type { LayoutPersistence, TileSpan, TileSpec } from './types.js';
|
|
import { useWorkspaceLayout } from './useWorkspaceLayout.js';
|
|
|
|
export interface WorkspaceProps {
|
|
/** Tile registry. */
|
|
tiles: TileSpec[];
|
|
/** Persistence key (route-scoped). */
|
|
storageKey: string;
|
|
/** Optional persistence override (default localStorage). */
|
|
persistence?: LayoutPersistence;
|
|
/**
|
|
* Number of grid columns. Tiles' `span` is interpreted as a fraction
|
|
* of this. Default 4.
|
|
*/
|
|
columns?: 4 | 6 | 12;
|
|
/** Show the reset-layout CTA. Default true. */
|
|
showResetCta?: boolean;
|
|
/** Bypass drag entirely (read-only). Default false. */
|
|
readOnly?: boolean;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<Workspace>` — drag-reorderable tile grid with width control.
|
|
*
|
|
* Wave 13.F.1 + .2. HTML5 native drag (no `@dnd-kit` dependency).
|
|
* Persists via the `LayoutPersistence` adapter (default localStorage —
|
|
* hosts on `platform-service` pass a server-backed one).
|
|
*
|
|
* Keyboard shortcuts on a focused tile:
|
|
* - ←/→ resize span down/up (clamped to 1..4)
|
|
* - ↑/↓ move tile up/down by one slot
|
|
*/
|
|
export function Workspace({
|
|
tiles,
|
|
storageKey,
|
|
persistence,
|
|
columns = 4,
|
|
showResetCta = true,
|
|
readOnly = false,
|
|
className,
|
|
style,
|
|
}: WorkspaceProps) {
|
|
const { layout, move, resize, reset, hydrated } = useWorkspaceLayout(tiles, {
|
|
storageKey,
|
|
persistence,
|
|
});
|
|
const tileById = new Map(tiles.map((t) => [t.id, t]));
|
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
|
|
|
const onDragStart = useCallback(
|
|
(e: DragEvent<HTMLElement>, idx: number) => {
|
|
if (readOnly) return;
|
|
setDragIndex(idx);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
try {
|
|
e.dataTransfer.setData('text/plain', String(idx));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
},
|
|
[readOnly],
|
|
);
|
|
|
|
const onDragOver = useCallback(
|
|
(e: DragEvent<HTMLElement>, idx: number) => {
|
|
if (readOnly) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
setDropIndex(idx);
|
|
},
|
|
[readOnly],
|
|
);
|
|
|
|
const onDrop = useCallback(
|
|
(e: DragEvent<HTMLElement>, idx: number) => {
|
|
if (readOnly) return;
|
|
e.preventDefault();
|
|
const from = dragIndex;
|
|
setDragIndex(null);
|
|
setDropIndex(null);
|
|
if (from === null) return;
|
|
move(from, idx);
|
|
},
|
|
[dragIndex, move, readOnly],
|
|
);
|
|
|
|
return (
|
|
<div data-testid="bl-workspace" className={className} style={style}>
|
|
{showResetCta && !readOnly && (
|
|
<div className="bl-workspace-toolbar">
|
|
<button
|
|
type="button"
|
|
onClick={reset}
|
|
data-testid="bl-workspace-reset"
|
|
style={{
|
|
padding: '4px 10px',
|
|
fontSize: 12,
|
|
borderRadius: 999,
|
|
border: '1px solid var(--bl-border, rgba(0,0,0,0.14))',
|
|
background: 'var(--bl-surface-card, #fff)',
|
|
color: 'var(--bl-text-secondary, #555)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
Reset layout
|
|
</button>
|
|
{!hydrated && (
|
|
<span
|
|
data-testid="bl-workspace-hydrating"
|
|
style={{
|
|
marginLeft: 8,
|
|
fontSize: 11,
|
|
color: 'var(--bl-text-tertiary, #999)',
|
|
fontFamily: 'ui-monospace, monospace',
|
|
}}
|
|
>
|
|
loading…
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div
|
|
role="region"
|
|
aria-label="Workspace tiles"
|
|
data-testid="bl-workspace-grid"
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
gap: 12,
|
|
marginTop: 8,
|
|
}}
|
|
>
|
|
{layout.entries.map((entry, idx) => {
|
|
const tile = tileById.get(entry.id);
|
|
if (!tile) return null;
|
|
const span = Math.max(1, Math.min(columns, entry.span)) as TileSpan;
|
|
return (
|
|
<article
|
|
key={entry.id}
|
|
aria-label={tile.title}
|
|
data-testid="bl-workspace-tile"
|
|
data-tile-id={entry.id}
|
|
data-drop-target={dropIndex === idx ? 'true' : undefined}
|
|
tabIndex={0}
|
|
draggable={!readOnly}
|
|
onDragStart={(e) => onDragStart(e, idx)}
|
|
onDragOver={(e) => onDragOver(e, idx)}
|
|
onDragLeave={() => setDropIndex((v) => (v === idx ? null : v))}
|
|
onDrop={(e) => onDrop(e, idx)}
|
|
onKeyDown={(e) => {
|
|
if (readOnly) return;
|
|
if (e.key === 'ArrowLeft') {
|
|
e.preventDefault();
|
|
resize(entry.id, Math.max(1, span - 1) as TileSpan);
|
|
} else if (e.key === 'ArrowRight') {
|
|
e.preventDefault();
|
|
resize(entry.id, Math.min(columns, span + 1) as TileSpan);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
move(idx, Math.max(0, idx - 1));
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
move(idx, idx + 1);
|
|
}
|
|
}}
|
|
style={{
|
|
gridColumn: `span ${span}`,
|
|
background: 'var(--bl-surface-card, #fff)',
|
|
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
|
borderRadius: 14,
|
|
padding: 12,
|
|
outline:
|
|
dropIndex === idx
|
|
? '2px dashed var(--bl-accent, #6366f1)'
|
|
: 'none',
|
|
cursor: readOnly ? 'default' : 'grab',
|
|
transition: 'outline-color 120ms ease',
|
|
}}
|
|
>
|
|
<header
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'baseline',
|
|
justifyContent: 'space-between',
|
|
gap: 8,
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
<div>
|
|
<h4
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
color: 'var(--bl-text-primary, #111)',
|
|
}}
|
|
>
|
|
{tile.title}
|
|
</h4>
|
|
{tile.subtitle && (
|
|
<p
|
|
style={{
|
|
margin: '2px 0 0',
|
|
fontSize: 11,
|
|
color: 'var(--bl-text-tertiary, #999)',
|
|
}}
|
|
>
|
|
{tile.subtitle}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{!readOnly && (
|
|
<div
|
|
role="group"
|
|
aria-label={`Resize ${tile.title}`}
|
|
style={{ display: 'inline-flex', gap: 2 }}
|
|
>
|
|
{([1, 2, 3, 4] as TileSpan[]).filter((n) => n <= columns).map((n) => (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
aria-pressed={span === n}
|
|
onClick={() => resize(entry.id, n)}
|
|
data-testid={`bl-workspace-tile-size-${n}`}
|
|
style={{
|
|
width: 22,
|
|
height: 22,
|
|
fontSize: 11,
|
|
borderRadius: 6,
|
|
border:
|
|
span === n
|
|
? '1px solid var(--bl-accent, #6366f1)'
|
|
: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
|
background:
|
|
span === n
|
|
? 'color-mix(in srgb, var(--bl-accent, #6366f1) 16%, transparent)'
|
|
: 'transparent',
|
|
color:
|
|
span === n
|
|
? 'var(--bl-accent, #6366f1)'
|
|
: 'var(--bl-text-tertiary, #999)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{n}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</header>
|
|
<div>{tile.content}</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|