Skip to content

Make the web client behave like an app (shell, hosted session, navigation, mobile chrome) - #3080

Open
BlueCannonBall wants to merge 43 commits into
UltiMafia:masterfrom
BlueCannonBall:app-shell-and-mobile-chrome
Open

BlueCannonBall wants to merge 43 commits into
UltiMafia:masterfrom
BlueCannonBall:app-shell-and-mobile-chrome

Conversation

@BlueCannonBall

@BlueCannonBall BlueCannonBall commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Make the web client behave like a single application: keep the shell and game session mounted across navigation, make internal navigation continuous, add phone navigation and detail sheets, improve viewport/keyboard handling, and make lobby and chat updates less disruptive.

This description catalogs the final net diff, not the implementation history. Scope at head a10f0fdf: 59 files, +2,015 / −297 lines, entirely frontend code plus a design document. No backend/game-engine, database, API-contract, dependency/lockfile, deployment, or nginx changes are included.

Screenshots

Current local development build, 390×844 CSS-pixel viewport, signed out, with local game fixtures. These show the responsive layout and sheet, not native iOS/Android browser validation. Images are hosted on a separate preview-assets branch; no screenshot binaries are added to this PR's code diff.

Phone lobby and bottom navigation Game information in a phone sheet
Phone lobby with persistent bottom navigation Game information sheet with a dismissal handle; site navigation is hidden

User-facing changes

1. Continuous navigation and retained game sessions

  • Site chrome stays mounted when entering a game. It is hidden rather than discarded while the game has the screen.
  • The last site page stays mounted behind the game, preserving its local UI state and scroll container instead of remounting it on return. This is not a general keep-alive cache for every site route.
  • The game stays mounted when browsing site pages. Its socket, game state, selected UI state, and transcript remain attached to that session.
  • Leaving a session tears it down. A server-driven leave while browsing the site does not redirect the user away from the page they are reading. Selecting a different game replaces the hosted session by game id.
  • Confirming a ready check while on a site page returns to the game.
  • The visible, participating, in-progress game installs a reload/tab-close warning. Ordinary in-app navigation is not blocked.
  • Hidden game sessions mute the normal audio channels; custom death-sound playback consults the current visibility state when choosing its volume.
  • The game Escape listener is only installed while that session is visible.
  • The “You are in a game in progress” warning is shown on site pages, not over the game itself. It is centered above phone navigation and left-aligned on wider screens; Return navigates client-side.
  • Shared alerts and the connection banner are rendered at the application level, outside the retained site/game subtrees.

2. Internal links and actions stay in the application

The following surfaces use router navigation or in-place state refresh rather than opening another tab or reloading the document:

  • Player/profile links in miniprofiles, game player rows, Kudos player cards, and Liar's Dice avatar clicks.
  • The in-game site logo.
  • Welcome-to-your-first-game resource links: learning pages, rules, mentoring, feedback, and contribution/community links. The welcome modal closes when these links are followed. External Discord and donation links remain external.
  • Detail-card title links in PopoverContent.
  • Setup edit and copy actions.
  • Game links in moderation verdicts and report details; original-report links in report details. A verdict closes when its game link is followed.
  • Privacy-policy links in cookie consent and cookie settings.
  • Create-deck / create-word-deck buttons and shop/slot-purchase links in deck and word-deck screens.
  • Email/password and Google popup sign-in refresh the client session in place; the Welcome page's Firebase redirect-result flow does the same.
  • Logout in the header and settings clears user state and navigates without a document reload.
  • Accepting a family invitation and removing a family member reload the family data in place.
  • Profile-background upload/removal refreshes cache/user state rather than reloading the document.
  • Host/create game-filter URL updates replace the current history entry. Going Play → Host no longer inserts a second entry merely to add ?game=Mafia; changing the create-setup game filter likewise does not add a navigation entry.

These are the converted call sites, not a claim that every full reload in the application has been removed. Rehosting, recovery reloads, and external OAuth flows remain outside this conversion.

3. Phone shell, navigation, keyboard, and browser chrome

  • New bottom navigation: Play · Host · Community · Profile for signed-in users; Play · Community · Login for guests. Host is available only when signed in.
  • Active-tab matching prefers the most specific route, so Host is selected on /play/host rather than Play.
  • The phone header retains the menu/logo and signed-in user controls, while signed-out Login/Register entry points live in bottom navigation. The large site footer is omitted on phones; desktop footer/navigation remain.
  • The phone shell is a fixed edge-to-edge frame with content scrolling inside it; desktop uses a 100dvh minimum height. The game is a separate fixed frame.
  • The viewport declaration includes viewport-fit=cover and interactive-widget=resizes-content.
  • Viewport-derived keyboard state hides the site bottom navigation and the game's meeting-tabs/timer strip while the keyboard is open. The game's own navigation is not hidden by those rules.
  • Keyboard dismissal is detected through viewport recovery, not text-field blur. Width changes reset the viewport-height baseline.
  • Visual-viewport top/bottom offsets size the game frame where the browser does not resize the layout viewport for the keyboard.
  • A reader already following the transcript stays at its bottom when its container resizes, including when the keyboard opens. A reader scrolled up is not forced down.
  • Document-level overscroll is suppressed. Game transcript and site-chat overscroll are contained within their scrollers while keeping their local edge affordance.
  • Shared safe-area variables are available for the top, bottom, left, and right edges. The offline banner includes the top inset.
  • The bottom navigation and phone sheet use a browser-specific bottom-inset policy: Firefox's Firefox/ UA is treated as reserving the strip; other browsers let the page apply env(safe-area-inset-bottom). FxiOS is not classified as Gecko. This is a heuristic based on reported behaviour, not a browser-independent feature test.
  • <meta name="theme-color"> follows the shell's resolved background so browser chrome can match the app theme. The manifest's static colours are unchanged; native browser/OS colour rendering remains browser-dependent.

