`) rather than a structural wrapper. */
function isCdIdLeaf(el: Element): boolean {
return el.querySelector('[data-cd-id]') === null;
}
function elementIsCandidate(el: Element): boolean {
const tag = el.tagName.toLowerCase();
if (RECTS_INTERACTIVE_TAGS.has(tag)) return true;
if (el.hasAttribute('role') && el.getAttribute('role') === 'button') return true;
if (el.hasAttribute('tabindex')) return true;
if (!isCdIdLeaf(el)) return false; // structural wrapper — a leaf sibling/descendant covers it
return shortText(el, 1).length > 0;
}
function buildCanvasRectsManifest(): CanvasRectsManifest {
const empty: CanvasRectsManifest = { artboards: [], elements: [], elementsTruncated: false };
if (typeof document === 'undefined') return empty;
const host = document.querySelector('.dc-canvas') as HTMLElement | null;
if (!host) return empty; // bare specimen / no world plane — nothing to resolve against
const vp = getLiveViewport() ?? { x: 0, y: 0, zoom: 1 };
const hostRect = host.getBoundingClientRect();
const toWorld = (r: DOMRect): { x: number; y: number; w: number; h: number } => ({
x: (r.left - hostRect.left - vp.x) / vp.zoom,
y: (r.top - hostRect.top - vp.y) / vp.zoom,
w: r.width / vp.zoom,
h: r.height / vp.zoom,
});
const artboards: ArtboardRect[] = [];
const artboardEls = Array.from(document.querySelectorAll('[data-dc-screen]'));
for (const el of artboardEls) {
const rect = (el as HTMLElement).getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
const id = el.getAttribute('data-dc-screen');
if (!id) continue;
// `kind` is part of ArtboardRect and the manifest's consumers (canvas-rects
// → the whiteboard toolkit, DDR-151) read it. It was being dropped here —
// invisible until A2 put this file under a checker. Same element carries
// `data-dc-kind`, always stamped by DCArtboard; 'digital' is its own default.
const kindAttr = el.getAttribute('data-dc-kind');
const kind: ArtboardKind =
kindAttr === 'print' || kindAttr === 'web' || kindAttr === 'video' ? kindAttr : 'digital';
artboards.push({ id, kind, ...toWorld(rect) });
}
const elements: CanvasRectsElement[] = [];
let truncated = false;
const candidateEls = Array.from(document.querySelectorAll('[data-cd-id]'));
for (const el of candidateEls) {
if (elements.length >= RECTS_ELEMENT_CAP) {
truncated = true;
break;
}
if (el.closest(RECTS_CHROME_SELECTOR)) continue;
const artboardEl = el.closest('[data-dc-screen]');
if (!artboardEl) continue; // off-artboard chrome (menubar, panels, toolbars)
if (!elementIsCandidate(el)) continue;
const rect = (el as HTMLElement).getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
const cdId = el.getAttribute('data-cd-id');
const artboardId = artboardEl.getAttribute('data-dc-screen');
const selector = cdId ? scopedCdSelector(cdId, artboardId) : '';
const index = cdId ? selectorIndex(document, selector, el) : 0;
elements.push({
cdId,
selector,
index,
artboard: artboardId,
...toWorld(rect),
tag: el.tagName.toLowerCase(),
text: shortText(el, 120),
});
}
return { artboards, elements, elementsTruncated: truncated };
}
declare global {
interface Window {
__maudeCanvasRects?: () => CanvasRectsManifest;
}
}
if (typeof window !== 'undefined') {
window.__maudeCanvasRects = buildCanvasRectsManifest;
}
// ─────────────────────────────────────────────────────────────────────────────
// DrawProof (Phase 25) — the render/verify harness for the draw engine. Renders
// ONE vector mark across a size ladder × {light, dark, single-color flatten} as
// labeled DCArtboards, so a single `maude design screenshot --all-screens`
// operationalizes the whole graphic rubric at once:
// • per-size legibility (does the 16px instance survive the favicon test?)
// • dark-mode correctness (does `currentColor` flip cleanly?)
// • the single-color flatten test (pure #000 on #fff — logo must hold)
// Reference frames are FIXED (not DS tokens) on purpose: the flatten/legibility
// tests must be objective, independent of whichever DS the canvas declares.
// Additive export (DDR-025) — no existing canvas-lib surface changes.
const DRAW_PROOF_MODES = {
light: { bg: '#ffffff', fg: '#111111', label: 'light' },
dark: { bg: '#111111', fg: '#f5f5f5', label: 'dark' },
flatten: { bg: '#ffffff', fg: '#000000', label: 'single-color flatten' },
} as const;
export type DrawProofMode = keyof typeof DRAW_PROOF_MODES;
/**
* Render a single mark across the verification ladder. `mark` is the inline SVG
* (the engine's `toJsx` output, dropped in as JSX). Each mode becomes one
* labeled DCArtboard (a `--all-screens` target) showing the mark at every size,
* so the proof PNGs are `proof-
.png`.
*/
export function DrawProof({
mark,
name = 'mark',
sizes = [16, 24, 48, 256],
modes = ['light', 'dark', 'flatten'],
}: {
mark: ReactNode;
name?: string;
sizes?: number[];
modes?: DrawProofMode[];
}) {
const maxSize = Math.max(...sizes, 64);
const cellGap = 32;
const padding = 32;
const boardWidth =
padding * 2 + sizes.reduce((acc, s) => acc + Math.max(s, 56), 0) + cellGap * (sizes.length - 1);
const boardHeight = padding * 2 + maxSize + 28;
return (
{modes.map((mode) => {
const m = DRAW_PROOF_MODES[mode];
return (
{sizes.map((s) => (
{mark}
{s}px
))}
);
})}
);
}
DrawProof.displayName = 'DrawProof';
// ─────────────────────────────────────────────────────────────────────────────
// PhotoLayer (feature-photo-editor, Task 6) — the non-destructive WebGL photo
// compositor surface. Renders a source photo (artboard ` ` or annotation
// `ImageStroke`) with a live `PhotoEdit` applied through pixi.js.
//
// LAZY-BUNDLE GUARANTEE (the plan's load-bearing acceptance criterion + BUILDER's
// flagged top risk): an UNEDITED photo (`isDefaultEdit(edit)`) renders as the
// plain ` ` and NEVER touches pixi. The compositor module (photo/pipeline.ts)
// is only reached through a DYNAMIC `import()` inside the effect, so a canvas
// with zero edited photos pays zero pixi.js/bg-removal cost. Verified empirically
// against `buildCanvasModule` (no eager `pixi.js` import in the default-edit
// bundle) — see test/photo-canvas-bundle.test.ts.
//
// A11y: the pixi output is a `` (a black box to AT), so it carries
// `role="img"` + `aria-label` from `alt` (validation step 7 requirement).
// Reduced-motion: the compositor renders statically (autoStart:false, no ticker).
export interface PhotoLayerProps {
/** Relative `assets/.` source (validated upstream). */
source: string;
/** Live non-destructive edit. Absent / neutral ⇒ plain ` `, no pixi. */
edit?: PhotoEdit | null;
width: number;
height: number;
alt?: string;
className?: string;
style?: CSSProperties;
/** Resolve a relative asset path to a fetchable URL (defaults to identity —
* relative `assets/…` already resolves against the canvas iframe origin). */
resolveUrl?: (rel: string) => string;
/** Fired once the pixi compositor has mounted + drawn its first frame. The
* preview bridge hides the original element only AFTER this, so a background
* cutout never flashes the untouched original underneath (and no flicker). */
onReady?: () => void;
}
export function PhotoLayer({
source,
edit,
width,
height,
alt = '',
className,
style,
resolveUrl,
onReady,
}: PhotoLayerProps) {
const canvasRef = useRef(null);
const rendererRef = useRef<{ destroy(): void; update(e: PhotoEdit): void } | null>(null);
const active = !isDefaultEdit(edit);
// Mount / tear down the pixi compositor only while an edit is active. The
// compositor is DYNAMICALLY imported (lazy-bundle guarantee — see header).
// Re-created only when the source/box identity changes; edit-param changes are
// pushed via the second effect below (no teardown → smooth live scrub).
// biome-ignore lint/correctness/useExhaustiveDependencies: `edit` is deliberately excluded — re-creating the pixi Application on every scrub would thrash; edit updates flow through the second effect's `update()` (mount seeds from the current edit).
useEffect(() => {
if (!active) return;
const canvas = canvasRef.current;
if (!canvas) return;
let disposed = false;
import('./photo/pipeline.ts')
.then(({ PhotoRenderer }) =>
PhotoRenderer.create({
canvas,
source,
edit: (edit ?? {}) as PhotoEdit,
width,
height,
resolveUrl,
})
)
.then((r) => {
if (disposed) {
r.destroy();
return;
}
rendererRef.current = r;
onReady?.();
})
.catch((err) => {
console.error('[PhotoLayer] compositor failed to mount', err);
});
return () => {
disposed = true;
rendererRef.current?.destroy();
rendererRef.current = null;
};
}, [active, source, width, height, resolveUrl, onReady]);
// Live-update the mounted compositor on edit-param change (no re-create).
useEffect(() => {
if (active && edit && rendererRef.current) rendererRef.current.update(edit);
}, [edit, active]);
if (!active) {
const src = resolveUrl ? resolveUrl(source) : source;
return (
);
}
return (
);
}
PhotoLayer.displayName = 'PhotoLayer';
// PhotoPreviewBridge (feature-photo-editor) — applies a live/persisted
// PhotoEdit DIRECTLY to the real photo element (artboard ` ` or
// annotation ``) by swapping its `src`/`href` to a baked data URL,
// instead of floating a separate WebGL-rendered decoy on top of it.
//
// Iteration 1 of this bridge did the "decoy" version: a `position:fixed` div
// tracked the real element's screen rect via a per-frame rAF loop and hid the
// original underneath (`visibility:hidden`) while a live `` pixi
// canvas rendered on top. That broke every bit of free native DOM behavior
// the real element used to have:
// - cmd+click / right-click hit-testing landed on whatever was BEHIND the
// now-invisible original (a hidden element isn't hit-testable), while the
// decoy was `pointer-events:none` so it couldn't take the click either —
// net result, nothing was clickable.
// - the decoy rendered at a STABLE "world" pixel size and was CSS-stretched
// to the live screen box on zoom; the stretch didn't reliably track the
// real box, so the visible photo grew/shrank relative to its own frame.
// - it needed its own z-index (originally 30 — drew over the context menu)
// instead of just sitting at the element's normal stacking position.
// - it only knew about an edit via the transient postMessage below, so any
// iframe remount (Cmd+R, HMR) reset it to nothing until a human reopened
// the Inspector and nudged a knob.
// Swapping the REAL element's `src`/`href` sidesteps all of it: resize, zoom,
// hit-testing, and stacking become the browser's native ` `/``
// behavior, not a hand-rolled tracker. Non-destructive still holds — only the
// LIVE DOM attribute is mutated, never the authored TSX/SVG source; the
// on-disk `PhotoEdit` sidecar (`/_api/photo-edit`) stays the persisted source
// of truth, re-applied by the hydration scan below on every canvas (re)mount.
// The bake is at the source's NATIVE resolution (`renderPhotoDataUrl`), so
// the result stays sharp across any later resize/zoom with no re-bake.
/** `assets/.` substring inside a `src`/`href`/`xlink:href`. */
const ASSET_REF_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
/** Matches a photo element by the `data-photo-asset` tag `apply()` stamps on
* first touch, falling back to a literal src/href substring match for an
* element this bridge hasn't touched yet. The tag is load-bearing: once
* baked, the element's `src`/`href` is a `data:` URL that no longer contains
* the original asset path, so the substring match alone would lose track of
* it on the very next edit. */
function photoRefOf(el: Element): string {
return el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
}
/**
* Is the element's `data-photo-asset` tag still true? The tag is this bridge's
* memory of a node it baked — while baked, the node shows a `data:` URL; left
* alone, it shows the asset. Once the canvas points the node at something
* else (Replace… on the image), the memory is stale: the node is no longer
* this photo, and acting on the tag would put the old picture back over the
* new one on the machine that made the replace (plan T31/L12).
*/
function photoTagCurrent(el: Element, tagged: string): boolean {
const ref = photoRefOf(el);
return ref.startsWith('data:') || ref.includes(tagged);
}
export function findPhotoEl(asset: string): Element | null {
if (typeof document === 'undefined') return null;
// Scoped to img/image (not a bare `[data-photo-asset]` attribute selector) —
// the canvas iframe is untrusted content (DDR-054); an authored canvas could
// otherwise stamp the tag on an arbitrary element to redirect a bake.
for (const n of document.querySelectorAll('img[data-photo-asset], image[data-photo-asset]')) {
if (n.getAttribute('data-photo-asset') !== asset) continue;
if (photoTagCurrent(n, asset)) return n;
n.removeAttribute('data-photo-asset');
}
for (const n of document.querySelectorAll('img, image')) {
if (photoRefOf(n).includes(asset)) return n;
}
return null;
}
export function extractAssetRef(el: Element): string | null {
// Only trust `data-photo-asset` when it actually has the `assets/.`
// shape — the tag is attacker-controllable (untrusted canvas content,
// DDR-054), and an unshaped value would otherwise ride unbounded into
// `_active.json`/the WS broadcast via inspect.ts's `enrich()`.
const tagged = el.getAttribute('data-photo-asset');
if (tagged && ASSET_REF_RE.test(tagged) && photoTagCurrent(el, tagged)) return tagged;
return photoRefOf(el).match(ASSET_REF_RE)?.[0] ?? null;
}
function setPhotoElSrc(el: Element, url: string): void {
if (el.tagName.toLowerCase() === 'image') {
el.setAttribute(el.hasAttribute('xlink:href') ? 'xlink:href' : 'href', url);
} else {
el.setAttribute('src', url);
}
}
const BAKE_DEBOUNCE_MS = 80;
export function PhotoPreviewBridge() {
// The ORIGINAL (unedited) src/href per asset, captured the first time this
// bridge touches that element — so turning an edit off restores exactly
// what the element pointed to, not a guess.
const originalRef = useRef>(new Map());
// Per-asset bake generation — guards a slow (or out-of-order) render from
// clobbering a NEWER edit that already resolved first.
const tokenRef = useRef>(new Map());
const bakeTimers = useRef>>(new Map());
// Per-asset FETCH generation — the same guard one step earlier. Two synced
// edits in a row start two sidecar fetches, and the older answer can arrive
// last: applying it put the previous edit back on screen while every disk had
// the newer one (surface run 2026-09-16, L13 on the hub: shown, then
// reverted). A live preview counts as newer than any fetch in flight.
const fetchGenRef = useRef>(new Map());
const nextFetchGen = useCallback((asset: string): number => {
const g = (fetchGenRef.current.get(asset) ?? 0) + 1;
fetchGenRef.current.set(asset, g);
return g;
}, []);
const isLatestFetch = useCallback(
(asset: string, g: number): boolean => fetchGenRef.current.get(asset) === g,
[]
);
const bake = useCallback((asset: string, original: string, edit: PhotoEdit) => {
const token = (tokenRef.current.get(asset) ?? 0) + 1;
tokenRef.current.set(asset, token);
import('./photo/pipeline.ts')
.then(({ renderPhotoDataUrl }) => renderPhotoDataUrl({ source: original, edit }))
.then((dataUrl) => {
if (tokenRef.current.get(asset) !== token) return; // superseded by a newer edit
const live = findPhotoEl(asset);
if (live) setPhotoElSrc(live, dataUrl);
})
.catch((err) => {
console.error('[PhotoPreviewBridge] bake failed', err);
});
}, []);
const apply = useCallback(
(asset: string, edit: PhotoEdit | null) => {
const el = findPhotoEl(asset);
if (!el) return;
if (!el.hasAttribute('data-photo-asset')) el.setAttribute('data-photo-asset', asset);
if (!originalRef.current.has(asset)) {
const orig =
el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
originalRef.current.set(asset, orig);
}
const original = originalRef.current.get(asset) ?? '';
const timers = bakeTimers.current;
clearTimeout(timers.get(asset));
if (!edit || isDefaultEdit(edit)) {
timers.delete(asset);
// A bake already rendering an earlier edit must not land over this.
tokenRef.current.set(asset, (tokenRef.current.get(asset) ?? 0) + 1);
setPhotoElSrc(el, original);
return;
}
timers.set(
asset,
setTimeout(() => bake(asset, original, edit), BAKE_DEBOUNCE_MS)
);
},
[bake]
);
useEffect(() => {
const timers = bakeTimers.current;
return () => {
for (const t of timers.values()) clearTimeout(t);
};
}, []);
// A peer's photo edit arrived: the sidecar changed on disk, the shell heard
// it on the HMR socket and re-dispatched it here. Re-fetch and re-apply —
// deliberately OUTSIDE the hydration scan's once-per-asset `attempted` set,
// which exists to stop mutation-driven fetch amplification and which is
// exactly why a synced edit used to appear only after Cmd+R (the scan had
// already spent this asset's one attempt on mount).
useEffect(() => {
const onRefreshed = (e: Event) => {
const sha8 = (e as CustomEvent<{ sha8?: unknown }>).detail?.sha8;
if (typeof sha8 !== 'string' || !/^[0-9a-f]{8}$/i.test(sha8)) return;
// Resolve the full `assets/.` ref from the live DOM — the
// event carries only the hash (the sidecar name has no extension).
let asset: string | null = null;
for (const n of document.querySelectorAll('img, image')) {
const ref = extractAssetRef(n);
if (ref?.includes(`assets/${sha8}.`)) {
asset = ref;
break;
}
}
if (!asset) return; // no photo on this canvas uses that asset
const target = asset;
const g = nextFetchGen(target);
fetch(`/_api/photo-edit?asset=${encodeURIComponent(target)}`)
.then((r) => (r.ok ? r.json() : null))
.then((edit) => {
if (!isLatestFetch(target, g)) return; // a newer answer owns the render
// A deleted/reset edit applies as null → restores the original src.
apply(target, edit && !isDefaultEdit(edit) ? (edit as PhotoEdit) : null);
})
.catch(() => {});
};
document.addEventListener('maude:photo-edit-refreshed', onRefreshed);
return () => document.removeEventListener('maude:photo-edit-refreshed', onRefreshed);
}, [apply, nextFetchGen, isLatestFetch]);
useEffect(() => {
const onMsg = (e: MessageEvent) => {
const m = e.data as { dgn?: string; asset?: unknown; edit?: unknown; busy?: unknown } | null;
if (!m) return;
// Background-removal busy shimmer (Task 12 reveal) — a `data-photo-busy`
// attribute toggle on the real element, styled by inspect.ts's single CSS
// injection point (mirrors `.dc-activity-scan`'s sweep language). No
// separate tracked overlay — see the header comment above `apply()`.
// `m.asset` must pass the same shape check `extractAssetRef` applies
// elsewhere in this file (fix-photo-editor-followup-debt, Task 8) —
// an empty string previously matched EVERY photo element via
// `findPhotoEl`'s substring-match fallback (`src.includes('')` is always
// true), so an empty/malformed `asset` is now a no-op instead of toggling
// the busy shimmer on every photo on the canvas.
if (m.dgn === 'photo-busy' && typeof m.asset === 'string' && ASSET_REF_RE.test(m.asset)) {
const el = findPhotoEl(m.asset);
el?.toggleAttribute('data-photo-busy', !!m.busy);
return;
}
if (m.dgn !== 'photo-preview' || typeof m.asset !== 'string') return;
const asset = m.asset;
const edit = (m.edit ?? null) as PhotoEdit | null;
nextFetchGen(asset);
apply(asset, edit);
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, [apply, nextFetchGen]);
// Boot-time (+ ongoing) hydration from the PERSISTED sidecar, not just the
// live `photo-preview` message above. Without this, a saved PhotoEdit is
// invisible after anything that re-mounts the canvas doc (Cmd+R, an
// HMR remount, or a resize that recreates the photo's DOM node) — the
// message-only bridge starts every fresh mount with an empty `edits` map,
// and nothing re-sends the already-saved edit until a human happens to
// reopen the Inspector Photo tab and touch a knob. A MutationObserver
// re-scan (not just an initial one-shot) is what makes this self-heal after
// those remounts, since the photo element's DOM node is often a NEW node
// post-remount, not the one the original message targeted.
useEffect(() => {
if (typeof document === 'undefined') return;
let cancelled = false;
let raf = 0;
// Every asset gets AT MOST one fetch attempt for this bridge's lifetime —
// without this, a canvas whose DOM keeps mutating (an animation, a
// re-rendering component) reissues the full unfetched set on every
// mutation frame, forever (security review finding: unbounded fetch
// amplification against the dev server from a zero-gesture background
// scan).
const attempted = new Set();
// Safety ceiling per pass — a pathological/hostile canvas DOM (thousands
// of img/image elements) shouldn't be able to fan out unbounded fetches
// in one scan; the next mutation-triggered pass picks up where this left
// off since `attempted` persists across passes.
const MAX_SCAN_PER_PASS = 500;
const scan = () => {
let scanned = 0;
for (const n of document.querySelectorAll('img, image')) {
const asset = extractAssetRef(n);
if (!asset || attempted.has(asset)) continue;
if (++scanned > MAX_SCAN_PER_PASS) break;
attempted.add(asset);
const g = nextFetchGen(asset);
fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`)
.then((r) => (r.ok ? r.json() : null))
.then((edit) => {
if (cancelled || !isLatestFetch(asset, g) || !edit || isDefaultEdit(edit)) return;
apply(asset, edit);
})
.catch(() => {});
}
};
scan();
const observer = new MutationObserver(() => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(scan);
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'href'],
});
return () => {
cancelled = true;
cancelAnimationFrame(raf);
observer.disconnect();
};
}, [apply, nextFetchGen, isLatestFetch]);
return null; // mutates the real elements directly — no visible DOM of its own.
}
PhotoPreviewBridge.displayName = 'PhotoPreviewBridge';
// PhotoBgRemoveHarness (feature-photo-editor, Task 18) — the headless CLI proof
// harness `photo-bg-remove.sh` mounts inside a throwaway `_photo/.bgremove.tsx`
// canvas (mirrors DrawProof's role for the draw engine). Runs the EXACT SAME
// client-side ML flow the interactive "Remove Background" button uses (Task 12,
// app.jsx `onPhotoRemoveBackground`) — @imgly/background-removal, WASM/WebGPU,
// pixels never leave the browser — then persists the result and reports back to
// the driving CLI script via DOM attributes it polls (no return value crosses
// the process boundary; agent-browser reads attributes off the DOM instead).
//
// `/_api/asset` and `/_api/photo-edit` are BOTH canvas-safe routes (see their
// CANVAS_SAFE_API comments in http.ts), so this harness posts directly from the
// canvas origin — no main-origin relay / cross-origin workaround needed, despite
// the split-origin (DDR-054) boundary the rest of the canvas runs inside.
export interface PhotoBgRemoveHarnessProps {
/** Relative `assets/.` source to remove the background from. */
source: string;
}
export function PhotoBgRemoveHarness({ source }: PhotoBgRemoveHarnessProps) {
const [status, setStatus] = useState<'pending' | 'done' | 'error'>('pending');
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const ranRef = useRef(false);
useEffect(() => {
if (ranRef.current) return; // one ML pass per mount — a dev-mode double-effect must not double-run it
ranRef.current = true;
let cancelled = false;
(async () => {
try {
const srcRes = await fetch(`/${source.replace(/^\/+/, '')}`);
if (!srcRes.ok) throw new Error(`source fetch failed: ${srcRes.status}`);
const srcBlob = await srcRes.blob();
const { removeBackground } = await import('@imgly/background-removal');
const matte = await removeBackground(srcBlob);
const up = await fetch('/_api/asset', {
method: 'POST',
headers: { 'content-type': matte.type || 'image/png' },
body: matte,
});
const upJson = (await up.json().catch(() => ({}))) as { path?: string };
if (!up.ok || !upJson.path) throw new Error(`asset upload failed: ${up.status}`);
const maskAsset = upJson.path;
// Merge onto whatever's already in the sidecar (mirrors photo-adjust.sh's
// merge-by-default behavior) instead of clobbering unrelated fields.
const base: unknown = await fetch(`/_api/photo-edit?asset=${encodeURIComponent(source)}`)
.then((r) => (r.ok ? r.json() : {}))
.catch(() => ({}));
const nextEdit = {
...(base && typeof base === 'object' ? base : {}),
backgroundRemoved: { enabled: true, maskAsset },
};
const put = await fetch(`/_api/photo-edit?asset=${encodeURIComponent(source)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nextEdit),
});
if (!put.ok) throw new Error(`photo-edit save failed: ${put.status}`);
if (cancelled) return;
setResult(maskAsset);
setStatus('done');
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
setStatus('error');
}
})();
return () => {
cancelled = true;
};
}, [source]);
return (
photo-bg-remove: {status}
{result ? ` → ${result}` : ''}
{error ? ` (${error})` : ''}
);
}
PhotoBgRemoveHarness.displayName = 'PhotoBgRemoveHarness';
// ─────────────────────────────────────────────────────────────────────────────
// SnapGuideOverlay (Phase 4.2) — renders 1 px guide lines while a drag is in
// flight. Mounted by canvas-shell as a chrome layer outside `.dc-world`, so
// the lines are in screen coords (no CSS-zoom subpixel weirdness). Guides
// come from `dragBus.current.snap.guides`; world→screen projection uses the
// live viewport (`v.x + worldCoord * v.zoom` — same convention as `writeTransform`).
// DDR-046 — render kind-aware snap guides + distance pills. `kind === 'sibling'`
// gets the confident magenta + glow; `kind === 'grid'` gets a lighter gray (the
// grid is fallback when no sibling fires). Pre-DDR-046 guides emit no `kind`
// field — treat as sibling for back-compat. The `Δ{Math.round(delta)}` pill
// renders mid-span when |delta| > 0 and screen-span exceeds 60 px (smaller
// spans hide the pill so it never overlaps the line itself).
const MIN_PILL_SPAN_PX = 60;
const GUIDE_THICKNESS_PX = 2;
export function SnapGuideOverlay() {
const dragBus = useDragStateContext();
const world = useWorldContext();
if (!dragBus || !world) return null;
const s = dragBus.current;
if (s.kind !== 'dragging') return null;
const vp = world.viewport;
if (!vp) return null;
return (
<>
{s.snap.guides.map((g, i) => {
const kindClass = g.kind === 'grid' ? 'dc-snap-guide--grid' : 'dc-snap-guide--sibling';
const delta = g.delta ?? 0;
const showPill = g.kind !== 'grid' && Math.abs(delta) > 0;
if (g.axis === 'x') {
const sx = vp.x + g.pos * vp.zoom;
const sFrom = vp.y + g.from * vp.zoom;
const sTo = vp.y + g.to * vp.zoom;
const screenSpan = sTo - sFrom;
return (
{showPill && screenSpan >= MIN_PILL_SPAN_PX && (
Δ{Math.round(Math.abs(delta))}
)}
);
}
const sy = vp.y + g.pos * vp.zoom;
const sFrom = vp.x + g.from * vp.zoom;
const sTo = vp.x + g.to * vp.zoom;
const screenSpan = sTo - sFrom;
return (
{showPill && screenSpan >= MIN_PILL_SPAN_PX && (
Δ{Math.round(Math.abs(delta))}
)}
);
})}
>
);
}
SnapGuideOverlay.displayName = 'SnapGuideOverlay';
export function DCPostIt({ children }: { children: ReactNode }) {
return ;
}
// ─────────────────────────────────────────────────────────────────────────────
// Floating overlays (Phase 4 T3) — outside `.dc-world`, so they stay fixed
// to the canvas iframe chrome while the world pans/zooms underneath. Mounted
// by DesignCanvas; consumers opt out per-overlay via ``.
// Styling lives inline so the engine drops into ANY DS without requiring
// `.dc-mm` / `.dc-zoom-tb` rules in `_components.css`. CV-01 references the
// same vocabulary; if a DS wants to restyle, it can target `.dc-mm` /
// `.dc-zoom-tb` directly.
// DDR-046 — Floating chrome (mini-map, zoom HUD, tool palette, popovers, comment
// composer, export dialog) drops the brutalist 4 × 4 × 0 hard offset shadow in
// favor of a soft ambient. The hard offset stays on app-shell chrome only
// (menubar, header, tab strip) — that's the project's intentional brutalist
// identity. Floating layer = soft. App frame = hard.
const FLOATING_SHADOW =
'0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent))';
const FLOATING_RADIUS = '8px';
const OVERLAY_CSS = `
.dc-mm {
position: absolute;
right: 16px;
bottom: 16px;
width: 196px;
height: 132px;
background: var(--maude-chrome-bg-0, #ffffff);
border: 1px solid var(--maude-chrome-fg-0, #1c1917);
border-radius: ${FLOATING_RADIUS};
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 10px;
color: var(--maude-chrome-fg-1, rgba(40,30,20,0.7));
z-index: 6;
user-select: none;
box-shadow: ${FLOATING_SHADOW};
overflow: hidden;
}
.dc-mm-hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 8px 4px;
border-bottom: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08));
letter-spacing: 0.05em;
text-transform: uppercase;
font-size: 9px;
background: var(--maude-chrome-bg-1, #f4f1ea);
}
.dc-mm-count { font-variant-numeric: tabular-nums; color: var(--maude-chrome-fg-2, rgba(40,30,20,0.55)); }
.dc-mm-body {
position: relative;
width: 100%;
height: calc(100% - 22px);
overflow: hidden;
cursor: pointer;
background: var(--maude-chrome-bg-1, #f4f1ea);
}
.dc-mm-rect {
position: absolute;
background: color-mix(in oklab, var(--maude-chrome-fg-0, #1c1917) 14%, transparent);
border: 1px solid color-mix(in oklab, var(--maude-chrome-fg-0, #1c1917) 28%, transparent);
border-radius: 1px;
}
/* Filled viewport indicator — FigJam / Figma both ship a tinted fill, not
outline-only. Reads from a glance as "what slice of the world you're on". */
.dc-mm-vp {
position: absolute;
background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 12%, transparent);
border: 1.5px solid var(--maude-hud-accent, #d63b1f);
border-radius: 1px;
pointer-events: none;
}
.dc-zoom-tb {
position: absolute;
left: 16px;
bottom: 16px;
display: flex;
align-items: stretch;
background: var(--maude-chrome-bg-0, #ffffff);
border: 1px solid var(--maude-chrome-fg-0, #1c1917);
border-radius: ${FLOATING_RADIUS};
overflow: hidden;
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11px;
color: var(--maude-chrome-fg-1, rgba(40,30,20,0.85));
z-index: 6;
box-shadow: ${FLOATING_SHADOW};
}
.dc-zoom-tb button {
appearance: none;
background: transparent;
border: 0;
border-right: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08));
padding: 7px 12px;
font: inherit;
color: inherit;
cursor: pointer;
min-width: 36px;
text-align: center;
transition: background 80ms linear;
}
.dc-zoom-tb button:last-child { border-right: 0; }
.dc-zoom-tb button:hover { background: color-mix(in oklab, var(--maude-chrome-fg-0, #1c1917) 5%, transparent); }
.dc-zoom-tb button:focus-visible { outline: 2px solid var(--maude-hud-accent, #d63b1f); outline-offset: -2px; }
.dc-zoom-tb-pct { font-variant-numeric: tabular-nums; min-width: 52px; }
`.trim();
function ensureOverlayStyles(): void {
if (typeof document === 'undefined') return;
if (document.getElementById('dc-overlay-css')) return;
const s = document.createElement('style');
s.id = 'dc-overlay-css';
s.textContent = OVERLAY_CSS;
document.head.appendChild(s);
}
interface MiniMapGeometry {
scale: number;
offsetX: number;
offsetY: number;
bbox: { x: number; y: number; w: number; h: number };
}
function computeMiniMapGeometry(
artboards: ArtboardRect[],
mapW: number,
mapH: number,
pad = 6
): MiniMapGeometry {
if (artboards.length === 0) {
return { scale: 1, offsetX: 0, offsetY: 0, bbox: { x: 0, y: 0, w: 0, h: 0 } };
}
let xMin = Number.POSITIVE_INFINITY;
let yMin = Number.POSITIVE_INFINITY;
let xMax = Number.NEGATIVE_INFINITY;
let yMax = Number.NEGATIVE_INFINITY;
for (const r of artboards) {
if (r.x < xMin) xMin = r.x;
if (r.y < yMin) yMin = r.y;
if (r.x + r.w > xMax) xMax = r.x + r.w;
if (r.y + r.h > yMax) yMax = r.y + r.h;
}
const bw = Math.max(1, xMax - xMin);
const bh = Math.max(1, yMax - yMin);
const scale = Math.min((mapW - pad * 2) / bw, (mapH - pad * 2) / bh);
const offsetX = pad + (mapW - pad * 2 - bw * scale) / 2 - xMin * scale;
const offsetY = pad + (mapH - pad * 2 - bh * scale) / 2 - yMin * scale;
return { scale, offsetX, offsetY, bbox: { x: xMin, y: yMin, w: bw, h: bh } };
}
/**
* Bottom-right floating world map. Renders every DCArtboard rect scaled-to-fit
* plus a red viewport indicator. Click-drag inside the map pans the main view;
* click outside the viewport rect recenters on that point. Decorative for
* accessibility — SR users navigate via DCArtboard label buttons (T4).
*/
export function DCMiniMap() {
ensureOverlayStyles();
const world = useWorldContext();
const controller = useViewportControllerContext();
const chrome = useChromeVisibility();
const bodyRef = useRef(null);
// 132 - 22 (header) = 110 body height; width matches the chrome.
const MAP_W = 196;
const MAP_BODY_H = 110;
const dragRef = useRef<{ active: boolean; pointerId: number }>({
active: false,
pointerId: -1,
});
// Live, not published: the minimap indicator is one of the two things that
// must follow the gesture frame by frame (the other is the zoom readout).
// Everything else in the tree deliberately sits still until settle.
// Called before the early returns below — hooks cannot sit behind a branch.
const vp = useLiveViewport();
if (!world || !controller) return null;
// Menubar "View ▸ Minimap" toggle + Presentation Mode (which hides ALL
// chrome). `chrome` is null in a bare DS specimen — then default-visible.
if (chrome && (!chrome.minimap || chrome.present)) return null;
const geometry = computeMiniMapGeometry(world.artboards, MAP_W, MAP_BODY_H);
const host = world.hostRef.current;
// Visible-area rect in world coords, then projected into map coords.
let vpRect: { left: number; top: number; w: number; h: number } | null = null;
if (host && Number.isFinite(vp.zoom) && vp.zoom > 0) {
const wLeft = -vp.x / vp.zoom;
const wTop = -vp.y / vp.zoom;
const wW = host.clientWidth / vp.zoom;
const wH = host.clientHeight / vp.zoom;
vpRect = {
left: wLeft * geometry.scale + geometry.offsetX,
top: wTop * geometry.scale + geometry.offsetY,
w: wW * geometry.scale,
h: wH * geometry.scale,
};
}
function mapToWorld(mx: number, my: number): { x: number; y: number } {
return {
x: (mx - geometry.offsetX) / geometry.scale,
y: (my - geometry.offsetY) / geometry.scale,
};
}
function centerOnWorld(wx: number, wy: number) {
const h = world?.hostRef.current;
const c = controller;
if (!h || !c) return;
const cur = c.viewport;
c.setViewport({
x: h.clientWidth / 2 - wx * cur.zoom,
y: h.clientHeight / 2 - wy * cur.zoom,
zoom: cur.zoom,
});
}
const onPointerDown = (e: ReactPointerEvent) => {
const body = bodyRef.current;
if (!body) return;
const r = body.getBoundingClientRect();
const mx = e.clientX - r.left;
const my = e.clientY - r.top;
const w = mapToWorld(mx, my);
centerOnWorld(w.x, w.y);
try {
body.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
dragRef.current.active = true;
dragRef.current.pointerId = e.pointerId;
};
const onPointerMove = (e: ReactPointerEvent) => {
if (!dragRef.current.active || e.pointerId !== dragRef.current.pointerId) return;
const body = bodyRef.current;
if (!body) return;
const r = body.getBoundingClientRect();
const w = mapToWorld(e.clientX - r.left, e.clientY - r.top);
centerOnWorld(w.x, w.y);
};
const endDrag = (e: ReactPointerEvent) => {
if (!dragRef.current.active) return;
dragRef.current.active = false;
try {
bodyRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
};
return (
World
{world.artboards.length} / {world.artboards.length}
{world.artboards.map((r) => (
))}
{vpRect ? (
) : null}
);
}
DCMiniMap.displayName = 'DCMiniMap';
/**
* Bottom-center floating toolbar — zoom out · current % · zoom in · fit · 1:1.
* Clicking the % indicator resets to 100 %.
*/
export function DCZoomToolbar() {
ensureOverlayStyles();
const controller = useViewportControllerContext();
const chrome = useChromeVisibility();
// Before the early returns — hooks cannot sit behind a branch. Live, because
// a zoom readout that only updates on settle reads as a frozen UI.
const liveVp = useLiveViewport();
if (!controller) return null;
// Menubar "View ▸ Zoom controls" toggle + Presentation Mode.
if (chrome && (!chrome.zoom || chrome.present)) return null;
const pct = Math.round(liveVp.zoom * 100);
return (
−
{pct}%
+
[ ]
1:1
);
}
DCZoomToolbar.displayName = 'DCZoomToolbar';
// ─────────────────────────────────────────────────────────────────────────────
// Specimen helpers
/** SKU + breadcrumb trail + optional ThemeToggle. Maps to `.specimen-hd`. */
export function SpecimenHeader({
sku,
crumbs,
showThemeToggle = true,
}: {
sku: string;
crumbs: string[];
showThemeToggle?: boolean;
}) {
return (
{sku}
{crumbs.map((c, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: crumbs may repeat; index disambiguates static breadcrumb labels.
{c}
))}
{showThemeToggle ? : null}
);
}
/** `` ladder. */
export function SpecimenMeta({ entries }: { entries: Array<{ label: string; value: ReactNode }> }) {
return (
{entries.map(({ label, value }) => (
{label}
{value}
))}
);
}
/** chrome — keyboard hint. */
export function KbdHint({ children }: { children: ReactNode }) {
return {children} ;
}
/** Inline `var(--name)` value visualiser — small chip + token name. */
export function TokenChip({ name, swatch }: { name: string; swatch?: boolean }) {
return (
{swatch ? (
) : null}
{name}
);
}
/** Color swatch — square + token label + optional caption. */
export function ColorSwatch({
token,
caption,
height = 96,
}: {
token: string;
caption?: ReactNode;
height?: number;
}) {
return (
{token}
{caption ? {caption} : null}
);
}
/** Single row of a type-ladder specimen — label + sample at given token. */
export function TypeScaleRow({
token,
label,
sample,
}: {
token: string;
label: string;
sample?: string;
}) {
return (
{label}
{sample ?? 'The quick brown fox jumps over the lazy dog'}
);
}
/** Light/dark toggle. Writes `data-theme` on `` and persists to memory. */
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
setTheme('light')}
>
LIGHT
setTheme('dark')}
>
DARK
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Hooks
/**
* Read resolved CSS custom property values from ``. Returns the full set
* when prefix is omitted; otherwise filters to vars beginning with `--`.
* Re-resolves on `data-theme` mutation.
*/
export function useTokens(prefix?: string): Record {
const [tokens, setTokens] = useState>({});
useEffect(() => {
if (typeof window === 'undefined') return;
function read() {
const root = document.documentElement;
const cs = getComputedStyle(root);
const out: Record = {};
const len = cs.length;
for (let i = 0; i < len; i++) {
const name = cs.item(i);
if (!name.startsWith('--')) continue;
if (prefix && !name.startsWith(`--${prefix}`)) continue;
out[name] = cs.getPropertyValue(name).trim();
}
setTokens(out);
}
read();
const mo = new MutationObserver(read);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => mo.disconnect();
}, [prefix]);
return tokens;
}
/**
* Current theme + setter. Mirrors the `data-theme` attribute on ``.
* Defaults to whatever attribute is already set (or "light"). No persistence
* to localStorage — canvases are ephemeral; specimens reset per-load.
*/
export function useTheme(): { theme: string; setTheme: (t: string) => void } {
const [theme, setThemeState] = useState(() => {
if (typeof document === 'undefined') return 'light';
return document.documentElement.dataset.theme ?? 'light';
});
const setTheme = useCallback((t: string) => {
if (typeof document !== 'undefined') {
document.documentElement.dataset.theme = t;
}
setThemeState(t);
}, []);
useLayoutEffect(() => {
if (typeof document === 'undefined') return;
const obs = new MutationObserver(() => {
const t = document.documentElement.dataset.theme ?? 'light';
setThemeState(t);
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => obs.disconnect();
}, []);
return useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
}
/**
* ResizeObserver wrapper. Pass a ref to any element (typically the active
* artboard); returns its current `{ width, height }` in CSS pixels.
*/
export function useArtboardBounds(ref: RefObject): {
width: number;
height: number;
} {
const [bounds, setBounds] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver((entries) => {
const e = entries[0];
if (!e) return;
const r = e.contentRect;
setBounds({ width: r.width, height: r.height });
});
ro.observe(el);
return () => ro.disconnect();
}, [ref]);
return bounds;
}
// Re-export `useRef` so `useArtboardBounds` consumers can keep a single
// import line from `@maude/canvas-lib`.
export { useRef };
// ─────────────────────────────────────────────────────────────────────────────
// Motion subsystem (Phase 3.7 / DDR-049 — Motion One is the canonical runtime)
//
// These helpers are tree-shakeable. A canvas that does not import any of them
// pays no bundle cost (motion/react is externalised via RUNTIME_PACKAGES, so
// even when imported the byte cost lives in a single shared runtime bundle,
// not per-canvas).
//
// Roles map 1:1 to the 8 motion-vocabulary names enforced by motion-critic +
// design-system-keeper. Each role binds to a DS duration + easing token from
// colors_and_type.css; useMotionTokens() reads the live CSS custom property
// values so the binding survives token edits without rebuilding canvas-lib.
//
// Bounded-geometry guarantee — every root sets `overflow: hidden`
// in inline style. That defends against sparkle-on-tile overflow regardless of
// the host class chrome. See SUB-AGENT-PROMPTS.md → ANIMATION SAFETY.
// ─────────────────────────────────────────────────────────────────────────────
export type MotionRole =
| 'flip'
| 'panel'
| 'route'
| 'soft'
| 'spring'
| 'scroll'
| 'drag'
| 'presence';
export type MotionLoop = 'always' | 'hover' | 'once';
interface RoleConfig {
durationToken: string;
easingToken: string;
keyframes: Record;
fallbackMs: number;
}
export const MOTION_ROLE_DEFAULTS: Record = {
flip: {
durationToken: '--dur-flip',
easingToken: '--ease-out',
keyframes: { y: [0, -12, 0] },
fallbackMs: 220,
},
panel: {
durationToken: '--dur-panel',
easingToken: '--ease-in-out',
keyframes: { x: [-80, 0, -80] },
fallbackMs: 320,
},
route: {
durationToken: '--dur-route',
easingToken: '--ease-out',
keyframes: { opacity: [0, 1, 0], scale: [0.92, 1, 0.92] },
fallbackMs: 480,
},
soft: {
durationToken: '--dur-soft',
easingToken: '--ease-out',
keyframes: { opacity: [0, 1, 0] },
fallbackMs: 160,
},
spring: {
durationToken: '--dur-panel',
easingToken: 'spring',
// Spring physics animate toward a single target; a 3-point array
// (`[0,-16,0]`) makes motion/react's spring no-op (no movement). Use a
// 2-point target and let `repeatType: 'reverse'` carry the return leg.
keyframes: { y: [0, -16] },
fallbackMs: 320,
},
scroll: {
// `--dur-route` is intentionally ~instant (route changes are snap, often
// 1ms), which makes a scroll-linked drift imperceptible. Bind to the
// longer `--dur-soft` so the demo (and any time-driven scroll fallback)
// is actually visible.
durationToken: '--dur-soft',
easingToken: '--ease-in-out',
keyframes: { x: [0, 24, 0] },
fallbackMs: 480,
},
drag: {
durationToken: '--dur-flip',
easingToken: '--ease-out',
keyframes: { rotate: [0, 4, 0] },
fallbackMs: 220,
},
presence: {
durationToken: '--dur-soft',
easingToken: '--ease-out',
keyframes: { opacity: [0, 1], scale: [0.9, 1] },
fallbackMs: 160,
},
};
/**
* Reads --dur-* + --ease-* CSS custom properties from documentElement and
* returns a plain map suitable for plugging into motion/react's transition
* config. ms values parsed to numbers; easing tokens returned as strings (the
* raw token value, e.g. "cubic-bezier(0, 0, 0.2, 1)" — motion/react accepts
* the string form).
*/
export function useMotionTokens(): {
durations: Record;
easings: Record;
} {
const [snap, setSnap] = useState(() => readMotionTokensOnce());
useEffect(() => {
setSnap(readMotionTokensOnce());
if (typeof MutationObserver === 'undefined') return;
const obs = new MutationObserver(() => setSnap(readMotionTokensOnce()));
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-reduced-motion'],
});
return () => obs.disconnect();
}, []);
return snap;
}
function readMotionTokensOnce(): {
durations: Record;
easings: Record;
} {
if (typeof window === 'undefined' || typeof getComputedStyle === 'undefined') {
return { durations: {}, easings: {} };
}
const cs = getComputedStyle(document.documentElement);
const durations: Record = {};
const easings: Record = {};
const durKeys = ['--dur-flip', '--dur-panel', '--dur-route', '--dur-soft'];
const easeKeys = ['--ease-out', '--ease-in', '--ease-in-out'];
for (const k of durKeys) {
const raw = cs.getPropertyValue(k).trim();
if (!raw) continue;
const n = Number.parseFloat(raw);
if (Number.isFinite(n)) {
durations[k] = raw.endsWith('s') && !raw.endsWith('ms') ? n * 1000 : n;
}
}
for (const k of easeKeys) {
const raw = cs.getPropertyValue(k).trim();
if (raw) easings[k] = raw;
}
return { durations, easings };
}
/**
* Maps a DS easing token name to the value motion/react's `transition.ease`
* accepts. Returns the live CSS string when readable, otherwise a sane default
* matching Material's "standard" curve.
*/
export function easingFromToken(
token: string,
easings: Record
): string | undefined {
const live = easings[token];
if (live) return live;
if (token === '--ease-out') return 'cubic-bezier(0, 0, 0.2, 1)';
if (token === '--ease-in') return 'cubic-bezier(0.4, 0, 1, 1)';
if (token === '--ease-in-out') return 'cubic-bezier(0.4, 0, 0.2, 1)';
return undefined;
}
/**
* The same token, in the shape MOTION accepts.
*
* `easingFromToken` returns CSS (`cubic-bezier(a, b, c, d)`), which is right for
* a stylesheet and wrong for motion: its `Easing` is a named curve or a
* four-number BezierDefinition, never a CSS string. Both MotionDemo call sites
* were handing it the CSS form, so motion silently fell back to its own default
* — a DS motion specimen was not demonstrating the DS's easing at all. Invisible
* until A2 put this file under a checker.
*
* Named curves pass through; anything unparseable returns undefined, which is
* the same "let motion decide" the bug produced, only deliberately.
*/
function motionEaseFromToken(token: string, easings: Record): Easing | undefined {
const css = easingFromToken(token, easings);
if (!css) return undefined;
const named = css.trim();
if (
named === 'linear' ||
named === 'easeIn' ||
named === 'easeOut' ||
named === 'easeInOut' ||
named === 'circIn' ||
named === 'circOut' ||
named === 'circInOut' ||
named === 'backIn' ||
named === 'backOut' ||
named === 'backInOut' ||
named === 'anticipate'
) {
return named;
}
// CSS keywords motion spells differently.
if (named === 'ease-in') return 'easeIn';
if (named === 'ease-out') return 'easeOut';
if (named === 'ease-in-out') return 'easeInOut';
const m = /^cubic-bezier\(([^)]*)\)$/.exec(named);
if (!m || !m[1]) return undefined;
const nums = m[1].split(',').map((n) => Number(n.trim()));
if (nums.length !== 4 || nums.some((n) => !Number.isFinite(n))) return undefined;
return [nums[0], nums[1], nums[2], nums[3]] as [number, number, number, number];
}
interface MotionDemoProps {
role: MotionRole;
loop?: MotionLoop;
children?: ReactNode;
small?: boolean;
className?: string;
label?: string;
}
/**
* The foundational motion building block. Wraps motion/react's animated
* with token-bound duration + easing + reduced-motion short-circuit.
*
* Default loop="always" so initial paint shows motion — the "looks dead at
* rest" failure mode is the regression Phase 3.7 exists to prevent.
*/
export function MotionDemo({
role,
loop = 'always',
children,
small = false,
className,
label,
}: MotionDemoProps) {
const cfg = MOTION_ROLE_DEFAULTS[role];
const tokens = useMotionTokens();
const reduced = _useReducedMotion();
const durationMs = tokens.durations[cfg.durationToken] ?? cfg.fallbackMs;
const isSpring = cfg.easingToken === 'spring';
const ease = isSpring ? undefined : motionEaseFromToken(cfg.easingToken, tokens.easings);
const repeat = reduced || loop === 'once' ? 0 : Number.POSITIVE_INFINITY;
const repeatType: 'reverse' | 'loop' = loop === 'always' ? 'reverse' : 'loop';
const animate = reduced ? undefined : cfg.keyframes;
// DS durations are micro-interaction speeds (often <200ms). Looping them with
// no gap strobes ~10×/s. Insert a rest between cycles so each loop replays the
// REAL token speed, then pauses — readable cadence, not a flicker. Spring's
// own settle is the pause, so it skips the extra delay.
const repeatDelay = loop === 'always' && !isSpring ? 0.9 : 0;
return (
<_motionImpl.div
animate={animate}
transition={{
duration: durationMs / 1000,
ease,
type: isSpring ? 'spring' : 'tween',
repeat,
repeatType,
repeatDelay,
}}
className="motion-demo__target"
aria-label={label}
style={small ? { width: 32, height: 32 } : undefined}
>
{children ??
}
);
}
interface MotionTrackProps {
children: ReactNode;
staggerMs?: number;
className?: string;
}
/**
* Row container with CSS animation-delay stagger between children.
*/
export function MotionTrack({ children, staggerMs = 40, className }: MotionTrackProps) {
const items = Array.isArray(children) ? children : [children];
return (
{items.map((c, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: stagger row is index-positional by design; no reorder/insertion semantics
{c}
))}
);
}
interface TokenPlaybackProps {
duration: string;
easing?: string;
label?: string;
keyframes?: Record
;
}
/**
* Click-to-fire single-shot replay chip. Used in the motion specimen so
* reviewers can probe a single token without hovering a card.
*/
export function TokenPlayback({
duration,
easing = '--ease-out',
label,
keyframes = { y: [0, -8, 0] },
}: TokenPlaybackProps) {
const tokens = useMotionTokens();
const reduced = _useReducedMotion();
const durationMs = tokens.durations[duration] ?? 220;
const ease = easing === 'spring' ? undefined : motionEaseFromToken(easing, tokens.easings);
const [tick, setTick] = useState(0);
const fire = useCallback(() => {
if (!reduced) setTick((n) => n + 1);
}, [reduced]);
return (
{label ?? duration}
<_motionImpl.span
key={tick}
animate={tick === 0 ? undefined : keyframes}
transition={{
duration: durationMs / 1000,
ease,
type: easing === 'spring' ? 'spring' : 'tween',
}}
style={{
display: 'inline-block',
width: 8,
height: 8,
background: 'var(--accent, currentColor)',
borderRadius: '50%',
}}
/>
{durationMs}ms
);
}
/**
* Chrome toggle for the motion specimen — flips data-reduced-motion="true"
* on so reviewers can eyeball both branches without OS settings.
* Inspection aid, never a replacement for prefers-reduced-motion.
*/
export function ReducedMotionToggle() {
const [on, setOn] = useState(false);
useEffect(() => {
const el = document.documentElement;
const initial = el.getAttribute('data-reduced-motion') === 'true';
setOn(initial);
}, []);
const toggle = useCallback(() => {
const el = document.documentElement;
const next = !on;
if (next) el.setAttribute('data-reduced-motion', 'true');
else el.removeAttribute('data-reduced-motion');
setOn(next);
}, [on]);
return (
reduced-motion: {on ? 'on' : 'off'}
);
}
export {
_MotionAnimatePresence as AnimatePresence,
_motionImpl as motion,
_useReducedMotion as useReducedMotion,
};