learning_ai_common_plat/packages/customizable-workspace/src/Workspace.tsx
saravanakumardb1 8562711f49 fix(workspace+theme): sanitise corrupt spans, short-circuit re-reconcile, drop unreachable regex
──────────────────────────────────────────────────────────────────
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.
2026-05-27 18:46:19 -07:00

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>
);
}