The signed-in frame paints before the layout's reads — and the swap moves nothing

PR #10981: the workspace layout now streams an empty frame (the rail column, the top-bar strip, the content well) at first byte, and the real frame replaces it when the layout's reads resolve. These captures put the two frames side by side from ONE page load each, at desktop width, phone width and in dark mode, with each frame's rail and top bar measured.

23 Sept 2026 · branch perf/prospects-hard-load-shell at 59afbe6d84 · captured against propflow-stage (checked from the running server's /api/health) · smoke user · route /leasing/prospects

How the ghost was held on screen. On a warm dev server the layout's reads finish in well under a second, too fast to photograph. For these captures only, a LOCAL, UNCOMMITTED line at the top of WorkspaceResolved waited 8 s when a capture-only cookie was present. Nothing else was changed. Each variant warms the route first, sets the cookie, reloads, and shoots twice in the same tab: once when the ghost is attached, once after the real top bar mounts. The line was removed after capture, and the branch contains no delay.

Why this page is hand-built. scripts/ui-evidence.ts gates each section on rendered text and waits for domcontentloaded. The ghost renders no text by design, and a held stream delays that event until the real frame lands. So the tool can't photograph this state. Instead, the same pieces (the stage-only target guard, the smoke sign-in and the cookie staging) drive a short Playwright script. The "assertion" is geometry: getBoundingClientRect() of each frame's rail and top bar, and the element that owns the top-left corner pixel.

1 · Desktop, 1440 px, light

Frame ghost, desktop-light
1 · the frame ghost, while the layout's reads are held
Real frame, desktop-light
2 · the real frame, same tab, same load

The rail column is 240 px wide and full height in both frames, and the top bar spans the full 1440 px at 56 px tall in both. The real frame fills in the navigation, the picker and the page; nothing that was already painted moves. The rail owns the top-left corner in both frames: the ghost's rail sits at Z.rail, above the top bar's z-30, as SideNav does. That is the fix for the previous round's corner-colour finding.

Measured:

Frame ghostReal frame
Railleft 0 · top 0 · 240×900left 0 · top 0 · 240×900
Top barleft 0 · top 0 · 1440×56left 0 · top 0 · 1440×56
Top-left corner (4,4)div.pf-frame-ghost-rail bg=rgb(255, 255, 255)nav.pf-nav-drawer bg=rgb(255, 255, 255)
Painted after0.5 s10.3 s

2 · Phone, 390 px, light

Frame ghost, phone-light
1 · the frame ghost, while the layout's reads are held
Real frame, phone-light
2 · the real frame, same tab, same load

Below 768 px the real rail is an off-canvas drawer (measured at left −288, i.e. fully off screen) and reserves no column. The ghost hides its rail column at the same breakpoint, so the phone never paints a sliver that then disappears. The top bar strip is the full 390 px in both frames.

Measured:

Frame ghostReal frame
Railnot paintedleft -288 · top 0 · 288×844
Top barleft 0 · top 0 · 390×56left 0 · top 0 · 390×56
Top-left corner (4,4)div.z-30 bg=rgb(250, 250, 250)header.fixed.left-0 bg=rgb(250, 250, 250)
Painted after0.5 s10.1 s

3 · Desktop, 1440 px, dark

Frame ghost, desktop-dark
1 · the frame ghost, while the layout's reads are held
Real frame, desktop-dark
2 · the real frame, same tab, same load

With the browser set to dark and no stored preference, the pre-hydration script picks dark before the ghost paints. The ghost and the real frame share the same surface tokens (FRAME_SURFACE), so the rail and corner paint the same dark colour in both frames, with no light flash.

Measured:

Frame ghostReal frame
Railleft 0 · top 0 · 240×900left 0 · top 0 · 240×900
Top barleft 0 · top 0 · 1440×56left 0 · top 0 · 1440×56
Top-left corner (4,4)div.pf-frame-ghost-rail bg=rgb(10, 10, 15)nav.pf-nav-drawer bg=rgb(10, 10, 15)
Painted after0.5 s10.3 s

What this page does not show

The content well is empty while the ghost is up. The page's own loading.tsx skeleton sits under the resolved layout, so it can't render inside the layout's fallback; that is how the PR describes the design. The capture is from a dev server, so the Next.js dev badge shows bottom-left in every frame. The 8 s hold is artificial; the production timing claim in the PR (a 9.3 s cold-load wait before first paint) is not measured here.

PropFlow Docs