4. Shared phone detail sheets

  • SheetProvider / useSheet provide one content-agnostic sheet mechanism. It accepts a React element and an optional dismissal callback; callers retain responsibility for data and actions.
  • Phone sheets open from the bottom, have rounded top corners, a maximum height of 70vh, internally scrollable content, a soft upward shadow, and the applicable bottom safe-area padding. The shared Drawer uses a 360px right panel at wider widths when invoked there.
  • Actual phone sheet entry points in this PR:
    • Player miniprofiles from NameWithAvatar surfaces that already support includeMiniprofile, including game player rows and forum authors.
    • Lobby game information from PlayerCount.
    • Role-marking choices from RoleMarkerToggle.
    • Setup and deck details rendered through InfoPopover, where their existing triggers are available.
  • InfoPopover and normal desktop miniprofiles retain their anchored presentation on wider screens. This is not a blanket conversion of all popovers, menus, tooltips, newspaper, voting, inventory/action menus, or ordinary role-icon detail cards.
  • Player profiles fetch before the sheet opens. The provider retains the last loaded profile for reopening that user; empty guest ids do not fetch/open a profile.
  • Profile rendering is lazy-loaded, with a profile skeleton while that code chunk resolves. This keeps the miniprofile's D3/chart, report-form, markdown-editor, and colour-picker dependencies out of the initial eager import chain.
  • The phone handle is subtle (32×3px at 40% opacity inside a 28px-high full-width button), matches the detail title's background, has no ripple, and exposes an accessible “Close sheet” label plus keyboard focus indication.
  • Tapping the handle closes the sheet. Downward dragging from the handle moves the sheet; releasing at least 40px down dismisses it. Short/cancelled drags reset without treating the synthesized click as a tap-to-close. This does not make all sheet content draggable.
  • Drag motion is applied directly to the Paper's translate property, with pointer capture and one scheduled update per animation frame. It does not update React state or an inherited CSS custom property on each move. The release offset is retained through the closing animation.
  • Sheets close on backdrop/Escape or navigation and notify the opener. Content remains through the exit transition and is cleared when that transition completes, unless another open supersedes it.
  • The site nav is hidden while the sheet is open and restored before the closing animation paints, so the descending sheet reveals it rather than the nav appearing after dismissal. The backdrop still blocks interaction during the exit.
  • Prediction-choice rows select a role without opening their nested role-detail hover card. Normal role-detail interactions are unchanged.

5. Overlay lifecycle and Back behaviour

  • Hosting closes the host dialog before navigating into the game.
  • Moderation drawers close when a game takes the screen.
  • Header dropdowns and popovers using usePopoverOpen close on pathname/query changes and on the site-to-game transition, even though the site subtree sees a frozen location.
  • Leave-game, change-setup, and role-reveal dialogs use a shared Back-dismissal hook: opening adds a same-URL marker, Back closes the top tracked overlay, and UI dismissal cleans up its current marker.
  • Sheets do not use that marker hook; they close on navigation and through their own dismissal controls. There is no universal Back interception for every overlay.

6. Lobby loading, freshness, pagination requests, and row identity

  • Play route components have stable module-scope lazy declarations, so changing lobby/filter query parameters no longer changes the component type and remounts the whole page.
  • The lobby keeps an existing list visible while fetching another list. Skeleton rows are used when loading with no existing games; manual refresh retains its spinning-button feedback rather than replacing populated rows with skeletons.
  • Selecting a lobby resets to page 1 and issues one list request rather than requests for both page 1 and the previous lobby's page.
  • Refresh, row-refresh callbacks, and background polling reuse the cursor associated with the current page instead of recomputing it from items that may have endTime: 0. Explicit page navigation still computes a new cursor using the existing paging helper.
  • One row per game id is kept before assigning the list, preserving first-occurrence order and avoiding duplicate React keys if the API returns duplicate game records. This is a client defence, not a server-side uniqueness or pagination fix.
  • The selected lobby's list and open-game counts refresh every 20 seconds while the tab is visible and no game has the screen.
  • Hidden-tab / in-game ticks are skipped. Returning to the tab or from a game triggers a background refresh; the polling loop has an in-flight guard and reads current callbacks/filter/page through a ref.
  • Background refreshes preserve the existing list, leave foreground loading state alone, and suppress repetitive failure alerts. Normal foreground error handling remains.
  • A full, not-yet-started Open game's Join button is hidden instead of offering a join that the game cannot accept.
  • Lobby picker sizing remains the existing MUI tab sizing; there is no net compact-tab restyling in this diff.

7. Loading presentation and first paint

  • Shared ListSkeleton, PageSkeleton, ProfileSkeleton, and ThreadSkeleton components provide loading shapes.
  • They replace in-page spinners in the lobby, profile, settings, inbox, shop, family, forum categories, forum board, and forum thread screens.
  • Main, Play, and User route-loading boundaries use compact Loading small fallbacks. Initial application bootstrap still has its full loading gate.
  • useLoading defaults its minimum delay to zero; callers can still request an explicit minimum.
  • A head script marks scripted documents with html.js; inline CSS hides the static landing <main> inside #root before it paints. The landing copy and <noscript> messaging remain available without JavaScript. This removes the marketing-copy flash without hiding the mounted application.

