Grading desk on a phone

The shipped three-pane desk put the 👍/👎 below the whole transcript on a 390px column. Below `lg` the same three parts are re-ranked: the queue collapses to one line, the item takes the width, and the verdict bar sticks to the bottom edge. Captured from the branch's own dev server at both phone widths.

11 Aug 2026 · branch fede/grading-desk-mobile · captured against propflow-stage · at 4ee6b86796+local

Data note. Every name and balance below comes from propflow-stage, which is anonymized by construction (ADR-0097/0110 — phones in the impossible +1000 NPA, emails @example.test). The identical capture against prod would carry real tenants' names and balances and is not safe to attach anywhere, which is why the generator refuses to run against one.

1 · 390×844 — the bar is there with the transcript at the TOP

This is the whole point of the change. The verdict bar is position: sticky; bottom: 0 — a normal child of the page flow, so it reserves its own space and needs no magic offset on the column above it, and once its natural position would fall below the fold it pins to the viewport bottom and stays there.

The transcript has been scrolled to its first message here (it opens at the newest). Nothing about the bar moved. The queue is the single line at the top: position, who, and the ranker's own reason chip — which on a phone is the only place the machine's read of this item is visible before a sheet is opened.

Asserted in the captured DOMRecord verdict · Conversations · Juniper Whittington · Clara at Camellia Apartments. Four strings, checked against the DOM INSIDE THIS CROP. Three are one per band — “Record verdict” is the sticky bar at the bottom, “Conversations” the item pane's header in the middle, “Juniper Whittington” the collapsed queue line at the top — and a bar that had scrolled out of the fold fails the first. The fourth, “Clara at Camellia Apartments”, is the conversation's OPENING line, so it is what makes “scrolled to the top” a checked claim rather than a caption: the first attempt at this page used `scrollIntoViewIfNeeded` on the transcript's first block, which is taller than the viewport and therefore already “in view” — the scroll silently did nothing and the picture showed the bottom while the prose said top.

Phone-width grading desk with the transcript scrolled to its first message and the verdict bar pinned at the bottom

2 · 390×844 — and with the transcript at the BOTTOM

The same screen with the transcript where it opens — scrolled to the newest message. Same bar, same place. That invariance is what a grader needs: the two buttons pressed on every item never move, whatever the conversation is doing.

The action row deliberately stops 48px short of the right edge. Ask Clara's pill is position: fixed at Z.orb (250) — above this bar's Z.rail (50) — and rests bottom-right as a 48px square with a 28px margin, so its left edge lands 76px in while this card's inner right edge is 28px in. Un-reserved, the orb covered a third of the Record button; you can see it now sitting in the gap the row leaves for it.

Asserted in the captured DOMRecord verdict · Conversations · Juniper Whittington · move-in day tomorrow. Same three bands, plus “move-in day tomorrow” — the conversation's LAST line, which is what makes “at the bottom” checkable rather than asserted in prose.

The same desk with the transcript at its newest message, verdict bar still pinned

3 · 360×800 — the narrower phone, both ends of the transcript

The tighter of the two widths asked for, transcript at the top. Nothing reflows into a different shape — the bar's thumbs are elastic and the Record button takes what is left after the orb's corner, which is why it survives 30px of lost width without wrapping.

Measured rather than eyeballed: the Record button's box ends at x=283 of 360 here and x=313 of 390 above; the orb's left edge is at 284 and 314. It clears by a pixel on purpose — that is the reservation being exactly the orb's footprint rather than a round number.

Asserted in the captured DOMRecord verdict · Conversations · Juniper Whittington · Clara at Camellia Apartments. Four strings, checked against the DOM INSIDE THIS CROP. Three are one per band — “Record verdict” is the sticky bar at the bottom, “Conversations” the item pane's header in the middle, “Juniper Whittington” the collapsed queue line at the top — and a bar that had scrolled out of the fold fails the first. The fourth, “Clara at Camellia Apartments”, is the conversation's OPENING line, so it is what makes “scrolled to the top” a checked claim rather than a caption: the first attempt at this page used `scrollIntoViewIfNeeded` on the transcript's first block, which is taller than the viewport and therefore already “in view” — the scroll silently did nothing and the picture showed the bottom while the prose said top.

