From eb69fbcd6846c02a980b2f277b972c9771955369 Mon Sep 17 00:00:00 2001 From: ribdsp <113304041+ribdsp@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:47:59 +0700 Subject: [PATCH 1/8] feat: add type, radius, elevation and focus tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six problems in the presentation layer trace back to one cause: there were no tokens to reach for, so every component invented its own. 84 hardcoded `text-[Npx]` sizes, no radius, no elevation, no focus treatment, no transition. - Type scale named by role, not size: `text-micro`/`label`/`meta`/`body`/`title`. Body copy floors at 13px because this app is demonstrated through a compressed screen recording, where 10px prose becomes grey texture. Naming by role is what lets the floor move in one file next time. - `borderRadius` and `boxShadow` are *replaced* rather than extended: nothing above 6px resolves, and the only shadows are two inset top highlights. "No oversized rounded cards, no drop shadows" becomes a build error instead of a review note. - `border-line-strong` for the outline of a raised surface, since an inset highlight lifts one edge and leaves the other three flush. - A global `:focus-visible` outline rather than a class, so it covers elements nobody has written yet. `outline` not `ring-*`: a ring offset must be painted the colour of the surface behind it, and there are three surfaces here. - 120ms colour/border transitions, scoped to elements that answer a pointer. CONTRIBUTING.md § UI conventions records the floor, the radius scale and the four places motion is allowed, so the convention and the code agree afterwards. --- CONTRIBUTING.md | 44 ++++++++++++++++++++++++ traces/src/app/globals.css | 48 ++++++++++++++++++++++++-- traces/tailwind.config.ts | 69 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 156 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a91114..7eb4333 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -154,6 +154,50 @@ marketing voice in an ops tool. Empty, loading, and error states are all three required, not just the one you happened to hit while developing. +### The type scale has a floor, and the floor is 13px + +Ask for a role, not a size: `text-micro` `text-label` `text-meta` `text-body` `text-title`, defined in +`tailwind.config.ts`. Never write `text-[11px]` in a component — a hundred hardcoded sizes cannot be +raised together, and this scale has already had to move once. + +| Token | Size | For | +|---|---|---| +| `text-micro` | 10px | A glyph and nothing else: a `kbd` key cap, a timeline tick label, an ordinal. Never a phrase. | +| `text-label` | 11px | Uppercase section labels, status chips, author badges, counts. | +| `text-meta` | 12px | Mono metadata, secondary notes. | +| `text-body` | 13px | **The floor for any sentence a person has to read.** | +| `text-title` | 15px | The wordmark, and a title that has to win against the body under it. | + +Why a floor at all: this app is demonstrated through a screen recording, and a compressed 1080p frame +scaled into someone else's player turns 10px body copy into grey texture. Density comes from padding +and line-height, which cost nothing on video; it does not come from shrinking the words. + +### Radius stops at 6px, and depth is never a shadow + +`rounded-sm` (3px) for controls and chips, `rounded-md` (6px) for panels, `rounded-full` for dots +only. Larger values are not in the theme, so `rounded-lg` and friends do not resolve — the scale is +*replaced* rather than extended, which turns "no oversized rounded cards" above from a request into a +build error. + +`boxShadow` is replaced the same way and holds only `shadow-panel` / `shadow-raised`, both inset top +highlights. A raised surface is lit along its top edge and paired with `border-line-strong`; nothing +in an instrument floats above the chassis. + +### Focus is global, so do not suppress it + +`globals.css` puts a 2px `ink` outline on `:focus-visible` for every element at once. Do not add +`focus-visible:outline-none` to get a custom treatment — change the background alongside the ring if +you want more, but every interactive element keeps a visible focus state, and every dropdown and +disclosure stays keyboard-operable. + +### Motion is for four things + +A blocking gate waiting, a claimed task working, the bisect probe sequence arriving, a recording +loading. Nothing else moves — no page-load animation, no scroll effect, no hover lift. Because +`globals.css` zeroes animation duration under `prefers-reduced-motion`, **any state signalled by +movement must also be legible standing still**: two icons rather than one rotated, a colour change +under the pulse. `webmcp-badge.tsx`'s chevron is the reference for that pattern. + --- ## Pull requests diff --git a/traces/src/app/globals.css b/traces/src/app/globals.css index 9e7f7e9..24a3ba6 100644 --- a/traces/src/app/globals.css +++ b/traces/src/app/globals.css @@ -5,8 +5,9 @@ /* * * Deliberately almost empty. The visual direction is carried by tailwind.config.ts (palette, type - * scale) and src/app/fonts.ts (the Plex Sans / Plex Mono pair, and why); what is here is only what the - * replay mechanism needs, plus the two globals that would otherwise be rediscovered at 2am. + * scale, radius, elevation) and src/app/fonts.ts (the Plex Sans / Plex Mono pair, and why); what is + * here is only what the replay mechanism needs, plus the few globals that would otherwise be + * rediscovered at 2am. * * One thing worth deciding early rather than late: this is an operations tool, so it wants density and * quiet — small type, tight rows, one accent that means something. Two accents are already in use and @@ -17,6 +18,49 @@ color-scheme: dark; } +@layer base { + /* + * The focus ring, once, for everything. + * + * Written as a global rule rather than a `.focus-ring` class because "apply it everywhere" and "each + * of sixty interactive elements remembers a class" are different promises, and only one of them + * survives the next component. Anything focusable is covered the moment it exists, including the + * elements the replay iframe's chrome puts on the page and the ones nobody has written yet. + * + * `outline`, not Tailwind's `ring-*`: a ring's offset is painted in `--tw-ring-offset-color`, which + * has to be told which surface it is sitting on, and this app has three (`base`, `panel`, `raised`) + * stacked within a few pixels of each other. An outline's offset is simply transparent, so the same + * declaration is correct on all three. `ink` rather than an accent hue, because focus is neither a + * severity nor an authorship claim and the two colour families here both already mean something. + */ + :focus-visible { + outline: 2px solid theme('colors.ink'); + outline-offset: 1px; + } + + /* + * Interactive surfaces settle into their hover and focus colours instead of snapping. 120ms is the + * config default (see `transitionDuration` there for the reasoning); this only names the properties, + * which are colour and border and nothing else — no size, no position, no opacity on layout. + * + * Scoped to elements that respond to a pointer, so a repaint of the timeline is not a hundred + * simultaneous transitions. The `[role]` cases catch the dropdown options, which are `li`s. + */ + button, + a, + summary, + input, + select, + textarea, + [role='option'], + [role='button'] { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, + fill, stroke; + transition-duration: 120ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + } +} + /* * The Replayer builds its own iframe and sizes it from this element, so the mount must have real * dimensions before construction. A zero-height parent yields a player that exists, reports no error, diff --git a/traces/tailwind.config.ts b/traces/tailwind.config.ts index 7bae5f8..e761cf2 100644 --- a/traces/tailwind.config.ts +++ b/traces/tailwind.config.ts @@ -3,17 +3,59 @@ import type { Config } from 'tailwindcss' /** * Traces is an instrument, not a landing page. The palette is deliberately narrow and dark, and the * type scale is small — see the UI conventions in CONTRIBUTING.md before adding anything here. + * + * Three of the scales below are *replaced* rather than extended, and that is the point of them: + * + * - `borderRadius` stops at 6px, so `rounded-lg` and friends do not exist. A panel that reads as a + * marketing card is one `rounded-2xl` away, and the cheapest way to not have that argument is for + * the class to not resolve. + * - `boxShadow` holds two inset highlights and `none`. Depth here comes from surface value and a + * lighter top edge, never from a drop shadow — an instrument does not float. + * - `fontSize` is named by *role*, not by size, because the sizes are the thing that kept drifting. + * Every component used to carry its own `text-[10px]`, and a hundred of those cannot be raised + * together. Ask for `text-body` and the floor moves in one file. */ const config: Config = { content: ['./src/**/*.{ts,tsx}'], theme: { + /** + * Small for controls and chips, medium for panels, and nothing beyond. Both values are small + * enough to read as machined rather than soft, which is the whole reason to have any radius at + * all: a 3px corner says "this is a button" without saying "this is a website". + */ + borderRadius: { + none: '0', + sm: '3px', + DEFAULT: '3px', + md: '6px', + /** Dots only — a status light, a bisect probe. Never a container. */ + full: '9999px', + }, + + /** + * Elevation without shadows. A raised surface is lit along its top edge, the way a physical panel + * set into a darker chassis is, and it is paired with `border-line-strong` at the call site so the + * whole outline lifts rather than just the one edge. + */ + boxShadow: { + none: 'none', + panel: 'inset 0 1px 0 rgb(255 255 255 / 0.03)', + raised: 'inset 0 1px 0 rgb(255 255 255 / 0.05)', + }, + extend: { colors: { // Surfaces, darkest to lightest. base: '#0e1013', panel: '#15181d', raised: '#1c2027', - line: '#272c34', + + // Borders. `strong` is for a raised surface, whose outline has to lift with it — the inset + // highlight alone leaves three sides sitting at the same value as the surface below. + line: { + DEFAULT: '#272c34', + strong: '#343b45', + }, // Text. ink: '#e6e8eb', @@ -38,8 +80,29 @@ const config: Config = { mono: ['var(--font-plex-mono)', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'], }, fontSize: { - // Dense by default. - '2xs': ['0.6875rem', { lineHeight: '1rem' }], + /** + * 10px, and the only thing allowed here is a glyph: a `kbd` key cap, a timeline tick label, an + * ordinal beside a control. Anything that forms a phrase takes `label` or larger. This is the + * one size that survived the pass, and it survived because a two-character key cap set at 12px + * is wider than the word it sits beside. + */ + micro: ['0.625rem', { lineHeight: '0.875rem' }], + /** 11px. Uppercase section labels, status chips, author badges, counts. */ + label: ['0.6875rem', { lineHeight: '1rem' }], + /** 12px. Mono metadata, secondary notes, the second line of a two-line row. */ + meta: ['0.75rem', { lineHeight: '1.125rem' }], + /** 13px. Body copy, and the floor for anything that is a sentence someone has to read. */ + body: ['0.8125rem', { lineHeight: '1.25rem' }], + /** 15px. The wordmark, and a panel title that has to win against the body under it. */ + title: ['0.9375rem', { lineHeight: '1.25rem' }], + }, + transitionDuration: { + /** + * 120ms, on everything that does not say otherwise. Long enough that a hover reads as a + * response rather than a repaint, short enough that a pointer crossing four controls does not + * leave a trail behind it. Nothing in this app animates for longer. + */ + DEFAULT: '120ms', }, }, }, From b61f248e6eea2b8786161fb6758beefe75cafc9c Mon Sep 17 00:00:00 2001 From: ribdsp <113304041+ribdsp@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:03:26 +0700 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20give=20the=20chrome=20presence=20?= =?UTF-8?q?=E2=80=94=20header,=20status=20pill,=20dropdown,=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header was a 12px label and two rows of bare bordered buttons. Now: - Header carries a wordmark at the title step, the subtitle beside it, then status, recording and keys in that order. - The healthy WebMCP row is gone from the full-width banner and is a compact pill in the header instead. It portals into a slot page.tsx renders, because registration is owned by ToolSurface in the root layout — a sibling of the page, not a parent. The three degraded states keep their full-width in-flow rows, their live regions, their remediation prose and no dismiss control. - Recording selector is a real listbox: trigger with the open id in mono, panel with each sample's blurb and — for the one that is loaded — its duration, event count and viewport. Arrows, Home/End, Enter, Escape back to the trigger, click-outside, aria-expanded/listbox/option/selected. - Player: speeds are one segmented control with a raised active segment; the scrubber is a 20px hit area over a 4px track with a filled portion and a visible handle. Nothing it writes to the store changed. The scrubber's handle lives in globals.css: two vendor pseudo-elements needing the same eight declarations is unreadable as arbitrary Tailwind variants. --- traces/src/app/globals.css | 56 +++ traces/src/app/page.tsx | 43 ++- .../src/components/player/player-controls.tsx | 148 +++++--- traces/src/components/ui/recording-picker.tsx | 265 +++++++++++++-- .../src/components/ui/tool-status-banner.tsx | 320 ++++++++++++++---- 5 files changed, 665 insertions(+), 167 deletions(-) diff --git a/traces/src/app/globals.css b/traces/src/app/globals.css index 24a3ba6..6dc7ef5 100644 --- a/traces/src/app/globals.css +++ b/traces/src/app/globals.css @@ -61,6 +61,62 @@ } } +/* + * The scrubber's handle. + * + * Here rather than in `player-controls.tsx` because `::-webkit-slider-thumb` and `::-moz-range-thumb` + * are only reachable from a utility class as arbitrary variants, and the same handle needs the same + * eight declarations under two vendor prefixes — twenty-odd bracketed classes on one element, which is + * unreadable in a way that a plain rule is not. The track and the fill behind the handle *are* + * Tailwind, in the component, because those are ordinary elements. + * + * The input itself is transparent and full-height: it is the 20px hit area over a 4px track, so the + * handle can be aimed with a mouse and seen on video. Suppressing the native track is what makes the + * painted one visible underneath. + */ +.traces-scrubber { + appearance: none; + -webkit-appearance: none; + background: transparent; +} + +.traces-scrubber::-webkit-slider-runnable-track { + background: transparent; + height: 100%; +} + +.traces-scrubber::-moz-range-track { + background: transparent; + height: 100%; +} + +.traces-scrubber::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 11px; + height: 11px; + border-radius: 9999px; + background: theme('colors.ink'); + /* A ring in the surface colour, so the handle reads as sitting *on* the track rather than in it. */ + border: 2px solid theme('colors.panel'); +} + +.traces-scrubber::-moz-range-thumb { + width: 11px; + height: 11px; + border-radius: 9999px; + background: theme('colors.ink'); + border: 2px solid theme('colors.panel'); +} + +.traces-scrubber:disabled::-webkit-slider-thumb { + background: theme('colors.line.strong'); +} + +.traces-scrubber:disabled::-moz-range-thumb { + background: theme('colors.line.strong'); +} + /* * The Replayer builds its own iframe and sizes it from this element, so the mount must have real * dimensions before construction. A zero-height parent yields a player that exists, reports no error, diff --git a/traces/src/app/page.tsx b/traces/src/app/page.tsx index 69e4f38..c217745 100644 --- a/traces/src/app/page.tsx +++ b/traces/src/app/page.tsx @@ -12,6 +12,7 @@ import { ReplayStage } from '@/components/player/replay-stage' import { Timeline } from '@/components/timeline/timeline' import { RecordingPicker } from '@/components/ui/recording-picker' import { ResizableSplit } from '@/components/ui/resizable-split' +import { TOOL_STATUS_SLOT_ID } from '@/components/ui/tool-status-banner' /** * The whole app, on one screen. @@ -119,22 +120,38 @@ export default function Home() { return (
-
+
-

