Backlog cleanup — the phone tier, rendered

the phone tier, rendered: the dock's close, the nav drawer, and the leasing funnel at 390px

28 Aug 2026 · branch feat/backlog-cleanup · captured against propflow-stage · at 2a7f9d85b8+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 · Ask Clara docked at 390px — the state the fix is about

Ask Clara's docked surface at phone width. It fills the viewport and is aria-modal, so the page behind it is correctly inert — that part is right and is not the defect.

The defect was that its X did not close it. The audit clicked it three times, on two routes, and photographed the panel still there in every frame; Escape does nothing either, because Clara's key handler only maps Esc for the popup surface. On a phone that is no navigation at all until the page is reloaded — since the rail went off-canvas the hamburger is the only door, and it sits inert behind the panel.

This section reaches the docked state with two ⌘/ presses rather than inheriting it. The surface state is persisted per user, and the fix persists the close — so a capture that relied on finding Clara already docked could be taken once and never reproduced.

Asserted in the captured DOMAsk Clara

Ask Clara docked at 390px — the state the fix is about

2 · After tapping the X — the close STICKS, and the nav is reachable

Same route, same width, one tap on the panel's X, then a tap on the hamburger.

The drawer opening is the proof, and it is proof of the exact thing that was broken. The hamburger is only reachable once Clara is gone — while the panel is up it is inert and the modal's subtree intercepts the pointer. So a capture showing the nav's own links cannot be produced by a panel that re-opened itself.

The cause was a seam, not a broken button: the provider mirrors one way on purpose ("AskClara's state machine drives the dock; the dock never drives state"), while DockedPanel's X called dock.close(id) — the registry, not the state. The panel closed and the mirror re-opened it on the next render. It survived because a title-less panel renders no DockedPanel header on a desktop; the phone tier's forceHeader added one, correctly, and handed the panel a second close button nobody had wired.

Asserted in the captured DOMCollections · Vendors · Tenants. Three nav links asserted in the state the screenshot shows. They are unreachable while the modal is up, so this assertion cannot pass against the bug — which is what makes it evidence rather than decoration.

After tapping the X — the close STICKS, and the nav is reachable

3 · Dashboard at 390px — drawer CLOSED

The rail used to cost every workspace page 56px of a 390px screen — it only ever collapsed, never hid. Here the content starts at the viewport edge and the only nav chrome is the hamburger, top-left.

Every card is full-bleed and stacked: the occupancy and revenue figures keep their full type scale rather than being shrunk to fit, and the renewal and leasing pipelines keep their bars. Nothing is horizontally scrolled and nothing is clipped at the right edge.

Ask Clara opens full-screen at this width — a server-persisted org setting, not a code default — so every section here dismisses it first. With the dock open the page behind it is correctly inert, which is why the hamburger is present, visible, and deliberately unclickable until it is closed.

Asserted in the captured DOMrenewal rate · available · move-out notices. Three figures from three different cards, asserted in the state the screenshot shows. They are mixed-case source text rather than the uppercase labels above them — those are CSS `text-transform`, so an assertion on them would pass or fail on a stylesheet rather than on what the page says.

Dashboard at 390px — drawer CLOSED

4 · Dashboard at 390px — drawer OPEN

The drawer itself: position: fixed + visibility: hidden + 100dvh, with a scrim and drawer sharing Z.modal resolved by source order. None of those claims is checkable in jsdom — no viewport, no box model, getBoundingClientRect() returns zeroes — which is exactly why the review asked for a picture rather than another passing unit test.

What to look for: the drawer sits above the scrim, the scrim covers the page behind it, and the section links are reachable.

Asserted in the captured DOMCollections · Vendors · Tenants. Three section links asserted inside the OPEN drawer — a drawer that renders but paints empty would fail here rather than publish a picture that looks fine.

Dashboard at 390px — drawer OPEN

