Skip to content

Responsive UI cleanup: fix resize data loss, centralize compact detection - #33

Merged
dmigwi merged 7 commits into
masterfrom
cleanup-responsive-devices-ui
Aug 3, 2026
Merged

Responsive UI cleanup: fix resize data loss, centralize compact detection#33
dmigwi merged 7 commits into
masterfrom
cleanup-responsive-devices-ui

Conversation

@dmigwi

@dmigwi dmigwi commented Aug 3, 2026

Copy link
Copy Markdown
Owner

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 postcss
vulnerability, 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() nulls maze,
mazeDimensions, and the rest of the round fields. Both call sites then persisted state with
persistNow("state"), which unconditionally calls saveActiveRoundSnapshot. buildRoundSnapshot
refuses to build a snapshot once those fields are null and returns null, and
saveActiveRoundSnapshot(modeName, null) is clearPersistedRound — so shrinking didn't just
show 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
(loadPersistedSnapshotWithFallbacksnoValidRoundExists) found no snapshot, so it fell through
to renderState() with state.status still "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 before
    applyTooSmallState, capturing the still-valid round first, then a new persistProgressOnly()
    (preferences only, via saveGameProgress) replaces persistNow("state") so the round snapshot
    just saved isn't immediately overwritten with the cleared state.
  • restoreValidPersistedRound: same persistProgressOnly() swap. No extra snapshot save is
    needed 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.ts adds preserves the last valid round snapshot when resize enters too-small, which
captures every saveActiveRoundSnapshot call's mazeDimensions/status and asserts both remain
{ numCols: 4, numRows: 4, area: 16 } / "running" — i.e. never null, never "too-small" — across
the 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 given
a 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 redrawRoundForViewport mechanism the shrink path already had:

  • No persisted round to recover, viewport already fits. handleResize's recovery branch only
    ever tried restoring a persisted round; a session that never had one to lose (a brand-new player,
    or applyTooSmallState having nulled the round with nothing behind it) fell straight through to
    renderState() still showing "too small" forever, even once the window was clearly big enough —
    the only way out was "Reset Progress". handleResize now falls back to
    redrawRoundForViewport(state.level) when nothing was restored, generating a fresh maze for the
    current level if it now fits.
  • Bootstrap measuring before web fonts settle. getTerminalSize derives the terminal's
    column/row count from measured font metrics (dom.ts). If bootstrapGame runs before web fonts
    finish loading, that measurement is against fallback-font metrics — and once the real font swaps
    in, nothing fires a resize event to prompt a re-check, so a wrong initial too-small/fits decision
    could stick around indefinitely. bootstrapGame now re-runs handleResize once
    document.fonts.ready resolves, the same pattern page-chrome.ts already uses for its own
    compact-viewport check.

game.test.ts adds re-measures once web fonts finish loading and corrects a stale too-small bootstrap (mutation-verified) and auto-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 had
locked in this exact bug as intended behavior.

Fix: "Reset Progress" offered from too-small with nowhere smaller to go

canShowRestart treated every too-small state as restartable, but restartGame always restarts at
level 1 (game.ts). At level 1 too-small, tapping the only visible touch control just regenerated
the same maze into the same too-small state — a dead-end affordance. canShowRestart now takes the
current level and only offers restart from too-small when level > 1; at level 1 too-small, the
touch-control row now hides entirely (touch-controls.hidden follows the visible-button count, which
is now zero). render.test.ts and status.test.ts updated/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 both
open at once wasn't just overlap — it was literal superimposition. openAgentConfigForm and
openAgentDeleteDialog now each close the other before opening, guaranteeing at most one is ever
visible. This also collapsed a duplicated isAgentConfigFormOpen() || isAgentDeleteDialogOpen()
check into one shared isAnyAgentOverlayOpen() helper, and let the outer-click handler delegate to
the pre-existing closeActiveAgentOverlay() (previously used only by the Escape handler) instead of
repeating the same two hidden-checking blocks. Verified live: opening the delete dialog for one
seat 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.ts and page-chrome.ts each independently reimplemented "is the viewport compact":
render.ts's version included a body.getBoundingClientRect() candidate that page-chrome.ts's
didn't, and only page-chrome.ts had the brand-wrap/scrollWidth overflow check added earlier for
the top-menu collapse bug. Two implementations of the same decision drift by construction; this
merges them into one.

viewport.ts exports:

  • viewportSizeCandidates() / viewportWidth() — the shared candidate list (screen, visualViewport,
    documentElement, innerWidth/Height). The body rect candidate is dropped; nothing in the new
    candidate list needs an Elements reference, which is why displayText/navigationText/
    statusText in render.ts no longer take elements as 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/finally that guarantees restoration even
    if a query selector throws).
  • observeCompactViewportChanges(onChange) — wires both breakpoint MediaQueryLists to one
    callback and returns an unsubscribe function.
  • fittingColumnCount() / cssPixelValue() / fittingTouchActionColumnCount() — the layout-capacity
    math used by the touch-action responsive fix below.

