Skip to content
50 changes: 50 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 115 additions & 2 deletions traces/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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;
}
}
43 changes: 33 additions & 10 deletions traces/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -119,22 +120,38 @@ export default function Home() {

return (
<main className="flex min-h-0 flex-1 flex-col">
<header className="relative flex shrink-0 items-start justify-between gap-3 border-b border-line px-3 py-1.5">
<header className="relative flex shrink-0 items-center justify-between gap-3 border-b border-line bg-panel px-3 py-2">
<div className="flex min-w-0 items-baseline gap-2">
<h1 className="shrink-0 text-xs font-medium tracking-tight text-ink">Traces</h1>
{/*
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.
*/}
<h1 className="shrink-0 text-title font-semibold tracking-tight text-ink">Traces</h1>
<span aria-hidden className="hidden h-3 w-px shrink-0 self-center bg-line-strong md:block" />
{/*
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.
*/}
<p className="hidden truncate text-[11px] text-muted md:block">
<p className="hidden truncate text-meta text-muted md:block">
agent-interrogable session replay
</p>
</div>

<ShortcutLegend />
<RecordingPicker />
<div className="flex min-w-0 items-center gap-2">
{/*
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.
*/}
<div id={TOOL_STATUS_SLOT_ID} className="contents" />
<RecordingPicker />
<ShortcutLegend />
</div>
</header>

<ResizableSplit
Expand Down Expand Up @@ -181,7 +198,7 @@ function ShortcutLegend() {
<>
<ul
title={LEGEND_TITLE}
className="hidden shrink-0 items-center gap-2 pt-0.5 text-[10px] text-faint lg:flex"
className="hidden shrink-0 items-center gap-2 text-label text-faint lg:flex"
>
{LEGEND.map((item) => (
<li key={item.keys} className="flex items-center gap-1">
Expand All @@ -194,22 +211,22 @@ function ShortcutLegend() {
<details className="relative shrink-0 lg:hidden">
<summary
title={LEGEND_TITLE}
className="flex cursor-pointer list-none items-center gap-1 border border-line px-1 py-0.5 text-[10px] text-muted marker:content-none hover:border-faint hover:text-ink focus-visible:border-ink focus-visible:text-ink focus-visible:outline-none [&::-webkit-details-marker]:hidden"
className="flex cursor-pointer list-none items-center gap-1 rounded-sm border border-line-strong bg-raised px-1.5 py-0.5 text-label text-muted shadow-raised marker:content-none hover:border-faint hover:text-ink [&::-webkit-details-marker]:hidden"
>
{/*
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.
*/}
<Keyboard aria-hidden size={12} strokeWidth={1.5} />
<Keyboard aria-hidden size={13} strokeWidth={1.75} />
keys
</summary>

{/*
`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.
*/}
<ul className="absolute right-0 top-[calc(100%+3px)] z-20 w-max space-y-1 border border-line bg-raised px-2 py-1.5 text-[10px] text-muted">
<ul className="absolute right-0 top-[calc(100%+4px)] z-20 w-max space-y-1 rounded-md border border-line-strong bg-raised px-2 py-1.5 text-label text-muted shadow-raised">
{LEGEND.map((item) => (
<li key={item.keys} className="flex items-center gap-1.5">
<LegendKey keys={item.keys} />
Expand All @@ -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 <kbd className="border border-line px-1 font-mono text-[9px] text-muted">{keys}</kbd>
return (
<kbd className="rounded-sm border border-line-strong bg-base px-1 font-mono text-micro text-muted">
{keys}
</kbd>
)
}
31 changes: 23 additions & 8 deletions traces/src/components/agent/activity-feed.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<Author, string> = {
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

Expand Down Expand Up @@ -65,9 +76,11 @@ export function ActivityFeed() {
of ground does: everything above it is open work, everything on this surface already happened.
*/
<section className="flex min-h-[8rem] flex-1 flex-col bg-panel p-3">
<SectionHeading rank="record" label="Activity">
<SectionHeading rank="record" label="Activity" icon={History}>
{activity.length > 0 ? (
<span className="ml-auto font-mono text-[10px] text-faint">{activity.length}</span>
<span className="ml-auto font-mono text-label tabular-nums text-faint">
{activity.length}
</span>
) : null}
</SectionHeading>

Expand All @@ -89,8 +102,10 @@ export function ActivityFeed() {

function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) {
return (
<li className="flex items-baseline gap-1 text-xs leading-relaxed text-muted">
<span className="min-w-0">{entry.description}</span>
<li
className={`flex items-baseline gap-1 border-l-2 pl-1.5 text-body leading-relaxed ${RAILS[entry.author]}`}
>
<span className="min-w-0 text-ink">{entry.description}</span>
<AuthorBadge author={entry.author} />

<span className="ml-auto flex shrink-0 items-baseline gap-1.5 pl-1">
Expand All @@ -99,7 +114,7 @@ function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) {
the build could not have made.
*/}
<span
className="font-mono text-[10px] text-faint"
className="font-mono text-label tabular-nums text-faint"
title={new Date(entry.at).toLocaleTimeString()}
>
{now === null ? '' : formatAgo(entry.at, now)}
Expand All @@ -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
</button>
Expand All @@ -122,7 +137,7 @@ function FeedRow({ entry, now }: { entry: ActivityEntry; now: number | null }) {

function EmptyFeed() {
return (
<p className="text-[11px] leading-relaxed text-faint">
<p className="text-meta leading-relaxed text-faint">
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.
Expand Down
Loading
Loading