5 · Work orders at 390px — drawer OPEN over a data table

The same drawer over a wide data table, which is the case where a scrim that fails to cover, or a table that scrolls the page sideways underneath it, would show.

Asserted in the captured DOMUnit Turnovers

Work orders at 390px — drawer OPEN over a data table

6 · The leasing funnel at 390px — all FOUR stages on screen

This is the capture the fix came from. Tapping a metric-strip tile expands the funnel in place; at 390px the fourth stage — Signed Leases — was not on screen at all. Not clipped at the edge: absent, along with its bar, its value, its conversion pill and its axis label.

The cause was not in the funnel. This panel's root is a grid item, grid items take min-width: auto, and an auto minimum resolves to min-content — so the months table's 483px min-content became the floor of the whole panel, which then laid out 483px wide inside a 356px card and got clipped by the card's overflow: hidden. The widest descendant was silently setting every sibling's width.

What to check here: four bars, four values, four conversion pills and four stage labels, all inside the card.

Asserted in the captured DOMSigned Leases · Applications · Tours · Inquiries. All four stage labels asserted in the state the screenshot shows. On the broken build the fourth was outside the card — present in the DOM but off the captured frame — which is exactly the miss this harness aborts on.

The leasing funnel at 390px — all FOUR stages on screen

7 · The per-stage notes, readable instead of printed over each other

Second defect from the same capture. maxWidth: COL% caps the stage's box; a white-space: nowrap span inside it is unconstrained, so three notes of 98/178/164px overlapped inside ~108px columns and rendered as literal mush: 294 still at this st84estil at this stage · 6 bookedaheadstage, awaiti.

Under 640px the rail now leaves its columns entirely and restacks into four rows — each carrying its bar's own hue as a dot, which once a row is no longer under the bar it names is the only thing tying the two together. Above that breakpoint the horizontal rail stays, and an ellipsis guard makes a note that still doesn't fit truncate rather than overprint. Overlap is impossible at every width, not just under the breakpoint.

Asserted in the captured DOMstill at this stage

The per-stage notes, readable instead of printed over each other

8 · The same panel at 1280px — the content control

The identical expansion where the plot has room. This is what the two phone captures above have to match in content: four bars, four values, three conversion pills, four axis labels, four readable notes, and a months table with all six columns. A responsive fix is only correct if nothing was dropped to achieve it — the narrow form restacks the rail, it does not thin it.

Asserted in the captured DOMProspect Pipeline · Inquiries · Tours · Applications · Signed Leases

The same panel at 1280px — the content control

9 · Collections at 390px — the whole page, including what is still wrong

The page as a phone gets it. Recorded here in full rather than cropped to the part that improved, because an artifact that shows only the fixed half is how a surface gets declared done twice.

Still wrong, and visible above: the aging cards' composition labels truncate to Pa…, Ju…, At t… — the same underlying stage truncating at different points in different cards, so it does not even look like the same stage twice. Fixing it needs a BreakdownCard prop that does not exist yet. And the page still pans sideways into blank grey: measured on this branch's preview on 2026-08-28, this full-page capture is far wider than the 390px viewport, where /dashboard measures exactly 390. A containment guard was written for that and reverted — it was verifiably inert, and the commit message says so.

This section deliberately asserts nothing about the Stage fix; the section below does, under conditions where the check can actually tell the two builds apart.

Asserted in the captured DOMCollections policy. A page-level landmark only. This section is `fullPage: true`, so the haystack is the whole document — and `sr-only` text is `position: absolute; width: 1px`, not `display: none`, so it IS in `innerText`. A stage-label assertion here would pass identically on the build where the label was hidden. That is the section below's job.

Collections at 390px — the whole page, including what is still wrong

10 · …and the Stage column itself, scrolled into frame

The Stage column compacts to a coloured dot on a phone, and its label lived only in a hover tooltip — which a tap does not open. Worse, a tap on the dot fell through to the row and navigated to the tenant. So the one column that says where an account actually stands was unreadable on the device most likely to be reading it in a hallway.

