Detail Page Standard
The deep-dive Gera commissioned 2026-08-25: inventory every detail surface, root-cause the double skeleton, propose the one boilerplate everything fills into. Captures: detail-pages-inventory.
"We want to have a standard boilerplate detailed structure. And then everything kind of fills up on that... we never want to see double skeleton... I really like the work order structure — but the work order one is kind of noisy. I like the checkpoints where it kind of shows the company colors."
1 · The good news: the standard half-exists
DetailPage (src/components/feature/detail/DetailPage.tsx, ADR-0095, docs/architecture/detail-page.md) is already the codified host: 8 of 12 PM-facing detail surfaces render through it, a permanent drift test pins adoption and forbids hand-rolled back arrows, and its core design is exactly right — loading / error / loaded render from the same props, so a route skeleton structurally cannot drift from the loaded page, and the back contract makes a dead back-button a compile error.
So the effort is not "invent a standard." It is: fix the skeleton class-bug, close ten gaps in the host, adopt the last four surfaces, and unify three vocabularies (banners, progress rails, back labels) that the host doesn't govern yet.
2 · The inventory — 12 surfaces, one table
| Surface | Host | Width | Back | Banner | Progress idiom | Skeletons seen | LOC |
| Prospect | DetailPage | wide | Back to Prospects | — | brand-run activity rail ★ | 2 (list's + own) | 1,739 |
| Renewal | DetailPage (no rail) | wide | Back to Renewals | gradient hero + amber banner + 2 stat cards | Stepper (semantic) + phase grid | 2 (board's + own) | 2,623 |
| Work order | DetailPage | wide | Back to Work Orders | orange strip, hardcoded hex | Stepper (semantic) | 1 | 929 |
| Turnover | DetailPage | wide | Back to Unit Turnovers | gradient hero (TurnoverHero) | Stepper (semantic) | 2 (list's + own) | 934 |
| Tenant | DetailPage | wide | Back to Tenants | — | pill-node lifecycle rail | 1 | 502 |
| Unit | DetailPage | wide | Back to property | semantic status strip ✓ | severity-dot signals | 1 | 207 |
| Vendor | DetailPage | wide | Back to Vendors | — | — | 2 (list's + own) | 350 |
| Agent session | DetailPage | wide | Back to Agents | — | — | 1 | — |
| Property | hand-rolled | wide | Back to Properties | accent upload notice | — | 2 (list's + own) | 1,854 |
| Conversation | hand-rolled | narrow | bare "Back" (history) | — | checkpoint dividers | 1 | 396 |
| Collections demand | hand-rolled | narrow | "Collections" (no prefix) | — | — | 2 (collections list's!) | 1,303 |
| Mass send | hand-rolled | wide | Back to Building communications | — | queue-drain bar | 1 | 772 |
★ = the owner's anchor idiom (logo colors on progress). The captures artifact shows every row the same day.
3 · The double skeleton — root-caused, and it's a class, not a bug
Why it happens: Next.js wraps a segment's page and every child segment in the Suspense boundary its loading.tsx defines. The prospects list and the prospect detail share the prospects path segment — so the list's table-skeleton is an ancestor fallback of the detail route. Next then guarantees both windows on every list→detail click, twice over: the child slot is keyed by segment (a fresh boundary shows the parent fallback immediately), and prefetch deliberately stops at the first loading.tsx walking down — the list's — so the detail shell can never be cached at click time. Result: list skeleton → detail skeleton → content. The ADR-0095 work already made detail-skeleton → content seamless; the list-level boundary sits outside its reach.
Six routes have the class: prospects, renewals, properties, vendors, turnovers — and collections/demand, where the first skeleton is the collections list's, a different page entirely.
The fix (Wave A, URL-preserving): move each list page + its loading.tsx into a route group — prospects/(list)/ — leaving [id]/ outside it. URLs don't change; the list keeps its own entry skeleton; the first boundary on the detail path becomes the detail's own ghost, which prefetch can now cache — so a click paints the correct shell instantly, then content. This is exactly the shape maintenance/ and tenants/ already have (their lists live at /list), which is why they never had the bug. Plus a drift test pinning the invariant: no segment may own both a loading.tsx and a child [param]/loading.tsx — so the class can't come back.
4 · The boilerplate: DetailPage v2, grown in place
Replacing the host would re-fight a consolidation that already worked. Every gap is additive and slot-shaped; every current consumer keeps compiling:
- Banner slot with a policy: semantic status strips (the unit page's left-border alert — the good species) get a first-class
banner position between back and header; today renewal/unit smuggle banners into header and property hand-rolls one outside the host.
- Structured header option —
{title, subtitle, actions} — so the ghost can mirror the real header instead of a generic title+pill shape, and the action bar standardizes.
- Width passthrough —
variant="narrow" unlocks conversation + collections demand (the app's forms-get-narrow rule).
- Hub affordances — optional tabs row + full-width sections + rail options (sticky, ratio) bring property detail inside the fence its own docs currently exile it from.
- Error contract — kinds (not-found / denied / unavailable) + optional action + an error-branch test id.
- Loading-twin explicitness — a columns prop instead of the subtle bare-
sidebar attr; kill the default ghost's hardcoded "Activity Log" heading.
- Footer/action-bar slot — brings the form-shaped surfaces (mass send, demand builder) inside.
Adoption roadmap = the gap list: each gap unblocks a named hand-rolled surface. The drift test's ADOPTED list grows as each lands.
5 · Three unifications the host can't do alone (owner calls)
a. Banners: status yes, heroes no
Two gradient heroes exist (renewal, turnover — violet/cyan radial glows, animated pill, AuroraText). Proposal: retire the hero species; the renewal header calms to the standard header + ONE status element, its stats become ordinary cards. The unit page's semantic strip becomes the sanctioned banner. Decision: keep any hero at all?
b. One progress vocabulary
Four today: brand-run activity rail (prospect — your stated favorite), semantic Stepper (WO/renewal/turnover), pill-node lifecycle rail (tenant collections), checkpoint dividers (conversations). Proposal: journey rails ride the brand run (Stepper gains the spectrum treatment the activity rail has — "checkpoints in company colors"), while pass/fail state stays semantic (a failed step is red, not sky). Decision: convert Stepper's done-state from emerald to the run?
c. Back labels
"Back to X" everywhere: conversations' bare history-"Back" gets a contextual label, demand's "Collections" gets its prefix. The BackLink contract already encodes this; two call sites drift from it.
6 · Waves
| Wave | What | Risk |
| A | Double-skeleton sweep: 6 route-group moves + the boundary drift test. No pixel changes, URLs unchanged. | low — mechanical |
| B | DetailPage v2 slots (banner, header, variant, error, twins, footer). No consumer breaks. | low — additive |
| C | Adopt property / conversation / demand / mass-send; back-label fixes. | medium — per-page diffs |
| D | Calm passes: renewal hero, WO details-card sectioning + tokenized strip, progress-rail unification. | medium — needs your taste sign-off per surface |
Wave A is ready to build the moment you nod — it's the "boom, you see the right one" fix, and it needs none of the taste decisions.