Traces

+ {/* + The wordmark is the one place in this app allowed to be a size larger than its neighbours. + It is not decoration: a screen recording that opens on a grey instrument with no name on it + is a recording nobody can attribute afterwards. + */} +

Traces

+ {/* Short enough to sit at 900px without truncating, and hidden below `md` rather than clipped. The sentence that used to be here — the one that explained what interrogating a replay means — moved to `StageEmptyState`, where it has room and where it is actually wanted. `truncate` stays as a guard so a future edit to this string cannot push the picker off the right edge. */} -

+

agent-interrogable session replay

- - +
+ {/* + Where the WebMCP status pill lands. `ToolStatusBanner` owns the element's name and portals + into it, because registration is held by `ToolSurface` in the root layout — a sibling of this + page rather than a parent. `display: contents` so the wrapper generates no box: an empty slot + must not leave a gap in the header before the first render, and once filled the pill should be + a flex item of this row rather than a child of a spacer. + */} +
+ + +
    {LEGEND.map((item) => (
  • @@ -194,14 +211,14 @@ function ShortcutLegend() {
    {/* Names the control rather than decorating a heading: collapsed, this is one word in a crowded header, and the glyph is what makes it findable at a glance. The word beside it is still the accessible name, so the icon stays hidden from assistive tech. */} - + keys @@ -209,7 +226,7 @@ function ShortcutLegend() { `raised` rather than a heavier border to lift the popover off the header. Drop shadows are out, so elevation here is carried by the surface token that exists for it. */} -
      +
        {LEGEND.map((item) => (
      • @@ -222,6 +239,12 @@ function ShortcutLegend() { ) } +/** 10px is the documented floor for a key cap and nothing else: `esc` set at 13px is wider than the + * word it labels, and the legend is five of them in a header that has to survive 720px. */ function LegendKey({ keys }: { keys: string }) { - return {keys} + return ( + + {keys} + + ) } diff --git a/traces/src/components/player/player-controls.tsx b/traces/src/components/player/player-controls.tsx index 1bf1d74..2a7a0f5 100644 --- a/traces/src/components/player/player-controls.tsx +++ b/traces/src/components/player/player-controls.tsx @@ -34,6 +34,22 @@ import { sessionActions, useSessionStore } from '@/lib/store/session' * - a marker when the agent moved the playhead last, derived by `useLastSeekAuthor` rather than from a * store field, and gone again as soon as the human moves it * - space toggles, arrows step 100ms, shift-arrow 1s — ignored while focus is in something typable + * + * Two notes on the appearance, since both look like decoration and are not: + * + * The scrubber is drawn as three stacked pieces — a track, a fill, and a native `input[type=range]` on + * top with its own track suppressed — rather than left as a default slider. A default range input in a + * dark UI is a 4px line with a 4px handle, which is unaimable with a mouse and invisible in a + * compressed screen recording. The input keeps the full 20px row as its hit area while the visible + * track stays 4px, and the fill behind the handle is the one cue that says *how far through this + * recording we are* without reading the clock. The handle's own vendor pseudo-elements live in + * `globals.css`, because `::-webkit-slider-thumb` cannot be reached from a utility class without a + * dozen arbitrary variants that nobody will read twice. + * + * The speeds are one segmented control rather than three separate buttons. Three equal bordered + * rectangles state that there are three options and stay silent about which one is on; a single + * enclosure with one raised segment answers "what speed is this playing at" from across a room, which + * is the question the control exists for. */ /** So Day 6's shortcut can put focus on the scrubber without threading a ref through the layout. */ @@ -71,6 +87,9 @@ export function PlayerControls() { const durationMs = recording?.durationMs ?? 0 const disabled = recording === null + /** The fill behind the handle. Clamped, because `currentTime` is not the scrubber's to bound. */ + const progress = durationMs === 0 ? 0 : Math.min(100, Math.max(0, (currentTime / durationMs) * 100)) + /** * Space and the arrows, bound at the window. * @@ -110,72 +129,103 @@ export function PlayerControls() { the alternative was hiding the duration, and a player that stops saying how long the recording is has lost something a viewer actually reads. */ -
        +
        {(currentTime / 1000).toFixed(3)}s - / {(durationMs / 1000).toFixed(3)}s + + / {(durationMs / 1000).toFixed(3)}s + {/* - `step` matches the arrow-key step so a scrubber that has focus behaves like the global shortcut - rather than like a 1ms slider nobody can aim. + Three layers, one control. The track and fill are painted here; the handle is + `.traces-scrubber` in globals.css. `min-w` keeps it from collapsing to nothing when the row wraps + at 720px, where it is the widest thing on the second line. */} - { - // A drag is the human taking over, so it stops the ticker rather than fighting it. - playback.pause() - sessionActions().setCurrentTime(Number(event.target.value), 'human') - }} - /* - `accent-ink` rather than the `human` token: both parties move this playhead — the agent's seeks - write to the same store — so the control itself must not claim an author. The `moved by AGENT` - badge at the end of the row is what says who did, and it is the only thing here that may. - */ - className="h-1 min-w-[6rem] flex-1 cursor-pointer accent-ink disabled:cursor-default disabled:accent-line" - /> - -
        - {PLAYBACK_SPEEDS.map((speed) => ( - - ))} +
        +
        +
        + {/* + `step` matches the arrow-key step so a scrubber that has focus behaves like the global shortcut + rather than like a 1ms slider nobody can aim. + */} + { + // A drag is the human taking over, so it stops the ticker rather than fighting it. + playback.pause() + sessionActions().setCurrentTime(Number(event.target.value), 'human') + }} + /* + Neutral, not `human`: both parties move this playhead — the agent's seeks write to the same + store — so the control itself must not claim an author. The `moved by AGENT` badge at the end + of the row is what says who did, and it is the only thing here that may. + */ + className="traces-scrubber relative h-5 w-full cursor-pointer disabled:cursor-default" + /> +
        + + {/* + One enclosure, three segments. `aria-pressed` per segment rather than a radiogroup: these are + toggles onto a live player, not a form value that gets submitted, and `pressed` is what a screen + reader should read back for "2× is on". + */} +
        + {PLAYBACK_SPEEDS.map((speed) => { + const active = playback.speed === speed + + return ( + + ) + })}
        {/* @@ -183,7 +233,7 @@ export function PlayerControls() { it explains something already on screen rather than announcing it — and it disappears the moment the human moves the playhead themselves. */} - + {lastSeekAuthor === 'agent' ? ( <> moved by diff --git a/traces/src/components/ui/recording-picker.tsx b/traces/src/components/ui/recording-picker.tsx index 082ab2f..14316da 100644 --- a/traces/src/components/ui/recording-picker.tsx +++ b/traces/src/components/ui/recording-picker.tsx @@ -1,7 +1,9 @@ 'use client' -import { TriangleAlert } from 'lucide-react' -import { SAMPLE_RECORDINGS } from '@/components/ui/sample-recordings' +import { Check, ChevronDown, ChevronUp, TriangleAlert } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { SAMPLE_RECORDINGS, type SampleRecording } from '@/components/ui/sample-recordings' +import { formatSeconds } from '@/components/ui/format-time' import { useSampleLoader } from '@/components/ui/use-sample-loader' import { useSessionStore } from '@/lib/store/session' @@ -15,60 +17,253 @@ import { useSessionStore } from '@/lib/store/session' * All three are ordinary, and all three have to name the file and the reason. A picker that * silently does nothing on click is the single worst thing this component could do, because * the next person debugs the player instead of the missing file. - * loaded — the button for the open recording is marked, so the header answers "which one is this". + * loaded — the trigger *is* the answer to "which one is this", so the header stops needing a legend. * * The fetch itself lives in `useSampleLoader`, shared with the empty state's one-click load. * * The labels are the file stems rather than prose, deliberately: they are the same ids the agent sees in * `read_session_meta`, so a human reading over the agent's shoulder does not have to translate. + * + * Why a dropdown rather than the three bare buttons this used to be: three toggles of equal weight said + * nothing about which was open, cost the width of all three ids in a header that has to survive 720px, + * and had nowhere to put the one thing a first-time viewer needs — *what the bug is*. A trigger plus a + * panel puts the open recording's id in the header and each sample's blurb where it can be read. + * + * Written by hand against the listbox pattern rather than pulled from a UI library, because a headless + * dropdown is roughly this much code once and a dependency forever. What the pattern requires, and what + * is therefore not optional here: `aria-expanded` on the trigger, `role="listbox"` on the panel with + * `role="option"` and `aria-selected` on each row, arrows to move, Enter to choose, and Escape to close + * *and hand focus back to the trigger* — a dropdown that closes and drops the keyboard on the body is a + * dropdown a keyboard user cannot get out of without a mouse. + * + * Duration and event count are shown for the open recording only. They are not in `SAMPLE_RECORDINGS`, + * which is a static three-line manifest, and they cannot be without either fetching every sample at + * mount to measure it or hardcoding numbers that go stale the next time a sample is re-recorded. */ +/** `aria-activedescendant` needs a stable id per row, and the panel needs to scroll one into view. */ +function optionId(id: string): string { + return `recording-option-${id}` +} + export function RecordingPicker() { - const openId = useSessionStore((s) => s.recording?.id ?? null) + const recording = useSessionStore((s) => s.recording) const { load, loadingId, error } = useSampleLoader() + const [open, setOpen] = useState(false) + /** Which row the arrows are on. Separate from the selection: moving is not choosing. */ + const [activeIndex, setActiveIndex] = useState(0) + + const triggerRef = useRef(null) + const listRef = useRef(null) + const wrapRef = useRef(null) + + const openId = recording?.id ?? null + const openIndex = SAMPLE_RECORDINGS.findIndex((sample) => sample.id === openId) + + /** Opening lands on the current recording, or the top of the list when nothing is loaded yet. */ + const show = () => { + setActiveIndex(openIndex === -1 ? 0 : openIndex) + setOpen(true) + } + + const hide = (returnFocus: boolean) => { + setOpen(false) + if (returnFocus) triggerRef.current?.focus() + } + + const choose = (sample: SampleRecording) => { + hide(true) + void load(sample) + } + + /* Focus the panel itself and drive it with `aria-activedescendant`, rather than moving DOM focus + through the options — one focus target means Escape always has somewhere to return from. */ + useEffect(() => { + if (open) listRef.current?.focus() + }, [open]) + + /** A click anywhere else closes it, and deliberately does *not* pull focus back to the trigger. */ + useEffect(() => { + if (!open) return + + const onPointerDown = (event: PointerEvent) => { + const wrap = wrapRef.current + if (wrap && event.target instanceof Node && !wrap.contains(event.target)) setOpen(false) + } + + document.addEventListener('pointerdown', onPointerDown) + return () => document.removeEventListener('pointerdown', onPointerDown) + }, [open]) + + /** Keep the arrow-selected row visible in a panel that scrolls at narrow heights. */ + useEffect(() => { + if (!open) return + const row = document.getElementById(optionId(SAMPLE_RECORDINGS[activeIndex]?.id ?? '')) + row?.scrollIntoView({ block: 'nearest' }) + }, [open, activeIndex]) + + const onListKeyDown = (event: React.KeyboardEvent) => { + const last = SAMPLE_RECORDINGS.length - 1 + + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + setActiveIndex((index) => (index >= last ? 0 : index + 1)) + return + case 'ArrowUp': + event.preventDefault() + setActiveIndex((index) => (index <= 0 ? last : index - 1)) + return + case 'Home': + event.preventDefault() + setActiveIndex(0) + return + case 'End': + event.preventDefault() + setActiveIndex(last) + return + case 'Enter': + case ' ': { + event.preventDefault() + // `noUncheckedIndexedAccess`: `activeIndex` is only ever set from this list's own bounds, but + // the compiler cannot know that and a silent no-op is the right answer if it is ever wrong. + const sample = SAMPLE_RECORDINGS[activeIndex] + if (sample) choose(sample) + return + } + case 'Escape': + event.preventDefault() + hide(true) + return + case 'Tab': + // Let focus leave normally, but do not leave an orphaned panel open behind it. + setOpen(false) + return + default: + return + } + } + + const loading = loadingId !== null + return ( -
        -
        - - recording +
        + + + {open ? ( +
          + {SAMPLE_RECORDINGS.map((sample, index) => { + const isOpen = sample.id === openId + const isActive = index === activeIndex - {SAMPLE_RECORDINGS.map((sample) => { - const isOpen = sample.id === openId - const isLoading = sample.id === loadingId - - return ( - - ) - })} -
        + return ( +
      • choose(sample)} + onMouseEnter={() => setActiveIndex(index)} + className={`cursor-pointer rounded-sm px-2 py-1.5 ${ + isActive ? 'bg-panel' : '' + }`} + > +

        + {isOpen ? ( + + ) : ( + + )} + + {sample.id} + + {isOpen ? ( + + open + + ) : null} +

        + +

        + {sample.blurb} +

        + + {/* Only the loaded recording can say how long it is — see the note at the top. The + count comes from `meta.eventCount`, which is the number `read_session_meta` hands + the agent, so a human comparing the two is comparing the same figure. */} + {isOpen && recording ? ( +

        + {formatSeconds(recording.durationMs)} + · + {recording.meta.eventCount} events + · + + {recording.meta.viewport.width}×{recording.meta.viewport.height} + +

        + ) : null} +
      • + ) + })} +
      + ) : null} {/* `max-w-full` rather than a fixed measure: at 720px a `36rem` paragraph is wider than the window, - and the one component whose job is to explain a failure must not become one. + and the one component whose job is to explain a failure must not become one. Absolutely + positioned, because the header is `shrink-0` and a wrapping error message that grew it would take + the height out of the replay panel — the frame must not move because a fetch failed. */} {error ? (

      - + {error.id} did not load: {error.message}.{' '} diff --git a/traces/src/components/ui/tool-status-banner.tsx b/traces/src/components/ui/tool-status-banner.tsx index d902baf..be8f136 100644 --- a/traces/src/components/ui/tool-status-banner.tsx +++ b/traces/src/components/ui/tool-status-banner.tsx @@ -2,6 +2,7 @@ import { TriangleAlert } from 'lucide-react' import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' import { onToolChange } from '@/lib/webmcp/tool-change' import type { RegistrationResult } from '@/lib/webmcp/register-tools' @@ -18,8 +19,8 @@ interface ToolStatusBannerProps { * * So the four states are stated plainly: * - * native — `document.modelContext` exists *and* tools registered. The real thing. Say which - * browser and that it's live. + * native — `document.modelContext` exists *and* tools registered. The real thing. This is the one + * state that does *not* get a full-width row: see the note on the pill below. * rejected — `document.modelContext` exists and `registered` is empty. See below; this is the one * state that used to lie. * polyfill — our shim. Tools are callable from the console via `window.tracesTools`, but no agent @@ -35,6 +36,21 @@ interface ToolStatusBannerProps { * `headers()` in next.config.mjs calls that "the worst available failure" and it is: every other broken * state announces itself. Branch on the count, not just the mode. * + * **The healthy state is a pill in the header, and the three degraded states are still full-width rows + * in flow.** That asymmetry is the whole design, and it is not a space saving. A green row across the + * top of a 720px window spent a line of permanent vertical budget restating something true 99% of the + * time, and it trained the eye to skip the exact strip of pixels that has to be read the other 1%. So + * healthy compresses to five words beside the wordmark; amber and red keep the row, keep their live + * region, keep their remediation prose, and cannot be collapsed or dismissed — there is no control here + * that hides them, by construction rather than by default. The pill turns amber and red too, but it is + * never the *only* thing that does. + * + * The pill reaches the header through a portal into a slot `page.tsx` renders. It has to: registration + * is owned by `ToolSurface` in the root layout, which is a sibling of the page rather than a parent, and + * the alternative — a second component in the header deriving health from `document.modelContext` + * itself — would give this app two sources of truth about whether it works, in the one place where that + * cannot be allowed to disagree. One `registration` prop, one `healthOf`, two renderings. + * * A note on colour, because two vocabularies meet near here: everything below is `ok`, `warn` or * `error` — severity. `agent` and `human` are a separate family, reserved for who authored a * contribution, and this banner never renders one. Keeping them apart is what stops a `warn` @@ -42,11 +58,51 @@ interface ToolStatusBannerProps { * * Severity is never carried by colour alone. Each degraded state leads with a word — "unavailable", * "Every tool was rejected", "Polyfill" — and the glyph beside it is `TriangleAlert` in all three, so a - * monochrome screen loses nothing that was load-bearing. + * monochrome screen loses nothing that was load-bearing. The pill carries the same word for a screen + * reader and in its tooltip. */ +/** The header slot the pill portals into. `page.tsx` renders the element; this file owns the name. */ +export const TOOL_STATUS_SLOT_ID = 'traces-tool-status' + +type Health = 'idle' | 'live' | 'warn' | 'error' + /** - * Rough browser name for the `native` line, so "it's live" is attributable to something. + * The four-way branch, once. `webmcp-badge.tsx` deliberately duplicates it rather than importing — + * see the note there — but the banner and its own pill must never diverge, so they share this. + */ +function healthOf(registration: RegistrationResult | null): Health { + if (registration === null) return 'idle' + if (registration.mode === 'unavailable') return 'error' + if (registration.mode === 'native' && registration.registered.length === 0) return 'error' + if (registration.mode === 'polyfill') return 'warn' + return 'live' +} + +/** What the dot means, in words, for a screen reader and for anyone who cannot tell the dots apart. */ +const STATE_WORD: Record = { + idle: 'registering', + live: 'live', + warn: 'polyfill only', + error: 'unavailable', +} + +const PILL: Record = { + idle: 'border-line bg-panel text-muted', + live: 'border-line bg-panel text-ink', + warn: 'border-warn/40 bg-warn/10 text-warn', + error: 'border-error/50 bg-error/10 text-error', +} + +const DOT: Record = { + idle: 'bg-faint', + live: 'bg-ok', + warn: 'bg-warn', + error: 'bg-error', +} + +/** + * Rough browser name, so "it's live" is attributable to something. * * Deliberately crude. This is a caption on a status bar, not analytics, and the alternative — * `navigator.userAgentData.brands`, itself behind availability caveats — buys nothing for a string a @@ -63,12 +119,21 @@ function browserLabel(userAgent: string): string { export function ToolStatusBanner({ registration }: ToolStatusBannerProps) { const [browser, setBrowser] = useState('') const [changes, setChanges] = useState(0) + const [slot, setSlot] = useState(null) /** Prerendered by `next build`, so `navigator` is read after mount or hydration disagrees. */ useEffect(() => { setBrowser(browserLabel(navigator.userAgent)) }, []) + /** + * The header slot, found once after the first commit. Effects run after the whole tree is in the DOM, + * so the element `page.tsx` renders exists by now even though this component is mounted above it. + */ + useEffect(() => { + setSlot(document.getElementById(TOOL_STATUS_SLOT_ID)) + }, []) + /** * `toolchange` is how a surface that grew a tool mid-investigation shows up here without a reload — * the promoted-hypothesis tool from `registerDynamicTool` is the case worth demoing. The event fires @@ -94,39 +159,146 @@ export function ToolStatusBanner({ registration }: ToolStatusBannerProps) { return onToolChange(context, bump) }, [registration]) - if (!registration) return null - - const count = registration.registered.length + const health = healthOf(registration) + const count = registration?.registered.length ?? 0 const countLabel = `${count} ${count === 1 ? 'tool' : 'tools'}` const changeLabel = changes > 0 ? `+${changes} since load` : null + const pill = + slot === null + ? null + : createPortal( + , + slot, + ) + + /* + * The pill renders in every state including the degraded ones, and the row below renders alongside it + * rather than instead of it. Two signals for one fact is the point: the header is where the eye + * already is, and the row is what cannot be missed. + */ + return ( + <> + {pill} + {registration === null ? null : ( + + )} + + ) +} + +interface StatusPillProps { + health: Health + count: number + /** `toolchange` events seen since load, shown as `+n` beside the count when the host emits any. */ + changes: number + browser: string + ready: boolean +} + +/** + * Five words in the header: a state dot, the surface's name, and how many tools are on it. + * + * `role="status"` rather than nothing, because this inherited the healthy row's live region along with + * its job — a surface that registers late, or grows a tool mid-session, should still be announced. + * `aria-live` politeness is the default for `status`, which is right for a count that changes on its own. + */ +function StatusPill({ health, count, changes, browser, ready }: StatusPillProps) { + const where = browser ? ` in ${browser}` : '' + const title = !ready + ? 'Registering the WebMCP tool surface…' + : health === 'live' + ? `WebMCP live — ${count} tools registered with document.modelContext${where} and callable by a connected agent.` + : health === 'warn' + ? `${count} tools registered against the local development shim, not the browser's own WebMCP. No external agent can see them.` + : 'No tools are agent-callable. The banner below the header says why.' + + return ( +

      + {health === 'warn' || health === 'error' ? ( + + ) : ( + + )} + WebMCP + + + {ready ? count : '–'} + {changes > 0 ? ( + + {' '} + +{changes} + + ) : null} + + {/* The dot is decoration to a screen reader; this is the state it stands for. */} + — {STATE_WORD[health]} +
      + ) +} + +interface DegradedRowProps { + registration: RegistrationResult + health: Health + browser: string + countLabel: string + changeLabel: string | null +} + +/** + * The full-width row, for the three states that have something to explain. + * + * Shared shell, per-state contents. The shell is what carries the guarantees — in flow, `shrink-0`, a + * live region, a leading glyph, and no close button anywhere in it. + */ +function DegradedRow({ + registration, + health, + browser, + countLabel, + changeLabel, +}: DegradedRowProps) { + if (health === 'live' || health === 'idle') return null + if (registration.mode === 'unavailable') { return ( -
      - - WebMCP unavailable - 0 tools registered + + WebMCP unavailable + 0 tools registered - Nothing on this page is agent-callable. Likely cause: the Origin-Trial header is + Nothing on this page is agent-callable. Likely cause: the Origin-Trial header is missing or expired. - Set NEXT_PUBLIC_WEBMCP_ORIGIN_TRIAL_TOKEN in .env.local and restart — + Set NEXT_PUBLIC_WEBMCP_ORIGIN_TRIAL_TOKEN in .env.local and restart — README, “Getting WebMCP in your browser”. Needs Chrome 149+ or Edge 150+ and a{' '} token for this origin . -
      + ) } @@ -135,75 +307,77 @@ export function ToolStatusBanner({ registration }: ToolStatusBannerProps) { * the isolation header, and the cached response that still lacks it — a hard reload is the fix people * do not think to try, because the page it produces looks identical. */ - if (registration.mode === 'native' && count === 0) { + if (registration.mode === 'native') { return ( -
      - - Every tool was rejected - 0 tools registered + + Every tool was rejected + 0 tools registered - document.modelContext exists{browser && ` in ${browser}`}, so this looks healthy - and is not: every registerTool call threw and nothing here is agent-callable. + document.modelContext exists{browser && ` in ${browser}`}, so this looks healthy + and is not: every registerTool call threw and nothing here is agent-callable. WebMCP refuses to register unless the document is origin-isolated. Check that the response - carries Origin-Agent-Cluster: ?1 — and hard-reload, because a response cached from + carries Origin-Agent-Cluster: ?1 — and hard-reload, because a response cached from before that header was added produces exactly this. The console has one{' '} - host rejected tool warning per tool with the reason. + host rejected tool warning per tool with the reason. -
      - ) - } - - if (registration.mode === 'polyfill') { - return ( -
      - - Polyfill - - {countLabel} - {changeLabel ? ` · ${changeLabel}` : ''} - - - Callable by hand as window.tracesTools. No agent can see them, so a run recorded - against the polyfill is a rehearsal, not a demo. - -
      + ) } return ( -
      - - WebMCP live - - · - - {countLabel} - {changeLabel ? ( - - {changeLabel} - - ) : null} - - · + + Polyfill + + {countLabel} + {changeLabel ? ` · ${changeLabel}` : ''} - document.modelContext - {browser && ` in ${browser}`} + Callable by hand as window.tracesTools. No agent can see them, so a run recorded + against the polyfill is a rehearsal, not a demo. + + ) +} + +/** + * `role` is chosen by tone, and both are live regions: `alert` interrupts, `status` waits its turn. A + * surface that is entirely dead is worth interrupting for; one running on the shim is not. + */ +function Row({ tone, children }: { tone: 'warn' | 'error'; children: React.ReactNode }) { + return ( +
      + + {children}
      ) } + +/** The state, as a word, in a chip — so the row reads as a labelled state and not as a sentence. */ +function Tag({ tone, children }: { tone: 'warn' | 'error'; children: React.ReactNode }) { + return ( + + {children} + + ) +} + +/** Header names and env vars, in mono, at a size that survives being video. */ +function Code({ children }: { children: React.ReactNode }) { + return {children} +} From ab91ad893a12c1737b9538e6bb92844933489793 Mon Sep 17 00:00:00 2001 From: ribdsp <113304041+ribdsp@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:26:54 +0700 Subject: [PATCH 3/8] feat: rebuild the agent column as an instrument surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every section in the right-hand column now says what it is at a glance, and what state it is in without being read word by word. section-heading.tsx now takes the icon *component* rather than a node, so size, stroke and tint are decided once. Shape says which section you are looking at; tint says the rank. That overrides the docstring's old argument for a uniform marker, which was written when every section shared one glyph. agent-lane.tsx is a task queue rather than a chat box, and the copy names the mechanism: claim_next_task blocks, so what you type is what the call returns. The open -> claimed flip is carried by four signals at once — the status word, its chip, the rail and the row tint — because it happens in under a second. AGENT_LANE_INPUT_ID is unchanged. activity-feed.tsx grows a 2px rail per row in its author's colour. The badge stays: nothing here may depend on telling violet from blue. Scroll anchoring and per-entry undo are untouched. hypothesis-cards.tsx and report-draft.tsx take the table treatment — mono tabular-nums in fixed right-aligned columns, quiet zebra ground rather than a hairline per row, real chips, evidence that looks clickable. report-draft.tsx is a literal table with a sticky header; hypothesis-cards.tsx is not, and that is measured: in a 380px panel its three fixed columns leave a sentence about fifteen characters a line. Report steps now carry a chip either way, ok for verified and warn for unverified. An absent chip reads as "no opinion", which is exactly what a report must not imply about a step the agent inferred. --- traces/src/components/agent/activity-feed.tsx | 31 ++- traces/src/components/agent/agent-lane.tsx | 96 +++++++-- .../agent/ask-human-visual-prompt.tsx | 30 ++- .../src/components/agent/hypothesis-cards.tsx | 103 +++++++--- traces/src/components/agent/report-draft.tsx | 184 +++++++++++++----- traces/src/components/ui/author-badge.tsx | 6 +- traces/src/components/ui/section-heading.tsx | 50 ++++- 7 files changed, 368 insertions(+), 132 deletions(-) diff --git a/traces/src/components/agent/activity-feed.tsx b/traces/src/components/agent/activity-feed.tsx index d1226d0..2ed2851 100644 --- a/traces/src/components/agent/activity-feed.tsx +++ b/traces/src/components/agent/activity-feed.tsx @@ -1,11 +1,12 @@ 'use client' +import { History } from 'lucide-react' import { useEffect, useRef } from 'react' import { AuthorBadge } from '@/components/ui/author-badge' import { formatAgo, useWallClock } from '@/components/ui/use-clock' import { SectionHeading } from '@/components/ui/section-heading' import { sessionActions, useSessionStore } from '@/lib/store/session' -import type { ActivityEntry } from '@/types/domain' +import type { ActivityEntry, Author } from '@/types/domain' /** * A running account of who did what. @@ -29,8 +30,18 @@ import type { ActivityEntry } from '@/types/domain' * for: the human can revert the agent, and the agent cannot revert the human. * - an empty state that says what will appear here. "No activity" describes the widget; this describes the * mechanism, and the mechanism is the thing being demonstrated. + * - a 2px rail down the left of every row in its author's colour. This is the surface that proves two + * parties are working on one session, and a reader should be able to see the interleaving from across + * the room without reading a word of it. The badge stays: the rail is the pattern, the word is the fact, + * and nothing here may depend on telling violet from blue. */ +/** Authorship, as an edge. The same two colours as the badge, which is the only other place they mean this. */ +const RAILS: Record = { + human: 'border-human', + agent: 'border-agent', +} + /** Within this many pixels of the top counts as "reading the newest", so the list keeps following. */ const AT_TOP_PX = 8 @@ -65,9 +76,11 @@ export function ActivityFeed() { of ground does: everything above it is open work, everything on this surface already happened. */
      - + {activity.length > 0 ? ( - {activity.length} + + {activity.length} + ) : null} @@ -89,8 +102,10 @@ export function ActivityFeed() { function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) { return ( -
    • - {entry.description} +
    • + {entry.description} @@ -99,7 +114,7 @@ function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) { the build could not have made. */} {now === null ? '' : formatAgo(entry.at, now)} @@ -110,7 +125,7 @@ function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) { type="button" onClick={() => sessionActions().undo(entry.id)} title="Undo exactly this contribution. Everything else the agent did stays." - className="text-[10px] uppercase tracking-wide text-muted underline decoration-dotted hover:text-ink focus-visible:bg-raised focus-visible:text-ink focus-visible:outline-none" + className="rounded-sm text-label uppercase tracking-wide text-muted underline decoration-dotted hover:text-ink" > undo @@ -122,7 +137,7 @@ function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) { function EmptyFeed() { return ( -

      +

      Every action lands here as it happens, labelled with who took it — the agent seeking, bisecting and annotating, and you marking, rejecting and answering. Anything the agent did can be undone from its own line. diff --git a/traces/src/components/agent/agent-lane.tsx b/traces/src/components/agent/agent-lane.tsx index 1f2f6dc..31ce4bd 100644 --- a/traces/src/components/agent/agent-lane.tsx +++ b/traces/src/components/agent/agent-lane.tsx @@ -1,5 +1,6 @@ 'use client' +import { ListTodo } from 'lucide-react' import { useState } from 'react' import { AuthorBadge } from '@/components/ui/author-badge' import { formatAgo, useWallClock } from '@/components/ui/use-clock' @@ -21,9 +22,17 @@ import type { Task, TaskStatus } from '@/types/domain' * with no click in between, is the clearest demonstration in the whole app that something else is * genuinely reading this page. * + * It is labelled a queue rather than a lane, and the copy is written to say so. "Agent lane" over a + * textarea reads as a chat box, and a viewer who reads it that way is waiting for a reply that will never + * come: nothing here answers, because the other end of this box is a *blocking call*. Naming the mechanism + * costs two sentences in the empty state and is the difference between a demo that looks broken and one + * that looks inverted on purpose. + * * What shipped, and why: * - three treatments, because the flip between them is the demonstration: `open` waiting, `claimed` - * with a live "working" indication and how long it has been held, `done` struck through + * with a live "working" indication and how long it has been held, `done` struck through. The flip is + * carried by four things at once — the status word, the chip behind it, the rail on the left and the + * row's own tint — because it happens in well under a second and a viewer gets one chance to see it. * - Enter submits and Shift+Enter starts a newline, which needs a textarea rather than an input * - example tasks while the lane is empty. They fill the box rather than submitting, because the thing * worth teaching is that this is where you talk to the agent — not that clicking here queues work. @@ -35,6 +44,7 @@ import type { Task, TaskStatus } from '@/types/domain' * One thing the lane cannot show: an agent already blocked inside `claim_next_task` with nothing to claim. * The gate is held in `lib/webmcp`, `SessionState` is frozen, and nothing observable says a caller is * waiting — so the lane says what happens when a task arrives instead of claiming to know who is listening. + * Every line of copy below is written to that limit: it describes the call, never a caller. */ /** So Day 6's shortcut can put focus here without threading a ref through the layout. */ @@ -52,10 +62,28 @@ const EXAMPLES = [ 'Explain why the address form rejected a valid postcode', ] -const TREATMENTS: Record = { - open: { row: 'border-line', label: 'text-muted', text: 'text-ink' }, - claimed: { row: 'border-warn/50', label: 'text-warn', text: 'text-ink' }, - done: { row: 'border-line', label: 'text-faint', text: 'text-muted line-through' }, +/** + * The three states, as four simultaneous signals each. + * + * `rail` is the 2px edge, `chip` the status word's own background, `row` the surface behind the whole + * line, `text` the task itself. `claimed` is the only one that tints the row, because it is the only one + * that is *happening* — and `warn` is the right family for it: something is holding the line and the + * viewer should look. It is not an error, which is why nothing here reaches for `error`. + */ +const TREATMENTS: Record = { + open: { rail: 'border-line-strong', chip: 'bg-raised text-muted', row: '', text: 'text-ink' }, + claimed: { + rail: 'border-warn', + chip: 'bg-warn/20 text-warn', + row: 'bg-warn/5', + text: 'text-ink', + }, + done: { + rail: 'border-line', + chip: 'bg-panel text-faint', + row: '', + text: 'text-muted line-through', + }, } export function AgentLane() { @@ -70,9 +98,24 @@ export function AgentLane() { setDraft('') } + const claimed = tasks.filter((task) => task.status === 'claimed').length + const open = tasks.filter((task) => task.status === 'open').length + return (

      - + + {/* + A count, not a status: it says how deep the queue is, which is the one number a queue is read + for. Mono and tabular so a task flipping from open to claimed does not shuffle the digits. + */} + {tasks.length > 0 ? ( + + {claimed > 0 ? {claimed} claimed : null} + {claimed > 0 && open > 0 ? · : null} + {open > 0 ? `${open} open` : null} + + ) : null} + {tasks.length === 0 ? ( @@ -95,9 +138,9 @@ export function AgentLane() { submit() }} rows={2} - placeholder="Hand the agent a task — Enter to send, Shift+Enter for a newline" - aria-label="Hand the agent a task" - className="w-full resize-none border border-line bg-base px-2 py-1 text-xs leading-relaxed text-ink placeholder:text-faint focus:border-ink focus:outline-none" + placeholder="Add a task to the queue — Enter to queue it, Shift+Enter for a newline" + aria-label="Add a task to the queue" + className="w-full resize-none rounded-sm border border-line-strong bg-base px-2 py-1 text-body leading-relaxed text-ink placeholder:text-faint hover:border-faint" />
      ) @@ -107,23 +150,29 @@ function TaskRow({ task, now }: { task: Task; now: number | null }) { const treatment = TREATMENTS[task.status] return ( -
    • - {task.status} +
    • + + {task.status} + {/* The working indication. `animate-pulse` rather than a spinner because globals.css zeroes animation duration under prefers-reduced-motion, which turns this into a static dot instead of removing it. */} {task.status === 'claimed' ? ( - + ) : null} - {task.text} + {task.text} {task.status === 'claimed' && task.claimedAt !== undefined && now !== null ? ( - - working, claimed {formatAgo(task.claimedAt, now)} + + held {formatAgo(task.claimedAt, now)} ) : null}
    • @@ -132,19 +181,24 @@ function TaskRow({ task, now }: { task: Task; now: number | null }) { function EmptyLane({ onPick }: { onPick: (text: string) => void }) { return ( -
      -

      - Nothing queued. An agent calling claim_next_task waits here - until you add something, then takes it without being asked twice. +

      +

      + Nothing queued — and nothing is polling for it either. An agent takes work from here by calling{' '} + claim_next_task, which{' '} + blocks instead of returning empty: whatever you type below is + what that call returns, at the moment you press Enter.

      -
        +

        Something worth handing over:

        + +
          {EXAMPLES.map((example) => (
        • diff --git a/traces/src/components/agent/ask-human-visual-prompt.tsx b/traces/src/components/agent/ask-human-visual-prompt.tsx index b18568c..79bb68c 100644 --- a/traces/src/components/agent/ask-human-visual-prompt.tsx +++ b/traces/src/components/agent/ask-human-visual-prompt.tsx @@ -96,24 +96,19 @@ export function AskHumanVisualPrompt() { return (
          {/* - The one glyph in the column, on the one section that can stop the agent. `Eye` rather than a warning - triangle: nothing is broken — the agent has hit a judgement a person has to make by looking, which is - also what the answer consists of. `MarkPointOverlay` carries the same glyph on the player, so the two - halves of this one interaction are recognisable as each other. + `Eye` rather than a warning triangle: nothing is broken — the agent has hit a judgement a person has + to make by looking, which is also what the answer consists of. `MarkPointOverlay` carries the same + glyph on the player, so the two halves of this one interaction are recognisable as each other. */} - } - > - + + waiting {Math.round(waitedMs / 1000)}s -

          {pendingAsk.question}

          +

          {pendingAsk.question}

          -

          +

          Answer on the player: put the playhead on the moment you mean, then pick one of the options over the replay. {pendingAsk.hintAtMs !== undefined @@ -122,7 +117,7 @@ export function AskHumanVisualPrompt() {

          {timedOut ? ( -

          +

          The agent’s call has already returned — it waited {Math.round(GATE_TIMEOUT_MS / 1000)}s and got a ticket back, so it is retrying rather than sitting still. Your answer still reaches it.

          @@ -142,21 +137,22 @@ export function AskHumanVisualPrompt() { -

          {resolved.question}

          +

          {resolved.question}

          -

          +

          {resolved.outcome}

          {resolved.answered ? ( -

          +

          The moment you marked is now a marker on the timeline, and the agent has the timestamp.

          ) : ( -

          +

          The agent was told you skipped it, rather than being left waiting.

          )} diff --git a/traces/src/components/agent/hypothesis-cards.tsx b/traces/src/components/agent/hypothesis-cards.tsx index f533ca9..bee21f6 100644 --- a/traces/src/components/agent/hypothesis-cards.tsx +++ b/traces/src/components/agent/hypothesis-cards.tsx @@ -1,6 +1,6 @@ 'use client' -import { Check, X } from 'lucide-react' +import { Check, FlaskConical, X } from 'lucide-react' import { AuthorBadge } from '@/components/ui/author-badge' import { formatSeconds } from '@/components/ui/format-time' import { SectionHeading } from '@/components/ui/section-heading' @@ -32,16 +32,42 @@ import type { Hypothesis, HypothesisStatus } from '@/types/domain' * - promoted cards stay promoted and keep the human's accent; rejected ones fade and strike through rather * than vanishing, so what was considered and set aside is still readable * - evidence chips seek, and carry their `note` — a bare timestamp is not evidence of anything + * - each card sits on its own ground rather than inside a hairline box, and the rank digit is right-aligned + * in a fixed column so five claims start on the same pixel. Both are the table treatment that survives + * this content; see below for the part that does not. + * + * Not a ``, and that is measured rather than preferred. The columns would be rank, claim, confidence + * and verdict; in a 380px panel the three fixed ones cost about 220px between them, which leaves the claim — + * a sentence — roughly fifteen characters a line. A grid whose one prose column is a chimney is less legible + * than the stack, not more, so the numeric and striping conventions carry over and the element does not. + * `report-draft.tsx` is a real table, because its rows genuinely are short and its columns genuinely align. * * Not implemented, deliberately: clicking a card to highlight *all* of its evidence on the timeline at once. * That needs a piece of cross-component state ("which hypothesis is selected") that `SessionState` has no * slot for and that is frozen. Each chip seeks on its own, which costs one click per point instead of one. */ -const TREATMENTS: Record = { - proposed: { card: 'border-line', text: 'text-ink', tag: null }, - promoted: { card: 'border-human/50 bg-human/5', text: 'text-ink', tag: 'promoted' }, - rejected: { card: 'border-panel opacity-50', text: 'text-muted line-through', tag: 'rejected' }, +/** + * `bg` is the card's ground. A surface rather than a border, because five hairline boxes stacked in a narrow + * column read as a fence and the thing worth seeing is which one the human has already ruled on. + */ +const TREATMENTS: Record< + HypothesisStatus, + { card: string; text: string; tag: string | null; chip: string } +> = { + proposed: { card: 'border-line bg-panel/40', text: 'text-ink', tag: null, chip: '' }, + promoted: { + card: 'border-human/50 bg-human/5', + text: 'text-ink', + tag: 'promoted', + chip: 'bg-human/15 text-human', + }, + rejected: { + card: 'border-line bg-panel/20 opacity-60', + text: 'text-muted line-through', + tag: 'rejected', + chip: 'bg-raised text-muted', + }, } export function HypothesisCards() { @@ -55,9 +81,9 @@ export function HypothesisCards() { return (
          - + {undecided > 0 ? ( - + {undecided === hypotheses.length ? 'the agent is waiting on your call' : `${undecided} still undecided`} @@ -65,7 +91,7 @@ export function HypothesisCards() { ) : null} -
            +
              {hypotheses.map((hypothesis, index) => ( ))} @@ -82,17 +108,21 @@ function HypothesisCard({ hypothesis, position }: { hypothesis: Hypothesis; posi const confidence = Math.min(Math.max(hypothesis.confidence, 0), 1) return ( -
            • +
            • - {position} -

              {hypothesis.text}

              + {/* Right-aligned in a fixed column: the rank is a number in a list of numbers, and 10 must not + push the tenth claim a character further in than the first nine. */} + + {position} + +

              {hypothesis.text}

              -
              - confidence +
              + confidence {/* @@ -101,21 +131,25 @@ function HypothesisCard({ hypothesis, position }: { hypothesis: Hypothesis; posi */} {treatment.tag ? ( /* - The verdict, as a glyph and a word. Both tags were `muted` and the same size, so telling a promoted - card from a rejected one down a stack of five meant reading two words that share four letters. The - glyph is the status; `tag` is non-null for exactly the two decided states, so the pair is complete. + The verdict, as a glyph and a word on its own ground. Both tags used to be `muted` at the same + size, so telling a promoted card from a rejected one down a stack of five meant reading two words + that share four letters. `promoted` takes `human` because a person is what promoted it — the same + authorship claim the card's border already makes — and `rejected` stays neutral, because a set-aside + explanation is not a failure and `error` would say it was. */ - + {hypothesis.status === 'promoted' ? ( - + ) : ( - + )} {treatment.tag} @@ -123,44 +157,51 @@ function HypothesisCard({ hypothesis, position }: { hypothesis: Hypothesis; posi
              {hypothesis.evidence.length > 0 ? ( -
                +
                  {hypothesis.evidence.map((item, itemIndex) => (
                • + {/* + A raised chip with a lighter border, which is what everything else clickable in this app + looks like. It used to be a hairline box on the card's own ground — indistinguishable from + the label beside it, and the one control here whose whole purpose is to invite a click. + */}
                • ))}
                ) : ( -

                +

                No evidence attached — nothing on the timeline backs this one up yet.

                )} -
                +
                sessionActions().promoteHypothesis(hypothesis.id, 'human')} /> sessionActions().rejectHypothesis(hypothesis.id, 'human')} />
                @@ -193,10 +234,10 @@ function Verdict({ ? `Change the record to ${label}d. The agent already has your first answer — this does not ask it again.` : `Mark this ${label}d. This is what the agent's call is waiting for.` } - className={`border px-1.5 py-0.5 text-[10px] uppercase tracking-wide focus-visible:border-ink focus-visible:outline-none ${ + className={`rounded-sm border px-2 py-0.5 text-label font-medium uppercase tracking-wide ${ active ? `${activeClass} cursor-default` - : 'border-line text-muted hover:border-faint hover:text-ink' + : 'border-line-strong bg-raised text-muted shadow-raised hover:border-faint hover:text-ink' }`} > {label} diff --git a/traces/src/components/agent/report-draft.tsx b/traces/src/components/agent/report-draft.tsx index 54b3242..7fa4530 100644 --- a/traces/src/components/agent/report-draft.tsx +++ b/traces/src/components/agent/report-draft.tsx @@ -1,6 +1,6 @@ 'use client' -import { Check, Copy, TriangleAlert } from 'lucide-react' +import { Check, Copy, FileText, TriangleAlert } from 'lucide-react' import { useEffect, useState } from 'react' import { AuthorBadge } from '@/components/ui/author-badge' import { formatSeconds } from '@/components/ui/format-time' @@ -27,6 +27,14 @@ import type { Recording, Report } from '@/types/domain' * - every field of `Report`, with each timestamp a seek * - unverified steps say the word and explain it in a sentence, once, under the list. Colour alone would * leave the distinction to whoever noticed the amber, and it is the most important distinction here. + * - the steps and the evidence are real tables, with a header row that stays put while the column scrolls + * and the times right-aligned in mono. That is not styling for its own sake: a reproduction is checked by + * reading down the *times* rather than across the prose, and a column of `tabular-nums` is the only way + * ten of them line up. The `check` column exists because the verified/unverified split is the report's + * most important claim about itself, and a column is what makes an exception visible at a glance. + * - every step now carries a chip either way — `ok` for verified, `warn` for unverified. An absent chip is + * not a statement; a reader cannot tell "supported by a recorded event" from "nobody has looked". This is + * the same rule as above, stated in the other direction. * - copy as Markdown, which is what actually happens to a report — it gets pasted into a tracker * - title and summary editable in place, and **held locally until approval**. That is not a stylistic * choice: `propose_report` watches the store and treats *any* human-authored `setReport` as an approval @@ -105,14 +113,16 @@ export function ReportDraft() { return (
                - + {awaitingAgent ? ( - + waiting on your decision ) : decision !== null ? ( - {decision.kind} + + {decision.kind} + ) : null} @@ -124,7 +134,7 @@ export function ReportDraft() { value={title} onChange={(event) => setTitle(event.target.value)} aria-label="Report title" - className="w-full border border-transparent bg-transparent text-xs font-medium text-ink hover:border-line focus:border-ink focus:outline-none" + className="w-full rounded-sm border border-transparent bg-transparent text-body font-medium text-ink hover:border-line focus:border-ink" />