v3.1 · Forensic spec, hardened by 15 judges across 3 panels

Ariadne chat: reproduce the restraint, then earn the polish.

A build-oriented spec for the Ariadne chat UI, reverse-engineered from four Hyperagent recordings and then put through two review panels (Sonnet 5.8/10 → Opus 4.8/10). v3 fixes what they caught: a 2× DPR correction, frame-honest timings, a real two-layer token system, a WCAG 2.1 AA floor, full state matrices, a streaming data model, and a feel thesis that gates every "innovation."

4 clips · per-clip DPR/fps corrected WCAG 2.1 AA = release gate 10 judges · 2 panels scope: chat · dark
00

Capture & Methodology

Read this first — it governs the trust level of every number below.

ClipFrame sizeDPRTrue viewport (CSS)fpspx rule
V1 chrome1166×1456~583 px wide27.17device ÷ 2
V2 content1166×1456~583 px wide27.42device ÷ 2
V3 interactions1166×1456~583 px wide27.50device ÷ 2
V4 narrow616×1454~1×616 px wide27.42as-is
Measurement-confidence rules (normative)

• All V1–V3 pixel/type values are stated in CSS px (device ÷ 2); raw device-px shown as "Nd" where useful. Confirm against a known element or source CSS before locking.
• Any motion under ~75 ms (≤2 frames @ 27 fps) is reported as instant — sub-frame durations are not measurable from this footage.
• Every color token is a single value; capture chroma ≤~30 and ±1-level hex drift are treated as JPEG/AA noise, not design intent.
• Claims are marked observed (in-frame) vs proposed (Ariadne decision; reference never showed it).

Net effect: the desktop captures are a ~583 px window, not a wide desktop — so true wide-desktop layout (≥1200 px, persistent sidebar) is a proposed extrapolation, anchored by the sidebar that is visible in V3.

01

The Feel Thesis

Why the reference feels smooth — the principles every change must respect.

The reference's "smoothness" is not a motion story; it's restraint and information architecture. Name the principles, or innovations will quietly destroy the very thing we're copying.

P1

Asymmetric containment

User input is contained (navy bubble); assistant output is liberated (flush, full reading width). The single biggest feel driver.

P2

Hierarchy by luminance

~5 text-brightness tiers on near-black; no borders, no shadows. Calm comes from the absence of boxes.

P3

Monochrome discipline

Exactly two saturated colors in the whole surface (navy bubble, danger red). Color is a rare signal, not decoration.

P4

Progressive disclosure

Reasoning collapses to a line; tool calls collapse to pills. The surface stays quiet until you ask.

P5

Content-shaped, not container-shaped

Code goes flush/full-bleed; timestamps tuck into padding. Layout yields to content, not the reverse.

⚖ The gate

Every item in the Roadmap (§10) carries a feel-thesis check. Anything that adds color or chrome (semantic status colors, links, syntax highlighting, accent rails) must justify itself against P2/P3 or be A/B'd — these are the changes most likely to make Ariadne feel less like the reference, not more.

02

Design Tokens

A two-layer system: raw primitives → semantic aliases → named scales. Dark-only v1, structured so a light theme is a sibling map.

/* ── PRIMITIVES — raw ramp, no UI meaning (v3.1: system closed, every card color traces here) ── */
--gray-950:#090909; --gray-900:#111111; --gray-850:#161616; --gray-825:#202024; --gray-800:#1d1d1d;
--gray-775:#252525; --gray-700:#383838; --gray-600:#6a6a6a; --gray-500:#7c7c7c; --gray-400:#838383;
--gray-300:#adadad; --gray-250:#e0e0e0; --gray-200:#d4d4d4; --gray-150:#efefef; --gray-100:#e1e1e1; --gray-50:#f4f4f4; --white:#fff;
--navy-900:#01132f; --navy-850:#03132e; --navy-700:#1b283f; --navy-600:#1e273a; --navy-300:#dfefff;
--blue-600:#1d4ed8; --blue-500:#2563eb; --blue-400:#3b82f6; --blue-300:#5b8cff;
--green-500:#079e7c; --green-400:#31a56b; --red-400:#f87171; --red-950:#2a1416; --steel-400:#8a93a3; --steel-350:#70859e;

