Files
sokoban/plans/reports/brainstormer-260428-2352-portrait-landscape-layouts.md
T

14 KiB

Portrait + Landscape Mobile Layout Brainstorm

Date: 2026-04-28 Scope: Diagnose perceived "stuck-to-bottom" portrait layout. Recommend landscape strategy. KISS, no framework deps.

Research (mobile puzzle/grid games)

Quick scan (Game UI DB, Touch Control Design refs). Common landscape patterns for grid/puzzle:

  • Side-anchored controls. Console-emulator UX: D-pad bottom-left, action buttons bottom-right, board fills middle. Standard for two-handed grip.
  • Two-column split. Board left/center, control panel pinned right (or both sides). Used by mobile chess apps, turn-based puzzles, Slay the Spire.
  • Hide HUD / collapse. Mini Metro, 2048 mobile: shrink stats to a corner, give 90%+ of viewport to play area.
  • Lock to portrait. Threes, Monument Valley, most match-3. Cheapest UX. Acceptable when portrait is the canonical experience.
  • Floating overlay controls. Touch fighters, Dead Cells mobile: semi-transparent buttons over the board.

Sokoban specifically: most ports either lock portrait or require swipe-on-board input (no on-screen d-pad), avoiding the layout problem entirely. Side-anchored controls are the conventional landscape answer when a d-pad is mandatory.

Problem A: Portrait diagnosis

Flex math (Pixel 7 412x915, Microban 1 = 8w x 6h, coarse)

  • computeTileSize: maxByW = (412-80)/8 = 41, maxByH = (915-195-100)/6 = 103, tile = min(56,41,103) = 41.
  • Board renders 8x6 at 41px = 328 x 246.
  • #app coarse padding 12px 12px 0. HUD wraps to ~52px (level-name 22 + stats 14 + minor gap).
  • .game has gap: 10px (coarse).
  • .play-stack available height: 915 - 12(top pad) - 52(hud) - 10(gap) = 841px.
  • .play-group content: 246 (board) + 8 (gap) + ~110 (dock: 48+6+48 arrows + 20px bottom inset, no top padding) = 364px.
  • safe center puts group at top margin (841-364)/2 = 238.5px.
  • Group bottom sits at 12+52+10+238.5+364 = 676.5px from top → 238.5px gap to viewport bottom.
  • Group center at y=494.5; viewport center at y=457.5. Group center is 37px BELOW viewport center.

