nexus/ui/src/components/assistant/PromoteTransition.tsx
Nexus Dev fb76b5eeef refactor(nexus): wave 3a controller integration pass
Three coordinated fixes after phases 12, 13, 14, 15 all landed in
parallel on nexus/design-system-migration:

1. TopStrip.test.tsx provider stubs for phase 14 contexts.

Phase 14 wired CmdKButton to a new CommandPaletteContext via the
useCommandPalette() hook. TopStrip renders CmdKButton internally,
so its existing tests (written in phase 8 before the context
existed) throw "useCommandPalette must be used within a
CommandPaletteProvider" on every case. Same issue on GlobalMicButton
which now reads from VoiceContext.

Fix: add module-scope vi.mock() stubs for both contexts returning
minimal shapes, matching the existing CompanyContext mock pattern
already in the file. No test semantics change - the TopStrip is
still rendered in isolation, still verified to contain the three
children (ModeBreadcrumb, CmdKButton, GlobalMicButton) and the
header landmark. All 4 TopStrip tests pass again.

Phase 15's report flagged this breakage explicitly and confirmed
via git stash that it was pre-existing on phase 14's HEAD, not
introduced by phase 15.

2. PromoteTransition mobile variant.

Phase 15 deferred the mobile variant of the promote-to-project
transition because PromoteTransition.tsx did not yet exist when
phase 15 started (phase 12 created it mid-dispatch). The defer
was correct per explicit instructions.

Fix: add a CSS media query inside the existing scoped <style>
block in PromoteTransition.tsx:

  @media (max-width: 767px) {
    .nx-promote-ribbon[data-pstate="prompting"],
    .nx-promote-ribbon[data-pstate="creating"] {
      max-height: 0;
      box-shadow: none;
      overflow: hidden;
    }
    .nx-promote-label[data-pstate="prompting"],
    .nx-promote-label[data-pstate="creating"] {
      display: none;
    }
  }

On mobile the brainstormer completely covers the chat thread
instead of sharing a 30/70 split, per spec section 9.1. The panel
itself already takes the remaining viewport height via flex, so
once the ribbon collapses to zero the brainstormer naturally
fills the whole area.

Pure CSS - no JS media query, no useMediaQuery call, no test
changes needed. Single 768px breakpoint matches the rest of the
Nexus frame.

3. Layout.tsx onSearch now calls useCommandPalette().setOpen(true)
   directly instead of dispatching a synthetic Cmd+K keydown.

Phase 14 installed a real command palette context with a provider
mounted in main.tsx, but left Layout.tsx's onSearch callback using
the old synthetic keydown shim because Layout is phase 15-owned
(parallel dispatch rules). The synthetic dispatch worked end-to-end
because the provider's global keydown listener catches it, but it's
a code smell: a Layout-level callback generating a synthetic event
for a listener the Layout also owns.

Fix: import useCommandPalette, hold a commandPalette ref in the
Layout body, and replace the synthetic keydown dispatch with
commandPalette.setOpen(true). Drop the Phase 8 shim comment; leave
a single-line Phase 14 explanation.

All 294 tests pass across 37 test files (frame, assistant, studio,
projects, settings, Voice/CommandPalette contexts, home-status and
gate-indicator and promote-to-project hooks, StudioWorkshopDetail
page). Typecheck clean across every wave 1-3A file plus App.tsx
and Layout.tsx.

Known deferrals not addressed in this commit:
  - MobileTabBar does not port MobileBottomNav's "new issue" FAB or
    inbox badge count. Spec section 9.1 says 4 destinations only;
    requires user decision on whether to restore as a separate mobile
    affordance or route through the command palette.
  - VoiceMicButton.tsx and useVadRecorder.ts are now dead code after
    phase 14's ChatInput migration. Both are in the phase 16 cleanup
    plan's deletion list.
  - Destination regexes are duplicated between IconRail and
    MobileTabBar. Phase 16 DRY target via a shared frame/destinations
    module.
  - Phase 13 left ui/src/lib/instance-settings.ts whitelisting
    /heartbeats and /experimental paths. Redirect routes catch these,
    so the whitelist is cosmetic; phase 16 cleanup target.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:32:46 +00:00

164 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// [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;
}
}
/* Phase 15: on mobile the brainstormer completely covers the chat
instead of rendering a 30/70 split. The ribbon and source label are
hidden; the panel takes the full viewport height. Single 768px
breakpoint matches the rest of the Nexus frame. */
@media (max-width: 767px) {
.nx-promote-ribbon[data-pstate="prompting"],
.nx-promote-ribbon[data-pstate="creating"] {
max-height: 0;
box-shadow: none;
overflow: hidden;
}
.nx-promote-label[data-pstate="prompting"],
.nx-promote-label[data-pstate="creating"] {
display: none;
}
}
`;
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>
);
}