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:
Nexus Dev 2026-04-11 13:19:32 +00:00
parent 70698b9e58
commit b277527510
2 changed files with 302 additions and 0 deletions

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

View 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`:
//
// 0200ms : chat ribbon collapses 100% → 30vh (inset shadow fades in)
// 200500ms: brainstormer panel slides up from below into bottom 70%
// 500700ms: 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>
);
}