8. Real-time chat and connection state

  • The game-history reducer ignores repeated message ids within the current state's relevant meeting or alert list, using a pure check rather than mutating reducer state. Messages without ids continue to append.
  • When new transcript rows arrive while the reader is scrolled away from the bottom, an “N new messages” pill appears above the speech-input/Kudos footer. Activating it resumes following the newest message and resets the counter.
  • A ResizeObserver keeps following readers pinned to the bottom on transcript-container resizes.
  • Shared socket status aggregates idle, live, connecting, reconnecting, and browser offline states. Each socket registers an id, updates on open/close, and unregisters on explicit client cleanup.
  • The React 17 connection-status hook subscribes to that external store. The app-level banner is intentionally shown only for browser offline state, not every transient connection/reconnection state.
  • Game WebSocket URLs fall back to window.location.hostname when the configured host is unset or localhost, allowing LAN/tailnet clients to connect. Explicit configured/per-port hosts and existing port/path modes remain supported.

Internal architecture and complete file catalogue

Paths below are relative to react_main/ unless prefixed with docs/. This table covers every file in the net diff; detailed behaviour is catalogued above.

Changed files Responsibility
src/Main.jsx Persistent site/game composition, hosted-session selection/teardown, frozen site location, global alerts/banner, phone shell/nav/header/footer, in-game warning, viewport/platform initialization, shared sheet/profile/theme providers. Footer Terms/Privacy router links also carry viewTransition props; no custom sitewide snapshot-transition implementation is included.
src/Contexts.jsx Adds InGameContext; exposes stable refreshUser() and clear() callbacks; centralizes user bootstrap/sign-in refresh, CSRF setup, online announcement, referral handling and first-account messaging; uses a ref for current site alerts and cleans up the online heartbeat.
src/pages/Game/Game.jsx Accepts hosted gameId, visibility and leave callbacks; reads context game ids in TopBar/Notes; lifecycle/audio/ready-check/unload handling; socket-host fallback; transcript resize/new-message affordance; pure message-id dedupe; internal resource/profile/logo navigation.
src/Socket.js, src/lib/connectionStatus.js, src/hooks/useConnectionStatus.jsx, src/components/ConnectionBanner.jsx Socket registration/cleanup, aggregate status external store, browser connectivity events, React subscription and offline banner.
src/components/Sheet.jsx, src/components/ProfileSheet.jsx Generic shared sheet API, retention/dismissal/drag lifecycle; profile data adapter/cache and lazy miniprofile rendering.
src/components/Popover.jsx Router-backed detail title links; responsive InfoPopover sheet transport; action-driven sheet/popover closure while preserving desktop anchors.
src/components/Roles.jsx Prediction choices suppress nested detail hover/popover behaviour.
src/pages/User/UserWidgets.jsx, src/components/Miniprofile.jsx Phone includeMiniprofile taps open the shared profile sheet; mobile hover/anchored profile presentation is disabled; miniprofile profile links remain in-app.
src/components/MobileNav.jsx, src/components/ThemeColorMeta.jsx Phone section navigation and active matching; shell-colour-driven browser theme meta with root attribute observation.
src/hooks/useAppViewport.js, src/css/main.css, src/css/game.css, src/css/chat.css Fixed game frame, viewport/keyboard detection and CSS flags, safe-area variables/browser policy, nav/sheet/keyboard visibility, offline-banner inset, overscroll rules and new-message/footer layout.
src/utils/platform.js, src/utils/pushNotifications.js Shared iOS detection plus bottom-safe-area UA predicate. Push helpers import/re-export the shared isIos function; subscription/permission behaviour is otherwise unchanged.
public/index.html Pre-paint scripted landing-copy suppression; viewport-fit and interactive-widget declaration. Existing structured data/landing copy/manifest references remain.
src/hooks/useDismissOnBack.js, src/components/LeaveGameDialog.jsx, src/components/gameComponents/ChangeSetupDialog.jsx, src/components/gameComponents/RoleRevealModal.jsx Tracked overlay stack, same-URL history markers and cleanup; opt-in Back dismissal for these three dialogs.
src/hooks/usePopoverOpen.jsx, src/components/NavDropdown.jsx, src/components/ModerationSideDrawer.jsx, src/components/HostGameDialogue.jsx Navigation/game-visibility dismissal of portalled UI; host-success closure.
src/pages/Play/Play.jsx, src/pages/Play/CreateSetup/CreateSetup.jsx Stable lazy route types, compact Play fallback, game-filter history replacement.
src/pages/Play/LobbyBrowser/LobbyBrowser.jsx, src/pages/Play/LobbyBrowser/GameRow.jsx List loading/freshness, single page-1 lobby request, cursor reuse, per-game dedupe, background poll and full-open-game Join gating.
src/components/Setup.jsx Client-side setup edit/copy navigation.
src/pages/Play/Decks/CreateDeck.jsx, src/pages/Play/Decks/DeckSelector.jsx, src/pages/Play/WordDecks/CreateWordDeck.jsx, src/pages/Play/WordDecks/WordDeckSelector.jsx Router links for deck creation and slot/shop purchases.
src/components/Auth.jsx, src/pages/Welcome/Welcome.jsx, src/pages/User/UserNavSection.jsx In-place successful sign-in/redirect refresh and logout navigation.
src/pages/User/Settings.jsx Settings skeleton; router privacy link; in-place background upload/removal and logout.
src/pages/User/Family.jsx Family skeleton and data reload after invitation acceptance/member removal.
src/pages/User/Profile.jsx Profile skeleton; moves the skill-rating import into the import block.
src/pages/User/Inbox.jsx, src/pages/User/Shop.jsx, src/pages/User/User.jsx Inbox/shop skeletons and compact User route fallback.
src/components/Skeletons.jsx, src/pages/Community/Forums/Board.jsx, src/pages/Community/Forums/Categories.jsx, src/pages/Community/Forums/Thread.jsx Shared skeleton primitives and forum loading presentations.
src/hooks/useLoading.jsx Defaults the optional artificial minimum-loading interval to zero while preserving explicit caller overrides.
src/components/CookieBanner.jsx, src/components/RapSheet.jsx, src/components/ReportDetail.jsx In-app privacy/game/report links and verdict closure on game navigation.
src/pages/Game/LiarsDiceGame.jsx, src/pages/Game/components/Kudos.jsx In-app player-profile navigation; no forced new tabs on the affected player surfaces.
docs/plan-app-shell-and-game-session.md Architecture/scope plan and implementation-status document for shell, hosted session, navigation, viewport and interaction work.

