Renewals detail on the standard

The renewals detail page stops owning its own columns, its own label idiom and its own conversation reader. Captured on the renewal Gera pointed at, as a real before/after: the BEFORE pages come from a second dev server running current origin/main, not from memory.

30 Aug 2026 · branch feat/renewals-detail-standard · captured against propflow-stage · at 94a1791d7d

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 · BEFORE — the page that didn't look like the others

Captured from a second dev server running origin/main. Read it against the tenant or prospect detail page and the four things Gera named are all here.

Two big stat numbers under the header. PROGRESS over a 2xl value over a caption, and ELAPSED over a duration over a caption from a vocabulary that exists nowhere else in the app — Handed to you, Approval → signed, Agent not run yet. The value restates what the header status pill already says; the count restates what the rail below already draws step by step.

A bare heading over a stack of cards for every phase — each timeline step is its own <Card>, so the column is a run of card edges and no section frames its contents the way any other detail page does. The eyebrows are four different colours, two of them raw hex literals.

The whole conversation rendered inline: Clara and tenant chat bubbles, an audio player, voicemail markers, guard notes. A second conversation reader maintained beside the real one, on a page whose subject is a renewal.

A 4-column grid the page owns itself (3/4 + 1/4) — the only detail page in the app that does. The offer summary inside it is a <dl> of hand-rolled rows under its own text-xs uppercase micro-label — a fourth label idiom on a page that already had three. (Its Clara avatar is already gone here: #6613 landed the app-wide summary-card standard while this branch was in flight, and this BEFORE is captured from main after that merge.)

Asserted in the captured DOMRenewal progress · ELAPSED · PROGRESS · RENEWAL OFFER SUMMARY · Tenant Outreach. "ELAPSED" and "PROGRESS" are the load-bearing pair — the two stat cards this PR deletes. They are asserted in the CSS-uppercased casing the DOM's innerText actually carries. The other three pin that this is the same renewal, one build apart.

The renewals detail page on origin/main: two stat cards, bare phase headings over stacked step cards, an inline chat transcript, and a 4-column grid

2 · AFTER — the same renewal, on the standard

Same renewal, same width, this branch. The host owns the grid now — the standard 2/3 + 1/3 the tenant, prospect, work-order and turnover pages already render — so the offer summary is a real right rail rather than a column the page drew for itself.

The stat strip is gone. In its place, under the progress rail, a Run field row: Progress (the step count, which is what the percentage was a coarser version of) and Approved / Offer sent / Signed — the milestones the ELAPSED card was differencing. Stated instead of subtracted, which is more than a duration told you. Each is a TimeText format="dateOnly": the DATE on the card, the exact zoned instant on hover, which is the app-wide standard and what its drift guard requires. Offer sent reads preparedOffer.preparedAt — "wall-clock when the offer was successfully prepared in the PMS" — and not lease.lastSyncedAt, which seven writers stamp to now() and which could therefore print an "Offer sent" instant LATER than "Signed" (caught in review).

Every phase is one info card: SummaryCardHeader — the same opening row as "Tenant info" and "Prospect info" — carrying the positional eyebrow, the phase name and the status chip, with the steps as hairline-separated rows inside it. One card edge per phase instead of one per step, and one ink for every eyebrow.

The offer summary is the standard summary card: an <h2> heading, three labelled sections separated by hairlines, and the shared Field pair — label lighter, value stronger. No Clara avatar, which is the app-wide summary-card standard landing in parallel on #6613.

Asserted in the captured DOMRenewal progress · Run · Approved · Offer sent · Renewal offer summary · Tenant Outreach · Read the conversation with this tenant. Three of these are the BEFORE page's own strings, unchanged, which is what makes this a comparison rather than two unrelated screenshots — and note they are asserted here in SENTENCE case, because the headings are real

s now rather than CSS-uppercased micro-labels. "Approved" / "Offer sent" are the timestamps that replaced the elapsed duration, and the conversation CTA is the control this PR adds: a control that fails to render is the failure mode this gate exists for.

The rebuilt renewals detail page: the standard two-column grid, phase info cards, a Run field row of timestamps, and a prominent conversation link

3 · AFTER — the conversation is a link, and the thread is not on the page

The Tenant Outreach card, scrolled into the fold. Gera: "we should have like a link to the conversation tenant… but we don't want to be showing the conversation in there."

What stays on the renewal page is what a PM needs at a glance — how many messages, on which channels, when the last one was. What leaves is the thread itself: this renewal has 13 messages across two threads — SMS, voice and email — every one of which used to render here as a chat bubble or a transcript block.

The button goes to /tenants/<id>/conversation?thread=<id>, built by buildTenantPersonConversationHref — the scheme the tenant page's own per-thread rows already use, not a second one invented here. That url opens the tenant's whole unified history with this renewal's thread carrying the highlight band, so the PM lands on the relevant portion without losing the surrounding context.

It is a real Button rather than the muted footer text-link the arsenal uses elsewhere, because it is now the only door to the outreach story — and secondary rather than primary, so it never competes with Approve & send or Retry preparation.

Asserted in the captured DOMTenant Outreach · MESSAGES · CHANNELS · LAST ACTIVITY · Read the conversation with this tenant. The facts that survive on the page, plus the CTA. The three field labels are asserted in the CSS-uppercased casing the DOM's innerText actually carries. What canNOT be asserted here is an ABSENCE — this tool checks strings that are present — so "no message bodies are rendered" is carried by the picture beside the BEFORE, and by a unit test that asserts the tenant's exact sentence is missing from the DOM while its message count is there.

The Tenant Outreach card: message count, channels and last activity, with a prominent button reading Read the conversation with this tenant

4 · AFTER — 390px, the same page on a phone

The host's grid collapses to one column below md, so the rail's offer summary falls under the main content instead of being squeezed into a quarter of a 390px screen. The packed field layout is what keeps a sparse card from painting half-empty rows at this width: each fact is exactly as wide as what it has to say.

Asserted in the captured DOMRenewal progress · Renewal offer summary · Read the conversation with this tenant. The rail's card is present in the single-column flow, and the conversation CTA survives the narrow width rather than being clipped out of its card.

The rebuilt renewals detail page at 390px, single column

5 · AFTER — the unhappy state: a renewal with no outreach at all

A different renewal — declined, resolved, and Clara never texted anyone. This is the judgment call Gera is being asked to rule on, so it is here as a picture rather than as a sentence in a PR body.

Every other phase is still hidden when it has no rows. Tenant Outreach is not: the card is the page's one door to the conversation, and a door that vanishes on every renewal with no messages yet is a door a PM learns not to look for. It reads MESSAGES — None yet and the button degrades to Open this tenant's conversation, pointing at the unanchored /tenants/<id>/conversation — still the tenant's real thread, never a dead control.

The Run row is also worth reading here: this renewal has no approval run, so Approved, Offer sent and Signed all hide themselves (Field renders nothing for a null value) and only Progress remains. That is the sparse case the packed layout exists for — no half-empty grid cells, no labelled blanks.

If Gera would rather the card disappear when there is nothing to say, that is a one-line change to visiblePhases.

Asserted in the captured DOMTenant Outreach · MESSAGES · None yet · Open this tenant’s conversation · PROGRESS. The zero-outreach copy and the degraded CTA, plus the surviving Progress field — i.e. that the sparse card still says something true rather than rendering a heading over nothing.

A renewal with no outreach: the Tenant Outreach card still renders, reading Messages None yet, with the unanchored conversation link

One real bug these captures caught, and what they cannot prove

The capture found a day-shift bug that no test was looking for. On the first run the offer summary read Lease expires April 30, 2026 directly above New start April 30, 2026 — the renewed term starting the day the old lease ends. The BEFORE shot shows it is not new: the start date was addDays(new Date(leaseEnd + 'T00:00:00'), 1), local midnight plus a day, then rendered in the fleet display zone, and east of that zone the instant re-reads as the previous day. That is trap 1 in CLAUDE.md § Timestamps, and it is fixed in this PR with pure calendar arithmetic plus a unit test covering month, year and leap-day rollovers. Nothing but a picture was going to find it.

The comparison worth making on the two full-page shots is not "is the after prettier" — it is put the AFTER beside the tenant or prospect detail page and try to tell which team built which. That was the complaint.

Two things these captures do NOT prove, said plainly so nobody reads them as proving it. First, an absence: this tool asserts strings that are present, so "the conversation is not rendered inline" is carried by the picture and by a unit test that asserts the tenant's exact sentence is missing from the DOM while its message count is there. Second, the highlight landing: the button's href is asserted as a literal url in the unit suite, but following it renders the conversation reader, which is a different page and out of this artifact's scope.

PropFlow Docs