Skip to content

2026-07-28 — Mobile table chrome: the Twitch-model collapsed mode

A phone now shows a fully usable live table. The table page carries two chrome modes chosen by the geometry itself: persistent (today's desktop chrome, byte-identical — pinned by a committed-markup tripwire) and collapsed, the Twitch-model mobile chrome. The mode is a pure function of the measured container (+ a hysteresis dead band so resize can't flicker) — no CSS breakpoints, no second spatial authority: the thresholds derive from the felt geometry's own constants, so chrome and felt can never disagree about how much room there is.

Collapsed, the fixed side panels and CoPilot column unmount and the real BratTableV3 gains the freed width through its existing geometryOverrides seam (side columns zeroed, phone pad/band tune — the felt was not rebuilt). Chat/history/thoughts live in one pager: portrait docks it under the felt (the felt shrinks to a bounded top slot — the one animated shape change), landscape docks it as a right column with the felt at full height. While the pager is closed, the last few chat messages float TikTok-style over the felt — capped, fading, pointer-transparent — and clear the moment the full feed is visible. Typing compresses the layout naturally via visualViewport (API absent → no compression, no crash); rotation re-docks the chrome without touching game state.

The views are per-kind: the thoughts pane exists only on hosted-agent tables with something to show; CoPilot tables get the advisor surface (timer, time bank, autopilot, Q&A, notes) as a fourth pager pane — the same CopilotSeatPanel the desktop renders as its fixed column. The seat-taking strip is bounded to half the screen with internal scroll, so the deposit flow can never bury the table. A dropped connection shows the existing reconnect surfaces in either mode, and recovery re-renders in the current mode — connection state is not a mode input.

Shipped alongside: the table page's chrome finally decomposed out of page.tsx (shell, pager, floats, CoPilot panel, chat chip, mode hook — the page is now smaller than before the plan started, with a whole second chrome mode added); the dead PlayerList/HandHistory components deleted behind a grep gate; and the design story published on the frontpage — the table-geometry article joined the articles index and a new mobile-chrome article embeds the interactive design mockup with a standalone play page at /notebook/mobile-chrome/demo.

All look/feel constants (felt budget, float fade, column width, thresholds) ship as lab-tunable knobs for the real-phone retune pass.

Phase 7 (first real-phone smoke findings): fullscreen + standalone + viewport honesty + hideable header. Safari's chrome was eating the landscape viewport and the site header was overhead on phones — four fixes, each with the plan's graceful-absence discipline. The collapsed table strip gains a fullscreen chip (standard requestFullscreen with webkit fallback, fullscreenchange-driven active state; API absent → no chip, no crash). A minimal web-app manifest (display: standalone, brat noir colors, the RangeBotMark icon) plus appleWebApp meta lets Add-to-Home- Screen launch with no browser chrome — deliberately no service worker. The layout's height authority moves to h-dvh with viewport-fit=cover, and collapsed mode pads the outer chrome by the safe-area insets (header strip top, pager bottom). And the site header hides off-canvas in collapsed mode behind a slim brat-lime grabber notch — tap or drag down ≥24px to reveal it as an overlay, tap out to dismiss; AppShell frees the 56px header reserve for the felt. Persistent mode renders every one of these surfaces byte-identically to before (T2 tripwire + T20's baseline); the mode crosses the page boundary through one seam — useChromeMode mirrors its output onto <html data-chrome-mode>, and shell chrome observes it.

Phase 8 (install nudge): the collapsed header strip gains the lobby mockup's lime install nudge — 📲 add-to-home-screen, honest per-platform (iOS gets the share → Add to Home Screen instructional line; Android/Chrome captures beforeinstallprompt and renders a real install button), never shown standalone, ✕ dismisses forever, every detection effect-gated with zero crash paths (T22).