The fix paints the short stage label beside the dot at the compact tier, buying the width by dropping a type step rather than dropping the word, and reusing stageLabelShort — the vocabulary the aging cards already render — so no second set of names was invented. The full name stays in the assistive tree.

The capture scrolls the pane to the Stage column, because at 390px it is off-screen to the right until you do.

Asserted in the captured DOMApplied. "Applied" is the SHORT form of `assistance_applied`, and this check is built so it cannot pass on the pre-fix build. The section is `fullPage: false`, so the haystack is the frame — which excludes the aging cards above, the other place short labels render. Within that frame the pre-fix DOM held only the FULL label, "Assistance applied", through the cell's `sr-only` provenance line; substring matching is case-sensitive, so "Applied" is not found in "…applied". Four of the fifteen stages have a short form that is a distinct string — `assistance_applied`, `assistance_docs`, `assistance_approved`, `eviction_scheduled`. All four were probed against this bench; this is the one present in the data.

…and the Stage column itself, scrolled into frame

11 · A work order's progress stepper at 390px

Two defects on the stepper, both found by measuring rather than reading. The selected step's travelling pill is opaque and sits at inset: 0 of a label chip that sized to its own words, so at 390px "In progress" painted over the tail of "Dispatched" — the step you are on was legible only by destroying the one before it. And six steps at a 72px floor need more width than the card has, so the last steps clipped to a single letter with nothing saying more existed.

The cause was the column, not the pill: an explicit min-w-0 alongside the numeric floor disabled flexbox's own min-content floor, letting the chip outgrow its column. A column may not be narrower than its own longest word, so the label now wraps instead of overflowing — and because the pill is the chip's box, it can no longer reach a neighbour. The rail also scrolls the selected step into view and veils whichever edge is actually hiding one, measured rather than guessed from a breakpoint.

Asserted in the captured DOMWork order progress · Dispatched. A work-order DETAIL route, not the list. An earlier draft of this section pointed at `/maintenance/work-orders` and asserted the page heading — a string the list, the detail page, and a build with the stepper deleted all satisfy. `Work order progress` is the string doing the real work: it is the progress card's own title and the list page has no such heading, so the section can no longer be satisfied by the wrong route. `Dispatched` is corroborating rather than decisive — `StatusBadge` paints it too, and this section is `fullPage: true`, so a build with the rail deleted could still satisfy that one string on its own.

A work order's progress stepper at 390px

What this proves, and what it does not

The review's carried-forward finding was that the largest new UI surface in this PR had no rendered evidence. jsdom performs no layout, so the unit suite asserts CSS variables, class names and focus order — declarations, not results. These captures come from this branch's own preview against the anonymized propflow-stage source, and each section's assert strings are checked against the DOM in the state the screenshot shows; a miss aborts the run rather than publishing a picture that looks fine.

They do not prove the drawer feels right to use. A capture shows a frame, not a gesture: the scrim tap, the Escape return and the focus trap are pinned by test, and touch behaviour on a real device is unverified.

Two near-misses are worth recording, because they point opposite ways. The second run failed on button:has-text('Close') timing out on every section, which reads like a phone drawer with no way out — a user trapped under a full-screen panel. It was a selector bug, not a product one: the dock's X has no button text, its accessible name is Close panel, and DockedPanel forces the header bar on precisely so the affordance is always reachable on a mobile drawer. And the first: the run failed with the hamburger marked that is worth recording. The first run of this spec failed with the hamburger marked inert, which reads exactly like a broken drawer. which reads exactly like a broken drawer. It was not — Ask Clara was open full-screen and the page was correctly inert behind it. Both would have been fabricated bugs, and in both cases a careful reading of the source pointed at the bug rather than away from it. The picture is what settled them.

PropFlow Docs