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 correctedWCAG 2.1 AA = release gate10 judges · 2 panelsscope: chat · dark
△
v3 → v3.1 (this revision). The third panel scored v3 8.0/10 (up from 4.8) and named a 5-item punch list, now closed: (1) the token system is closed — every card/AA color is a defined primitive; (2) two contrast tokens that still failed are fixed (timestamp #70859e, busy arrow #8a93a3) + buffers; (3) the reduced-motion block now explicitly stops the shimmer/pulse/caret; (4) the streaming snippet is runnable (defined firstFresh, stable keys, both fill); (5) empty/first-run is a fully-specced Phase-0 state. (v3's own corrections: 2× DPR halving, frame-honest timings, AA as a release gate; the two disputed facts were pixel-verified and the spec was right on both.)
00
Capture & Methodology
Read this first — it governs the trust level of every number below.
Clip
Frame size
DPR
True viewport (CSS)
fps
px rule
V1 chrome
1166×1456
2×
~583 px wide
27.17
device ÷ 2
V2 content
1166×1456
2×
~583 px wide
27.42
device ÷ 2
V3 interactions
1166×1456
2×
~583 px wide
27.50
device ÷ 2
V4 narrow
616×1454
~1×
616 px wide
27.42
as-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)
Ratio
Need
Status
Corrected
Timestamp #5a6c82 on bg/navy/chip
3.1–3.7
4.5
FAIL
#70859e → 5.3 / 4.9 / 4.6 (all pass)
"Reasoned for X" #737373 on bg
4.20
4.5
FAIL
#7c7c7c → 4.8 (buffered)
All essential borders #1d1d1d/#383838/#444/#575757 (composer · divider · edit · "+" ring · chevron)
1.07–2.43
3.0
FAIL
unified --border #6a6a6a → ~3.5 on bg/surface/field
Busy send arrow #778090 on #1e273a
3.75
4.5
FAIL
#8a93a3 → 4.5 (aria-disabled ≠ exempt)
Enabled send #2563eb edge / white arrow
3.85 / 5.17
3.0 / 4.5
PASS
keep
Body #e1e1e1 · secondary #adadad · muted #838383 (on #090909)
15.2 / 8.9 / 5.3
4.5
PASS
keep (don't darken)
User text #d9e6ff on navy
14.7
4.5
PASS
strongest 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) */
}
Element
Keyboard
ARIA / note
Composer
Enter send · Shift+Enter newline · ⌘/Ctrl+Enter force · Esc blur
role="form"
Send (busy)
Enter/Space
aria-disabled (not disabled) + aria-describedby
Stop
first in tab order while streaming; Enter/Space; global Esc
labeled
Tool-row / Reasoning
Enter/Space toggles
role=button · aria-expanded · aria-controls
Hover toolbar
reveal on focus-within too; roving tabindex; arrows move
role=toolbar — not hover-only
Inline edit
⌘/Ctrl+Enter Save · Esc Cancel · focus trap; restore on close
labeled buttons
Message log
—
role=log · aria-live=polite · aria-busy while streaming
Copy code
Enter/Space
aria-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.
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.
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.
// 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 commitconst 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 / flow
Spec
Phase
Empty / first-run ⭐
The 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 untilinput.trim()>0. No message log chrome until first send. Respects P2 (no boxes beyond the chips).
0 · Parity
Error / stream-fail
Inline 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 → idle
Partial 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-attachment
Chips wrap within the bubble; "+N" overflow chip beyond 3 opens a tray.
2
Long message / copy
Code 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.
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.
#
Item
Impact
Effort
Phase
Feel-thesis
0
Empty / first-run state ⭐ (the first screen every user sees)
High
Low
0 · Reproduce
P2-clean
1
Asymmetric layout (bubbled user / flush assistant)
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.