/* ── SEMANTIC — what UI binds to (no raw hexes; no orphans) ── */
--bg:gray-950; --surface:gray-850; --hover-surface:gray-825; --inline-code:gray-775; --field:gray-900;
--border:gray-600;        /* #6a6a6a — ALL essential borders/chevron/divider/edit/+ring (~3.5:1, buffered) */
--border-quiet:gray-800;  /* decorative only (exempt from 1.4.11) */
--text:gray-100; --text-strong:gray-50; --text-2:gray-300; --text-muted:gray-400;
--text-faint:gray-500;    /* #7c7c7c — was #737373 → 4.8:1 (buffered) */
--timestamp:steel-350;     /* #70859e — passes bg + navy bubble + chip (was #6a7f99, failed on navy) */
--on-navy:navy-300;
--bubble-user:navy-850;    /* one token; narrow darkening = JPEG tolerance */
--chip:navy-700; --send-busy:navy-600; --send-busy-arrow:steel-400; --send:blue-500; --send-hover:blue-600; --accent-save:green-500;
--live:green-400; --danger:red-400; --danger-bg:red-950; --stop-glyph:gray-250; --icon-idle:gray-150; --link:blue-300; --focus:blue-400;

/* ── SCALES — named, not ad-hoc (cards reference these, not raw px) ── */
--radius: xs 4 · sm 8 · md 12 · pill 999      /* rationalized from 8/10/12/14/17/24 */
--space : 1=4 · 2=8 · 3=12 · 4=16 · 6=24       /* 8px base grid */
--type  : h1 28/34·700 · h2 20/28·600 · body 16/24 (narrow 15/26) · label 13/16 · code 13/20 · ts 12

Palette (corrected — AA-safe text tiers)

bg
#090909
surface / code
#161616
inline-code
#252525
field
#111111
border ✎
#6a6a6a
text-strong
#f4f4f4
text
#e1e1e1
text-2
#adadad
text-muted
#838383
text-faint ✎
#7c7c7c
timestamp ✎
#70859e
bubble-user
#03132e
chip
#1b283f
send (enabled) ✎
#2563eb
save
#079e7c
danger ✎ (unified)
#f87171
live
#31a56b
link ✎
#5b8cff

✎ = changed from the raw sample for AA compliance. The phantom #191919 "tool-row pill" token is removed (identical to the #161616 code surface). v3.1: the system is now closed — every color used in the cards/state-matrix (hover surface, send-hover, danger-bg, stop glyph, idle icon, the AA-corrected borders) is a defined primitive, so the token layer can actually enforce the AA floor it gates on.

03

Accessibility — WCAG 2.1 AA (normative)

A release gate, not an innovation. The reference fails several criteria; Ariadne must fix, not reproduce, them.

Contrast remediation (recomputed, sRGB)

Pair (fg on bg)RatioNeedStatusCorrected
Timestamp #5a6c82 on bg/navy/chip3.1–3.74.5FAIL#70859e → 5.3 / 4.9 / 4.6 (all pass)
"Reasoned for X" #737373 on bg4.204.5FAIL#7c7c7c → 4.8 (buffered)
All essential borders #1d1d1d/#383838/#444/#575757 (composer · divider · edit · "+" ring · chevron)1.07–2.433.0FAILunified --border #6a6a6a → ~3.5 on bg/surface/field
Busy send arrow #778090 on #1e273a3.754.5FAIL#8a93a3 → 4.5 (aria-disabled ≠ exempt)
Enabled send #2563eb edge / white arrow3.85 / 5.173.0 / 4.5PASSkeep
Body #e1e1e1 · secondary #adadad · muted #838383 (on #090909)15.2 / 8.9 / 5.34.5PASSkeep (don't darken)
User text #d9e6ff on navy14.74.5PASSstrongest pair

Corrected the prior panel: #838383 and #adadad were false-flagged — both pass (ratios above are recomputed against the true #090909 bg). The real misses were the near-invisible composer border (1.18:1) and divider (1.07:1). v3.1: remediated tokens carry a buffer (no razor's-edge 3.00), and the two that previously still failed (timestamp-on-navy, busy arrow) are now fixed.

