v0.98.0 — Mobile play shell
Shipped 2026-09-20 — merged to main as d6cf7f71, tagged v0.98.0.
Built on version/0.98.0, thirteen commits. The eighth of the
releases the near-term roadmap sequences from the
August 2026 heuristic evaluation; the roadmap page is
mobile-play-shell, part two of the
navigation refactor and the whole of the
evaluation's Mobile Improvements project (its bugs shipped in v0.90.0).
The design reference is Style Guide 3.1.0;
the help page is Playing on a Phone.
"Not too many changes relative to current", in the evaluation's words:
MobileContextBar (v0.75.0) was already a fixed bottom bar with a play tab,
an impersonation flow and a chat overlay. This release makes its model
explicit — three tabs, Map / Me / Chat, each taking the whole screen above
the bar, with up to two slots per tab — moves that model into a store,
retires the mobile top nav (the desktop top bar it mirrored went in
v0.97.0), and chooses the shell by input rather than width. Decision
M-D1 (the Initiative Tracker stays a Chat sub-view) at its
recommendation.
The model
mobileShellStore(M.1) —tab, onedrawerat a time (each owned by a tab; opening one selects its tab, selecting a tab closes it), andchooser(Change Character's request for the chooser while somebody is impersonated). The rules are pure functions beside it:meView(nobody impersonated → the chooser, always) andslotsFor(Map → Actions and Palette; Me → Change and Stop while impersonating; Chat → View — never more than two). Stop returns to Map; targeting mode resets everything.- The bar renders it (M.2) — no
useStateleft but the nav drawer's flag. Arole="tablist"of the three tabs witharia-selectedfrom the store and the Chat tab's unread badge; the selected tab's slots to the right, a caret per sheet (aria-expanded, disabled when the slot is) or a button per action.
The screens and the sheets
Under Layout/MobileShell/:
TabBody— a tab's whole screen: fixed, portalled, from the top to the bar,role="tabpanel", in theMOBILE_OVERLAYband. Map has none: the map is beneath everything.MobileDrawer— a bottom sheet in theMOBILE_MENUband whose frame and overlay end at the bar (inline styles, since Mantine'sinset: 0ties a class), as tall as its content up to 70vh; outside tap dismisses.- Map (M.3) — Actions: the v0.96.0 list grouped by power, a sheet
instead of a centred modal. Palette:
ToolPalettegains a vertical embed ({ vertical: true, onPick }) — every tool named, Find a token by name, Undo / Redo, the layers as a list with the current one marked, Edit Layers — with no overflow plan consulted; a pick closes the sheet. The floating palette on a phone is gone. - Me (M.4) —
MeChooser("Select your character": touch a token on the map, or pick from the tokens you may play) or the sheet:CharacterContextat full width with the sheet as the one scroller (measured: the only scrolling element in the tab), or the token sheet for a character-less token. The header is v0.42.0's switcher; no close button — Map is a tab. - Chat (M.5) — the chat context filling the tab, the header naming the
view with the voice-transcript control and Chat Settings;
ChatViewDraweris a list of the four views with the current one checked, built for chat scope's channels to append to.
Retiring the top nav
MobileTopNavdeleted (M.6). The nub renders on a phone too — from the viewport's top edge, centred on the viewport, up to 80vw. Off/playthe bar is one leftmost slot, Menu, openingMobileNavDrawer:LeftRailin a newvariant="drawer"(always wide, in flow, no toggle, no width published, closing on any navigation) — a phone has the same rail the desktop has. With the top nav went what only it kept alive:NavSections(T.1's section menus),SideNav(v0.24.0's drawer content, with its Characters badge andNotificationsBellMenuover pending invitations — Messages, v0.62.0, announce both), the deadTopNav.retiredChrome.guardholds all five.HoverTokenPortraitreadsLEFT_NAV_RAIL_WIDTH(60) instead of the retiredSIDENAV_COLLAPSED_WIDTH(44).
Gestures, input, chrome
- Gestures (M.7) — no swipe between tabs (a guard fails a touch handler
in the shell). The back gesture:
pushDrawerEntrypushes one history entry per open sheet — the same URL, the router's state kept, plus a marker — and closes the sheet when it is popped; a sheet closed any other way pops its own entry. Four tests on a fake window. - Chosen by input (M.8) —
getScreenSize(width, hasHover): a pointer that cannot hover getssmall(the shell) at any width; one that can gets the desktop down toBREAKPOINTS.small, now 768 (was 1024).UI.tsxreadsuseIsMobile()instead of its own width query. Landscape: the bar stays at the bottom, the tab fills the rest, the sheet's columns are its own container rule. - Bottom chrome generalised (M.9) —
aboveBottomChrome(extraPx)reads--vtt-bottom-chromefor the End Turn button, the token bars, the mobile token-context button, the multi-select bar and the action bubbles, so each is right whether or not the bar is mounted;MOBILE_CONTEXT_BAR_HEIGHThas no reader outside the bar. Every popup the theme defaults opens above the shell's bands;mobileShellBands.test.tsholds the order for both themes.
Finishing
- Guards (M.10) — seven bar guards deleted with the
useStatethey read, ten with the retired chrome;MobileContextBar.contract,MobileShell.contract,mobileShellBands,screenOwners.guard(the three tabs are the only screen owners) anddrawerHistoryadded; about twenty re-anchored. - Verification (M.11) — in Chrome at 390px and 740×390 through a
same-origin iframe (the window would not resize), with
requestAnimationFramepolyfilled because a background tab fires none and Mantine's transitions need it. A physical-device pass is still owed — real touch,hover: none, the Android back gesture, the keyboard over the chat composer. - Style Guide 3.1.0 (M.12) — a minor bump: the Mobile section, the shell's bands in the Layers table. Help: Playing on a Phone, and Core Concepts points to it.
Decisions
M-D1 at its recommendation. Made here: the Actions sheet keeps the grouped list rather than "the same component the Character Context's Actions section uses" (that is the module-rendered sheet section, not a list); a modal covers the bar rather than sitting below its band, since a bar tappable above a modal's overlay would close nothing; and the shell is input-first with a 768px floor for a hovering pointer, so a narrow desktop window is not left on the desktop layout with nowhere to put a sidebar.