Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions design-system/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,13 @@ For Spinner presentations, FlowChat integration and decorative playback:

```bash
pnpm --dir design-system run build:ui && node --test design-system/packages/ui/tests/spinner.test.mjs design-system/packages/ui/tests/brand.test.mjs design-system/packages/ui/tests/registry.test.mjs
pnpm --dir src/web-ui exec vitest run src/shared/ui/FlowChatRuntimeStatus.test.tsx src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx
```

The runtime status fixture checks resident geometry ownership and playback through
exit, reversal, cancellation and unmount. DOM tests do not prove native transition
rendering; the workload E2E records actual status fade-out outside resource windows.

For ColorPicker HEX drafts, flower selection, curved lightness dragging and keyboard input:

```bash
Expand All @@ -80,3 +85,16 @@ pnpm --dir src/web-ui run test:run src/shared/ui/ColorPicker.test.tsx src/app/co
```

These package and DOM checks do not establish visual fidelity or live remote behavior.

For Subagent motion rendering and playback, build UI and run the focused brand
contract above. After building the production Web UI, the opt-in native geometry
contract compares all 20 artworks, three sizes and authored actions to a named
Git revision, including opacity, pause/resume, reduced motion and cleanup:

```bash
OPENBITFUN_WORKBENCH_MOTION_CONTRACT=1 OPENBITFUN_MOTION_REFERENCE_COMMIT=HEAD OPENBITFUN_WORKBENCH_PANELS_ONLY=1 node tests/e2e/scripts/run-workbench-performance.mjs
```

