feat: instrument-grade visual pass across the presentation layer - #12
Merged
Merged
Conversation
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.
…port 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.
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.
The hero surface, and the one a viewer reads continuously. Every band here was drawn in hairlines at 9-10px, which survives a monitor and does not survive an encode. Ruler: two weights now — labelled majors plus five unlabelled minors between each pair, so the axis is a measure rather than a row of numbers and an eye can interpolate "just after 14s" from a marker between two labels. Playhead: a 1px rule with a handle in the ruler, above every band. It used to be a hairline among hairlines, indistinguishable from a tick. Events: lozenges rather than 1px marks — wider, rounder, more opaque, standing on a ground rule. The ratios between kinds are unchanged, so the reading is the same one, just visible. This band is the evidence a human checks a claimed timestamp against; if it does not survive the encode it is not evidence. Markers: fill is now authorship, matching what timeline.tsx has always claimed this axis shows. The diamond head stays, so a monochrome frame still carries it. Severity moved to pin height, the hover label's tint and the aria-label. Hit target is 24px on the axis, and there is a full-height guide on hover so a pin can say what is underneath it. Bisect: the funnel is a stack of shaded bars closing on an instant, and the answer is marked three ways — the surviving window shaded in the agent's colour, a 2px rule at the instant, and a readout of value, precision and probe count. The rule matters because the shading does not: converging to +/-41ms of a 44s recording leaves a window a third of a pixel wide, so the *successful* case is exactly the one where a shaded window disappears. The strip's budget went 96px -> 112px, spent on the ruler and the event track. Both numbers now live in axis.ts, because AnnotationMarker draws a guide the full height of a strip it is a child of.
Four animated things, no more: a gate held open, a claimed task being worked, the bisect probe sequence arriving, and a recording loading. Two of the four already shipped in earlier phases; this adds the gate and the loading indication, and fixes the mechanism all four rely on. globals.css zeroed animation-duration under prefers-reduced-motion, which does not stop an infinite animation — it runs the same keyframes a hundred thousand times a second. Capping animation-iteration-count at 1 is what actually makes each pulse settle on its last keyframe (opacity: 1, a solid dot), which is the fallback those components document. The dot may stop moving; it must not disappear, because in every case it is the thing saying something is still happening. mark-point-overlay.tsx and stage-empty-state.tsx had never been through the phase 1-2 token pass, so they get it here rather than in a second edit over the same lines: type floor, radius, raised elevation on the dialog, 14-15px icons, and the global focus ring in place of three focus-visible:outline-none.
…missed
Four components were never reached by phases 1-2 and still carried hardcoded
sizes, zero radius and recessive 12px icons. Closing that, plus two real
regressions found while grepping for it.
The docked WebMCP badge was pinned at `bottom-24` to clear a timeline that
phase 4 grew from 96px to 112px, so it had been overlapping the top 16px of the
ruler — the end-of-recording markers a judge is meant to click. It now offsets
by `TIMELINE_HEIGHT_PX` itself: a literal that has to be remembered is a
literal that goes stale silently, and this one already had.
`resizable-split.tsx`'s divider is `tabIndex={0}` with `outline-none`, leaving
a 3px colour change as the only focus signal — and the same colour it uses for
dragging. It keeps the global outline now. `recording-picker.tsx`'s listbox had
the same suppression on the element that actually holds focus while open;
options are driven by `aria-activedescendant`, so the row highlight cannot
stand in for a focus ring, not least because `onMouseEnter` sets it too.
Also: the status dot in the badge was a 6px square, alone among every other
dot in the app.
CONTRIBUTING.md's motion section repeated the reduced-motion mechanism that
67b06b0 corrected in the CSS. Fixed there too, since that section is where the
next person will read it.
npx tsc --noEmit clean; 293 tests in 24 files passing.
The panel was pinned open in both degraded states. At 720px — the width this is recorded at — the amber `polyfill` state put 288px of tool grid over the whole agent column: the task queue, the activity feed, and the textarea `page.tsx` focuses on `a`. A panel with no close button occluding a focusable input is exactly the failure this file's own note says must never happen, and `polyfill` is what Chrome shows without the testing flag, so it happened on every run. Only `error` stays pinned now, and the reason is mechanical rather than a softening of the rule: the tool grid is gated on `tools.length > 0` and an error state has no tools, so what is forced open there is a definition, a sentence and four prompts — around 180px, which clears the input. The state that can grow tall enough to cover something is the one that is no longer pinned. What discharges "a degraded surface must not be hideable" is `ToolStatusBanner`: full width, in flow, no close control, naming the mode in words. The chip keeps the amber triangle and the screen-reader state word either way, so collapsing changes how much is said, never whether the page admits it.
Measured at 720px and 1440px: `text-faint` (#5b626c) on `bg-base` (#0e1013) is 3.09:1, and this readout is 10px, so it needed 4.5:1. `text-muted` is 6.07:1. Nothing else about it changes. Not a new judgement — `timeline.tsx` already makes exactly this swap for the ruler's tick labels, at the same size, for the same reason, and says so in a comment. This is the one other 10px readout on the page and it sits alone over the replay frame, which is the surface a recording is pointed at for its whole runtime. The wider finding this came out of is a reporting item rather than a commit: roughly thirty `text-faint` sites are between 2.89:1 and 3.09:1 depending on which of the three surfaces they land on, and the palette has no room for a third text tier that clears 4.5:1 on `raised` without collapsing into `muted`. Some of those sites are exempt (disabled controls, placeholders); the ones that are prose are not. Deciding that is a colour-system change, not a verification pass.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Traces is judged partly from a compressed screen recording, so this treats appearance as part of the
deliverable. The direction is an instrument, not a dashboard and not a landing page — the reference
points are a DAW timeline and the DevTools performance panel, and
CONTRIBUTING.md§ UI conventions isbinding: dense, quiet, scannable.
Nothing under
src/lib/**orsrc/types/domain.tsis touched. No dependency added —lucide-reactwasalready there and is the only icon set.
AGENT_LANE_INPUT_ID,SCRUBBER_IDand all five keyboardshortcuts are unchanged.
The six problems, and what each commit did
eb69fbcfontSize(micro/label/meta/body/title= 10/11/12/13/15px) so a type floor moves in one file.borderRadiusandboxShadoware replaced, not extended, sorounded-lgandshadow-mdfail to resolve — "no oversized rounded cards" became a build error. Global:focus-visibleoutline.b61f248ab91ad8f18ea9cTIMELINE_HEIGHT_PX.67b06b07797ab8906c766cdb6a44Three colour vocabularies are kept disjoint so none of them has to be learned twice: authorship
(
human/agent), severity (ok/warn/error), and progress (a pulsing dot,warnwhen aperson is the blocking dependency,
mutedwhen the machine is working).Verification
npx tsc --noEmitclean; 293 tests in 24 files pass. Every test file is undersrc/lib/, so thispass cannot have moved one — no assertion was weakened, skipped or deleted.
No top-level scroll at 720 / 900 / 1100 / 1440, measured in both axes, empty and loaded:
Timeline strip measures exactly 112px at every width, matching
TIMELINE_HEIGHT_PX.Reduced motion:
globals.csscapsanimation-iteration-countat 1, which landsanimate-pulseonopacity: 1— a solid dot. Zeroinganimation-durationalone, the usual reset, does not stop aninfiniteanimation; it runs the cycle ~100,000×/second. Every state signalled by movement is alsolegible standing still (two chevron glyphs rather than one rotated, a word beside every dot).
Two things a reviewer should look at deliberately
906c766is a behaviour change inside a visual pass. The WebMCP panel was pinned open in bothdegraded states. At 720px — the width this gets recorded at, and
polyfillis what Chrome shows withoutthe testing flag — 288px of tool grid covered the whole agent column including the textarea
page.tsxfocuses on
a. A panel with no close button occluding a focusable input is the failure that file's ownnote says must never happen. Only
errorstays pinned now, and the reason is mechanical: the tool grid isgated on
tools.length > 0, an error state has no tools, so what is pinned there is ~180px that clearsthe input.
ToolStatusBanneris what actually discharges "a degraded surface must not be hideable" —full width, in flow, no close control. Revertible in one line if you disagree.
Contrast has a systemic finding I did not act on.
text-faint(#5b626c) measures 3.09:1 onbg-base,2.89:1 on
bg-panel— below AA for everything except disabled controls and placeholders, which areexempt. It is used at ~30 sites. Raising the token itself does not work: clearing 4.5:1 on the lightest
surface (
raised) needs it at L≈0.239, andmutedis L=0.284, so the third text tier collapses into thesecond and the quiet hierarchy goes with it. The real fix is retiring
faintfor prose and keeping it forthe genuinely exempt uses — a colour-system change, not a verification pass.
cdb6a44fixes only the onesite that sits alone over the replay frame.