Target size (SC 2.5.5 / 2.5.8): interactive targets ≥ 44×44px (24px AA minimum with spacing). The reference's ~9px icons / ~17px pills fail this — at the compact breakpoint, tap-to-reveal the toolbar and pad all targets to 44px.

Focus, keyboard & reduced-motion (required)

--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--focus);  /* :focus-visible only, ≥2px, ≥3:1 */

@media (prefers-reduced-motion: reduce){
  *,*::before,*::after{ animation-duration:.001ms!important; animation-iteration-count:1!important;
    transition-duration:.001ms!important; scroll-behavior:auto!important; }
  /* v3.1: the blanket reset is NOT enough for bespoke infinite loops — kill them explicitly: */
  .shimmer,.pulse{ animation:none!important }        /* stop the loop (don't run-once to a blank frame) */
  .shimmer::after{ content:"Working…" }              /* static label substitute */
  .caret{ animation:none!important; opacity:1 }      /* steady bar, no blink (SC 2.2.2) */
}
ElementKeyboardARIA / note
ComposerEnter send · Shift+Enter newline · ⌘/Ctrl+Enter force · Esc blurrole="form"
Send (busy)Enter/Spacearia-disabled (not disabled) + aria-describedby
Stopfirst in tab order while streaming; Enter/Space; global Esclabeled
Tool-row / ReasoningEnter/Space togglesrole=button · aria-expanded · aria-controls
Hover toolbarreveal on focus-within too; roving tabindex; arrows moverole=toolbar — not hover-only
Inline edit⌘/Ctrl+Enter Save · Esc Cancel · focus trap; restore on closelabeled buttons
Message logrole=log · aria-live=polite · aria-busy while streaming
Copy codeEnter/Spacearia-live announces "Copied"
Traps NOT to copy from the reference

Hover-only affordances (toolbar) · no focus ring on the edit field · sub-3:1 borders · hidden scrollbar + instant jump-scroll · color-only status. And: adding semantic color to "Failed" must keep the word + an icon (WCAG 1.4.1) — don't regress to color-alone.

04

Motion System

Named curves + durations. Frame-honest: sub-75ms = instant.

--ease-out: cubic-bezier(.16,1,.3,1);   /* decel — entrances, reveals */
--ease-in : cubic-bezier(.4,0,1,1);     /* accel — exits, collapses */
--ease-std: cubic-bezier(.2,0,0,1);     /* disclosure, generic */
--dur-instant:0ms · --dur-fast:120ms · --dur-base:180ms · --dur-slow:240ms · --pulse:1000ms
TransitionReference (corrected)Ariadne spec
Hover toolbar reveal / hideopacity, 140 / 110ms, no stagger obsopacity, ease-out/in; +24ms per-icon stagger prop
Inline-edit enter1 frame = instant obseditIn 120ms ease-out (scaleY .96→1) prop
Inline-edit exit~145ms (was "218") obseditOut 140ms ease-in
Disclosure / tool-row expand~140ms ease-out (est.) obsgrid-rows 0fr→1fr 200ms ease-std + chevron rotate 0→90° same curve
Tool-row entryscroll-revealed obstranslateY(8px)+fade 150ms, 40ms stagger prop
"Working…" shimmerbraille wave, ≈1s, waveform unconfirmed obs1000ms loop; freeze under reduced-motion
Auto-scrollteleport 120–137px/frame obssmooth follow ~200ms ease-out + pin-to-bottom guard

obs = measured in footageprop = Ariadne proposal

05

Agentic Chrome

Clip V1 · dimensions in corrected CSS px (2× ÷ 2).

V1 · bubbles · tool rows · reasoning · streaming · composer
1

User Bubble & Attachment Chip

V1 · corrected
Anatomy (CSS px)
Bubble
right-aligned, width ~407px (813d ÷2), ~70% of column; fill #03132e; radius 8px; no border
Chip
~125×40px (251×79d ÷2); radius 8px; fill #1b283f
Chip text
filename #dfefff · "255.0 KB" + add type badge ·zip
Timestamp
tucked in top padding, #6a7f99 (AA-corrected)
⚖ Feel-thesis