Architecture details reviewers should know

  • Main owns the hosted session rather than a game route owning its lifetime. Game ids are passed explicitly, with route-params fallback for compatibility; game children use GameContext where route params no longer exist.
  • The retained site subtree receives its last site URL through React Router's UNSAFE_LocationContext. InGameContext separately announces when the game has the screen, because frozen-location consumers cannot detect that navigation normally.
  • Shared providers are outside that frozen router context so sheets can observe the real route and close on navigation.
  • Sheets store caller-built React elements in state; fetching/rendering adapters are separate from the Drawer transport. The global sheet API does not currently have owner/request tokens.
  • The socket status store is module-level, because sockets are below the shell in different trees. It is observable without moving those socket implementations into shell React state.
  • Lazy boundaries are retained for route pages and miniprofile dependencies; this PR does not replace the router, React version, MUI version or build tool.
  • Viewport/platform state is exposed as CSS variables/HTML attributes; drag motion uses the element's independent translate property rather than interfering with MUI Slide's transform.

Verification

Observed / exercised

  • Fresh production build at the current head passed, using a temporary alias for qrcode.react because the package is missing from the host's installed dependencies. The normal dependency remains unchanged; this is not a clean-install build or QR-component verification. Temporary config was removed afterward.
  • ESLint over all changed JS/JSX files reports four existing errors, not a clean lint run: two conditional useMemo calls in Roles.jsx, and onLeaveGameClick/restricted global history in Game.jsx. The corresponding errors were checked in baseline code; ordinary warnings also remain. Latest Sheet.jsx lint and whitespace checks pass.
  • Local-browser navigation: Play → Host → Back returns to the lobby in one press; lobby changes send one page-1 list request, and a poll repeats the same current-page query.
  • Network observation: visible lobby polling issues list/count requests; manual refresh issues them too and spins the refresh icon without replacing a populated list with a skeleton.
  • A duplicate-id payload taken from the dev instance reproduces incorrect DOM reconciliation with duplicated keys; deduplication preserves order and uniqueness before rendering.
  • Local-browser phone-layout checks exercised actual game-info/profile content in sheets, backdrop dismissal/reopening, and the desktop anchored-card path.
  • Emulated bottom-inset checks exercised both padded/unpadded nav and sheet layouts, and nav restoration on dismissal. These are DOM/geometry checks, not native OS rendering checks.
  • Browser meta inspection confirms theme-color follows a changed shell palette variable and returns with it.
  • Scripted landing copy is hidden before app mount; removing the scripted marker restores the fallback copy, and the mounted app is not hidden.
  • Gauss's phone testing confirmed role marking works, navbar reveal on dismissal works, handle colour matches, and direct translate drag motion resolves the reported stutter.
  • The screenshots above were captured from the current local build.

Not fully verified / remaining review risks

  • No new automated regression suite accompanies this refactor; backend npm test was not run as part of this description update. This is not a claim of all tests passing.
  • Session retention, reconnect replay/deduplication, ready-check return, all overlay-history paths, and all changed auth/family/settings flows have not received comprehensive signed-in/live-game automated coverage.
  • No complete iOS Safari/PWA device test matrix; Android Firefox/Chrome reports informed safe-area handling. Keyboard detection and safe-area ownership remain heuristics. Native theme-color rendering is not guaranteed by a DOM-meta test.
  • The frozen router context is an internal-API dependency. Recheck on React Router upgrades; this is the most unconventional architectural choice in the PR.
  • Keeping a tree mounted does not suspend its effects: hidden sessions/pages may still run timers, listeners and fetches. This PR gates selected behaviour, not every retained-tree side effect. Portalled UI must be reviewed independently of its parent's visibility.
  • Profile/lobby requests do not yet have general cancellation/latest-request guards. Rapid navigation or out-of-order responses deserve race tests.
  • The sheet API lacks ownership tokens; global closing and stored React elements deserve review for overlapping-open/stale-content cases.
  • Individual mobile setup/deck triggers and every detail-card variant are not all runtime-tested. Ordinary role details, action menus and other unrelated popovers are not converted.
  • The tracked Main.jsx still contains an unused import reference to hooks/useThemeColorMeta; the active implementation is ThemeColorMeta. The production build above passed, but this is remaining import housekeeping rather than an additional hook implementation.

