diff --git a/plans/reports/ui-ux-review-260902-0210-game-screen-overlaps.md b/plans/reports/ui-ux-review-260902-0210-game-screen-overlaps.md new file mode 100644 index 0000000..28e8708 --- /dev/null +++ b/plans/reports/ui-ux-review-260902-0210-game-screen-overlaps.md @@ -0,0 +1,269 @@ +# Game screen — overlap / layering / spatial-collision audit + +Date: 2026-09-02 · Scope: `/game` (GameClient tree) · Report only, no source touched. +Standards cited from `ak-ui-ux-pro-max` `data/ux-guidelines.csv` (no numeric ids in that CSV — cited as `ux//`). + +## Measured box model (used throughout) + +Derived from the classes in the tree, not guessed: + +| Band | Height | Source | +|---|---|---| +| Header | 60px + `safe-area-inset-top` | `py-2` (16) + tallest child `min-h-11` (44) — GameClient.js:399 | +| Footer strip | 44px + `safe-area-inset-bottom` | `min-h-11` links + `pb-[env(...)]` — DebugFooter.js:34,39 | +| Game content | `100dvh − 104 − insets` | `flex-1 min-h-0` between them | +| Action bar | 72px | `p-3` (24) + `size="lg"` = `h-12` (48) — GameClient.js:509, button.jsx:44 | +| Minimap (collapsed) | 144px tall, top edge at `content − 228` | `h-36` + `bottom-[5.25rem]` (84) — GuessMapPanel.js:49,50 | +| Hint banner | 48px tall at y 12..60 | `top-3` + `py-2` (16) + `size-8` close (32) — FirstRoundHint.js:50,64 | + +Viewport budgets: 375×667 → content 563px. 360×640 → 536px. **667×375 landscape → 271px. 640×360 landscape → 256px.** + +--- + +## CRITICAL + +### C1 — Hint banner is a free-floating overlay in a box two other elements already own + +**Symptom.** "Drag to look around · … · Submit" is painted over / clipped on the game screen. + +**Files.** `src/app/components/FirstRoundHint.js:50` · `src/app/components/GameClient.js:446-449` · `src/app/components/GuessMapPanel.js:49,68` · `src/app/components/PanoramaViewer.js:108` + +**Root cause — two distinct collisions, one shared cause.** + +The banner is `absolute left-1/2 top-3 -translate-x-1/2` inside the **whole game-content box** (`GameClient.js:446`, `relative flex-1 … lg:grid lg:grid-cols-2`). Nothing reserves that box for it; three other elements are positioned into the same coordinates. + +*Desktop (`lg`), the reported case — the banner loses the stacking fight.* +- Banner is centred on the **grid container**, i.e. on the gutter between the two columns. Its intrinsic width (`w-max`, one line at `lg`) is ≈510px (63 chars @14px + `pl-4` + `pr-2` + `gap-2` + 32px close), so it reaches ≈255px into the **map column**. +- `MapSearchBox`'s wrapper is `absolute top-3 left-3 w-72 z-[1200]` (`GuessMapPanel.js:68`) → x = colLeft+18 .. colLeft+306, y = 24..68. +- Overlap at 1280px wide: x 646..895, y 24..60. At 1024px it is worse (≈243px of horizontal overlap) because the banner width is fixed while the column narrows. +- **Why the search box wins:** `GuessMapPanel` is `absolute z-[500]` on phones (a stacking context, which clamps its `z-[1100]`/`z-[1200]` children) but `lg:relative lg:z-auto` (`GuessMapPanel.js:49`). `position:relative` + `z-index:auto` creates **no** stacking context, and neither does the `lg:flex` wrapper (`GameClient.js:496`) nor the `relative` grid (`GameClient.js:446`) — all `z-auto`. So at `lg` the search box's `1200` escapes into the **root** stacking context and beats the banner's `700` directly. Same escape applies to Leaflet's `.leaflet-top/.leaflet-bottom` (`z-index:1000`) and `.leaflet-control` (`800`) — currently harmless only because `overflow-hidden` on the panel clips them inside the panel box. + +*Mobile — the banner wins and buries a legally required credit.* +- Mapillary attribution: `absolute top-2 left-2 z-10` (`PanoramaViewer.js:108`), x 8..≈135, y 8..≈30. +- Banner at 375px: `max-w-[calc(100%-1.5rem)]` = 351px, text ≈455px → wraps to 2 lines, box x 12..363, y 12..≈68. +- Banner (`z-[700]`, same root stacking context, `bg-card/95` opaque) **covers the attribution**. `PanoramaViewer.js:103-107` states the credit is required by the Mapillary ToU ("visibly displayed"). This is a compliance regression, not just cosmetics. + +*Landscape phone — the banner also lands on the minimap.* See H1. + +**Ruled out (checked, so nobody re-checks):** `animate-fade-in-up` (globals.css:266-278, `forwards`) animates `transform`, while Tailwind v4.3.3 compiles `-translate-x-1/2` to the standalone `translate` property (verified in `node_modules/tailwindcss/dist/lib.js`: `["translate","var(--tw-translate-x) var(--tw-translate-y)"]`). They compose; centring is **not** broken by the animation. The clipping is geometric, not transform-related. + +**Fix — layout-level, no z-index change. Give the hint a flow cell that already excludes its neighbours.** + +*Option A (recommended — costs zero vertical budget, fixes both collisions at once).* Put the hint into the panorama pane's **top bar**, in flow beside the Mapillary credit. That confines it to the panorama column on `lg` (so it can never reach the map column) and makes it a flex sibling of the credit on phones (so it can never cover it). `PanoramaViewer` keeps ownership of the credit (it is also used by `src/app/debug/coverage/page.js:235`), and gains one slot prop: + +```jsx +// PanoramaViewer.js — the credit and any host-supplied chrome share one flow +// row. Two absolutely-positioned overlays in the same corner is how the +// how-to-play banner ended up covering an attribution the Mapillary ToU +// requires us to keep visible; a flex row cannot produce that state at all. +function PanoramaViewer({ imageUrl, onReady, onError, topBarSlot }) { + // ... +
+
+ {/* existing Mapillary logo + CC link, unchanged */} +
+ {topBarSlot ?
{topBarSlot}
: null} +
+} +``` + +```jsx +// GameClient.js — the hint rides in the panorama's own top bar, so it shares a +// row with the credit instead of a coordinate space with it, and on lg it is +// physically inside the panorama column, out of the map search box's reach. +} +/> +``` + +```jsx +// FirstRoundHint.js — no position, no translate, no z-index. It is a flow item +// in the panorama top bar now; the layout, not a stacking value, is what keeps +// it off the credit and off the guess map. +
+ … + {/* see M1: 32px is under the floor */} +
+``` +`mapExpanded` can then be dropped from the props: with the hint inside the panorama pane, the expanded minimap (a sibling that covers the pane at `z-[500]`) occludes it naturally, so the `hidden lg:flex` special case and the comment at `FirstRoundHint.js:15-18` about a "z-clamped stacking context" both become dead. + +*Option B (smaller diff, costs ~44px of an already tight budget).* Give the hint its own grid row between header and content: + +```jsx +
+
+ {/* auto row, collapses to 0 when it returns null */} +
…
+
+``` +Collision-proof by construction, but it takes 44px from a 271px landscape content band (see H1), so prefer A. + +--- + +## HIGH + +### H1 — Landscape phone: hint, minimap and action bar all collide; the panorama is squeezed to a sliver + +**Files.** `GameClient.js:509` · `GuessMapPanel.js:49-51` · `FirstRoundHint.js:50` + +**Symptom.** At 667×375 the content band is 271px. The minimap's top edge sits at `271 − 228 = 43px`, the hint occupies y 12..60 and x 78..589 (centred, ≈510px wide), the minimap occupies x 523..655. → **17px of vertical × 66px of horizontal overlap**; the hint (`z-[700]`) paints over the minimap (`z-[500]`). At 640×360 the minimap top is 28px and the overlap grows to 32px. At 844×390 landscape it is a 2px graze — i.e. the layout is *at* the failure boundary on current phones, not near it. + +**Root cause.** Three absolutely-positioned overlays measured from opposite edges of the same box, with no shared constraint. The minimap's height (`h-36`) and offset (`bottom-[5.25rem]`) are fixed pixels that never consult the available height, so as the band shrinks the minimap marches upward into the hint. Nothing clips first — they simply stack. + +Secondary, and worse for play: at 271px the chrome consumes `48 (hint) + 144 (minimap) + 72 (action bar) = 264px` of vertical extent. Effective unobstructed panorama height between hint and action bar is ≈139px, and the right 40% of that is minimap. + +**Fix.** +1. Option A of C1 removes the hint from this contest entirely (it becomes the panorama's top row). +2. Size the minimap against the band instead of hard-coding it, so it cannot climb: +```jsx +// The minimap is a preview, not a pane: cap it at a share of the band so a +// short landscape viewport shrinks it instead of pushing it up into the +// panorama chrome. 9rem stays the portrait size; the min() is what keeps +// 360px-tall landscape honest. +className={`… bottom-[5.25rem] right-3 h-[min(9rem,30vh)] w-[min(9rem,30vh)] …`} +``` +3. Consider hiding the credit footer strip on the `/game` route in landscape (see M4) — it is 44px of a 271px budget. + +### H2 — Dialog scrim renders *under* the game chrome + +**Files.** `src/components/ui/dialog.jsx:41` (`z-50` overlay) vs `GameClient.js:509` (`z-[600]`), `GuessMapPanel.js:49` (`z-[500]`) · `src/app/globals.css:241-249` + +**Symptom.** Open `RoundResultDialog` (or `DonateQRModal` from the game header): the action bar and the collapsed minimap keep full brightness on top of the black/50 scrim, while everything else dims. They are inert (Radix modal sets `pointer-events:none` on the body) so it reads as a rendering bug rather than an affordance. + +**Root cause.** `globals.css:242` rescues only the **content** (`[role="dialog"] { z-index: 9999 !important }` — Radix puts `role="dialog"` on Content, not on Overlay). The overlay keeps shadcn's default `z-50`, which is below the game's ad-hoc 500/600. Portal DOM order does not help: an explicit `z-index` beats source order. + +**Fix.** Bring the overlay into the same scale as the content and delete the `!important` escape hatches (see the ladder below). Concretely: overlay → `z-50`, content → `z-60`, popper → `z-70`, and drop `z-[500]/z-[600]` to `z-20/z-30`. No `!important` anywhere. + +### H3 — No left/right safe-area handling anywhere + +**Files.** grep of `src/`: only `safe-area-inset-top` (GameClient.js:399, debug/layout.js:16) and `safe-area-inset-bottom` (DebugFooter.js:34, page.js:223). No `-left` / `-right`. + +**Symptom.** `viewport.viewportFit = "cover"` (`layout.js:29`) opts the app into the display cutout. In landscape on a notched iPhone the inset is 44–59px on one side: the header's Back button (`px-3`), the minimap (`right-3`), and the action bar (`p-3`) all sit under the notch or the rounded corner. The refactor to an in-flow footer covered the home indicator correctly but never addressed the horizontal axis — this predates the change and was not introduced by it. + +**Fix.** Add the horizontal inset once, on the two elements that own the screen edges, rather than per-child: +```jsx +// viewportFit:cover means the layout owns the cutout on every axis, not just +// the top. In landscape the notch takes a 44-59px bite out of one side, which +// is exactly where Back and the minimap live. +
+
+``` +Padding the game-content box (rather than each overlay) means the minimap's `right-3`, the action bar's `inset-x-0` and the hint all inherit the inset for free. + +### H4 — Hint dismiss button is 32×32 + +**File.** `FirstRoundHint.js:64` (`size-8`). + +**Symptom.** The only explicit way to dismiss the banner is a 32px target sitting over the panorama; a missed tap starts a panorama drag instead. Violates `ux/Touch/Touch Target Size` (min 44×44, severity High). + +**Fix.** `size-11` with the icon left at `size-4`; or make the whole banner the dismiss target (`