Phase 9 (desktop felt smoke findings): the feedback pill collapses to a slim brat edge tab (default collapsed on the table page, choice in localStorage — it can no longer block the felt's corner), and the desktop side columns are bottom-bounded via the exported BOTTOM_OVERLAY_CLEARANCE knob (112px) so aside content never slides under the Dashboard strip — geometry untouched, T2 baseline deliberately regenerated (T23/T24).

Phase 10 (install-nudge smoke findings): the nudge is now device-gated, not viewport-gated — the exported isTouchPrimaryDevice() ((pointer: coarse) AND (hover: none), OR the iOS detection; fails closed when matchMedia is absent or throws) keeps it off a narrow desktop window, where collapsed chrome engages but a real beforeinstallprompt made the install button meaningless (T25) — and its controls are actually tappable: ✕ and install carry the 44px platform minimum as real inline sizing, the nudge declares its own pointer-events-auto, and the off-canvas site header is pointer-transparent while hidden so its z-100 bar stops eating taps as it sweeps back up (T26).

Phase 11 (header-reveal smoke findings): the reveal control stops fighting iOS — it now sits HEADER_REVEAL_TOP_OFFSET (32px, exported beside the other chrome knobs) BELOW the safe-area top inset, clearing the ~20px band iOS reserves for its own downward swipe (in landscape that inset is 0, so the control was previously at y≈0 and a drag on it summoned Notification Center), and Phase 7's grabber pill becomes an unmistakable brat tap chip (bordered lime block, ▾/▴ chevron, ink-on-lime, the 44px platform minimum as real inline sizing). The drag-down reveal is retired with its HEADER_REVEAL_DRAG_PX threshold — one control, one gesture, nothing racing the OS — and the hidden header stays pointer-transparent and still animates (T27; T20 adapted to the tap path).

Phase 12 (the landscape notch): the notch RELOCATES — portrait puts it on safe-area-inset-top (handled since Phase 7), landscape moves it to a SIDE inset (~44–59px on -left or -right, top ≈ 0), and with viewport-fit=cover our content deliberately extends under it. The codebase consumed top/bottom in eight places and the side insets in zero. Now the collapsed header strip, the revealed site header, the felt, both pager docks, the chat floats and the collapsed lobby (content column + FAB) all clear it — through ONE shared definition, src/framework/safeArea.ts, whose helpers emit calc(<base> + env(…)) so existing insets are added to rather than replaced (and so jsdom can read the declaration back; it drops a bare env()). The governing rule is content insets, backgrounds do not — the felt gains an inset CONTENT box inside a deliberately full-bleed host, because a backdrop that stops at the inset trades the cutout for a black bar; each surface pads only the viewport edges it actually touches, so the landscape pager column pads right and leaves its internal seam alone. The felt needed no geometry change: it is container-driven, so padding an ancestor (never the measured box — getBoundingClientRect includes padding) shrinks it and the ring re-solves, shrinking scale where the box is width-bound and re-centering where it is height-bound, instead of clipping a seat plaque under the cutout (T28).

Phase 13 (mobile hand history + the z-ladder): tapping a hand in the mobile history view opened a modal you could barely see, for two independent structural reasons. It sat at z-50 while the collapsed chrome stacks from the claim bar (95) through the site header (100/101) to the bottom sheets (110) — so it opened behind the chrome; and because TablePager's track always carries a transform (that is how the panes slide), the track was the containing block for the modal's position: fixed, with pager-clip's overflow-hidden above it — so the modal was never viewport-positioned at all, it was drawn inside the pane. Raising the z-index alone would have fixed nothing. On phones the detail is now a real takeover: h-dvh, full-bleed backdrop with the four safe-area insets on the content box, no centering, opening straight onto the replay so the play button is right there, portalled to the body to escape the pager, and closing back to the pager with its view intact. Desktop keeps today's centered card verbatim, in the tree it has always been in — one body, two shells (T29). The structural half: src/framework/layers.ts is now the ONE z-layer ladder — every tier named in ascending order with the collision history recorded in-file, LAYER_CLASS carrying the Tailwind literals so the scanner still generates them, and FOREIGN_LAYER naming the two fixed layers we stack around but don't own (game-ui's pot-ceremony strip, globals.css's film grain). All 32 hardcoded fixed-overlay z-indexes across 23 files now read from it, each keeping exactly the number it already shipped, so nothing moved in the stack; T30 is a repo gate that fails the build on the next ad-hoc value. Known gap, flagged not fixed: HandReplayPlayer hardcodes the felt's default geometry and exposes no geometryOverrides passthrough, so at phone width the desktop side-rail bands (224 + 208) leave the ring using 32% of a ~360px box instead of 84% — the fix is one forwarded prop in packages/game-ui.

  • Phase 14HandReplayPlayer gained a geometryOverrides passthrough and the mobile history takeover passes the mobile band set: the replayed felt no longer reserves 432px of desktop side-bands inside a ~360px phone box (28px plaques at 32% of the width → 39px at 84%). Closes the blocker Phase 13 flagged rather than fixed under its read-only constraint.

  • Phase 15 — the cutout is a layout OBSTACLE, not just an inset number. The revealed site header offsets its content by the top safe-area inset as PADDING on the transformed box (so its ink still bleeds under the status bar and -translate-y-full — a percentage of the padded box — still parks it fully off-canvas; a top offset would have left a sliver exactly the inset tall on screen forever), fixing a 56px bar that rendered entirely inside the ~47–59px status-bar band under black-translucent + viewport-fit=cover. And the reveal chip leaves the Dynamic Island's lane: it was left-1/2 centered at the top — Phase 11 fixed only the vertical half — and is now anchored TOP-RIGHT with both side insets bounding its band and width: max-content + margin-left: auto hugging its right end, one rule for both orientations with no orientation branch. It also steps down to the revealed bar's bottom edge so it stops covering the header's own controls. The sweep that came with it found the same mistake twice more (the table page's top-centered "Reconnecting…" banner, which escapes downward past the inset rather than sideways; and the lobby's slim header, whose column now takes the top inset since collapsed mode frees the header reserve) and justified every other viewport-anchored surface as exempt, with the rule recorded once in src/framework/safeArea.ts (T32/T33).

  • Phase 16 — the reveal control STOPS FLOATING. Nothing may render in the cutout (a physical hole) and the band beside it in portrait is the iOS status bar, so no position at the top of the screen was ever going to be right: in PORTRAIT the control is now a chevron chip docked in the collapsed table strip's normal flow beside the fullscreen/chat chips (zero fixed-position controls above the felt), and in LANDSCAPE it docks in the side inset band our own layout already reserves past the cutout — flush to that edge, filling the band, hanging from the top of it rather than the middle because the landscape island is centred on its own edge. The header portals its ONE control into a display: contents slot (SiteHeaderRevealDock) that both collapsed surfaces offer — the lobby carried the same floating chip over its card list and gets the same fix from the same component — with a fail-safe float left for a collapsed surface that offers no dock, so the site header can never become unreachable (T34).

  • Phase 18 — the reveal, as designed. Three decisions from the user's side-by-side mockup review, two of them subtractions: (1) the header pushes content down instead of overlaying — an in-flow, overflow-hidden box whose height animates 0 ⇄ header + top inset, so the felt re-solves a little smaller and nothing is covered; the top safe-area inset changes hands (header holds it, strip drops it via the shared useSiteHeaderRevealed seam) so a notched phone never pays for the status bar twice. That retires the whole "revealed header eats taps" class rather than defending against it a third time (T26's transition window, T29's containing block). (2) the control is a slim 30×15 tab on the far left of the strip's bottom edge — a drawer pull on the actual drawer, in BOTH orientations; the painted ink stays small (the 44px square "read as a different species" beside the text chips) while a transparent hit-slop child carries the platform-minimum touch target. (3) landscape is full width — "no need to make that area off limits": the reserved-band dock is DELETED along with framework/cutoutBand + framework/safeAreaProbe (a rotation-time getComputedStyle probe whose only job was picking a band side), so the cutout side is ordinary content padding on a full-bleed row like every other surface. The frontpage article's finding 06 + demo were rewritten to the shipped design (T36/T37/T38; T34's landscape mechanism superseded, T35 adapted).

  • Phase 19 — smoke findings 9, three deletions and a falsy default. (1) the reveal tab belongs to the header, not to the mounted surface's strip ("the tab to pull down the header needs to be on the header itself not the sub header on the table … so its at the very top of the screen … as far left as possible"). Absolute against the header's own non-clipping box at top: 100%: the screen's top-left while hidden, riding down under the bar while open. That deleted the slot half of the Phase-16 seam (SiteHeaderRevealDock the component, useSiteHeaderRevealDock, the display: contents slot, both page mounts, the header's createPortal), the RevealPlacement union with its fail-safe float, HEADER_REVEAL_TOP_OFFSET / HEADER_REVEAL_SIDE_OFFSET / HEADER_REVEAL_TAB_DROP, and position: relative on two unrelated strips — five things removed, none added (T39/T40). ⚠️ TRADE for the Phase 6 glass gate: "the very top" is inside the portrait status-bar band where iOS draws the clock at the top-LEFT; obeyed as instructed, hit-slop biased DOWNWARD so all 44px is on glass, and the retune is one expression (top: "100%"safeAreaTop("100%")). (2) the click-away backdrop ate the lobby's scroll ("after pulling the header down in the lobby you can't scroll") — a fixed inset-0 transparent <button> over AppShell's overflow-y: auto content div, so every touch in the card list hit the dismiss layer. inset: 0 CONTAINS the scroller by construction, so the element is DELETED for a capture-phase pointerdown listener + Escape: a listener cannot eat a gesture it does not own. Invisible for twelve phases because the table page has nothing to scroll (T41). (3) the felt's "connect to play" nudge needs positive evidenceBoolean() on an OPTIONAL walletConnected made "nobody told me" mean "disconnected", so the hand-history replay (reported), the desktop history card and both frontpage demos all drew a wallet arrow; the gate is three-valued now and collapsed chrome WITHHOLDS the answer, because the arrow points at a header button that is off-screen there (T42/T43/T44). Folded in: the z-ladder's one FOREIGN tier blamed PotCeremony, which has no position: fixed — tier 80 is that nudge (T45). The article gained findings 08/09/10.

  • CI-only fix during Phase 12 (no phase section — recorded here + in the article's findings §07): the T1-style strict-equality geometry baseline is NOT portable across CPU architectures. libm's Math.cos/sin/** are only ~1-ULP accurate, so arm64 dev machines and x86-64 CI disagreed in the 15th significant digit (321.46266989387493 vs …48). Baselines over solved coordinates now normalize both sides to 6 decimals; a 2px perturbation still fails the tripwire, so the normalization did not gut it.