Explicitly outside this PR

  • Optimistic sends, pending/delivered/failed send lifecycle, typing/speaking ephemera, or a new message-delivery protocol.
  • General per-route/per-lobby data caches, hover prefetch, or caching/deploy-policy changes.
  • Server-side game-record uniqueness, finished-game pagination/deduplication, game-engine or moderation Break-game changes. Those dev-instance fixes are not in this GitHub diff.
  • Blanket sheet conversion of every popover/menu, route-transition animations, or extra motion for time-sensitive game actions.
  • A theme-dependent PWA manifest or a service-worker cache/update strategy.
  • The SSH/email deploy runbook and CI/CD proposals, which live outside this repository diff.

Diagnosis of why the client feels like a page, and the layered plan: a persistent shell, a game session owned above the view layer, app-like navigation, a mobile viewport, and the real-time synchronization contract. Records what was tried and removed (View Transitions, snapshot ghosting) and what remains deferred.
…m nav

Render the site shell unconditionally and hide it (not unmount) while a game is shown, and host the game session outside the route table keyed by gameId, so ordinary navigation no longer tears down its connection or state. Add an app-level connection banner, a mobile viewport (viewport-fit=cover, safe-area insets, dynamic viewport units, keyboard inset), a message-id dedup guard, and an app-style bottom navigation for phones.
Hoist Play.jsx lazy() to module scope so switching lobbies no longer remounts the whole page. Hide the Join button for a full, unstarted game; drop the artificial loading delay; keep the lobby list visible while loading; use compact in-place route fallbacks. Keep in-app navigation in-app: profiles, the in-game logo, help links, privacy links, and the report link no longer open new tabs, and sign-in, logout, family, and profile-background changes no longer reload the document. Return to the game when a ready check is confirmed from another page, and close popovers and menus on navigation.
…eletons

Tapping a player now opens their profile as a sheet over the game (bottom sheet on phones, side panel on desktop) through a global ProfileSheetProvider, instead of an anchored popover or a new tab -- matching how the native app lets you check a player mid-game. A useDismissOnBack hook pushes a same-URL history entry so the Android/browser Back button closes the top overlay (profile sheet, leave dialog, change-setup dialog, role reveal) instead of leaving the page. Loading spinners for in-page fetches are replaced by skeletons in the lobby, profile, settings, inbox, shop, family, and forum board/categories/thread. Also moves a misplaced import in Profile.jsx to clear a pre-existing lint error.
The phone shell is now a fixed-height frame with the bottom navigation as a layout child instead of a position:fixed bar, so scrolling no longer leaves a gap between the content and the nav (the fixed bar was anchored to the layout viewport while the mobile visual viewport moved). The profile sheet is shorter (70vh) and, when the bottom nav is visible, sits above it so it does not cover the navigation.
…ft in-game warning above the phone nav

The game WebSocket host came from REACT_APP_SOCKET_URI, which defaults to localhost, so any client that is not the host machine (a phone on the LAN or a tailnet) tried to connect to itself and games never loaded. Fall back to window.location.hostname when the configured host is localhost or unset, so the same build works from localhost, a LAN/tailnet address, or the real domain. The 'you are in a game' snackbar now sits above the phone bottom navigation instead of over it, and is slightly more compact.
…sheet on phones

The phone shell and the game session are sized to the visual viewport through --app-height/--app-offset-top from visualViewport, so the bottom navigation is flush (the earlier 100dvh frame left a permanent gap under it) and the game's top bar and bottom input stay visible when the on-screen keyboard opens. The separate keyboard-inset hack on the speech input is removed, along with its hook. On phones, tapping a player name opens only the profile sheet: the hover popover that opened alongside it is disabled. The sheet also closes when its name link navigates to the full profile.
… pad; dedupe profile sheet open

The app frame now fills the layout viewport normally (so the bottom bar is flush and no gap opens under it) and only shrinks to the visual viewport while the on-screen keyboard is open. Removes a stray safe-area under the game chat input that left a gap above the in-game bar. Opening the same profile twice no longer blanks and reloads the sheet.
…; keep sheet content on close

The phone frame is position:fixed with top/bottom offsets (0 normally), which reaches the screen edge the way the old fixed bottom bar did; a computed height from documentElement.clientHeight came up short on-device and left a strip under the navbar. The keyboard-open state still insets the frame. The profile sheet no longer clears its loaded profile on close, so the closing animation shows the content instead of a skeleton.
… put; desktop warning left

The profile sheet fetches first and opens with the content, so it no longer slides up as a skeleton and re-animates when the content arrives. The site bottom nav no longer moves up with the on-screen keyboard (the keyboard just covers it), and the phone frame paints the safe area itself so no browser colour shows under the nav. The 'you are in a game' snackbar is centred only on phones; on desktop it stays bottom-left.
… screen edge

A padding-bottom added to the phone frame pushed the bottom navigation up and left a strip below it. The frame (position:fixed; top:0; bottom:0) without that padding sits flush to the screen edge. Confirmed fixed on the device.
The sheet's 'sit above the phone nav' offset was recomputed from the current route, so navigating from a game (nav hidden) to a profile (nav shown) changed the offset before the close animation and the sheet jumped up before sliding down. The offset is now decided when the sheet opens and held until the next open.
… history markers