page-chrome.ts and render.ts now both call into this module instead of each other or duplicating
the logic. page-chrome.ts also caches the last computed compactMode in a closure variable instead
of re-invoking isCompactViewport() (now DOM-measuring and non-trivial) on every menu toggle, click,
and keydown — and adds window.addEventListener("resize", syncMenuMode) plus
document.fonts?.ready.then(syncMenuMode), so the overflow-based check re-runs on plain resizes
within a breakpoint bracket and once real font metrics are available, not only on breakpoint
crossings.

cssPixelValue reads --touch-controls-gap, --touch-action-button-min-width,
--touch-controls-padding, and --brand-wrap-tolerance from CSS custom properties (element inline
style → computed style → documentElement inline → documentElement computed, falling back to
0) instead of hardcoded TS constants matching tapoo.css by hand. The four values are now defined
once, in tapoo.css.

Fix: touch-action button row was a hardcoded 3 columns

.touch-controls--action-row used grid-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. updateTouchControls now computes
fittingTouchActionColumnCount(elements.touchControls, visibleButtons) and writes it to
--touch-action-columns inline on the element whenever the action-only row is showing, clearing the
property otherwise; the CSS rule reads repeat(var(--touch-action-columns), max-content).

render.test.ts adds reduces action-row touch columns when the viewport cannot fit three buttons,
asserting --touch-action-columns is "3" at normal width and "2" at 320px. The existing
too-small/compact-navigation test switches from mocking body.getBoundingClientRect() to setting
window.innerWidth/innerHeight directly, matching the real candidate list in viewport.ts now
that the body-rect candidate is gone.

Agent seats: 5 → 6, and tests decoupled from the number

CONFIG.agentConfig.maxSeats: 5 → 6. maxSeats is a configurable value by design, so the tests
exercising it were rewritten to derive from CONFIG.agentConfig.maxSeats rather than encode it as a
literal — 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.maxSeats via Array.from.
  • storage.test.ts's "normalizes fixed agent seats without reassigning occupied slots" test had a
    hand-written 6-entry literal input array that was deliberately one-over-capacity for the old
    maxSeats: 5. At maxSeats: 6 that same literal became exactly at-capacity — nothing was left to
    truncate, 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 + 1 entries and derive both expectations from a slice of that same generated
    array, 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-output
    array 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 example
strings 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:

  • The batch-efficiency rate was described as unique cells visited ÷ requests made. The live
    formula (context.ts, get_prediction_rules tool description) is unique cells visited ÷ decay
    units charged
    — traversal speed. Thresholds and the "starts as Trailblazer" default were already
    correct; only the denominator was wrong.
  • The per-agent stored config was described as holding "its own request count." It now holds both
    turnCount and decayUnitsCharged (types.ts PersistedGameSetup); the second field wasn't
    disclosed at all.
  • The traversal-history description ("each entry recording the open exits at that cell") is now
    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).
  • Added one orienting sentence at the top — "Tapoo is a browser-based maze game where you, or a
    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

postcss pinned to 8.5.18 via a workspace overrides entry in both pnpm-workspace.yaml and
pnpm-lock.yaml (was a transitive 8.5.16). No source changes.

Also in this branch

  • terminal-section.html: the agent endpoint input gained data-config-value="agentConfig.endpointPlaceholder"
    alongside its existing data-config-placeholder on the same key. page-chrome.ts's
    applyPageText() now also hydrates [data-config-value] inputs, setting both defaultValue and
    value from the resolved config text on load. The endpoint field now starts pre-filled with
    http://localhost:11434/api/chat (the working local-Ollama default) instead of showing it only as
    placeholder 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): one
    test confirms hydration sets value/defaultValue/placeholder from config, a second confirms an
    input without data-config-value is left alone. Verified by mutation — deleting the hydration
    block fails the first test and nothing else.
  • Compact copy shortened in two places (agentAwaitAction.compact, runningStatus.agentApi.compact)
    to read better at the widths this branch now collapses into more readily.
  • The agent endpoint input's name attribute changed from endpoint to Ollama API Endpoint, so
    password 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, no
lint/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).

@dmigwi dmigwi changed the title Cleanup responsive devices UI Responsive UI cleanup: fix resize data loss, centralize compact detection Aug 3, 2026
@dmigwi
dmigwi force-pushed the cleanup-responsive-devices-ui branch from 0828fe4 to 2d23b65 Compare August 3, 2026 12:21
@dmigwi
dmigwi merged commit eaf42a5 into master Aug 3, 2026
6 checks passed
@dmigwi
dmigwi deleted the cleanup-responsive-devices-ui branch August 3, 2026 13:45
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