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