While a game is shown, the site subtree is frozen at its last non-game location, so the page you entered the game from stays mounted and does not remount or refetch when you return. Also fixes the accumulating history entries: useDismissOnBack removes its marker entry when an overlay is closed from its own UI, and the profile sheet no longer pushes a marker (it navigates, so its marker could not be cleaned). Slightly reduced the gap between the in-game warning and the phone nav.
@BlueCannonBall
BlueCannonBall marked this pull request as ready for review October 13, 2026 22:05
The pill was anchored near the bottom of the speech wrapper, which is where the input sits, so it overlapped the textbox. The kudos dock and the input are now wrapped, and the pill is positioned just above them (bottom: 100% of that footer).
Add overscroll-behavior: contain to the in-game transcript (.speech-display) and the site chat (.channel-messages), and overscroll-behavior: none to the document, so touch drags no longer scroll past the bottom or rubber-band the page.
…oard is open

useAppViewport flags <html data-keyboard="open"> when the visual viewport shrinks for the keyboard, and the meeting-tabs + timer bar is hidden while it is set, so the chat has the room.
…transcript overscroll

Dragging on the chat input panned the visual viewport while the keyboard was up, which let the chat slide past the bottom; touch-action: none on the input area stops the browser from treating that drag as a pan. The transcript and site chat also use overscroll-behavior: none (was contain) to remove the local overscroll glow.
touch-action: none on the input did not stop the visual-viewport pan on the phone. The remaining pan is a browser behaviour while the keyboard is up and is left alone. The overscroll-behavior changes stay, since the general overscroll is fixed.
Opening the on-screen keyboard shrinks the game frame, which grows the transcript's scrollable range; the auto-scroll only ran on new messages, so a chat that was at the bottom ended up above it. A ResizeObserver now scrolls to the bottom while the reader is following the chat.
Add interactive-widget=resizes-content so the keyboard resizes the layout viewport instead of overlaying it, which fixes the visual-viewport pan (the chat could be dragged past the bottom) at the root. With the layout shrinking the site bottom nav would ride up, so it is hidden while a text field is focused. The keyboard is now detected by focus rather than the viewport, because resizes-content shrinks the layout and the viewport heuristic no longer distinguishes it. The game's own bottom bar is left visible.
The dialog only closed via a route change before; the lobby now stays mounted behind a game, and the dialog renders in a portal, so it stayed visible on top of the newly hosted game. Close it before navigating.
An earlier change in this branch made the lobby tabs scrollable and compact (min-height 40, smaller font, no uppercase). Restore the stock Tabs appearance.
The keyboard flag (data-keyboard, which hides the chat tabs+timer bar and the site bottom nav) had been switched to focus-based detection. On Android the keyboard's Back button closes the keyboard without blurring the field, so focusout never fired and the flag stuck: the chat's top bar stayed hidden after the keyboard closed. Detect the keyboard from the visible height instead -- the smaller of window.innerHeight and the visual viewport, measured against the height seen with the keyboard closed. That clears on its own and still works under interactive-widget=resizes-content.
The shell keeps the site subtree mounted while a game is shown, and MUI
overlays portal to <body>, which display:none on the shell does not reach.
Before, navigating into a game unmounted the page and everything with it.

- Main: render the "you are in a game in progress" snackbar only while the
  user is elsewhere. It is keyed off user.inGame, which is set on load when
  the server says you are in a game, so reloading inside a game put it over
  the game itself.
- FirstGameModal: close on the resource links, which navigate away.
- RapSheet: close the verdict dialog when its game link is followed.
- Add InGameContext (true while a game has the screen; the shell cannot see
  that transition because its location is frozen). ModerationSideDrawer,
  usePopoverOpen and NavDropdown close on it, so a drawer, popover or menu
  that portals out of the shell does not float over the game.
- Game: mute the session while it is hidden behind the site. Unmounting used
  to stop its audio; now it kept playing. Muting rather than stopping keeps
  the music where it was, so returning resumes it. Custom death sounds are
  muted too (they bypass the channel volumes).
The marketing copy inside #root (kept so crawlers that run no scripts get
something readable) painted for a moment on every load. Once the app's
stylesheet set a light body background, and before React's DOM update
replaced the copy, the black text showed over the app's first frame.

Hide it whenever scripting is on: a script in the head adds html.js as the
head is parsed, and html.js #root > main { display: none }. A crawler that
runs no scripts, and a reader with JavaScript off, still get the copy. That
is the only audience it ever served -- React clears #root on mount, so a
script-running crawler never saw it either.
The profile sheet imported Miniprofile statically, which defeated the lazy
boundary UserWidgets already uses and pulled d3 (PieChart), react-mde,
react-color and react-markdown, plus ReportDialog, into the initial chunk --
they hung off Miniprofile via ReportDialog -> Form. Nothing the shell needs
before a sheet is opened.

Miniprofile is now lazy, with the existing ProfileSkeleton as the fallback.
The sheet fetches the profile before opening, so the chunk is requested
alongside the data rather than after it.

Production build, initial critical path (gzip): 385.8 KB -> 303.4 KB.
The moved code becomes a 93 KB gz chunk loaded on first open.
Play rewrote the URL to carry the game filter (?game=) with a push. That
filter is derived state mirroring the current page, so the push left an entry
for the same page without the query: Back landed on /play/host, the effect
added ?game= again, and leaving the page took two presses. Create setup did
the same when picking a game type from its drawer.

Both replace now, matching what the lobby query already did
(LobbyBrowser.jsx:82).