Grading desk at 360px wide with the transcript scrolled to the top and the verdict bar pinned

4 · 360×800 — transcript at the bottom

The narrow width at the other end of the scroll. The item pane's header also stacks below sm: at phone width the audio pill and the id chip together are wider than the pane, and as one non-shrinking cluster they painted on top of the title and had their copy / deep-link buttons clipped off the pane's right edge — present but unreachable. Both are reachable here, which a geometry check now pins (every header button's box inside the pane's box).

Asserted in the captured DOMRecord verdict · Conversations · Juniper Whittington · move-in day tomorrow. Same three bands, plus “move-in day tomorrow” — the conversation's LAST line, which is what makes “at the bottom” checkable rather than asserted in prose.

Grading desk at 360px wide with the transcript at its newest message

5 · A 👎 asks its question where it is asked

Tapping 👎 opens a bottom sheet carrying everything a negative verdict additionally needs: the D2 message pinpoint, the principle chips, the free-text note, and the block reason explaining why Record is not yet available. It opens on the 👎 itself rather than behind a second tap, because that is the moment the question is asked.

This is not a phone-shaped copy of the verdict rail — it is VerdictRail, the same component the desktop grid mounts, with the same props and border-0 rounded-none so there is not a card inside a card. A second chip picker is how the two surfaces would drift into disagreeing about what a verdict is.

The sheet's open state is derived, not stored: requested && draft.verdict === 'down'. Recording resets the draft, so the sheet closes on its own when the verdict lands and the desk advances — nothing threaded through the submit path, and no way for one item's answers to survive onto the next.

Asserted in the captured DOMWhat went wrong? · Which message turned it? · What was the principle? · Anything else? · Close the loop · needs at least one principle chip. Every part Gera asked to be reachable in the sheet, checked in the state the screenshot shows: the sheet's own title, the D2 pinpoint, the chip picker, the note, one real chip label from the seeded vocabulary, and the D3 block reason. The block reason is the one that would catch a sheet that opened but rendered a stale draft.

Bottom sheet titled What went wrong? showing the message pinpoint, principle chips, a note field and the record button

6 · The queue is one tap, not a third of the screen

On a phone the list is navigation, not context — keeping it on screen costs a third of the viewport to answer a question you ask once. Tapping the collapsed line opens it as a sheet, with the same view toggle, search and reason filter the desktop pane has, because it is that pane: GradingQueuePane, same props, h-full border-0 rounded-none.

Picking a row selects it and closes the sheet. The prev/next carets either side of the collapsed line step without opening anything at all, driven by the same stepSelection the J/K keys use — so the buttons and the keyboard cannot land in different places.

The search is filled here so the capture shows a short list rather than 121 rows; it is also how this page pins the same conversation across all six captures.

