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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions docs/design/ui-contract/20260717-answer-arrival/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"schema": "nodebench-ui-contract-v1",
"capturedAt": "2026-07-17T22:20:00Z",
"slice": "answer-arrival",
"app": "nodebench-ai (decision workspace answer moment, /redesign/chat?qaState=answer)",
"notes": "The answer moment joins the ember motion identity: the packet settles in on --rd-ease-settle (rd-answer-settle, 480ms) and every section eyebrow carries a 24px terracotta ember tick that sweeps in (rd-ember-sweep, 180ms delay) — the recurring motif that evidence underlines every claim. The thinking state becomes 'the thread being drawn': an ember-tipped hairline grows beneath the phase label on a 1.6s settle loop, dots recolored to ember. prefers-reduced-motion flattens all three systems. NO BEFORE PAIR EXISTS honestly: this state was previously unphotographable without a paid authenticated run — this slice also introduces the QA-gated seeded answer state (?qaState=answer under the nodebench-redesign-qa-chrome flag, sampleAnswer fixture, unreachable organically) that makes the answer moment capturable by the nightly loop and forcible by future contract states. Filmstrip: motion-frame-{40,280,980}ms.png shows the settle-rise + tick sweep mid-flight.",
"pages": [
{ "route": "/redesign/chat?qaState=answer", "viewport": { "width": 1440, "height": 900 }, "theme": "dark", "after": "after-answer-dark-desktop.png" },
{ "route": "/redesign/chat?qaState=answer", "viewport": { "width": 1440, "height": 900 }, "theme": "dark", "after": "after-answer-dark-desktop-scrolled.png" },
{ "route": "/redesign/chat?qaState=answer", "viewport": { "width": 1440, "height": 900 }, "theme": "light", "after": "after-answer-light-desktop.png" },
{ "route": "/redesign/chat?qaState=answer", "viewport": { "width": 390, "height": 844 }, "theme": "dark", "after": "after-answer-dark-mobile.png" }
]
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions docs/design/ui-contract/20260717-ember-landing/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"schema": "nodebench-ui-contract-v1",
"capturedAt": "2026-07-17T21:44:53Z",
"slice": "ember-landing",
"app": "nodebench-ai (decision workspace landing, /redesign/chat)",
"notes": "Signature landing composition: 'Ember threads' — abstract source nodes whose hairline threads converge past the SVG floor into the composer dock, giving form to the product truth stated by the new tagline 'Answers with receipts.' Headline lifted to display scale (clamp 28-40px, -0.02em). Motion identity: one easing (--rd-ease-settle cubic-bezier(.22,1,.36,1)), staggered thread draw-in (110ms steps, 840ms) + node bloom, then a 12s near-imperceptible drift; composer focus warms the constellation (0.5 -> 0.85 opacity). prefers-reduced-motion flattens ALL of it to the static composition — verified: computed animationName 'none' on threads and container (motion-reduced-motion-static.png). Deterministic node geometry (no randomness) keeps screenshots replay-stable. Mobile prunes the two low side nodes and halves constellation opacity. Filmstrip frames: motion-frame-{0ms,380ms,1280ms}.png. Before = feat/chip-label-reduction head; surface contracts green on both sides.",
"pages": [
{ "route": "/redesign/chat", "viewport": { "width": 1440, "height": 900 }, "theme": "dark", "before": "before-decision-workspace.png", "after": "after-decision-workspace.png" },
{ "route": "/redesign/chat", "viewport": { "width": 1440, "height": 900 }, "theme": "light", "before": "before-decision-workspace-light.png", "after": "after-decision-workspace-light.png" },
{ "route": "/redesign/chat", "viewport": { "width": 390, "height": 844 }, "theme": "dark", "before": "before-decision-workspace-mobile.png", "after": "after-decision-workspace-mobile.png" },
{ "route": "/redesign/chat", "viewport": { "width": 390, "height": 844 }, "theme": "light", "before": "before-decision-workspace-mobile-light.png", "after": "after-decision-workspace-mobile-light.png" }
]
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
185 changes: 182 additions & 3 deletions src/features/redesign/agent-workspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@
}