P1 incarnate — keep input contained. Multi-attachment (§09 missing-states) must wrap, not break the bubble.

Attachment chip — corrected to ~125×40 CSS px.
2

Assistant "Reasoned" Block

V1 · P1 signature
Anatomy
Header
"Reasoned ›" #adadad + chevron #656565 (corrected)
Container
no bubble, no avatar — flush to column, full reading width
Prose
#e1e1e1, body 16/24
⚖ Feel-thesis

The bubbled-user / flush-assistant asymmetry (P1) is the #1 thing to reproduce. Any "accent rail" on assistant blocks adds chrome (violates P2) — hover-only, faint, optional.

Bubbled user vs. flush assistant — the core feel signature.
3

Tool-Call Rows & Status Pills

V1 · monochrome
Anatomy (CSS px)
Pill
bg #161616 (not "#191919" — phantom token removed), height ~17px, radius pill
Layout
icon >_ · label #adadad · desc #838383 · chevron · status
Statuses
Failed · Running · Fetching — monochrome obs ✓ confirmed
Expanded
chevron→⌄ + mono code block (full width)
⚖ Feel-thesis (tension)

Reference "Failed" has no red — verified. Adding semantic color improves scannability but risks P3; ship as icon + word + tint (not color-alone), keep tints desaturated.

Expanded rows → monospace command blocks.
4

Streaming — "Working…", Stop & the gap

V1 · the real feel fix
Observed

Content lands in blocks with 0.4–2.8s dead-air gaps; auto-scroll teleports 120–137px/frame; "Working…" is a braille shimmer (~1s), Stop = filled glyph, no pill.

The fix (re-prioritized)
1 · smooth auto-scroll2 · think-time indicator owns the gap3 · per-word fade (optional)

Per-word fade does not fix block-paste — the dead air does. See the streaming model in §09.

↗ Ariadne

Reuse the shimmer during gaps; reveal each block with one ease-out fade (~180ms). Caret = steady bar under reduced-motion.

"Working…" shimmer (period ≈1s, waveform unconfirmed).
5

Composer — "Execute" split-button

V1 · corrected
Anatomy (CSS px)
Container
~480px wide, ~97px tall (960×194d ÷2), radius 12px, border #5d5d5d (corrected)
Placeholder
"What's the task?" muted
Right
"⚡ Execute ⌄" split-button + circular send
Send button — relabeled

#1e273a + dim arrow #778090 is the busy/disabled state — the only one captured obs ✓. Enabled = #2563eb + white arrow prop. (See state matrix §09.)

↗ Ariadne

Keep the Execute split-button (action + mode dropdown) — strong pattern. Add focus ring; make enabled vs busy unmistakable.

Composer (busy state) — corrected to ~480px wide.
06

Content & Rendering

Clip V2 · markdown, code, streaming.

V2 · typography · code · streaming cadence
6

Markdown, Lists & Code

V2 · borderless code
Type & lists
Headings
brightness-tiered (P2), h2 20/28·600; add link color #5b8cff (A/B vs P3)
Lists
disc/numerals same gray; indent ~34px → text column
Code blocks — confirmed borderless
Container
flat #161616, 0px body radius, no border obs ✓ (the "border" was the header chip + luminance step)
Highlighting
none in reference obs ✓; add ≤3 desaturated hues (gate vs P3)
Copy button
idle clipboard → click → check #31a56b + "Copied" 1500ms → revert; copies raw text; aria-live
Borderless code, 0px radius — header chip only.
07

Interactions

Clip V3 · hover toolbar & inline edit (timings corrected).

V3 · hover toolbar · inline edit
7

Hover Toolbar & Inline Edit

V3 · corrected motion
Hover toolbar
Icons
edit #efefef · regen · delete #f87171 (unified danger); ~9px, ~17px pitch (corrected)
Reveal
opacity 140ms ease-out / hide 110ms ease-in; must also reveal on focus-within (a11y)
Inline edit — corrected timings
enter: instant (was "36ms" = 1 frame)exit: ~145ms (was "218")
Field
fill #111111, border #606060 (corrected), radius 8px; add focus ring
Controls
✓ Save #079e7c · ✗ Cancel — add text labels + ⌘↵/Esc
↗ Ariadne