Verified in a browser: from /play?lobby=Main, entering /play/host and pressing
Back once lands on /play?lobby=Main with the history length unchanged (3).
The lobby fetched only on mount and on a lobby change, so it sat at whatever
it showed when the page opened -- including the list you return to after a
game, because the shell keeps the lobby mounted behind it.

It refreshes every 20s now. A tick is skipped while the tab is hidden (nobody
is looking) or while a game has the screen (this lobby is behind it), and one
runs immediately when either ends, so the list you come back to is current.
A background refresh leaves the loading state alone -- otherwise an empty
lobby would flash its skeleton every tick -- and does not raise an error
alert; a failed tick just tries again.

Verified in a browser: one tick in 31s (/api/game/list 2 -> 3, openCounts
1 -> 2), no skeleton, refresh button not spinning.
b536f4d switched the in-game transcript and the site chat from
overscroll-behavior: contain to none, which also removed the element's own
overscroll affordance -- the glow on Android. contain keeps that affordance
while still stopping the overscroll from chaining to the page, and the
document keeps `none`, so the page itself still does not rubber-band or
pull-to-refresh.
Switching lobbies fired two list requests: page 1, and then the page number
left over from the previous lobby, with a cursor recomputed from the items on
screen. Whichever response landed last decided the list, so the same switch
could show different games.

Past page 1 that second request is entirely concluded games: the route slices
the open + in-progress array first, and beyond the first page that slice is
empty, so the finished-games top-up fills the whole page. An unfilled game of
your own sits on page 1, which is why it ended up below games that had
concluded.

A lobby change now resets to page 1 and makes one request. Refreshes and the
poll reuse the cursor the page on screen was fetched with rather than
recomputing one: the list endpoint returns endTime 0 for open and in-progress
games, so a cursor derived from the items is not the one that produced the
page (ending on an open game it becomes last=0, which drops the finished
tail).

Verified in a browser: load and switch each send one request
(last=Infinity&page=1), and a poll tick sends that same URL.
The list endpoint can return two documents for the same game id: dev serves
7Ufg1CxTF twice on every page of Main. Rows are keyed by game id, so the
duplicate collided in React's reconciliation. Rendering the live payload with
key={game.id} produced 11 rows for 10 games, with EOA2yd1Yf pushed below the
duplicate, and which row landed where depended on the previous render -- which
is the "switching lobbies isn't idempotent, my unfilled game ends up below
games that have concluded" report. The same payload with a unique key renders
exactly the array order.

Dedupe by game id before setting the list: 10 items in, 9 out, ids unique,
order preserved. The duplicate ids themselves are a server-side data problem
(two Game documents per game); this makes the client correct regardless.
…ttom safe area only where the browser does not

Sheets were a one-off for player profiles. Make the mechanism generic --
SheetProvider/useSheet take an element and render it as a bottom sheet on a
phone, a side panel on desktop, close it on navigation, and hand dismissal
back to whoever opened it -- and move the profile onto it. InfoPopover then
picks the transport: an anchored card on desktop, a sheet on phones. That
covers every caller without touching them: the role marker in game, game info
in the lobby, setup and deck cards, the in-game role icons.

Two things testing turned up along the way:

- TransitionProps is ignored by MUI 6 (the prop is slotProps.transition now),
  so a closed sheet never cleared its node and left <html data-sheet="open">
  set, which kept the site bottom nav hidden after the sheet closed.
- The bottom safe area was applied unconditionally, which is a gap on Firefox
  for Android: it keeps the layout viewport clear of the gesture strip itself
  and still reports a non-zero env(safe-area-inset-bottom), so the padding
  doubled the space. iOS browsers and Chrome on Android are the other way
  round: with viewport-fit=cover they hand the strip to the page, and the bar
  has to fill it or its icons sit in the home-indicator area. There is no
  feature test for which behaviour a browser has, so ask
  browserReservesBottomSafeArea() (Firefox/ only; Firefox on iOS reports FxiOS
  and is WebKit, so it stays with the browsers that hand the strip over) and
  expose the answer as <html data-bottom-inset="page">, which sets
  --bottom-safe-area for the nav and for the sheet.

Verified on an emulated phone layout: a lobby game-info tap renders in the
sheet with real content and not in an anchored popover; tapping an author name
opens the profile sheet with real profile content; backdrop dismissal closes
the sheet and gives the nav back, over two cycles; with a 34px inset emulated
the nav and the sheet take 34px and sit flush at the viewport bottom when the
page owns the inset, and 0px when it does not. The predicate was checked
against real user agents: Chrome/WebView/Samsung on Android and iOS Safari and
iOS Firefox fill it; Firefox on Android (phone and tablet) reserves it.
Where the browser paints the safe-area strip itself -- the toolbar and gesture
strip on Android Chrome, the tab bar on iOS Safari -- the colour comes from
<meta name="theme-color">, which shipped as a single hard-coded dark value. In
a light or custom site theme the chrome around the app was then the wrong
colour while the app itself was light.

Track the shell's resolved background instead: the shell is the surface the
bottom bar paints, so the strip under it matches the bar by construction. The
observer follows the scheme class MUI writes on <html> and any later site-theme
change.

A component rather than a hook called by Main, because it has to run once the
shell is on the page and the shell renders behind Main's loading gate.

