Responsive UI cleanup: fix resize data loss, centralize compact detection - #33
Merged
Conversation
dmigwi
force-pushed
the
cleanup-responsive-devices-ui
branch
from
August 3, 2026 12:21
0828fe4 to
2d23b65
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a real data-loss bug on viewport resize, replaces three duplicated compact/wide-detection
implementations with one shared module, makes the touch-action button row responsive instead of a
fixed 3 columns, raises the max agent seats from 5 to 6, patches a transitive
postcssvulnerability, and brings the privacy policy back in line with what the app actually does. Also
closes two more too-small/resize gaps found while testing the fix above, and stops the two agent
overlays (config form, delete dialog) from ever rendering superimposed. Browser SPA
2.2.0 → 2.2.1.Fix: growing the window back stayed stuck on "too small"
The observed bug: shrink the window below the maze's minimum, then grow it back — the "needs more
screen room" message should clear and the round should resume, but it didn't. The window was easily
large enough; the game acted as if it still wasn't.
Root cause was on the shrink side, not the grow side.
applyTooSmallState()nullsmaze,mazeDimensions, and the rest of the round fields. Both call sites then persisted state withpersistNow("state"), which unconditionally callssaveActiveRoundSnapshot.buildRoundSnapshotrefuses to build a snapshot once those fields are null and returns
null, andsaveActiveRoundSnapshot(modeName, null)isclearPersistedRound— so shrinking didn't justshow the message, it silently wiped the in-progress round from storage on the way there. By the
time the window grew back, there was nothing left to restore:
handleResize's recovery path(
loadPersistedSnapshotWithFallbacks→noValidRoundExists) found no snapshot, so it fell throughto
renderState()withstate.statusstill"too-small"— indistinguishable, from the outside,from "the window really is still too small."
Fixed by reordering, not by adding a guard:
handleResize:saveActiveRoundSnapshot(state.controlMode, state)now runs beforeapplyTooSmallState, capturing the still-valid round first, then a newpersistProgressOnly()(preferences only, via
saveGameProgress) replacespersistNow("state")so the round snapshotjust saved isn't immediately overwritten with the cleared state.
restoreValidPersistedRound: samepersistProgressOnly()swap. No extra snapshot save isneeded here — the snapshot being handled is already the one on disk from a previous session, and
it was never touched by the too-small branch, only avoided being clobbered.
game.test.tsaddspreserves the last valid round snapshot when resize enters too-small, whichcaptures every
saveActiveRoundSnapshotcall'smazeDimensions/statusand asserts both remain{ numCols: 4, numRows: 4, area: 16 }/"running"— i.e. never null, never"too-small"— acrossthe resize. This pairs with the pre-existing (unchanged by this branch)
restores a persisted round in paused mode once the viewport fits again, which proves the grow side restores correctly givena valid snapshot on disk. Together the two prove the full shrink-then-grow round trip: shrinking now
leaves a valid snapshot behind, and growing back finds and restores it.
Fix: growing back still stayed stuck on "too small" in two more cases
Reproducing the fix above in the browser surfaced two more paths into the same dead end, both fixed
by reusing the same
redrawRoundForViewportmechanism the shrink path already had:handleResize's recovery branch onlyever tried restoring a persisted round; a session that never had one to lose (a brand-new player,
or
applyTooSmallStatehaving nulled the round with nothing behind it) fell straight through torenderState()still showing "too small" forever, even once the window was clearly big enough —the only way out was "Reset Progress".
handleResizenow falls back toredrawRoundForViewport(state.level)when nothing was restored, generating a fresh maze for thecurrent level if it now fits.
getTerminalSizederives the terminal'scolumn/row count from measured font metrics (
dom.ts). IfbootstrapGameruns before web fontsfinish loading, that measurement is against fallback-font metrics — and once the real font swaps
in, nothing fires a
resizeevent to prompt a re-check, so a wrong initial too-small/fits decisioncould stick around indefinitely.
bootstrapGamenow re-runshandleResizeoncedocument.fonts.readyresolves, the same patternpage-chrome.tsalready uses for its owncompact-viewport check.
game.test.tsaddsre-measures once web fonts finish loading and corrects a stale too-small bootstrap(mutation-verified) andauto-restarts a too-small game when the viewport fits again without a persisted round— the latter replaces a prior test (does not auto-restart...) that hadlocked in this exact bug as intended behavior.
Fix: "Reset Progress" offered from too-small with nowhere smaller to go
canShowRestarttreated every too-small state as restartable, butrestartGamealways restarts atlevel 1 (
game.ts). At level 1 too-small, tapping the only visible touch control just regeneratedthe same maze into the same too-small state — a dead-end affordance.
canShowRestartnow takes thecurrent
leveland only offers restart from too-small whenlevel > 1; at level 1 too-small, thetouch-control row now hides entirely (
touch-controls.hiddenfollows the visible-button count, whichis now zero).
render.test.tsandstatus.test.tsupdated/added to cover both the level-1 (hidden)and level>1 (restart shown) cases.
Fix: agent-config-form and agent-delete-dialog could render superimposed
Both overlays share one screen position (
position: absolute; left: 50%; top: 44%), so having bothopen at once wasn't just overlap — it was literal superimposition.
openAgentConfigFormandopenAgentDeleteDialognow each close the other before opening, guaranteeing at most one is evervisible. This also collapsed a duplicated
isAgentConfigFormOpen() || isAgentDeleteDialogOpen()check into one shared
isAnyAgentOverlayOpen()helper, and let the outer-click handler delegate tothe pre-existing
closeActiveAgentOverlay()(previously used only by the Escape handler) instead ofrepeating the same two
hidden-checking blocks. Verified live: opening the delete dialog for oneseat while another seat's config form was already open now closes the form first, and vice versa.
Compact/wide detection centralized into
frontend/app/viewport.ts(new)render.tsandpage-chrome.tseach independently reimplemented "is the viewport compact":render.ts's version included abody.getBoundingClientRect()candidate thatpage-chrome.ts'sdidn't, and only
page-chrome.tshad the brand-wrap/scrollWidth overflow check added earlier forthe top-menu collapse bug. Two implementations of the same decision drift by construction; this
merges them into one.
viewport.tsexports:viewportSizeCandidates()/viewportWidth()— the shared candidate list (screen, visualViewport,documentElement, innerWidth/Height). The
bodyrect candidate is dropped; nothing in the newcandidate list needs an
Elementsreference, which is whydisplayText/navigationText/statusTextinrender.tsno longer takeelementsas a parameter.isCompactViewport()— the full decision: breakpoint media queries, raw candidate comparison,and the top-bar overflow-or-brand-wrap check (temporarily forces wide mode + open menus, measures,
restores — same technique as before, now with the
try/finallythat guarantees restoration evenif a query selector throws).
observeCompactViewportChanges(onChange)— wires both breakpointMediaQueryLists to onecallback and returns an unsubscribe function.
fittingColumnCount()/cssPixelValue()/fittingTouchActionColumnCount()— the layout-capacitymath used by the touch-action responsive fix below.
page-chrome.tsandrender.tsnow both call into this module instead of each other or duplicatingthe logic.
page-chrome.tsalso caches the last computedcompactModein a closure variable insteadof re-invoking
isCompactViewport()(now DOM-measuring and non-trivial) on every menu toggle, click,and keydown — and adds
window.addEventListener("resize", syncMenuMode)plusdocument.fonts?.ready.then(syncMenuMode), so the overflow-based check re-runs on plain resizeswithin a breakpoint bracket and once real font metrics are available, not only on breakpoint
crossings.
cssPixelValuereads--touch-controls-gap,--touch-action-button-min-width,--touch-controls-padding, and--brand-wrap-tolerancefrom CSS custom properties (element inlinestyle → computed style →
documentElementinline →documentElementcomputed, falling back to0) instead of hardcoded TS constants matchingtapoo.cssby hand. The four values are now definedonce, in
tapoo.css.Fix: touch-action button row was a hardcoded 3 columns
.touch-controls--action-rowusedgrid-template-columns: repeat(3, max-content)unconditionally.On narrow screens with more than 3 visible action buttons (or 3 buttons too wide for the available
width), this could overflow.
updateTouchControlsnow computesfittingTouchActionColumnCount(elements.touchControls, visibleButtons)and writes it to--touch-action-columnsinline on the element whenever the action-only row is showing, clearing theproperty otherwise; the CSS rule reads
repeat(var(--touch-action-columns), max-content).render.test.tsaddsreduces action-row touch columns when the viewport cannot fit three buttons,asserting
--touch-action-columnsis"3"at normal width and"2"at 320px. The existingtoo-small/compact-navigation test switches from mocking
body.getBoundingClientRect()to settingwindow.innerWidth/innerHeightdirectly, matching the real candidate list inviewport.tsnowthat the body-rect candidate is gone.
Agent seats: 5 → 6, and tests decoupled from the number
CONFIG.agentConfig.maxSeats: 5 → 6.maxSeatsis a configurable value by design, so the testsexercising it were rewritten to derive from
CONFIG.agentConfig.maxSeatsrather than encode it as aliteral — the standing requirement is that changing this config value should never itself require a
test-file edit, only the config change.
seats.test.ts's seat-id list and fixed-slots array were literal[1,2,3,4,5]/5-entry arrays;both now build from
CONFIG.agentConfig.maxSeatsviaArray.from.storage.test.ts's"normalizes fixed agent seats without reassigning occupied slots"test had ahand-written 6-entry literal input array that was deliberately one-over-capacity for the old
maxSeats: 5. AtmaxSeats: 6that same literal became exactly at-capacity — nothing was left totruncate, so the test silently stopped exercising the behavior its name describes without
reporting any failure; it simply passed for a different reason than intended. Rewritten to
generate
maxSeats + 1entries and derive both expectations from a slice of that same generatedarray, so it stays a genuine oversized-by-one case regardless of
maxSeats. The neighboring"normalizes oversized enabled agents..."test had the same latent issue in its expected-outputarray only (its input was already dynamic) — fixed the same way.
Swept the rest of the suite for the same failure mode (a test literal sized exactly to a
config-derived count, the way the seat lists were) — the other config-bounded values
(
playerNameMinLength/MaxLength,maxModelDisplayLength) are tested with hand-picked examplestrings that stay unambiguously over/under any reasonable limit rather than boundary values tied to
the exact number, so they're not fragile the same way. Nothing else found.
Privacy policy corrections (
frontend/templates/privacy-section.html)Brought back in line with the current agent-ranking implementation, which this description-writing
pass caught was stale:
formula (
context.ts,get_prediction_rulestool description) is unique cells visited ÷ decayunits charged — traversal speed. Thresholds and the "starts as Trailblazer" default were already
correct; only the denominator was wrong.
turnCountanddecayUnitsCharged(types.tsPersistedGameSetup); the second field wasn'tdisclosed at all.
precise about what's actually sent: each open exit mapped to its neighbouring cell and whether
that neighbour has already been visited (the adjacency-list shape).
configured AI agent, guide a player through a maze to a target." This is currently the only
visible (non-meta-tag) description of what Tapoo is anywhere in the app.
Dependency: postcss vulnerability
postcsspinned to8.5.18via a workspaceoverridesentry in bothpnpm-workspace.yamlandpnpm-lock.yaml(was a transitive8.5.16). No source changes.Also in this branch
terminal-section.html: the agent endpoint input gaineddata-config-value="agentConfig.endpointPlaceholder"alongside its existing
data-config-placeholderon the same key.page-chrome.ts'sapplyPageText()now also hydrates[data-config-value]inputs, setting bothdefaultValueandvaluefrom the resolved config text on load. The endpoint field now starts pre-filled withhttp://localhost:11434/api/chat(the working local-Ollama default) instead of showing it only asplaceholder ghost text — deliberately, to cut the repeat copy-pasting of the same local URL every
time a new agent seat gets configured. Now covered by
frontend/page-chrome.test.ts(new): onetest confirms hydration sets
value/defaultValue/placeholderfrom config, a second confirms aninput without
data-config-valueis left alone. Verified by mutation — deleting the hydrationblock fails the first test and nothing else.
agentAwaitAction.compact,runningStatus.agentApi.compact)to read better at the widths this branch now collapses into more readily.
nameattribute changed fromendpointtoOllama API Endpoint, sopassword managers/autofill describe the field by what it is rather than a generic form-field name.
Verification
pnpm run quality:frontend(typecheck + lint + test) is clean: 334 tests across 25 files, nolint/typecheck errors. The too-small/resize fixes were also reproduced and verified live in-browser
(fresh session bootstrapped small, then grown, self-heals without "Reset Progress"; level-1 too-small
hides all touch controls).