Don't reproduce the hard cut-in — add a 120ms editIn so enter matches the polished exit (symmetry).

Hover toolbar — keyboard-reachable in Ariadne.
Inline edit — focus ring added.
08

Layout & Responsive

Clip V4 (1× narrow) · rhythm, scroll, breakpoints.

V4 · narrow column · responsive
8

Bubbles, Rhythm & Breakpoints

V4 · 616px (1×)
Rhythm
Inter-role gap
~120px (≈4× line-height); tighten to ~80px + subtle divider
User bubble
navy, ~80% → cap 75%, shrink-to-content for short msgs
Sidebar
observed in V3 (~130px CSS: New thread/Search/Library/Learning/Integrations + thread list) — not "inferred"
Breakpoints
Compact <7681 col, no sidebar; bubble 75%; 15/26
Standard 768–1199collapsible sidebar; column max 760; bubble 70%
Wide ≥1200persistent sidebar ~260px + column 760; bubble 65%
⚖ Touch

Measured ~9px icons / ~17px pills fail the 44px touch minimum, and the toolbar is hover-only. Compact bp needs tap-to-reveal + 44px targets.

Narrow column (true 1× 616px).
09

Components, States & Data

The build layer the panels said was missing: DOM tree, state matrix, streaming model, missing flows.

Component tree

<ChatApp>
├─ <Sidebar>            (≥768: nav · ThreadList · AccountRow)        [V3 obs]
└─ <ChatPane>
   ├─ <MessageList role=log aria-live=polite>
   │  ├─ <UserMessage>   → Bubble(navy) · AttachmentChip · Timestamp · HoverToolbar(role=toolbar)
   │  │                  └─ edit state swaps in <EditableMessage> (no neighbour reflow)
   │  └─ <AssistantTurn> (flush, no bubble)
   │     ├─ ReasoningDisclosure(aria-expanded) · ToolCallRow(aria-expanded)→Pill+CodeBlock
   │     ├─ Markdown(headings/lists/links) · CodeBlock→Header(lang+CopyButton)+pre
   │     └─ StreamCaret (while streaming)
   └─ <Composer role=form> → StatusBar(Working…+Stop) · Textarea · ActionRow(Attach·Execute⌄·Send)

State matrix ✓ observed ◇ proposed

Componentidlehoverfocusdisabled / busyerrorempty
Send#2563eb / white ◇#1d4ed8 ◇ring ◇#1e273a / #778090 ✓shake ◇disabled ◇
Stop#e0e0e0 glyph ✓#2a1416/#f87171 ◇ring ◇n/an/an/a
Tool-row#161616 pill ✓#202024 ◇ring ◇"Running"+pulse ◇"Failed" icon+tint ◇n/a
Edit fieldring #3b82f6 ◇ (ref: none ✓)readonly+spinner ◇red border+msg ◇Save off ◇
Hover toolbaropacity 0 ✓faint circle ✓ → #202020 ◇focus-within reveal ◇n/an/an/a

Streaming data model

// SSE; accumulate in a ref, commit batched per animation frame (no per-chunk re-render storm)
const buf = useRef(""); const prevCount = useRef(0); const [text,setText] = useState("");
const raf = useRef(0);
const onChunk = c => { buf.current += c;
  if(!raf.current) raf.current = requestAnimationFrame(()=>{ raf.current=0; setText(buf.current); }); };
// stable id per word (NOT index→key); animate ONLY the words new since last commit
const words = useMemo(()=> text.split(/(\s+)/).filter(Boolean), [text]);
const firstFresh = prevCount.current;             // index of first new word this commit
useEffect(()=>{ prevCount.current = words.length; }, [words.length]);
words.map((w,i)=> <span key={`w${i}`} className="tok"
   data-fresh={i>=firstFresh || undefined}            // only fresh tail carries the entrance
   style={{animationDelay:`${Math.max(0,Math.min(i-firstFresh,8))*20}ms`}}>{w}</span>)