Verified in the browser: with the palette variable changed the meta follows the
shell to rgb(171, 205, 239) and back.
Prediction rows select a role rather than inspect it. Disable their role-detail popover and hover handlers so touch-generated hover cannot flash a card during sheet dismissal. Normal role-detail interactions remain unchanged.
Restore the site navbar in a layout effect when closing starts, rather than waiting for the sheet content to clear after its exit animation. The descending sheet reveals the navbar naturally; the backdrop continues blocking interaction during dismissal. Verified locally by Gauss.
Add a subtle phone-only handle matching the title background, with tap and downward-drag dismissal and a softer upward shadow. Preserve the release offset through the closing animation. Coalesce pointer updates per frame and write translate directly rather than updating React state or an inherited CSS variable; Gauss confirmed the latter change resolved the drag stutter on his phone.
BlueCannonBall added a commit to BlueCannonBall/Ultimafia that referenced this pull request Oct 14, 2026
BlueCannonBall added a commit to BlueCannonBall/Ultimafia that referenced this pull request Oct 14, 2026
Prevent the lazy-loaded deck stylesheet from globally resizing solid Font Awesome icons. Verified the icon font size remains unchanged through lobby, Word Decks, and back.
Refresh authoritative membership after socket load; clear the matching membership on leave and finish, and clear it on logout or a session refresh with no game. Sequence membership requests so late responses cannot restore stale state. State/race checks and a production build with the host QR-code stub pass; live-game verification remains outstanding.
Contain the site content scroller while allowing its local touch-edge affordance. Scope document overscroll suppression to the active game view rather than all site routes. CSS-state checks and lint pass; native device verification remains outstanding.
Use public retained-route matching with real navigation/activity signals and guarded site redirects. Add owned sheets and latest-request cancellation; preserve accepted lobby cursors. Dispose sockets and component subscriptions explicitly, bound replay, and reject superseded callbacks. Gate hidden-view focus, overlays, polling and transient audio; discard sessions across account changes. Preserve previous-page return on game leave.

Add 15 passing unit regressions, 18 passing browser regressions and 8 passing focused Web Audio checks. Changed-source lint and the real-dependency production build pass. Document baseline backend-suite failures/OOM and its misleading zero exit status; native iOS and live-game validation remain outstanding.
@BlueCannonBall

Copy link
Copy Markdown
Contributor Author

Follow-up: retained-session and overlay lifecycle hardening

Pushed eac5ecfd — this closes out the items on my own pre-merge risk list. It targets the failure modes that only show up once navigation is retained: stale sockets, orphaned timers, superseded requests, and inactive pages acting on the active view.

What changed

Sockets (component-owned).

  • react_main/src/Socket.js — disposal is now idempotent; heartbeat, queued native callbacks, status unregistration all run once and late events are rejected.
  • Added subscribeSocketListeners so each component owns and removes only its own handlers; unmount stops delivery for that subscriber.
  • Reconnect detaches the superseded connection; the catch-up buffer is bounded and excludes heartbeat/malformed frames. Queued sends drain once and are not replayed on reconnect.

Routing / activity.

  • react_main/src/hooks/useViewActivity.jsx — a public route-location API replaces the UNSAFE_LocationContext injection, with real navigation and view-activity kept as separate signals.
  • ViewNavigate / useViewNavigate replaced the router Navigate/useNavigate across site pages. An inactive retained page can no longer redirect the active game — a declarative redirect is deferred until the page is reactivated, and a stale imperative callback is discarded. Session-owner and ready-check navigation stay intentionally app-global.

Sheets and requests.

  • react_main/src/lib/sheetController.js and latestRequest.js — sheets are owned, and async content updates carry a request token so older responses cannot overwrite newer ones.
  • ProfileSheet, Popover, and the predication chooser adopt this; nested hover cards are suppressed on the choosing rows.
  • LobbyBrowser makes a single page-1 request, tracks the accepted cursor, and guards the poll — no more duplicate/misordered rows from a repeated id.

Hidden-view behavior.

  • Inactive views stop polling, do not steal focus or open overlays, and do not start transient audio; teardown runs on account-identity change so a retained authenticated game is not reused by a different sign-in. A same-account refresh preserves the session.

Game session.

  • useHostedGameSession returns to the previous page on leave, preserving query, hash, and the scroll element.

Verification

Check Result
Unit regressions (react_main/test/lifecycle.test.mjs) 15 pass
Browser regressions (react_main/test/browser.jsx, phone viewport 390×844) 18 pass, 0 console errors, 0 external requests
Web Audio (test/webAudio.test.js) 8 pass
Changed-source lint pass
Production build with real dependencies pass
Local app smoke (nav, real game sheet, native drag-dismiss, return-to-lobby) pass

The browser suite now includes the new case that inactive-page redirects and stale imperative callbacks cannot hijack a game.

Known limitations (not fixed here)

  • The full backend npm test suite is not a trustworthy gate: it fails on the committed pre-hardening baseline too (including an OOM) and exits 0 despite the errors. Treating that as a problem for the suite, not this branch. Details in docs/testing-app-lifecycle.md.
  • Native iOS device validation and a live in-progress competitive game have not been run yet — desktop Chromium emulation only, so far.
  • Four pre-existing lint errors elsewhere in the tree are untouched.

Files

New: react_main/src/components/RetainedSiteRoutes.jsx, react_main/src/components/ViewOverlayBoundary.jsx, react_main/src/hooks/{useViewActivity,useHostedGameSession,useGameMembership,useGameSessionIdentity}.jsx, react_main/src/lib/{latestRequest,sheetController}.js, react_main/test/{lifecycle.test.mjs,browser.jsx,run-browser.cjs}, react_main/rsbuild.lifecycle-test.config.ts, docs/testing-app-lifecycle.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant