Repository navigation
Make the web client behave like an app (shell, hosted session, navigation, mobile chrome) - #3080
BlueCannonBall wants to merge 43 commits into
Conversation
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.
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.
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.
Follow-up: retained-session and overlay lifecycle hardeningPushed What changedSockets (component-owned).
Routing / activity.
Sheets and requests.
Hidden-view behavior.
Game session.
Verification
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)
FilesNew: |
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.
User-facing changes
1. Continuous navigation and retained game sessions
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:
PopoverContent.?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
/play/hostrather than Play.100dvhminimum height. The game is a separate fixed frame.viewport-fit=coverandinteractive-widget=resizes-content.Firefox/UA is treated as reserving the strip; other browsers let the page applyenv(safe-area-inset-bottom).FxiOSis 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/useSheetprovide one content-agnostic sheet mechanism. It accepts a React element and an optional dismissal callback; callers retain responsibility for data and actions.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.NameWithAvatarsurfaces that already supportincludeMiniprofile, including game player rows and forum authors.PlayerCount.RoleMarkerToggle.InfoPopover, where their existing triggers are available.InfoPopoverand 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.translateproperty, 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.5. Overlay lifecycle and Back behaviour
usePopoverOpenclose on pathname/query changes and on the site-to-game transition, even though the site subtree sees a frozen location.6. Lobby loading, freshness, pagination requests, and row identity
endTime: 0. Explicit page navigation still computes a new cursor using the existing paging helper.7. Loading presentation and first paint
ListSkeleton,PageSkeleton,ProfileSkeleton, andThreadSkeletoncomponents provide loading shapes.Loading smallfallbacks. Initial application bootstrap still has its full loading gate.useLoadingdefaults its minimum delay to zero; callers can still request an explicit minimum.html.js; inline CSS hides the static landing<main>inside#rootbefore 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
ResizeObserverkeeps following readers pinned to the bottom on transcript-container resizes.idle,live,connecting,reconnecting, and browserofflinestates. Each socket registers an id, updates on open/close, and unregisters on explicit client cleanup.window.location.hostnamewhen the configured host is unset orlocalhost, 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 withdocs/. This table covers every file in the net diff; detailed behaviour is catalogued above.src/Main.jsxviewTransitionprops; no custom sitewide snapshot-transition implementation is included.src/Contexts.jsxInGameContext; exposes stablerefreshUser()andclear()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.jsxgameId, 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.jsxsrc/components/Sheet.jsx,src/components/ProfileSheet.jsxsrc/components/Popover.jsxInfoPopoversheet transport; action-driven sheet/popover closure while preserving desktop anchors.src/components/Roles.jsxsrc/pages/User/UserWidgets.jsx,src/components/Miniprofile.jsxincludeMiniprofiletaps 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.jsxsrc/hooks/useAppViewport.js,src/css/main.css,src/css/game.css,src/css/chat.csssrc/utils/platform.js,src/utils/pushNotifications.jsisIosfunction; subscription/permission behaviour is otherwise unchanged.public/index.htmlsrc/hooks/useDismissOnBack.js,src/components/LeaveGameDialog.jsx,src/components/gameComponents/ChangeSetupDialog.jsx,src/components/gameComponents/RoleRevealModal.jsxsrc/hooks/usePopoverOpen.jsx,src/components/NavDropdown.jsx,src/components/ModerationSideDrawer.jsx,src/components/HostGameDialogue.jsxsrc/pages/Play/Play.jsx,src/pages/Play/CreateSetup/CreateSetup.jsxsrc/pages/Play/LobbyBrowser/LobbyBrowser.jsx,src/pages/Play/LobbyBrowser/GameRow.jsxsrc/components/Setup.jsxsrc/pages/Play/Decks/CreateDeck.jsx,src/pages/Play/Decks/DeckSelector.jsx,src/pages/Play/WordDecks/CreateWordDeck.jsx,src/pages/Play/WordDecks/WordDeckSelector.jsxsrc/components/Auth.jsx,src/pages/Welcome/Welcome.jsx,src/pages/User/UserNavSection.jsxsrc/pages/User/Settings.jsxsrc/pages/User/Family.jsxsrc/pages/User/Profile.jsxsrc/pages/User/Inbox.jsx,src/pages/User/Shop.jsx,src/pages/User/User.jsxsrc/components/Skeletons.jsx,src/pages/Community/Forums/Board.jsx,src/pages/Community/Forums/Categories.jsx,src/pages/Community/Forums/Thread.jsxsrc/hooks/useLoading.jsxsrc/components/CookieBanner.jsx,src/components/RapSheet.jsx,src/components/ReportDetail.jsxsrc/pages/Game/LiarsDiceGame.jsx,src/pages/Game/components/Kudos.jsxdocs/plan-app-shell-and-game-session.mdArchitecture details reviewers should know
Mainowns the hosted session rather than a game route owning its lifetime. Game ids are passed explicitly, with route-params fallback for compatibility; game children useGameContextwhere route params no longer exist.UNSAFE_LocationContext.InGameContextseparately announces when the game has the screen, because frozen-location consumers cannot detect that navigation normally.translateproperty rather than interfering with MUI Slide's transform.Verification
Observed / exercised
qrcode.reactbecause 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.useMemocalls inRoles.jsx, andonLeaveGameClick/restricted globalhistoryinGame.jsx. The corresponding errors were checked in baseline code; ordinary warnings also remain. LatestSheet.jsxlint and whitespace checks pass.translatedrag motion resolves the reported stutter.Not fully verified / remaining review risks
npm testwas not run as part of this description update. This is not a claim of all tests passing.Main.jsxstill contains an unused import reference tohooks/useThemeColorMeta; the active implementation isThemeColorMeta. The production build above passed, but this is remaining import housekeeping rather than an additional hook implementation.Explicitly outside this PR