mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-14 18:19:30 +00:00
fix(layout): put game chrome in flow and contain third-party z-index
The how-to-play hint was a free-floating overlay centred on the whole game box, which put it over the guess map search field on desktop and over the Mapillary attribution -- required visible by their Terms of Use -- on phones. It now rides PanoramaViewer's topBarSlot as a flex sibling of that credit, so neither collision is representable. Replace the ad-hoc z-index values and the two forced 9999 rules with one ladder of tokens in globals.css. Leaflet and Photo Sphere Viewer ladders are contained by isolate on the panes that host them rather than out-bid, which lets the dialog scrim finally cover the action bar and the minimap. Also: pad the header and content box with the left/right safe-area insets that viewportFit cover requires, size the collapsed minimap against the viewport so a landscape phone shrinks it instead of pushing it into the panorama chrome, hide Leaflet's credit and zoom buttons on that thumbnail and lift the zoom control clear of the wrapped credit when it expands, move the desktop "Click to place your guess" badge off the tile credit, raise the hint dismiss target to 44px, and move the sticky-footer column off body so Radix portals are not flex items.
This commit is contained in:
1 parent
1efd2b3a8e
commit
23ee9d47ff
10 files changed
+202
-90
No files matched your search
@@ -10,6 +10,11 @@ import { useEffect, useRef, useState } from 'react';
|
||||
// it never overlaps the game's panorama, map, or action bar, and no layer
|
||||
// needs a z-index to win over it. Clicking the sha copies the FULL value
|
||||
// (the label shows the short form).
|
||||
//
|
||||
// Height is --footer-h (36px) rather than the 44px touch floor: this strip is
|
||||
// a band taken out of the game's non-scrolling surface, where in landscape
|
||||
// 44px is a sixth of the vertical budget. 36px clears the 24px WCAG 2.2 target
|
||||
// minimum, and both targets stay wide.
|
||||
export default function DebugFooter() {
|
||||
const sha = process.env.NEXT_PUBLIC_COMMIT_SHA || 'unknown';
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -31,12 +36,12 @@ export default function DebugFooter() {
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="flex shrink-0 items-center justify-center gap-1 border-t border-border bg-card pb-[env(safe-area-inset-bottom)]">
|
||||
<footer className="flex shrink-0 items-center justify-center gap-2 border-t border-border bg-card pb-[env(safe-area-inset-bottom)]">
|
||||
<a
|
||||
href="https://miti99.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 items-center rounded-md px-2 text-[11px] leading-none text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
className="flex min-h-(--footer-h) items-center rounded-md px-3 text-[11px] leading-none text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
>
|
||||
Made by <span className="underline underline-offset-2 mx-1">miti99</span> with{' '}
|
||||
<span aria-hidden="true" className="ml-1">❤️</span>
|
||||
@@ -44,7 +49,7 @@ export default function DebugFooter() {
|
||||
</a>
|
||||
<span className="text-[11px] leading-none text-muted-foreground" aria-hidden="true">·</span>
|
||||
{/* The visual is deliberately tiny, so the tap target comes from
|
||||
padding: ~44px tall, well past the label's own box. min-w in ch
|
||||
padding: --footer-h tall, well past the label's own box. min-w in ch
|
||||
keeps the width stable when the 7-char sha swaps for the 7-char
|
||||
'copied!', so the click never shifts the thing being clicked.
|
||||
aria-live announces that swap to screen readers. */}
|
||||
@@ -54,7 +59,7 @@ export default function DebugFooter() {
|
||||
aria-label="Copy build commit"
|
||||
aria-live="polite"
|
||||
title={sha}
|
||||
className="flex min-h-11 min-w-[9ch] cursor-pointer select-none items-center justify-center rounded-md px-3 font-mono text-[11px] leading-none text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground active:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
className="flex min-h-(--footer-h) min-w-[9ch] cursor-pointer select-none items-center justify-center rounded-md px-3 font-mono text-[11px] leading-none text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground active:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
>
|
||||
{copied ? 'copied!' : sha.slice(0, 7)}
|
||||
</button>
|
||||
|
||||
@@ -6,18 +6,21 @@ import { X } from 'lucide-react';
|
||||
const HINT_STORAGE_KEY = 'vngeoguessr_hint_seen';
|
||||
|
||||
/**
|
||||
* One-time how-to-play banner over the game screen. All other instruction
|
||||
* lives on the homepage, so a player who arrives by shared link would
|
||||
* otherwise get a panorama with no hint that it drags or that the map takes
|
||||
* a click. Shown until dismissed or until the first guess is placed --
|
||||
* placing a pin proves the hint has been understood.
|
||||
* One-time how-to-play banner, rendered into the panorama pane's top row. All
|
||||
* other instruction lives on the homepage, so a player who arrives by shared
|
||||
* link would otherwise get a panorama with no hint that it drags or that the
|
||||
* map takes a click. Shown until dismissed or until the first guess is placed
|
||||
* -- placing a pin proves the hint has been understood.
|
||||
*
|
||||
* A flow item, deliberately: as a free-floating overlay it was centred on the
|
||||
* whole game box, which put it over the guess map's search field on desktop
|
||||
* and over the Mapillary attribution on phones. Sharing a flex row with the
|
||||
* credit is what makes both collisions unrepresentable, so this component owns
|
||||
* no position, no transform and no z-index.
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.hasGuess True once a guess pin exists this round.
|
||||
* @param {boolean} props.mapExpanded True while the phone minimap covers the
|
||||
* screen. The banner yields then: the expanded panel's own controls live in
|
||||
* a z-clamped stacking context the banner would otherwise paint over.
|
||||
*/
|
||||
export default function FirstRoundHint({ hasGuess, mapExpanded }) {
|
||||
export default function FirstRoundHint({ hasGuess }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
// Read in an effect: localStorage does not exist on the server, and the
|
||||
@@ -44,24 +47,26 @@ export default function FirstRoundHint({ hasGuess, mapExpanded }) {
|
||||
if (!visible || hasGuess) return null;
|
||||
|
||||
return (
|
||||
// role="status" rather than "note": the banner appears after hydration, so
|
||||
// a screen-reader user already past the header would never meet a note.
|
||||
<div
|
||||
role="note"
|
||||
role="status"
|
||||
aria-label="How to play"
|
||||
className={`absolute left-1/2 top-3 z-[700] w-max max-w-[calc(100%-1.5rem)] -translate-x-1/2 items-center gap-2 rounded-xl border border-border bg-card/95 py-2 pl-4 pr-2 text-sm text-foreground shadow-lg backdrop-blur animate-fade-in-up ${
|
||||
mapExpanded ? 'hidden lg:flex' : 'flex'
|
||||
}`}
|
||||
className="flex items-start justify-start gap-2 animate-fade-in-up"
|
||||
>
|
||||
<span>
|
||||
<span className="pointer-events-auto rounded-xl border border-border bg-card/95 px-3 py-2 text-sm text-foreground shadow-lg backdrop-blur">
|
||||
Drag to look around · {' '}
|
||||
<span className="lg:hidden">Tap the minimap</span>
|
||||
<span className="hidden lg:inline">Click the map</span>
|
||||
{' '}to drop your guess · Submit
|
||||
</span>
|
||||
{/* size-11, not size-8: a missed tap on the old 32px target started a
|
||||
panorama drag instead of dismissing the hint. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss the how-to-play hint"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
className="pointer-events-auto flex size-11 shrink-0 items-center justify-center rounded-lg border border-border bg-card/95 text-muted-foreground shadow-lg backdrop-blur transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -395,8 +395,10 @@ export default function GameClient() {
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 vn-surface flex flex-col overflow-hidden">
|
||||
{/* Compact Header */}
|
||||
<header className="flex items-center justify-between gap-2 px-3 sm:px-4 py-2 pt-[calc(0.5rem+env(safe-area-inset-top))] bg-card border-b border-border shadow-sm">
|
||||
{/* Compact Header. viewportFit:cover hands the layout the display
|
||||
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 sits. */}
|
||||
<header className="flex items-center justify-between gap-2 py-2 pl-[calc(0.75rem+env(safe-area-inset-left))] pr-[calc(0.75rem+env(safe-area-inset-right))] pt-[calc(0.5rem+env(safe-area-inset-top))] sm:pl-[calc(1rem+env(safe-area-inset-left))] sm:pr-[calc(1rem+env(safe-area-inset-right))] bg-card border-b border-border shadow-sm">
|
||||
<Button
|
||||
onClick={handleGoBack}
|
||||
variant="ghost"
|
||||
@@ -426,7 +428,7 @@ export default function GameClient() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
onClick={() => setShowDonate(true)}
|
||||
@@ -443,10 +445,11 @@ export default function GameClient() {
|
||||
|
||||
{/* Game Content. Phones get a full-bleed panorama with the guess map
|
||||
floating over it; lg and up keeps the original two-column split. */}
|
||||
<div className="relative flex-1 min-h-0 lg:grid lg:grid-cols-2 lg:gap-3 lg:p-3">
|
||||
<FirstRoundHint hasGuess={Boolean(guessCoordinates)} mapExpanded={mapExpanded} />
|
||||
<div className="relative flex-1 min-h-0 pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)] lg:grid lg:grid-cols-2 lg:gap-3 lg:py-3 lg:pl-[calc(0.75rem+env(safe-area-inset-left))] lg:pr-[calc(0.75rem+env(safe-area-inset-right))]">
|
||||
{/* Panorama Viewer */}
|
||||
<div className="absolute inset-0 bg-neutral-900 overflow-hidden lg:relative lg:rounded-lg">
|
||||
{/* `isolate` traps the panorama library's internal ladder (it reaches
|
||||
9999) inside this pane, so a dialog scrim can still cover it. */}
|
||||
<div className="absolute inset-0 isolate bg-neutral-900 overflow-hidden lg:relative lg:rounded-lg">
|
||||
{loadError ? (
|
||||
<div className="w-full h-full flex items-center justify-center p-6" role="alert">
|
||||
<div className="text-center space-y-4 max-w-sm">
|
||||
@@ -470,6 +473,7 @@ export default function GameClient() {
|
||||
imageUrl={imageData.url}
|
||||
onReady={handlePanoramaReady}
|
||||
onError={handlePanoramaError}
|
||||
topBarSlot={<FirstRoundHint hasGuess={Boolean(guessCoordinates)} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center" role="status" aria-live="polite">
|
||||
@@ -506,7 +510,7 @@ export default function GameClient() {
|
||||
|
||||
{/* The global footer strip below owns the home-indicator safe area,
|
||||
so plain p-3 is enough here. */}
|
||||
<div className="absolute inset-x-0 bottom-0 z-[600] flex gap-2 border-t border-border bg-card/95 p-3 backdrop-blur lg:static lg:border-0 lg:bg-transparent lg:p-0 lg:backdrop-blur-none">
|
||||
<div className="absolute inset-x-0 bottom-0 z-(--z-appbar) flex gap-2 border-t border-border bg-card/95 p-3 backdrop-blur lg:static lg:z-auto lg:border-0 lg:bg-transparent lg:p-0 lg:backdrop-blur-none">
|
||||
<Button
|
||||
onClick={handleSubmitGuess}
|
||||
disabled={!guessCoordinates || !imageData || !sessionId || submitting || roundLoading}
|
||||
|
||||
@@ -7,7 +7,7 @@ import MapSearchBox from './MapSearchBox';
|
||||
|
||||
const LeafletMap = dynamic(() => import('./LeafletMap'), {
|
||||
ssr: false,
|
||||
loading: () => <div role="status" aria-live="polite" className="w-full h-full min-h-[400px] bg-muted flex items-center justify-center text-muted-foreground">Loading map...</div>
|
||||
loading: () => <div role="status" aria-live="polite" className="w-full h-full bg-muted flex items-center justify-center text-muted-foreground">Loading map...</div>
|
||||
});
|
||||
|
||||
// The guess map with its phone minimap behavior. `expanded` stays controlled
|
||||
@@ -24,12 +24,31 @@ export default function GuessMapPanel({
|
||||
const mapRef = useRef(null);
|
||||
// Mirrored into state so the search box renders once the map exists.
|
||||
const [mapInstance, setMapInstance] = useState(null);
|
||||
const collapseButtonRef = useRef(null);
|
||||
|
||||
const handleMapReady = useCallback((map) => {
|
||||
mapRef.current = map;
|
||||
setMapInstance(map);
|
||||
}, []);
|
||||
|
||||
// Expanded, the minimap covers the phone screen like a modal, so it owes
|
||||
// the player the same escape route one gives: Escape collapses it, and focus
|
||||
// moves to the collapse button rather than staying on a cover button that no
|
||||
// longer exists.
|
||||
useEffect(() => {
|
||||
if (!expanded) return undefined;
|
||||
collapseButtonRef.current?.focus();
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
// A dialog on top owns Escape; collapsing the map behind it would make
|
||||
// one keypress do two things.
|
||||
if (document.querySelector('[role="dialog"]')) return;
|
||||
onExpandedChange(false);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
// Leaflet caches its container size, so growing or shrinking the minimap
|
||||
// leaves it rendering at the old dimensions until it is told to remeasure.
|
||||
useEffect(() => {
|
||||
@@ -44,28 +63,54 @@ export default function GuessMapPanel({
|
||||
// Frame the map rather than restyling its tiles: a bright map inset
|
||||
// in a padded card with real elevation reads as a lit window on
|
||||
// dark chrome, which is how GeoGuessr handles the same problem.
|
||||
// bottom-21 clears the action bar, which no longer grows with the home
|
||||
// indicator: the global footer strip below the game owns that inset.
|
||||
className={`absolute z-[500] overflow-hidden rounded-xl border border-border bg-card p-1 shadow-lg transition-all duration-200 ease-out bottom-[5.25rem] lg:relative lg:inset-auto lg:z-auto lg:h-auto lg:w-auto lg:flex-1 lg:min-h-0 lg:p-1.5 ${
|
||||
expanded ? 'inset-x-3 top-3' : 'right-3 h-36 w-36'
|
||||
// Offsets derive from --action-bar-h: the bar no longer grows with the
|
||||
// home indicator (the global footer strip below the game owns that
|
||||
// inset), so one token keeps the collapsed and expanded panels aligned
|
||||
// to the same edge.
|
||||
// `isolate` at every breakpoint, not just where the panel happens to be
|
||||
// absolute: it is what keeps Leaflet's own 200-1000 ladder inside the
|
||||
// panel, so the chrome below can sit on the app-wide scale instead of
|
||||
// out-bidding a third-party stylesheet.
|
||||
className={`absolute isolate z-(--z-floating) overflow-hidden rounded-xl border border-border bg-card p-1 shadow-lg transition-all duration-200 ease-out bottom-[calc(var(--action-bar-h)+0.75rem)] lg:relative lg:inset-auto lg:h-auto lg:w-auto lg:flex-1 lg:min-h-0 lg:p-1.5 ${
|
||||
expanded ? 'inset-x-3 top-3' : 'right-3 h-[min(9rem,30vh)] w-[min(9rem,30vh)]'
|
||||
}`}
|
||||
>
|
||||
<LeafletMap
|
||||
center={center}
|
||||
bbox={bbox}
|
||||
zoom={10}
|
||||
// The search box owns the top-left corner on every breakpoint.
|
||||
zoomPosition="bottomleft"
|
||||
onMapClick={onMapClick}
|
||||
onReady={handleMapReady}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{/* Leaflet's own chrome is sized for a full map, and the phone states
|
||||
are not one, so each gets what fits.
|
||||
Collapsed: a 144px thumbnail behind an opaque "Tap to guess" cover.
|
||||
The tile credit wraps to five lines across it and the zoom buttons
|
||||
land on the cover's label -- both unreadable, and the cover eats
|
||||
every click anyway. Hidden here, shown on the expanded map one tap
|
||||
away; the permanent credit also lives on /credits.
|
||||
Expanded: the map is only ~340px wide, so the credit wraps to two
|
||||
lines and covers the bottom-left zoom control. Lift the control clear
|
||||
rather than dropping either one -- pinch is not the only way to zoom
|
||||
a guess.
|
||||
Both scoped to max-lg: neither state exists from lg up. */}
|
||||
<div
|
||||
className={`isolate h-full w-full ${
|
||||
expanded
|
||||
? 'max-lg:[&_.leaflet-bottom.leaflet-left]:mb-9'
|
||||
: 'max-lg:[&_.leaflet-control-attribution]:hidden max-lg:[&_.leaflet-control-zoom]:hidden'
|
||||
}`}
|
||||
>
|
||||
<LeafletMap
|
||||
center={center}
|
||||
bbox={bbox}
|
||||
zoom={10}
|
||||
// The search box owns the top-left corner on every breakpoint.
|
||||
zoomPosition="bottomleft"
|
||||
onMapClick={onMapClick}
|
||||
onReady={handleMapReady}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search rides above the map (same layer as the expand/collapse
|
||||
buttons). On phones it only makes sense once the minimap is
|
||||
expanded; right-14 keeps it clear of the collapse button there. */}
|
||||
expanded; right-16 keeps an 8px gap to the collapse button there. */}
|
||||
<div
|
||||
className={`absolute top-2 left-2 z-[1200] right-14 lg:right-auto lg:top-3 lg:left-3 lg:w-72 ${
|
||||
className={`absolute top-2 left-2 z-(--z-pane-chrome) right-16 lg:right-auto lg:top-3 lg:left-3 lg:w-72 ${
|
||||
expanded ? '' : 'hidden lg:block'
|
||||
}`}
|
||||
>
|
||||
@@ -74,9 +119,12 @@ export default function GuessMapPanel({
|
||||
|
||||
{/* Desktop counterpart of the phone cover's "Tap to guess": until a pin
|
||||
exists, nothing on a bare map says it is clickable. Non-interactive
|
||||
so it never steals the click it is inviting. */}
|
||||
so it never steals the click it is inviting.
|
||||
bottom-8 clears the attribution bar Leaflet paints along the bottom
|
||||
edge: at bottom-3 this badge's opaque background covered the tile
|
||||
credit, which has to stay legible. */}
|
||||
{!hasGuess && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-[1100] hidden justify-center lg:flex">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-8 z-(--z-pane-chrome) hidden justify-center lg:flex">
|
||||
<span className="rounded-lg bg-background/80 px-3 py-1.5 text-xs font-medium text-foreground shadow-md backdrop-blur">
|
||||
Click to place your guess
|
||||
</span>
|
||||
@@ -91,7 +139,7 @@ export default function GuessMapPanel({
|
||||
type="button"
|
||||
onClick={() => onExpandedChange(true)}
|
||||
aria-label="Expand the guess map"
|
||||
className="absolute inset-0 z-[1200] flex flex-col items-center justify-center gap-1 bg-background/35 text-xs font-semibold text-foreground backdrop-blur-[1px] lg:hidden"
|
||||
className="absolute inset-0 z-(--z-pane-chrome) flex flex-col items-center justify-center gap-1 bg-background/35 text-xs font-semibold text-foreground backdrop-blur-[1px] lg:hidden"
|
||||
>
|
||||
<Maximize2 className="size-4" aria-hidden="true" />
|
||||
{hasGuess ? 'Edit guess' : 'Tap to guess'}
|
||||
@@ -101,9 +149,10 @@ export default function GuessMapPanel({
|
||||
{expanded && (
|
||||
<button
|
||||
type="button"
|
||||
ref={collapseButtonRef}
|
||||
onClick={() => onExpandedChange(false)}
|
||||
aria-label="Collapse the guess map"
|
||||
className="absolute top-2 right-2 z-[1200] flex size-11 items-center justify-center rounded-full border border-border bg-card/90 text-foreground shadow-md backdrop-blur lg:hidden"
|
||||
className="absolute top-2 right-2 z-(--z-pane-chrome) flex size-11 items-center justify-center rounded-full border border-border bg-card/90 text-foreground shadow-md backdrop-blur lg:hidden"
|
||||
>
|
||||
<Minimize2 className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@ import Image from 'next/image';
|
||||
import { Viewer } from '@photo-sphere-viewer/core';
|
||||
import '@photo-sphere-viewer/core/index.css';
|
||||
|
||||
function PanoramaViewer({ imageUrl, onReady, onError }) {
|
||||
function PanoramaViewer({ imageUrl, onReady, onError, topBarSlot }) {
|
||||
const containerRef = useRef(null);
|
||||
const onReadyRef = useRef(onReady);
|
||||
const onErrorRef = useRef(onError);
|
||||
@@ -98,30 +98,47 @@ function PanoramaViewer({ imageUrl, onReady, onError }) {
|
||||
}, [imageUrl]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-neutral-900 overflow-hidden touch-none">
|
||||
// Fills its parent by insets, so the caller must position it. `h-full`
|
||||
// would collapse wherever that parent is a flex item, since a flexed
|
||||
// height is indefinite for percentage resolution.
|
||||
<div className="absolute inset-0 bg-neutral-900 overflow-hidden touch-none">
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
{/* Imagery attribution required by the Mapillary Terms of Use: the logo,
|
||||
visibly displayed, linking to the Mapillary homepage. Deliberately
|
||||
NOT the per-image page — the image id resolves the round's answer,
|
||||
and the server keeps it secret. Top-left: the navbar owns the bottom
|
||||
edge and the floating guess map owns the bottom-right. */}
|
||||
<div className="absolute top-2 left-2 z-10 flex items-center gap-2 rounded-md bg-black/50 px-2 py-1">
|
||||
<a
|
||||
href="https://www.mapillary.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Imagery from Mapillary"
|
||||
>
|
||||
<Image src="/mapillary-logo.svg" alt="Mapillary" width={61} height={14} className="h-3 w-auto" />
|
||||
</a>
|
||||
<a
|
||||
href="https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] leading-none text-white/80 hover:text-white"
|
||||
>
|
||||
CC BY-SA 4.0
|
||||
</a>
|
||||
{/* The pane's top row: the attribution, plus whatever chrome the host
|
||||
wants beside it. One flow row rather than two overlays in the same
|
||||
corner -- that is how the how-to-play banner ended up covering an
|
||||
attribution the Mapillary Terms of Use require to stay visible, and a
|
||||
flex row cannot reach that state at all.
|
||||
The row itself is click-through so it never eats a panorama drag;
|
||||
slot content marks its own opaque parts `pointer-events-auto`. */}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-(--z-pane-chrome) flex flex-wrap items-start gap-2 p-2">
|
||||
{/* Imagery attribution required by the Mapillary Terms of Use: the
|
||||
logo, visibly displayed, linking to the Mapillary homepage.
|
||||
Deliberately NOT the per-image page — the image id resolves the
|
||||
round's answer, and the server keeps it secret. */}
|
||||
<div className="pointer-events-auto flex shrink-0 items-center gap-2 rounded-md bg-black/50 px-2 py-1">
|
||||
<a
|
||||
href="https://www.mapillary.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Imagery from Mapillary"
|
||||
>
|
||||
<Image src="/mapillary-logo.svg" alt="Mapillary" width={61} height={14} className="h-3 w-auto" />
|
||||
</a>
|
||||
<a
|
||||
href="https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] leading-none text-white/80 hover:text-white"
|
||||
>
|
||||
CC BY-SA 4.0
|
||||
</a>
|
||||
</div>
|
||||
{topBarSlot ? (
|
||||
// basis-full below lg: sharing a 375px row with the credit left the
|
||||
// slot ~160px, which wrapped a one-sentence hint into five lines. Its
|
||||
// own row keeps the full width and still cannot reach the credit.
|
||||
<div className="min-w-0 basis-full lg:basis-0 lg:flex-1">{topBarSlot}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+32
-6
@@ -1,4 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
/* Scope class detection to src/. Tailwind 4 otherwise scans the whole repo,
|
||||
so prose that merely quotes a class name becomes a real utility: a report in
|
||||
plans/ citing `pb-[env(...)]` was enough to emit invalid CSS and fail the
|
||||
build. All markup lives under src/. */
|
||||
@import "tailwindcss" source("../");
|
||||
@import "tw-animate-css";
|
||||
|
||||
/* Import Leaflet CSS */
|
||||
@@ -238,14 +242,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure dialogs appear above Leaflet maps */
|
||||
[role="dialog"] {
|
||||
z-index: 9999 !important;
|
||||
/* The credit strip and the game action bar are fixed-height bands that other
|
||||
elements have to clear. One definition each, so changing a band moves
|
||||
everything that offsets against it. */
|
||||
:root {
|
||||
--footer-h: 2.25rem;
|
||||
--action-bar-h: 4.5rem;
|
||||
}
|
||||
|
||||
/* Ensure dialog overlay appears above Leaflet maps */
|
||||
/* One ladder for every layer that floats. Third-party stacks (Leaflet
|
||||
200-1000, Photo Sphere Viewer 50-9999) are not out-bid, they are contained:
|
||||
every pane that hosts one carries `isolate`, so its internal values stay
|
||||
inside it and the numbers below are the only ones that decide the screen.
|
||||
Ordered low to high; nothing here needs !important. */
|
||||
:root {
|
||||
--z-pane-chrome: 10; /* controls painted over a map or panorama */
|
||||
--z-floating: 20; /* the phone minimap panel */
|
||||
--z-appbar: 30; /* the game action bar */
|
||||
--z-fab: 40; /* the landing debug button */
|
||||
--z-overlay: 50; /* dialog scrim */
|
||||
--z-modal: 60; /* dialog content */
|
||||
--z-popover: 70; /* a select or tooltip opened inside a dialog */
|
||||
}
|
||||
|
||||
/* Radix portals its popper to <body>, outside the dialog it belongs to, so a
|
||||
select inside a modal needs the ladder value rather than DOM order. Radix
|
||||
also copies the content's own computed z-index onto this wrapper inline
|
||||
(see react-popper), which is why select.jsx carries --z-popover too; this
|
||||
rule is the floor for any popper whose content sets none. */
|
||||
[data-radix-popper-content-wrapper] {
|
||||
z-index: 9999 !important;
|
||||
z-index: var(--z-popover);
|
||||
}
|
||||
|
||||
/* Page ground, one step off --background. Named for what it is: a flat
|
||||
|
||||
+11
-6
@@ -50,14 +50,19 @@ export default function RootLayout({ children }) {
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
{/* Sticky-footer column: pages fill the viewport via flex-1 and the
|
||||
footer keeps its own strip below them, so it can never overlap the
|
||||
game's panorama, map, or action bar — and nothing covers it. */}
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased flex min-h-dvh flex-col`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<DebugFooter />
|
||||
{/* Sticky-footer column: pages fill the viewport via flex-1 and the
|
||||
footer keeps its own strip below them, so it can never overlap the
|
||||
game's panorama, map, or action bar — and nothing covers it. The
|
||||
column is a wrapper rather than <body> itself because Radix portals
|
||||
append to <body>; as flex items they would each become a row under
|
||||
the footer the moment one rendered anything in flow. */}
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
{children}
|
||||
<DebugFooter />
|
||||
</div>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</body>
|
||||
|
||||
+5
-4
@@ -214,13 +214,14 @@ export default function Home() {
|
||||
/>
|
||||
|
||||
{/* Debug Button. The bottom offset clears the global footer strip
|
||||
(min-h-11) plus the home indicator; hover/active states give the
|
||||
press the feedback the bare outline variant lacked. z-40 keeps it
|
||||
under dialogs. */}
|
||||
plus the home indicator, derived from --footer-h so it follows a
|
||||
change to the strip; hover/active states give the press the
|
||||
feedback the bare outline variant lacked. --z-fab keeps it under
|
||||
dialogs. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="fixed bottom-[calc(3.75rem+env(safe-area-inset-bottom))] right-6 z-40 size-12 rounded-full bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:border-brand/40 hover:text-foreground hover:shadow-md active:scale-95"
|
||||
className="fixed bottom-[calc(var(--footer-h)+1.5rem+env(safe-area-inset-bottom))] right-6 z-(--z-fab) size-12 rounded-full bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:border-brand/40 hover:text-foreground hover:shadow-md active:scale-95"
|
||||
>
|
||||
<Link
|
||||
href="/debug"
|
||||
|
||||
@@ -38,7 +38,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-(--z-overlay) bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
@@ -57,7 +57,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-(--z-modal) grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
@@ -58,7 +58,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-(--z-popover) max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
|
||||
Reference in new issue
Block a user