Asserted in the captured DOMWhat there is to grade · Needs a look · Everything · Every reason. The sheet's own title plus the queue pane's D4 view toggle and its reason filter — a sheet that opened empty, or opened over a forked mini-queue, fails these. (Asserted on the reason filter rather than the search box because the search box's only text is a PLACEHOLDER, which is not innerText and would have been a string no crop could ever contain.)

Bottom sheet listing queue rows with the view toggle, search box and reason filter

7 · The cold desk — the unhappy state, and a review finding

The phone bar is on screen from the first paint, before anything is selected. The review caught what that meant: a 👍 needs no principle chip, so verdictBlockReason returned null, Record went live on a desk with nothing selected, and tapping it did nothing at all — the submit path early-returns on a null item, so no toast, no error, no state change. The verdict rail never hit it because it renders its own empty state before asking.

Fixed at the shared rule rather than with a second guard at the bar: verdictBlockReason(draft, item) now takes the item, because “is this recordable” is not answerable from the draft alone. Both surfaces ask the same question and get the same answer. The same fix covers the warm case the review also named — a filter or search that drops the selected row out from under the grader.

Browser-checked on this build: Record is disabled here, still disabled after tapping 👍 with nothing selected, and enabled the moment a row is picked. Unit-checked too, with a positive control: deleting the null-item branch turns exactly the two cases that assert it red.

Asserted in the captured DOMPick something to grade first. · Open the queue · Record verdict · Pick something to grade. The block reason IS the fix, so it is asserted literally. “Open the queue” is the collapsed queue line's own cold-start label, and “Pick something to grade” the item pane's empty state — together they pin that this is genuinely the pre-selection desk and not a loading frame.

The phone grading desk before anything is selected, with Record disabled and the reason shown

8 · 1600×1100 — the desk, unchanged

The claim this PR has to survive is that the desktop desk did not move. Here it is: queue, item and verdict rail side by side, the keyboard hint still under them, and neither phone bar on screen.

Measured on the same run: all three panes at y=168 with height 836 — which is 1100 − 56 (top bar) − 208 (13rem), the shipped desk's own height expression, unchanged. The container is now one element that is a flex column below lg and this grid at it, with the two desktop-only panes behind hidden lg:contents wrappers; display: contents makes the wrapper vanish so each pane is still a direct grid item.

That single container is not a tidiness preference. The first cut put a phone subtree beside the desktop grid and hid one with CSS — but display: none does not stop an effect, so both GradingItemPanes ran their fetch: two GET /api/conversations/<id> and two audio HEAD probes on every selection, with two panes racing to answer onConversationLoaded. One request per selection was then verified at 390, 360 and 1600.

Asserted in the captured DOMNeeds a look · Every reason · Did Clara get this right? · Press J and K (or the arrow keys) to move through the queue.. One string from each of the three panes plus the desktop-only keyboard hint, all in the same crop — which is exactly the arrangement the phone layout replaces, so finding it intact here is the no-regression check.

The three-pane grading desk at desktop width with queue, transcript and verdict rail side by side

What a picture cannot show, and how it was checked instead

Auto-advance was proven by INDEX, not by eye. Recording a 👍 at 390×844 moved the collapsed queue line from 2 of 121 to 3 of 121; the prev/next carets stepped 1 → 2 → 3 → 2. Row titles were deliberately not used: two rows in this queue share a participant name, which is how an earlier check read a correct step as a stall. The same decision is unit-tested as a pure function — queuePosition / queuePositionLabel, six cases — because “the verdict saved and the desk advanced” is a claim a test can make about '3 of 14' → '4 of 14' and cannot make about a screenshot. Positive control: making it zero-based turned exactly the three one-basedness cases red.

Console was clean at both phone widths and at desktop. The one message logged on the phone runs is a CSP block on va.vercel-scripts.com/v1/script.debug.js — Vercel Analytics' dev-only script, ambient on every page of this app and untouched by this diff. Desktop logged nothing at all.

Generated, not assembled: npx tsx scripts/ui-evidence.ts evidence/pr-5696.json --publish. The generator refused to start until the running dev server proved, from its own /api/health, which DynamoDB table it was bound to — propflow-stage, anonymized by construction (ADR-0097/0110). Every name and handle on this page is a mirror alias, not a resident.

Two things about this page's own provenance, because an evidence page that overstates itself is the failure it exists to prevent. The capture tool did not run out of the box. viewportText hands collectFrameCandidates to page.evaluate, and tsx's esbuild transform injects __name helpers into that function's source — which do not exist in a browser context, so it threw ReferenceError: __name is not defined on every section. The throw is swallowed by a .catch(() => null), so it surfaced only as “the captured viewport contained no text” and read like a page problem rather than a tool problem. It is not specific to this PR: the committed evidence/pr-5489.json spec fails identically on a route this diff does not touch, which is how it was isolated. These captures were taken with a one-line local patch defining __name in the page before the walk; the patch is NOT part of PR #5696, and where it should land is an open question for a human. Second, propflow-docs/bin/evidence — the renderer half — lives only on the unmerged evidence-artifact-generator branch, so this page was rendered from a worktree of it. Until both land, this artifact is reproducible only by someone who repeats both steps.

PropFlow Docs