[data-redesign] .rd-chat-empty {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
Expand All @@ -293,13 +294,191 @@
padding: 28px 0;
}

/* ── Ember threads — the landing's signature composition ──────────────────
Motion identity: one easing ("settle"), three durations. Draw-in runs once
on arrival (staggered, < 1s total), then the whole constellation drifts
almost imperceptibly. Composer focus warms the threads — the evidence
leans in. Reduced motion flattens everything to the static composition. */
[data-redesign] {
--rd-ease-settle: cubic-bezier(0.22, 1, 0.36, 1);
--rd-dur-quick: 240ms;
--rd-dur-move: 480ms;
--rd-dur-enter: 840ms;
}

[data-redesign] .rd-ember {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
color: var(--rd-ink-mute);
pointer-events: none;
z-index: 0;
opacity: 0.5;
transition: opacity var(--rd-dur-move) var(--rd-ease-settle);
animation: rd-ember-drift 12s var(--rd-ease-settle) infinite alternate;
}

[data-redesign] .rd-chat-empty__hero,
[data-redesign] .rd-chat-empty__chips {
position: relative;
z-index: 1;
}

[data-redesign] .rd-ember__thread {
fill: none;
stroke: currentColor;
stroke-width: 1;
opacity: 0.26;
stroke-dasharray: 1;
stroke-dashoffset: 0;
animation: rd-ember-draw var(--rd-dur-enter) var(--rd-ease-settle) both;
animation-delay: calc(var(--i) * 110ms);
}

[data-redesign] .rd-ember__node {
fill: var(--rd-accent);
opacity: 0.85;
animation: rd-ember-node var(--rd-dur-move) var(--rd-ease-settle) both;
animation-delay: calc(var(--i) * 110ms);
}

/* The evidence leans in when the user commits attention to the composer. */
[data-redesign] .rd-chat-workspace:focus-within .rd-ember {
opacity: 0.85;
}

@keyframes rd-ember-draw {
from { stroke-dashoffset: 1; }
to { stroke-dashoffset: 0; }
}

@keyframes rd-ember-node {
from { opacity: 0; transform: scale(0.4); transform-origin: center; transform-box: fill-box; }
to { opacity: 0.85; transform: scale(1); transform-origin: center; transform-box: fill-box; }
}

@keyframes rd-ember-drift {
from { transform: translateY(0); }
to { transform: translateY(-7px); }
}

@media (prefers-reduced-motion: reduce) {
[data-redesign] .rd-ember,
[data-redesign] .rd-ember__thread,
[data-redesign] .rd-ember__node {
animation: none;
}
}

/* ── Answer arrival — the threads deliver ────────────────────────────────
The packet settles in on the same easing the landing draws with, and each
section eyebrow carries a small terracotta ember tick that sweeps in: the
recurring motif that evidence, not decoration, underlines every claim. */
[data-redesign] .rd-answer-arrive {
animation: rd-answer-settle var(--rd-dur-move) var(--rd-ease-settle) both;
}

[data-redesign] .rd-answer-arrive .rd-eyebrow {
position: relative;
padding-bottom: 3px;
}

[data-redesign] .rd-answer-arrive .rd-eyebrow::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 24px;
height: 1px;
background: var(--rd-accent);
transform-origin: left center;
animation: rd-ember-sweep var(--rd-dur-move) var(--rd-ease-settle) both;
animation-delay: 180ms;
}

@keyframes rd-answer-settle {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}

@keyframes rd-ember-sweep {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}

/* Thinking = the thread being drawn. A hairline grows beneath the phase
label on the settle easing, ember-tipped; the dots become embers. */
[data-redesign] .rd-chat-thinking {
position: relative;
padding-bottom: 6px;
}

