feat(mobile): portrait upward bias + landscape side-by-side layout

Portrait: add asymmetric padding-bottom: clamp(16px, 6vh, 56px) on
.play-stack so safe-center distributes the slack into the centroid,
lifting the play-group ~28px upward. Counters the perceptual "low"
feel caused by HUD top-weight + dock visual mass even though the
group is geometrically near-centered.

Landscape: flip .play-group to flex-direction: row so the dock
occupies a 160px right column instead of stacking below the board.
.mobile-dock switches to flex-direction: column with
justify-content: space-between (action stack on top, d-pad at bottom
for the natural two-handed thumb arc). On a Pixel 7 landscape
viewport this lifts Microban 1's tile size from 19px to 56px (3x
bigger) and brings the giant finale levels back into a playable
range.

computeTileSize now branches on orientation: landscape uses
chromeY=76 (HUD only) and reserves 160px of width for the side dock;
portrait keeps chromeY=295 (HUD + in-flow dock); desktop unchanged.
Orientation matchMedia listener wired into the existing pointer
listener so rotation reflows the tile budget live.

Touch targets unchanged (48px arrows, 48px action buttons). All
recent fixes (mistouch, top-aligned d-pad, LVLS grid, play-group
center) preserved — only the OUTER flex direction flips on
landscape; internal MobileControls grids are unaffected.
This commit is contained in:
tiennm99 committed 2026-04-29 00:05:01 +07:00
1 parent e08beb0c81
commit 0fa7a58521
2 files changed
+83 -12

No files matched your search

+60 -12
View File
@@ -41,13 +41,29 @@
if (!level) return 48;
const maxTile = 56;
const minTile = 16;
// Dock is now in-flow (flex row at bottom); margin only covers header + hud + padding.
const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
// 195 reflects the slimmer mobile chrome after R6+R7 (top-aligned d-pad
// at 48px arrows). If the dock layout changes, retune this.
const margin = isCoarse ? 195 : 120; // header + hud (+ in-flow mobile dock) + padding
const maxByWidth = Math.floor((window.innerWidth - 80) / level.width);
const maxByHeight = Math.floor((window.innerHeight - margin - 100) / level.height);
const hasMM = typeof window !== 'undefined';
const isCoarse = hasMM && window.matchMedia('(pointer: coarse)').matches;
const isLandscape = hasMM && window.matchMedia('(orientation: landscape)').matches;
// Chrome reserve depends on layout:
// coarse + landscape: dock lives in a side column, so vertical chrome
// is just HUD + paddings (no in-flow dock below the board).
// coarse + portrait : dock stacks under the board.
// desktop : HUD only.
let chromeY, chromeX;
if (isCoarse && isLandscape) {
chromeY = 76;
chromeX = 80 + 160; // base + side dock width
} else if (isCoarse) {
chromeY = 295;
chromeX = 80;
} else {
chromeY = 220;
chromeX = 80;
}
const maxByWidth = Math.floor((window.innerWidth - chromeX) / level.width);
const maxByHeight = Math.floor((window.innerHeight - chromeY) / level.height);
return Math.max(minTile, Math.min(maxTile, maxByWidth, maxByHeight));
}
@@ -127,14 +143,20 @@
tileSize = computeTileSize();
}
// Recompute tile size when pointer type changes (e.g. external keyboard
// attached/detached on iPad), so the bottom-controls reservation matches.
// Recompute tile size when pointer type or orientation changes — the
// chrome reserve differs between portrait (dock stacks below board) and
// landscape (dock moves to a side column), so the tile budget shifts.
$effect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(pointer: coarse)');
const mqCoarse = window.matchMedia('(pointer: coarse)');
const mqOrient = window.matchMedia('(orientation: landscape)');
const handler = () => onResize();
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
mqCoarse.addEventListener('change', handler);
mqOrient.addEventListener('change', handler);
return () => {
mqCoarse.removeEventListener('change', handler);
mqOrient.removeEventListener('change', handler);
};
});
const hasNext = $derived(levelIndex + 1 < MICROBAN_LEVELS.length);
@@ -280,8 +302,12 @@
.game { gap: 10px; }
/* Asymmetric padding-bottom shifts the safe-centered group upward by
roughly half the padding (~28px on a 915px viewport). Counters the
perceptual "low" feel caused by HUD top-weight + dock visual mass. */
.play-stack {
justify-content: safe center;
padding-bottom: clamp(16px, 6vh, 56px);
}
.play-group {
@@ -298,6 +324,28 @@
}
}
/* Landscape phones: flip .play-group to a row so the dock occupies a
narrow right column instead of stacking below the board. Lets the board
claim the full vertical viewport (minus HUD), 3x the tile size on most
Microban levels. Drop the portrait upward-bias padding — the row layout
fills space differently. */
@media (pointer: coarse) and (orientation: landscape) {
.play-stack { padding-bottom: 0; }
.play-group {
flex: 1 1 auto;
flex-direction: row;
align-items: stretch;
gap: 12px;
max-height: none;
}
.board-wrap {
flex: 1 1 auto;
max-width: none;
}
}
.error {
color: var(--danger);
font-weight: 700;
+23
View File
@@ -158,6 +158,29 @@
.action { height: 48px; }
}
/* Landscape phones: dock becomes a narrow right column. Action stack
sits at the top, d-pad at the bottom so the arrows land in the
natural thumb arc for a two-handed grip. */
@media (pointer: coarse) and (orientation: landscape) {
.mobile-dock {
flex-direction: column;
justify-content: space-between;
align-items: center;
width: 160px;
gap: 12px;
padding:
12px
calc(8px + env(safe-area-inset-right))
calc(12px + env(safe-area-inset-bottom))
8px;
}
/* Override portrait's align-self: flex-start; in column layout the
cross axis is horizontal and we want the d-pad centered. */
.dpad { align-self: center; }
.dock-left { align-self: center; }
}
/* Global `button` rules (touch-action, tap-highlight) live in app.css. */
.action {
min-width: 64px;