Use a pre-change reference commit when testing an already committed change.
The native fixture runs outside performance windows. Geometry equivalence does
not establish pixel-identical rasterization or coverage on other WebView engines.
23 changes: 23 additions & 0 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ export function Example() {

The package owns component anatomy, behavior, accessibility, and stable variants. It does not own theme selection persistence, product state, routes, locale resources, or platform APIs.

## Subagent artwork motion

The opt-in `@openbitfun/ui/brand` entry exports `useSubagentAvatarMotion` and
`createSubagentMotionPlayer`. Their default `svg` rendering preserves existing
avatar behavior. A standalone artwork surface may explicitly select `layers`
(the hook's fifth argument, or the player's fourth argument) after native
performance and rendering verification. The SVG must fill its sole parent
surface, with the authored `xMidYMid meet` viewBox and no additional SVG offset.

`layers` keeps the outer SVG as the layout and accessibility placeholder, moves
its authored shapes into SVG leaves in HTML transform layers, and maps SVG units
into the surface with a ResizeObserver. The HTML layers remain outside SVG:
`foreignObject` geometry can appear correct while WebKit draws its accelerated
contents at the wrong position or scale. Keyframes and playback controls are
shared with the default renderer. Unmeasurable artwork keeps the SVG renderer;
disposal cancels animation, disconnects sizing observation and restores markup.
Keep this choice local to measured large artwork; do not promote every small
avatar without measuring its memory and rendering cost. Verify actual native
screenshots as well as transform matrices when changing either renderer.

## Loading indicators

Use `Spinner` for the shared loading indicator. Its default `logo` presentation
Expand All @@ -39,6 +59,9 @@ The host supplies localized accessible text. Omit `aria-label` when adjacent
copy already describes the activity. `active={false}` shows a still mark.
Playback pauses offscreen or when the document is hidden and respects reduced
motion. `LoadingState` composes the same indicator with optional support copy.
`FlowChatRuntimeStatus` keeps its fixed slot mounted when hidden, but stops its
indicator after the content's actual exit transitions finish or are cancelled.
A reversed exit keeps the existing playback; a fully hidden slot owns no loop.
The legacy `variant="matrix"` value resolves to `logo` for existing callers;
`variant="bars"` selects the bar presentation. The legacy matrix layout tokens
continue to define the established size allocations.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { neutralMotionFrames, type MotionClip, type MotionPart } from "./subagentMotion";

const parts: readonly MotionPart[] = ["body", "lever", "face", "eyes"];
type Rig = Record<MotionPart, SVGGElement>;
export type SubagentMotionRenderMode = "svg" | "layers";
type Rig = Record<MotionPart, SVGGElement | HTMLElement>;

function createRig(svg: SVGSVGElement) {
function createRig(svg: SVGSVGElement, renderMode: SubagentMotionRenderMode) {
const original = Array.from(svg.childNodes, (node) => node.cloneNode(true));
const stem = svg.querySelector(":scope > rect");
const body = svg.querySelector(":scope > path");
Expand All @@ -25,15 +26,77 @@ function createRig(svg: SVGSVGElement) {
const face = group("face", [eyeRig]);
const bodyRig = group("body", [lever, body, face]);
svg.append(bodyRig);
return {
rig: { body: bodyRig, lever, face, eyes: eyeRig } satisfies Rig,
restore: () => svg.replaceChildren(...original),
const svgRig = { body: bodyRig, lever, face, eyes: eyeRig };
const restore = () => svg.replaceChildren(...original);
if (renderMode === "layers") {
const layered = createLayerRig(svg, svgRig, { stem, body, knob, eyes });
if (layered) return { rig: layered.rig, restore: () => { layered.dispose(); restore(); } };
}
return { rig: svgRig satisfies Rig, restore };
}

/** Opt-in for a standalone artwork surface, retaining its SVG layout box. */
function createLayerRig(svg: SVGSVGElement, rig: Record<MotionPart, SVGGElement>, artwork: Record<"stem" | "body" | "knob" | "eyes", Element>) {
const parent = svg.parentElement;
const view = svg.ownerDocument.defaultView;
const box = svg.viewBox.baseVal;
const aspect = svg.preserveAspectRatio.baseVal;
if (!parent || !view?.ResizeObserver || parent.childElementCount !== 1 || box.width <= 0 || box.height <= 0
|| aspect.align !== 6 || aspect.meetOrSlice !== 1 || !svg.clientWidth || !svg.clientHeight) return;
let bounds: DOMRect[];
try { bounds = parts.map(part => rig[part].getBBox()); } catch { return; }
if (bounds.some(rect => ![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) || rect.width <= 0 || rect.height <= 0)) return;
const document = svg.ownerDocument;
const overlay = document.createElement("span");
overlay.dataset.subagentMotionLayer = "true";
overlay.setAttribute("aria-hidden", "true");
Object.assign(overlay.style, { position: "absolute", inset: "0", pointerEvents: "none", overflow: "visible" });
const canvas = document.createElement("span");
Object.assign(canvas.style, { position: "absolute", left: "0", top: "0", width: `${box.width}px`, height: `${box.height}px`, transformOrigin: "0 0" });
overlay.append(canvas);
const layer = (...children: Element[]) => {
const element = document.createElementNS("http://www.w3.org/2000/svg", "svg");
element.setAttribute("width", String(box.width)); element.setAttribute("height", String(box.height));
Object.assign(element.style, { position: "absolute", left: "0", top: "0", overflow: "visible" });
element.append(...children); return element;
};
const group = (part: MotionPart, children: Element[]) => {
const boundsForPart = bounds[parts.indexOf(part)]!;
const element = document.createElement("span");
element.dataset.motionPart = part;
Object.assign(element.style, { position: "absolute", left: "0", top: "0", width: `${box.width}px`, height: `${box.height}px`,
transformOrigin: `${boundsForPart.x + boundsForPart.width / 2}px ${boundsForPart.y + boundsForPart.height * (part === "body" || part === "lever" ? 1 : 0.5)}px` });
element.append(...children); return element;
};
const lever = group("lever", [layer(artwork.stem, artwork.knob)]);
const eyes = group("eyes", [layer(artwork.eyes)]);
const face = group("face", [eyes]);
const body = group("body", [lever, layer(artwork.body), face]);
canvas.append(body);
rig.body.remove();
const oldPosition = parent.style.position;
const positioned = view.getComputedStyle(parent).position === "static";
if (positioned) parent.style.position = "relative";
parent.append(overlay);
// The untouched outer SVG remains the layout placeholder. HTML transforms
// live outside SVG; foreignObject transform layers misplace pixels in WebKit.
const fit = () => {
const width = svg.clientWidth, height = svg.clientHeight;
const scale = Math.min(width / box.width, height / box.height);
canvas.style.transform = `matrix(${scale}, 0, 0, ${scale}, ${(width - box.width * scale) / 2 - box.x * scale}, ${(height - box.height * scale) / 2 - box.y * scale})`;
};
fit();
const observer = new view.ResizeObserver(fit);
observer.observe(svg);
return { rig: { body, lever, face, eyes } satisfies Rig, dispose: () => {
observer.disconnect(); overlay.remove();
if (positioned && parent.style.position === "relative") parent.style.position = oldPosition;
} };
}

/** One player owns every size sample, all animation handles, and completion. */
export function createSubagentMotionPlayer(host: HTMLElement, onFinish: () => void, selector = '.subagent-motion-art svg') {
const rigs = Array.from(host.querySelectorAll<SVGSVGElement>(selector), createRig);
export function createSubagentMotionPlayer(host: HTMLElement, onFinish: () => void, selector = '.subagent-motion-art svg', renderMode: SubagentMotionRenderMode = "svg") {
const rigs = Array.from(host.querySelectorAll<SVGSVGElement>(selector), svg => createRig(svg, renderMode));
let animations: Animation[] = [];
let revision = 0;
let paused = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,65 @@
import { useEffect, useRef, type RefObject } from 'react';
import { createSubagentMotionPlayer, type SubagentMotionPlayer } from './subagentMotionPlayer';
import { createSubagentMotionPlayer, type SubagentMotionPlayer, type SubagentMotionRenderMode } from './subagentMotionPlayer';
import { pressHoldClip, pressReleaseClip, subagentMotionClips } from './subagentMotion';

const isWorking = (status: string) => status === 'running' || status === 'finishing';

/** Animate authored avatar parts, never the measured card or its text. */
export function useSubagentAvatarMotion(ref: RefObject<HTMLElement>, status: string, enabled: boolean, identity?: string) {
export function useSubagentAvatarMotion(ref: RefObject<HTMLElement>, status: string, enabled: boolean, identity?: string, renderMode: SubagentMotionRenderMode = "svg") {
const playerRef = useRef<SubagentMotionPlayer>();
const statusRef = useRef(status);
const previousStatus = useRef(status);
const enabledRef = useRef(enabled);
const synchronizeEnabled = useRef<() => void>();
statusRef.current = status;
enabledRef.current = enabled;
// Standalone layered artwork stays mounted while its scene is retained.
// Ordinary SVG callers may replace their artwork when motion is disabled.
const attach = enabled || renderMode === 'layers';

useEffect(() => {
const host = ref.current;
const view = host?.ownerDocument.defaultView;
if (!enabled || !host || !view || typeof host.animate !== 'function') return;
if (!attach || !host || !view || typeof host.animate !== 'function') return;
previousStatus.current = statusRef.current;
const document = host.ownerDocument;
const reduced = view.matchMedia?.('(prefers-reduced-motion: reduce)');
const forced = view.matchMedia?.('(forced-colors: active)');
let visible = typeof IntersectionObserver === 'undefined';
let pressing = false;
let hovering = false;
let player: SubagentMotionPlayer | undefined;
let lastEnabled = enabledRef.current;
const rest = () => {
if (pressing) return;
if (isWorking(statusRef.current)) {
player.play(subagentMotionClips.working, { notify: false });
player?.play(subagentMotionClips.working, { notify: false });
return;
}
if (hovering) {
player.play(subagentMotionClips.hoverBlink, { notify: false });
player?.play(subagentMotionClips.hoverBlink, { notify: false });
return;
}
player.play(subagentMotionClips.settle, { notify: false });
player?.play(subagentMotionClips.settle, { notify: false });
};
const player = createSubagentMotionPlayer(host, rest, '[data-subagent-motion-art] svg');
playerRef.current = player;
const active = () => visible && !document.hidden && !reduced?.matches && !forced?.matches;
const active = () => enabledRef.current && visible && !document.hidden && !reduced?.matches && !forced?.matches;
const sync = () => {
// Offscreen avatars release their animation handles instead of accumulating paused loops.
player.setReduced(!active());
if (active()) rest();
if (!active()) { player?.setReduced(true); return; }
// Delay the first rig until its artwork is visible and measurable.
if (!player) {
player = createSubagentMotionPlayer(host, rest, '[data-subagent-motion-art] svg', renderMode);
playerRef.current = player;
}
player.setReduced(false);
rest();
};
synchronizeEnabled.current = () => {
if (lastEnabled === enabledRef.current) return;
lastEnabled = enabledRef.current;
previousStatus.current = statusRef.current;
if (!lastEnabled) { pressing = false; hovering = false; }
sync();
};
const observer = typeof IntersectionObserver === 'undefined' ? undefined : new IntersectionObserver(entries => {
visible = entries.some(entry => entry.isIntersecting);
Expand All @@ -56,16 +75,16 @@ export function useSubagentAvatarMotion(ref: RefObject<HTMLElement>, status: str
// Pointer and focus changes must not replace or restart a working loop.
if (active() && !pressing && !isWorking(statusRef.current)) rest();
};
const press = () => { if (active()) { pressing = true; player.play(pressHoldClip, { notify: false }); } };
const release = () => { if (pressing) { pressing = false; if (active()) player.play({ ...pressReleaseClip, duration: 300 }); } };
const press = () => { if (active()) { pressing = true; player?.play(pressHoldClip, { notify: false }); } };
const release = () => { if (pressing) { pressing = false; if (active()) player?.play({ ...pressReleaseClip, duration: 300 }); } };
const leave = () => {
hovering = false;
if (pressing) release();
else if (active() && !isWorking(statusRef.current)) rest();
};
const click = () => {
pressing = false;
if (active()) player.play({ ...subagentMotionClips.nod, duration: 260 });
if (active()) player?.play({ ...subagentMotionClips.nod, duration: 260 });
};
const keyDown = (event: KeyboardEvent) => { if (!event.repeat && (event.key === ' ' || event.key === 'Enter')) press(); };
trigger?.addEventListener('pointerenter', hover);
Expand Down Expand Up @@ -94,10 +113,13 @@ export function useSubagentAvatarMotion(ref: RefObject<HTMLElement>, status: str
trigger?.removeEventListener('keyup', release);
trigger?.removeEventListener('blur', leave);
view.removeEventListener('pointerup', release);
player.dispose();
player?.dispose();
playerRef.current = undefined;
synchronizeEnabled.current = undefined;
};
}, [enabled, identity, ref]);
}, [attach, identity, ref, renderMode]);

useEffect(() => { synchronizeEnabled.current?.(); }, [enabled]);

useEffect(() => {
const before = previousStatus.current;
Expand Down
1 change: 1 addition & 0 deletions design-system/packages/ui/src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ const OverlaySurface = forwardRef<HTMLDivElement, OverlaySurfaceProps>(function
>
<DialogContext.Provider value={context}>
<div
data-motion="presence"
{...surfaceProps}
{...inertProps(!open)}
aria-describedby={ariaDescribedBy ?? (hasDescription ? descriptionId : undefined)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@
}

.surface { position: relative; }
/* Only layout chrome consumes the changing size in split mode. Stop the
private value at content boundaries so each drag frame does not invalidate
every descendant's inherited style. Overlay docks deliberately inherit it
through their arbitrary host wrappers to keep the composer beside content. */
.pane[data-pane-layout="split"] > *,
.headerColumns > * {
--_split-view-right-size: initial;
}
.surface[data-split-active="false"] { visibility: hidden; opacity: 0; pointer-events: none; }
.root[data-layout="overlay"][data-mode="split"] .secondary { z-index: 2; }
.primary { grid-area: primary; }
Expand Down
12 changes: 7 additions & 5 deletions design-system/packages/ui/src/components/SplitView/SplitView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import styles from './SplitView.module.css';

export type SplitViewMode = 'split' | 'primary' | 'secondary';
const SplitViewModeContext = createContext<SplitViewMode>('split');
const SplitViewLayoutContext = createContext<'split' | 'overlay'>('split');

/** Stable sibling hosts can change panes without reparenting their React tree. */
export function SplitViewSurface({ pane, active = true, className, children, ...props }:
HTMLAttributes<HTMLDivElement> & { pane: 'primary' | 'secondary'; active?: boolean }) {
const mode = useContext(SplitViewModeContext);
const layout = useContext(SplitViewLayoutContext);
const hidden = mode !== 'split' && mode !== pane;
return <div {...props} tabIndex={-1}
className={classNames(styles.pane, styles[pane], styles.surface, className)}
hidden={hidden} aria-hidden={hidden || !active}
{...inertProps(Boolean(hidden || !active))}
data-split-surface={pane} data-split-active={!hidden && active}>
data-split-surface={pane} data-split-active={!hidden && active} data-pane-layout={layout}>
{children}
</div>;
}
Expand Down Expand Up @@ -178,7 +180,7 @@ export function SplitView({
};

return (
<SplitViewModeContext.Provider value={mode}><div
<SplitViewLayoutContext.Provider value={layout}><SplitViewModeContext.Provider value={mode}><div
{...rest}
ref={rootRef}
className={classNames(styles.root, className)}
Expand All @@ -204,7 +206,7 @@ export function SplitView({
? header({ columnClassName: styles.headerColumns! }) : header}</div>}
<div {...primaryPaneProps} ref={primaryRef} id={primaryId} tabIndex={-1}
className={classNames(styles.pane, styles.primary, primaryPaneProps?.className)}
data-openbitfun-component="split-view" data-openbitfun-part="primary" hidden={mode === 'secondary'}>
data-openbitfun-component="split-view" data-openbitfun-part="primary" data-pane-layout={layout} hidden={mode === 'secondary'}>
{primary}
</div>
<div ref={dividerRef} className={styles.divider} data-openbitfun-component="split-view" data-openbitfun-part="divider" hidden={mode !== 'split'}>
Expand Down Expand Up @@ -258,10 +260,10 @@ export function SplitView({
</div>
<div {...secondaryPaneProps} ref={secondaryRef} id={secondaryId} tabIndex={-1}
className={classNames(styles.pane, styles.secondary, secondaryPaneProps?.className)}
data-openbitfun-component="split-view" data-openbitfun-part="secondary" hidden={mode === 'primary'}>
data-openbitfun-component="split-view" data-openbitfun-part="secondary" data-pane-layout={layout} hidden={mode === 'primary'}>
{secondary}
</div>
{surfaces}
</div></SplitViewModeContext.Provider>
</div></SplitViewModeContext.Provider></SplitViewLayoutContext.Provider>
);
}
Loading
Loading