[data-redesign] .rd-chat-thinking::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 72px;
height: 1px;
background: linear-gradient(90deg, var(--rd-accent), transparent);
transform-origin: left center;
animation: rd-thinking-thread 1.6s var(--rd-ease-settle) infinite;
}

[data-redesign] .rd-chat-thinking__dots span {
background: var(--rd-accent);
}

@keyframes rd-thinking-thread {
0% { transform: scaleX(0); opacity: 0.9; }
70% { transform: scaleX(1); opacity: 0.9; }
100% { transform: scaleX(1); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
[data-redesign] .rd-answer-arrive,
[data-redesign] .rd-answer-arrive .rd-eyebrow::after,
[data-redesign] .rd-chat-thinking::after {
animation: none;
}
}

/* Narrow viewports: quieter constellation, and the two low side nodes give
their space back to the stacked chips. */
@media (max-width: 760px) {
[data-redesign] .rd-ember {
opacity: 0.35;
}
[data-redesign] .rd-ember path:nth-of-type(5),
[data-redesign] .rd-ember path:nth-of-type(6),
[data-redesign] .rd-ember circle:nth-of-type(5),
[data-redesign] .rd-ember circle:nth-of-type(6) {
display: none;
}
}

[data-redesign] .rd-chat-empty__hero {
margin-bottom: 24px;
margin-bottom: 28px;
}

[data-redesign] .rd-chat-empty__h {
font-size: 24px;
line-height: 1.2;
font-size: clamp(28px, 4vw, 40px);
font-weight: 620;
letter-spacing: -0.02em;
line-height: 1.12;
}

[data-redesign] .rd-chat-empty__tag {
margin: 10px 0 0;
color: var(--rd-ink-mute);
font-size: 15px;
letter-spacing: 0.01em;
}

[data-redesign] .rd-chat-empty__tag em {
font-style: normal;
color: var(--rd-accent-strong);
}

[data-redesign] .rd-chat-empty__chips {
Expand Down
59 changes: 59 additions & 0 deletions src/features/redesign/components/ChatEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,70 @@ const STARTERS = [
{ icon: STARTER_ICONS.watch, title: "What's new in my watchlist?", prompt: "Summarize what changed in my watchlist over the last 7 days. Group by signal class." },
];

/**
* "Ember threads" — the landing's signature composition. Abstract source
* nodes whose threads converge into the composer below: the product truth
* (answers drawing on evidence) as form, not copy. Deterministic geometry
* (no randomness — replay-stable screenshots), aria-hidden decoration, and
* the draw-in/drift motion lives entirely in CSS so prefers-reduced-motion
* can flatten it to the static composition.
*/
const EMBER_NODES: Array<{ x: number; y: number }> = [
{ x: 96, y: 96 },
{ x: 232, y: 34 },
{ x: 598, y: 48 },
{ x: 756, y: 128 },
{ x: 152, y: 296 },
{ x: 694, y: 312 },
];
// Past the viewBox floor on purpose: threads clip at the SVG's bottom edge
// still converging, so they read as running INTO the composer dock below
// rather than dying in the empty band.
const EMBER_FOCUS = { x: 420, y: 560 };

function EmberThreads() {
return (
<svg
className="rd-ember"
viewBox="0 0 840 520"
preserveAspectRatio="xMidYMax meet"
aria-hidden="true"
data-testid="ember-threads"
>
{EMBER_NODES.map((node, i) => (
<path
key={`t-${node.x}`}
className="rd-ember__thread"
style={{ ["--i" as string]: i }}
pathLength={1}
d={`M ${node.x} ${node.y} Q ${(node.x + EMBER_FOCUS.x) / 2} ${
node.y + (EMBER_FOCUS.y - node.y) * 0.72
} ${EMBER_FOCUS.x} ${EMBER_FOCUS.y}`}
/>
))}
{EMBER_NODES.map((node, i) => (
<circle
key={`n-${node.x}`}
className="rd-ember__node"
style={{ ["--i" as string]: i }}
cx={node.x}
cy={node.y}
r={3}
/>
))}
</svg>
);
}

export function ChatEmptyState({ onPick, starters = STARTERS }: ChatEmptyStateProps) {
return (
<div className="rd-chat-empty">
<EmberThreads />
<div className="rd-chat-empty__hero">
<h2 className="rd-chat-empty__h">What do you need to know?</h2>
<p className="rd-chat-empty__tag">
Answers with <em>receipts</em>.
</p>
</div>

<div className="rd-chat-empty__chips">
Expand Down
25 changes: 23 additions & 2 deletions src/features/redesign/surfaces/ChatSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import { UniversalComposer, DEFAULT_TIERS, type RouterTier } from "../components/UniversalComposer";
import { Pill } from "../components/Pill";
import { StreamingMarkdown } from "../components/StreamingMarkdown";
import type { ActiveBatchRun, ChatAnswer } from "../fixtures";
import { sampleAnswer, type ActiveBatchRun, type ChatAnswer } from "../fixtures";
import { useBatchLive } from "../hooks/useBatchLive";
import { useLiveArtifacts, type LiveArtifactDetail } from "../hooks/useLiveArtifacts";
import { ChatThinking } from "../components/ChatThinking";
Expand Down Expand Up @@ -545,6 +545,27 @@ export function ChatSurface({
];
}, [liveDetail]);
const [turns, setTurns] = useState<Turn[]>([]);
// QA-only seeded answer state: `?qaState=answer` under the dogfood chrome
// flag renders the sampleAnswer fixture as a completed turn. This exists so
// the answer moment is photographable (nightly loop, evidence folders) and
// contract-forcible without a paid authenticated run. Never reachable
// organically: the flag is set only by the QA capture pipeline.
const qaAnswerSeededRef = useRef(false);
useEffect(() => {
if (qaAnswerSeededRef.current) return;
if (launchParams.get("qaState") !== "answer") return;
try {
if (window.localStorage.getItem("nodebench-redesign-qa-chrome") !== "1") return;
} catch {
return;
}
qaAnswerSeededRef.current = true;
const now = Date.now();
setTurns([
{ id: "qa-u", role: "user", text: "Summarize Orbital Labs. Keep it evidence-led and end with a next action.", createdAt: now },
{ id: "qa-a", role: "assistant", packet: sampleAnswer, tier: "auto", createdAt: now + 1 },
]);
}, [launchParams]);
const recoveredRunIdRef = useRef<string | null>(null);
const consumedContinuationRef = useRef<string | null>(null);
const [hasUserInteracted, setHasUserInteracted] = useState(false);
Expand Down Expand Up @@ -1839,7 +1860,7 @@ function AnswerPacket({
{/* Avatar gutter — parity-studio bot icon pattern */}
<div className="rd-chat-msg__avatar" aria-hidden="true">✦</div>

<article className="rd-chat-msg__body" style={{ padding: "var(--rd-s-2) var(--rd-s-3)", display: "flex", flexDirection: "column", gap: "var(--rd-s-2)", background: "var(--rd-paper-warm)", borderRadius: "var(--rd-r-md)", borderBottomLeftRadius: 4 }}>
<article className="rd-chat-msg__body rd-answer-arrive" style={{ padding: "var(--rd-s-2) var(--rd-s-3)", display: "flex", flexDirection: "column", gap: "var(--rd-s-2)", background: "var(--rd-paper-warm)", borderRadius: "var(--rd-r-md)", borderBottomLeftRadius: 4 }}>
<div className="rd-agent-lead">{packet.shortAnswer.length > 60 ? "Research complete" : "NodeBench"}</div>
<header className="rd-chat-msg__header" style={{ fontSize: 10, color: "var(--rd-ink-faint)" }}>
<span>{tierMeta.label}</span>
Expand Down
Loading