// .tok{animation:tokIn 80ms var(--ease-out) both} — `both` so settled words never replay
// .tok:not([data-fresh]){animation:none}            // belt-and-suspenders: old words static
// auto-scroll: pinned = (scrollHeight - scrollTop - clientHeight) ≤ 32 → scrollTo({top,behavior:reduced?'auto':'smooth'}); else show ↓ chevron
// caret: <span class="caret"> rendered as a SIBLING after the last word-span while streaming (steady bar under reduced-motion)
// send predicate (canonical): canSend = input.trim().length > 0 && !isStreaming;  inline `code` has NO copy button (block code only)

Missing flows now specced

State / flowSpecPhase
Empty / first-runThe first screen 100% of users see. Vertically centered: Ariadne wordmark (text-2) + one-line subtitle (text-faint) + 3 example-prompt chips (surface bg, --border, label text); composer pinned bottom, focused, placeholder "What's the task?", send disabled until input.trim()>0. No message log chrome until first send. Respects P2 (no boxes beyond the chips).0 · Parity
Error / stream-failInline error row (icon + word + --danger tint, not color-alone) with Retry (re-streams from the last user turn); partial output preserved above it; role=alert, focus moves to Retry.1 · Floor
Stop → idlePartial answer persists; "Working…" + caret removed; composer returns to idle; send re-enables next frame. (A first-class transition, also a Stop-component state row.)1 · Floor
Multi-attachmentChips wrap within the bubble; "+N" overflow chip beyond 3 opens a tray.2
Long message / copyCode block horizontal scroll; copy on block code (not inline); "show more" beyond N lines.2

v3.1: empty/first-run is now a fully-specced Phase-0 state (it was a 6-word stub) — you can't claim to "reproduce the feel" without specifying the first feel a user gets.

10

Roadmap — ranked & phased

Impact × Effort, feel-thesis-checked, split reproduce → innovate.

Phase 0 — Parity: reproduce the feel (tokens, asymmetric layout, luminance hierarchy, disclosure) with no innovations, so "smoothness" is verifiable against the frames. Phase 1 — Floor + feel: accessibility + the two highest-impact motion fixes. Phase 2 — Richness: gated visual upgrades.

#ItemImpactEffortPhaseFeel-thesis
0Empty / first-run state ⭐ (the first screen every user sees)HighLow0 · ReproduceP2-clean
1Asymmetric layout (bubbled user / flush assistant)HighLow0 · Reproducecore of P1
2Luminance type system (5 tiers, no chrome)HighLow0 · Reproduceis P2
3Progressive disclosure (collapse reasoning + tools)HighMed0 · Reproduceis P4
4Accessibility floor (contrast, focus, keyboard, reduced-motion)HighLow–Med1 · Floorrequired
5Smooth auto-scroll + pin-to-bottomHighLow1 · Floorneutral
6Think-time gap indicator (owns the 0.4–2.8s dead air)HighMed1 · Innovatereuses shimmer (P3)
7Composer enabled-send + focus ringMedLow1 · Floorfixes a gap
7bCompact touch targets (44px + tap-to-reveal toolbar)MedLow1 · FloorSC 2.5.5
8Status semantics — icon+word first, tint lightMedLow2⚠ tension P3
9Inline-edit symmetry + labels + ⌘↵/EscMedLow2neutral
10Block-reveal fade (whole block ~180ms)Low–MedLow2subtle
11Syntax highlighting (≤3 desaturated hues)MedMed2⚠ gate vs P3
12Brand-blue links + hover underlineLowLow2⚠ A/B vs P3
13Per-word fade-in + caret (was the headline)LowHigh2⚠ fixes wrong problem
14Dispatch command-counter (net-new)Low–MedLow2neutral

The reframe: the original "headline" (per-word fade-in, #13) is last by impact×effort; the real Phase-1 wins are smooth scroll (#5) and the think-time indicator (#6). The highest-leverage work of all (#1–3) was previously filed as "anatomy" — reproducing the restraint is the work.

11

Frame Evidence

Hero frames at the analysts' flagged timestamps.

V1 · full surface
V1 · attachment chip
V1 · expanded tool rows
V1 · "Working…"
V1 · composer (busy)
V2 · borderless code
V2 · heading tier
V3 · hover toolbar
V3 · inline edit
V4 · narrow (1×)