feat(nexus): add promote-transition css-first animation overlay
Phase 12 — renders the 700ms compress-and-rise transition from spec §5.6 using CSS keyframes inlined via a scoped <style> tag. Chat ribbon compresses to 30vh with an inset shadow (0–200ms), brainstormer panel slides up from the bottom (200–500ms), SOURCE CONVERSATION label fades in (500–700ms). Honors prefers-reduced-motion. No motion library. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
70698b9e58
commit
b277527510
2 changed files with 302 additions and 0 deletions
154
ui/src/components/assistant/PromoteTransition.test.tsx
Normal file
154
ui/src/components/assistant/PromoteTransition.test.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { PromoteTransition } from "./PromoteTransition";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("<PromoteTransition />", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root!.unmount();
|
||||||
|
});
|
||||||
|
root = null;
|
||||||
|
}
|
||||||
|
if (container.parentNode) container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(node: React.ReactNode) {
|
||||||
|
root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root!.render(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders nothing in idle state", () => {
|
||||||
|
render(
|
||||||
|
<PromoteTransition
|
||||||
|
state="idle"
|
||||||
|
panelChildren={<div data-testid="panel">panel</div>}
|
||||||
|
>
|
||||||
|
<div data-testid="ribbon">ribbon</div>
|
||||||
|
</PromoteTransition>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[data-testid="promote-transition"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing in done state", () => {
|
||||||
|
render(
|
||||||
|
<PromoteTransition
|
||||||
|
state="done"
|
||||||
|
panelChildren={<div data-testid="panel">panel</div>}
|
||||||
|
>
|
||||||
|
<div data-testid="ribbon">ribbon</div>
|
||||||
|
</PromoteTransition>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[data-testid="promote-transition"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing in error state", () => {
|
||||||
|
render(
|
||||||
|
<PromoteTransition
|
||||||
|
state="error"
|
||||||
|
panelChildren={<div data-testid="panel">panel</div>}
|
||||||
|
>
|
||||||
|
<div data-testid="ribbon">ribbon</div>
|
||||||
|
</PromoteTransition>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[data-testid="promote-transition"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the overlay, ribbon, panel and label in prompting state", () => {
|
||||||
|
render(
|
||||||
|
<PromoteTransition
|
||||||
|
state="prompting"
|
||||||
|
panelChildren={<div data-testid="panel">panel</div>}
|
||||||
|
>
|
||||||
|
<div data-testid="ribbon">ribbon</div>
|
||||||
|
</PromoteTransition>,
|
||||||
|
);
|
||||||
|
const overlay = container.querySelector('[data-testid="promote-transition"]') as HTMLElement;
|
||||||
|
expect(overlay).not.toBeNull();
|
||||||
|
expect(overlay.getAttribute("data-state")).toBe("prompting");
|
||||||
|
expect(overlay.getAttribute("aria-live")).toBe("polite");
|
||||||
|
expect(overlay.getAttribute("role")).toBe("region");
|
||||||
|
|
||||||
|
const ribbon = container.querySelector('[data-testid="promote-chat-ribbon"]') as HTMLElement;
|
||||||
|
const panelContainer = container.querySelector('[data-testid="promote-panel-container"]') as HTMLElement;
|
||||||
|
const label = container.querySelector('[data-testid="promote-source-label"]') as HTMLElement;
|
||||||
|
|
||||||
|
expect(ribbon).not.toBeNull();
|
||||||
|
expect(panelContainer).not.toBeNull();
|
||||||
|
expect(label).not.toBeNull();
|
||||||
|
|
||||||
|
// State data attribute is threaded to the animated children so CSS can
|
||||||
|
// select them.
|
||||||
|
expect(ribbon.getAttribute("data-pstate")).toBe("prompting");
|
||||||
|
expect(panelContainer.getAttribute("data-pstate")).toBe("prompting");
|
||||||
|
expect(label.getAttribute("data-pstate")).toBe("prompting");
|
||||||
|
|
||||||
|
// Children render inside the right slots.
|
||||||
|
expect(ribbon.querySelector('[data-testid="ribbon"]')).not.toBeNull();
|
||||||
|
expect(panelContainer.querySelector('[data-testid="panel"]')).not.toBeNull();
|
||||||
|
|
||||||
|
// Source conversation label text.
|
||||||
|
expect(label.textContent?.trim().toLowerCase()).toBe("source conversation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates data-state to creating while the API call is in flight", () => {
|
||||||
|
render(
|
||||||
|
<PromoteTransition
|
||||||
|
state="creating"
|
||||||
|
panelChildren={<div data-testid="panel">panel</div>}
|
||||||
|
>
|
||||||
|
<div data-testid="ribbon">ribbon</div>
|
||||||
|
</PromoteTransition>,
|
||||||
|
);
|
||||||
|
const overlay = container.querySelector('[data-testid="promote-transition"]') as HTMLElement;
|
||||||
|
expect(overlay.getAttribute("data-state")).toBe("creating");
|
||||||
|
const ribbon = container.querySelector('[data-testid="promote-chat-ribbon"]') as HTMLElement;
|
||||||
|
expect(ribbon.getAttribute("data-pstate")).toBe("creating");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inlines a <style> tag with the keyframe CSS", () => {
|
||||||
|
render(
|
||||||
|
<PromoteTransition
|
||||||
|
state="prompting"
|
||||||
|
panelChildren={<div data-testid="panel">panel</div>}
|
||||||
|
>
|
||||||
|
<div data-testid="ribbon">ribbon</div>
|
||||||
|
</PromoteTransition>,
|
||||||
|
);
|
||||||
|
const style = container.querySelector("style") as HTMLStyleElement;
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style.textContent ?? "").toContain("nx-promote-ribbon");
|
||||||
|
expect(style.textContent ?? "").toContain("max-height");
|
||||||
|
expect(style.textContent ?? "").toContain("prefers-reduced-motion");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has an accessible label on the overlay region", () => {
|
||||||
|
render(
|
||||||
|
<PromoteTransition
|
||||||
|
state="prompting"
|
||||||
|
panelChildren={<div>panel</div>}
|
||||||
|
>
|
||||||
|
<div>ribbon</div>
|
||||||
|
</PromoteTransition>,
|
||||||
|
);
|
||||||
|
const overlay = container.querySelector('[data-testid="promote-transition"]') as HTMLElement;
|
||||||
|
expect(overlay.getAttribute("aria-label")).toBe("Promote to project");
|
||||||
|
});
|
||||||
|
});
|
||||||
148
ui/src/components/assistant/PromoteTransition.tsx
Normal file
148
ui/src/components/assistant/PromoteTransition.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
// [nexus] Phase 12 — PromoteTransition.
|
||||||
|
//
|
||||||
|
// CSS-first animation container that drives the 700ms compress-and-rise
|
||||||
|
// moment from spec §5.6. When `state` is `prompting` or `creating`:
|
||||||
|
//
|
||||||
|
// 0–200ms : chat ribbon collapses 100% → 30vh (inset shadow fades in)
|
||||||
|
// 200–500ms: brainstormer panel slides up from below into bottom 70%
|
||||||
|
// 500–700ms: SOURCE CONVERSATION label fades in above the ribbon
|
||||||
|
//
|
||||||
|
// On `idle` / `done` the overlay unmounts. `prefers-reduced-motion` is
|
||||||
|
// honored via inline CSS — transitions are set to `none`.
|
||||||
|
//
|
||||||
|
// This component is intentionally CSS-only; no motion library dependency.
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type PromoteTransitionState = "idle" | "prompting" | "creating" | "done" | "error";
|
||||||
|
|
||||||
|
export interface PromoteTransitionProps {
|
||||||
|
/** The hook's promote state kind. */
|
||||||
|
state: PromoteTransitionState;
|
||||||
|
/**
|
||||||
|
* The compressed chat ribbon. Caller passes whatever the source view
|
||||||
|
* should look like — usually the current ChatMessageList in a scrollable
|
||||||
|
* container.
|
||||||
|
*/
|
||||||
|
children: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* The rising brainstormer panel content. Usually <BrainstormerPanel />.
|
||||||
|
*/
|
||||||
|
panelChildren: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inlined styles for the transition so we do NOT need to touch the global
|
||||||
|
// stylesheet. A <style> tag is rendered once per overlay mount and scopes
|
||||||
|
// via a class name.
|
||||||
|
const TRANSITION_STYLE = `
|
||||||
|
.nx-promote-ribbon {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 100%;
|
||||||
|
transition: max-height 200ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
box-shadow 200ms ease-out;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.nx-promote-ribbon[data-pstate="prompting"],
|
||||||
|
.nx-promote-ribbon[data-pstate="creating"] {
|
||||||
|
max-height: 30vh;
|
||||||
|
box-shadow: inset 0 -16px 24px -12px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.nx-promote-panel {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 300ms ease-out;
|
||||||
|
transition-delay: 0ms;
|
||||||
|
}
|
||||||
|
.nx-promote-panel[data-pstate="prompting"],
|
||||||
|
.nx-promote-panel[data-pstate="creating"] {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
transition-delay: 200ms;
|
||||||
|
}
|
||||||
|
.nx-promote-label {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 200ms ease-out;
|
||||||
|
transition-delay: 0ms;
|
||||||
|
}
|
||||||
|
.nx-promote-label[data-pstate="prompting"],
|
||||||
|
.nx-promote-label[data-pstate="creating"] {
|
||||||
|
opacity: 1;
|
||||||
|
transition-delay: 500ms;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.nx-promote-ribbon,
|
||||||
|
.nx-promote-panel,
|
||||||
|
.nx-promote-label {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function PromoteTransition({
|
||||||
|
state,
|
||||||
|
children,
|
||||||
|
panelChildren,
|
||||||
|
className,
|
||||||
|
}: PromoteTransitionProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// When the transition first mounts (state flipped to prompting), move
|
||||||
|
// focus to the overlay so screen readers announce the aria-live region.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state === "prompting" || state === "creating") {
|
||||||
|
rootRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
if (state === "idle" || state === "done" || state === "error") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
data-testid="promote-transition"
|
||||||
|
data-state={state}
|
||||||
|
aria-live="polite"
|
||||||
|
role="region"
|
||||||
|
aria-label="Promote to project"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 z-40 flex flex-col bg-background",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<style>{TRANSITION_STYLE}</style>
|
||||||
|
|
||||||
|
{/* SOURCE CONVERSATION label */}
|
||||||
|
<div
|
||||||
|
data-testid="promote-source-label"
|
||||||
|
data-pstate={state}
|
||||||
|
className="nx-promote-label px-6 pt-3 text-[10px] uppercase tracking-[0.14em] text-muted-foreground"
|
||||||
|
>
|
||||||
|
Source Conversation
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compressed chat ribbon */}
|
||||||
|
<div
|
||||||
|
data-testid="promote-chat-ribbon"
|
||||||
|
data-pstate={state}
|
||||||
|
className="nx-promote-ribbon flex-shrink-0 px-6 py-2"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rising brainstormer panel */}
|
||||||
|
<div
|
||||||
|
data-testid="promote-panel-container"
|
||||||
|
data-pstate={state}
|
||||||
|
className="nx-promote-panel relative flex-1 min-h-0"
|
||||||
|
>
|
||||||
|
{panelChildren}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue