diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a91114..110ab3c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -154,6 +154,56 @@ 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. + +Under `prefers-reduced-motion` `globals.css` caps `animation-iteration-count` at 1, which lands every +`animate-pulse` on its last keyframe — `opacity: 1`, a solid dot. Note *which* declaration does that +work: zeroing `animation-duration` alone, which is the usual reset and was what this file used to +claim, does not stop an `infinite` animation at all — it runs the same cycle a hundred thousand times a +second. So **any state signalled by movement must also be legible standing still**: two icons rather +than one rotated, a word beside the dot, a colour that stays. `webmcp-badge.tsx`'s chevron is the +reference for the pattern, and the dot may stop moving but must never disappear — in all four places it +is the thing saying something is still happening. + --- ## Pull requests diff --git a/traces/src/app/globals.css b/traces/src/app/globals.css index 9e7f7e9..443f578 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,105 @@ 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 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, @@ -40,11 +140,24 @@ color-scheme: light; } +/* + * Reduced motion, and `animation-iteration-count` is the load-bearing line rather than the duration. + * + * Zeroing the duration alone does not stop an `infinite` animation — it runs the same keyframes a hundred + * thousand times a second, which is not "no motion", it is the same motion with the frames dropped. Every + * animated thing in this app is an `animate-pulse` dot standing in for a live state (a gate held open, a + * task being worked, a recording loading), so capping the count at one is what makes each of them settle on + * its own last keyframe — `opacity: 1`, a solid dot — instead of sampling a cycle nobody asked to see. + * + * That is the contract those components document and rely on: the dot may stop moving, but it must not + * disappear, because in each case it is the thing saying something is still happening. + */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } 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/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..1e69db2 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,31 @@ 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. + The working indication. `animate-pulse` rather than a spinner because reduced motion caps this at one + iteration (see globals.css), which settles it on `opacity: 1` — a static dot rather than nothing. A + rotating glyph frozen at 0deg would say "idle" instead, and the row's amber rail and `claimed` chip + are what carry the state when the motion is gone. */} {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 +183,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..7dc7acd 100644 --- a/traces/src/components/agent/ask-human-visual-prompt.tsx +++ b/traces/src/components/agent/ask-human-visual-prompt.tsx @@ -32,6 +32,9 @@ import { useSessionStore } from '@/lib/store/session' * consumed by the tool, and `SessionState` has nowhere to keep either — so the question is held here * while it is open and paired with the store's own outcome line once it closes. Erasing it would erase * the most distinctive thing in the app at the moment it finally happened. + * - a pulsing dot beside the elapsed count, and it is one of only four animated things in the app. This + * is the single moment where a *person* is the blocking dependency of a running program, and a still + * card does not read as held open. See the note at the dot for how it degrades. */ /** The store's wording for the two outcomes, from `answerAsk` and `clearAsk`. */ @@ -96,24 +99,27 @@ 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. */} - } - > - + + + {/* + `animate-pulse` rather than a spinner, for the reason `webmcp-badge.tsx` uses two chevrons: + `globals.css` zeroes animation duration under `prefers-reduced-motion`, which lands opacity on + 1 and leaves a solid amber dot. A rotating glyph would stop dead and read as a hang. Nothing + here depends on the motion either way — the word "waiting" and a count that climbs every + second already say it, and the dot is the part that catches an eye that was elsewhere. + */} + 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 +128,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 +148,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" />