Verdict: not glued to bottom. Centering works. But group is slightly below true viewport center (~37px) because .play-stack starts after HUD+padding (the available stack, not the viewport, is what's centered). Combined with empty space below the group (238.5px) being symmetric to space above the stack-content (238.5px) but asymmetric to total above-group whitespace (12+52+10+238.5 = 312.5px), the negative space feels heavier above → eye reads group as "lower middle".

Why user perceives bottom-anchored

Three real factors:

  1. HUD whitespace dominates the top. 52px HUD + 10px gap reads as "small" content; the remaining 312px of upper void looks empty. Below the group, only 238px of void. Top void > bottom void by 74px → group leans visually low.
  2. Bottom-clinging dock. The d-pad+actions are visually heavier than the board (high-contrast borders, 48px buttons, dense). User's eye latches onto them; they're 110px from safe-area-inset-bottom+padding. Heavy element near bottom → "anchored".
  3. safe-area-inset-bottom (20px) is part of the dock, not external margin. So the visible gap dock-to-screen-edge equals exactly the safe area + the 238.5px symmetric center distance — but on devices without a home indicator, only the 20px reads as the perceived bottom gap once you discount the centering whitespace as "page padding".

Wait — re-read math: 238.5px IS the bottom void. So dock bottom-edge sits 238.5px above viewport bottom. That's not anchored. The user's complaint is likely about (1) top void being noticeably larger than bottom void.

Portrait fix options

Option Change Effect Cost
A. Status quo (centered) none Geometry-correct center; visual center reads "low" due to HUD top-weight 0
B. Bias toward upper-third (golden ratio) replace safe center with flex-start + dynamic top spacer ~30% of free space Group sits in visually-balanced "comfortable reading" zone; matches phone reading habits low
C. Anchor lower-third (thumb zone) justify-content: flex-end + bottom margin ~80px Dock lands in primary thumb-reach zone (Steve Hoober's thumb-zone research); board sits comfortably above it low
D. Hybrid bias safe center + padding-top: 0; padding-bottom: clamp(8px, 8vh, 80px) to pull center upward Cheapest visual bias; preserves overflow scroll behavior minimal

Recommendation A→D: Add a single padding-bottom (or margin-bottom) on .play-group (or asymmetric padding on .play-stack) that is consumed by safe center. Net effect: centroid shifts up by half the bias. ~16-24px is enough for the perceptual difference without breaking tall-puzzle overflow.

If user actually wants thumb-zone anchoring (option C), that's a separate UX decision — reasonable for a one-handed puzzle but contradicts "centered group" goal of commit 16520d1. Ask user before implementing C.

Problem B: Landscape diagnosis

Math (Pixel 7 landscape 915x412, Microban 1)

Current layout (single column):

  • tile = min(56, (915-80)/8=104, (412-195-100)/6=19) = 19 → board 152 x 114px. Tiny.

Microban 145 (~19w x 16h):

  • portrait: tile 17 → 323x272 (workable).
  • landscape current: min(56, 43, 7) = 7 → 133x112. Unplayable.

Root cause: vertical chrome reserve (195px) + dock (102px) + HUD (~52px) consumes ~349 of 412px viewport. Only 63px left for board height. computeTileSize is stacking chrome optimised for portrait.

Strategies

S1 — Side-by-side (board left, controls right column)

  • Markup: wrap .play-group contents in a row at landscape; OR add @media (orientation: landscape) and (pointer: coarse) that flips .play-group to flex-direction: row. Reorder so dock is right.
  • computeTileSize: detect orientation; in landscape, margin = 76 (HUD ~52 + padding ~24, no dock vertical reserve), maxByW = (window.innerWidth - 80 - 160) / level.width (160 = right dock width incl gap).
  • Math (Microban 1 landscape): tile = min(56, (915-80-160)/8=84, (412-76)/6=56) = 56. Board 448x336. 3x bigger.
  • Math (Microban 145): tile = min(56, 36, 21) = 21 → 399x336.
  • Controls: dock becomes flex-direction: column; action stack on top, d-pad on bottom (or vice versa). Both columns ~150-160px wide. Touch targets unchanged (still 48px).
  • HUD: stays top, full-width.
  • Risks: need to choose which side gets d-pad (right-handed default = right). Lefties suffer. Action buttons & d-pad must both fit vertically: 110px (action grid 2 rows) + gap + 110px (d-pad 2 rows) = ~232px > 412-52-24 = 336px available. Fits comfortably.
  • Trade-off: moderate CSS work + small JS change (orientation-aware computeTileSize).

S2 — Bottom controls + aggressive HUD shrink

  • Markup: none.
  • computeTileSize: in landscape, shrink HUD vertical to ~28px (single line, smaller font), drop .game gap, reduce dock vertical (maybe shrink arrows to 40px in landscape). New margin maybe ~150.
  • Math (Microban 1): tile = min(56, 41, (412-150-100)/6=27) = 27. Board 216x162. Better than 19 but still cramped.
  • Trade-off: preserves portrait code path; minimal change. But violates HIG 44pt minimum if shrinking arrows. Net win < S1.

S3 — Lock to portrait

  • Markup: none. Add <meta name="screen-orientation" content="portrait"> (limited support) OR JS screen.orientation.lock('portrait') on game start (only PWA/standalone).
  • CSS-only fallback: detect landscape via @media (orientation: landscape) and (pointer: coarse), show "rotate device" overlay covering viewport.
  • Trade-off: zero layout work. Acceptable if landscape isn't a target experience. Frustrating for users with locked-orientation phones in landscape mounts. Cheapest. Honest.

S4 — Floating overlay controls

  • Markup: .mobile-dock becomes position: fixed; d-pad bottom-right, actions top-right (or bottom-left).
  • Board: fills entire viewport behind, no chrome reserve.
  • computeTileSize: strip dock from margin; only HUD reserve ~52.
  • Math (Microban 1 landscape): tile = min(56, 104, 60) = 56 → 448x336 board fills perfectly.
  • Risks: controls overlap board on small puzzles (boxes/player obscured). Semi-transparent helps but reduces tap clarity. HUD overlap on tall puzzles. Z-order debugging. Most engineering risk.
  • Trade-off: maximises board real estate but compromises tap clarity and board legibility.

Comparison

Axis S1 Side-by-side S2 Bottom+shrink S3 Lock portrait S4 Floating
Board size win (Microban 1 landscape) 56px tile (3x) 27px tile (1.4x) n/a 56px tile (3x)
CSS LOC change ~30 ~15 ~10 (overlay) ~25
JS LOC change ~10 (orientation reactive) ~5 0 ~5
Touch target safety preserved risk if shrunk preserved preserved
HUD visible yes yes (smaller) n/a yes (or overlap risk)
Engineering risk low low very low medium (overlap/z-order)
Reversibility easy easy easy easy

Recommendations

Portrait: keep safe center, add small downward bias to top of stack (Option D)

Cheapest meaningful win. Add to coarse media query:

.play-stack { padding-top: 0; padding-bottom: clamp(16px, 6vh, 56px); }

Net: safe center of remaining stack distributes the 56px asymmetric padding into the centroid → group rises by ~28px. Preserves overflow scroll, no markup change. Verifies user complaint "feels low" without inventing a new model.

Alternative if user actually means thumb-anchored: Option C (justify-content: flex-end + 80px bottom margin). Diverges from commit 16520d1 philosophy. Confirm intent with user before picking C over D.

Landscape: S1 Side-by-side

Three reasons:

  1. 3x board size for Microban 1; massive UX win exactly where user complained.
  2. Conventional pattern (matches console-emulator + Game UI DB landscape conventions). No re-training needed.
  3. Preserves all recent fixes (mistouch, top-aligned d-pad, LVLS grid) — they're internal to MobileControls. Only the dock's outer flex-direction flips. Action grid grid-template-columns: auto auto and d-pad grid both work as columns when their parent is row.

Implementation outline (no code, per request):

  • New media query: @media (pointer: coarse) and (orientation: landscape).
  • .play-group { flex-direction: row; align-items: stretch; }.
  • .mobile-dock { flex-direction: column; width: ~150px; gap: 16px; } (stack action grid above d-pad, or reverse).
  • .dock-left { align-self: stretch; } and .dpad { align-self: stretch; }.
  • computeTileSize reads window.matchMedia('(orientation: landscape)').matches; in landscape, use margin = 76 and reserve dockWidth = 160 from maxByWidth.
  • Add resize/orientationchange recompute (already wired via onResize; just ensure matchMedia listener fires too).

If S1 feels heavy, fall back to S3 (lock portrait) as a 10-minute escape hatch. Only choose S2 if user wants bottom-only consistency. Avoid S4.

Orientation detection mechanism

Recommend @media (orientation: landscape) (CSS) + window.matchMedia('(orientation: landscape)') (JS).

Reasons:

  • Native semantic. Browser reports actual orientation, not aspect ratio (which gets weird on foldables/landscape-narrow tablets).
  • Reactive. MediaQueryList fires change event on rotate; mirrors existing pointer: coarse listener pattern in GameView.svelte (line 134).
  • Single threshold. aspect-ratio queries need tuning per-device; orientation is binary.
  • Avoid mixing max-aspect-ratio unless we hit a specific edge case (e.g. iPad split-screen) — YAGNI.

Existing code already uses matchMedia for pointer; same pattern extends cleanly.

Constraints check

  • Touch ≥44pt: preserved in S1 (48px arrows untouched, just rearranged column).
  • No reverts: c80b9ce + 18d63d0 + 179f052 + 16520d1 all internal/portrait → unaffected by S1's outer-direction flip.
  • No new deps: pure CSS + 5-10 LOC JS in computeTileSize.

Unresolved questions

  1. Portrait intent: does user want geometry-centered (current, math is right) with a small upward bias (option D), or thumb-zone-anchored (option C)? Brainstorm leans D; C is a different UX goal.
  2. Landscape dock side: right side (right-handed default) or split (d-pad left, actions right)? Split is more ergonomic for two-handed grip; single-side is simpler. Recommend single-side right; revisit if lefties complain.
  3. Hide HUD entirely in landscape? If S1 reaches Microban 145 tile=21 (workable but tight), shrinking HUD to a single 28px row buys ~24px more vertical → tile 22-23. Worth it? Probably not; HUD content (level name + moves + best) is gameplay-relevant.
  4. Lock landscape only on tiny levels? Conditional layout per level dimensions = over-engineering. Reject (YAGNI).
  5. Action stack vs d-pad order in landscape column: d-pad on top (closer to natural eye landing) or actions on top? D-pad-bottom matches portrait habit; d-pad-top mirrors a console face-button cluster. Recommend d-pad-top (eyes go right → see arrows first).

Sources: