html,
body {
	width: 100%;
	height: 100%;
	overflow: hidden;
	padding: 0;
	margin: 0;
}

/* Info panel font: Segoe UI, matching the typeface Unity uses for the
   "click anywhere to begin" button inside the WebGL build, so the DOM overlay
   and the engine UI read as one piece.

   System font, not a webfont. Segoe UI ships with Windows; the fallbacks cover
   the rest — macOS resolves to its system UI face, Android to Roboto, Linux to
   a DejaVu/Liberation-class face. Metrics are close enough across all of them
   that the panel layout holds without bundling a .woff2. */
:root {
	--modal-font: "Segoe UI", system-ui, -apple-system, Roboto, Arial, sans-serif;
}

/* ---- Rotation root ----
   Single wrapper that contains the loading overlay and game container.
   When the body has the `.simulated-landscape` class, the wrapper rotates 90°
   (direction set by the --simulated-landscape-angle CSS variable, updated by
   ViewportManager's tilt detection). The wrapper's dimensions use dvh/dvw so
   they auto-track the visible viewport (browser chrome show/hide etc.). */
#rotationRoot {
	position: fixed;
	inset: 0;
}

body.simulated-landscape #rotationRoot {
	/* Swap CSS dimensions so the wrapper covers the viewport after rotation.
	   width = visible viewport HEIGHT, height = visible viewport WIDTH. */
	inset: auto;
	width: 100dvh;
	height: 100dvw;
	left: 50%;
	top: 50%;
	transform: translate(-50%, -50%) rotate(var(--simulated-landscape-angle, 90deg));
	transform-origin: center center;
}

/* ---- Game container ---- */

div#gameContainer {
	background: transparent !important;
	position: fixed;
	inset: 0;
}

div#gameContainer canvas {
	position: absolute;
	inset: 0;
	/* Disable browser default touch gestures (pinch-zoom page, scroll, etc.)
	   so they don't interfere with Unity's input handling. */
	touch-action: none;
	-webkit-tap-highlight-color: transparent;
}

/* ---- Controls bar ----
   Container for tap-revealed UI buttons that sit above the canvas. The bar
   itself is invisible (no background); only the buttons inside have visuals.
   pointer-events: none on the container so empty regions pass touches through
   to the canvas; pointer-events: auto on individual buttons restores clicks.

   Two properties here are load-bearing, and they pull in opposite directions:

   1. NO z-index. A z-index creates a stacking context, which ISOLATES
      mix-blend-mode — #infoBtn's exclusion blend would resolve against this
      bar's own transparent backdrop (i.e. do nothing) instead of the mural.
      Verified: an identical icon renders flat white with z-index, and blends
      correctly without it.
   2. position: ABSOLUTE, not fixed. Fixed creates a stacking context, which
      isolates the blend just as a z-index does — verified on the real page,
      where a fixed bar left the icon flat instead of inverting the backdrop.

   The cost of absolute: this bar paints BELOW #loadingOverlay and
   #gameContainer, which are both fixed. That is fine for the buttons (they sit
   above the canvas they blend with) but it hid the tooltip balloons during
   loading. The balloons therefore live in their own fixed layer,
   #controlsTooltips — see that rule below.

   Do not add a z-index or position: fixed to this rule. */
#controlsBar {
	position: absolute;
	inset: 0;
	pointer-events: none;
}

/* Tooltip layer. Fixed, so it paints above #loadingOverlay and #gameContainer
   and the balloons are visible during the loading stage. Safe to be fixed
   because nothing in here uses mix-blend-mode — only the buttons do, and they
   stay behind in #controlsBar. */
#controlsTooltips {
	position: fixed;
	inset: 0;
	pointer-events: none;
}

/* The icon artwork (InfoIcon.png / FSIcon.png) is self-sufficient — it carries
   its own circular shape and contrast. The button itself contributes no
   background, border, or shadow; it's purely a hit target and positioner.
   --extras-btn-size drives the rendered icon size; the 44px floor keeps the
   touch target at the platform minimum even if the visual size is tuned down. */
:root {
	/* 90% of the original 44px. Desktop pass — mobile sizing gets its own
	   treatment later, including whether these need a larger touch target. */
	--extras-btn-size: 39.6px;
	/* Distance from the viewport edge to the button box. The icon PNGs carry a
	   few px of their own transparent padding, so the visible gap reads a
	   little larger than this value. Measured at ~15px visible with a 12px
	   inset; 22px here puts the visible gap at the requested ~25px. */
	--extras-btn-inset: 22px;
}

.extras-btn {
	position: absolute;
	background: transparent;
	border: none;
	width: var(--extras-btn-size);
	height: var(--extras-btn-size);
	padding: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
	user-select: none;
	/* Hidden by default. Buttons only become clickable and visible when the
	   .visible class is added (typically by the tap-reveal logic).
	   Asymmetric transitions: fade-out (when .visible is removed) is slower
	   than fade-in (when .visible is added) for a softer disappearance. */
	opacity: 0;
	pointer-events: none;
	transition: opacity 1s ease;
}

.extras-btn.visible {
	opacity: 1;
	pointer-events: auto;
	transition: opacity 0.4s ease;
}

/* Always-visible modifier: stays solid regardless of the fade group's state.
   Used by the enter-fullscreen button while out of fullscreen mode. JS removes
   this class (and the matching pointer-events override) when entering FS. */
.extras-btn.always-visible {
	opacity: 1;
	pointer-events: auto;
}

/* Custom icon artwork fills the button box. The source PNGs are 128x128, so
   they scale down cleanly and stay sharp on high-DPI displays. */
.extras-btn img {
	display: block;
	width: 100%;
	height: 100%;
	object-fit: contain;
	pointer-events: none;
}

#infoBtn {
	bottom: var(--extras-btn-inset);
	left: var(--extras-btn-inset);
}

/* Exclusion blend on the info icon.
   The artwork is white; exclusion inverts whatever is behind it, so the icon
   reads dark on light backgrounds and light on dark ones without needing a
   plate or shadow behind it.

   This depends on #controlsBar NOT creating a stacking context — see the note
   on that rule. A z-index there (or an opacity/filter/transform on any
   ancestor) would isolate the blend and the icon would render as flat white.

   Note exclusion against mid-grey (~#808080) returns mid-grey, so the icon can
   wash out over medium tones — check it over the busiest part of the mural,
   not just the dark areas. */
#infoBtn img {
	mix-blend-mode: exclusion;
}

/* 2026-09-27: once Unity draws the icon itself (InfoIconArt.cs, handed over by
   controls-overlay.js after Start is pressed), the page's image is hidden.
   Only the image: the button keeps its box, so clicks and tooltips work as
   before. Before the takeover -- loading, the Start screen -- this image is
   the icon, exactly as it was. */
body.unity-icon-art #infoBtn img {
	visibility: hidden;
}

/* Enter-FS and Exit-FS share the bottom-right corner: only one is active at a
   time depending on FS state, so they can occupy the same spot. */
#fullscreenEnterBtn,
#fullscreenExitBtn {
	bottom: var(--extras-btn-inset);
	right: var(--extras-btn-inset);
}

/* ---- Control tooltips ----
   Speech-balloon hints that point at the info / fullscreen buttons. Sit in
   #controlsBar so they inherit its stacking and rotate with #rotationRoot,
   keeping them attached to their buttons in simulated-landscape mode.
   Hidden until JS adds .visible. Dismissal behavior is still to be decided —
   for now they're shown/hidden explicitly by controls-overlay.js. */
.control-tooltip {
	position: absolute;
	/* Vertically centred on the button. The button's centre sits at
	   (inset + half the button height) up from the viewport bottom; anchoring
	   the balloon's own bottom edge there and pulling it back down by half its
	   height puts the two centres on one line, whatever the balloon's height.
	   (The previous rule hardcoded the old 12px inset and used a +50% translate
	   that pushed the balloon DOWN instead of centring it, which is why it sat
	   against the bottom edge of the screen.) */
	bottom: calc(var(--extras-btn-inset) + (var(--extras-btn-size) / 2));
	transform: translateY(50%);
	max-width: 15rem;
	padding: 8px 12px;
	background: #555555;
	color: rgba(255, 255, 255, 0.95);
	border-radius: 6px;
	font-family: Arial, Helvetica, sans-serif;
	font-size: 1.07rem;
	line-height: 1.3;
	white-space: nowrap;
	opacity: 0;
	/* Not clickable while hidden, so an invisible balloon can never swallow a
	   click meant for the canvas underneath. Re-enabled by .visible below. */
	pointer-events: none;
	transition: opacity 0.4s ease;
}

.control-tooltip.visible {
	opacity: 1;
	/* Clickable only while shown — tapping the balloon dismisses it early
	   (see _wireTooltipDismiss in controls-overlay.js). */
	pointer-events: auto;
	cursor: pointer;
}

/* Balloon tail — a CSS triangle on the side facing the button. */
.control-tooltip::after {
	content: '';
	position: absolute;
	top: 50%;
	margin-top: -6px;
	border: 6px solid transparent;
}

/* Info balloon: sits to the RIGHT of the bottom-left button, tail pointing left. */
.control-tooltip-left {
	left: calc(var(--extras-btn-inset) + var(--extras-btn-size) + 10px);
}

.control-tooltip-left::after {
	right: 100%;
	border-right-color: #555555;
}

/* Fullscreen balloon: sits to the LEFT of the bottom-right button, tail pointing right. */
.control-tooltip-right {
	right: calc(var(--extras-btn-inset) + var(--extras-btn-size) + 10px);
}

.control-tooltip-right::after {
	left: 100%;
	border-left-color: #555555;
}

/* Narrow viewports: allow wrapping so a long hint doesn't run off-screen. */
@media (max-width: 700px) {
	.control-tooltip {
		max-width: 45vw;
		white-space: normal;
		font-size: 1rem;
	}
}

/* ---- Loading overlay ---- */

#loadingOverlay {
	position: fixed;
	inset: 0;
	background: #404040;
	/* No z-index: the overlay precedes #controlsBar in the DOM, so the info
	   button already paints above it. A z-index here would put the overlay
	   back on top and hide the button during loading. */
	display: none;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	-webkit-tap-highlight-color: transparent;
	transition: opacity 0.5s ease;
}

#loadingOverlay.visible {
	display: flex;
}

#loadingOverlay.fading-out {
	opacity: 0;
}

/* ---- Logo progress + background ----
   .logo-progress is the single source of truth for sizing. Its width drives both
   the logo image AND the loading background, which is now a child of this container.
   This guarantees the BG cutout and the white logo can never drift apart regardless
   of how the browser resolves viewport units (URL bar visible vs. hidden, etc.). */

:root {
	/* Logo width as a fraction of the BG's rendered width (1195/1920). */
	--logo-bg-ratio: 0.6224;
	/* Landscape tuning: logo zone occupies this fraction of viewport width. */
	--logo-landscape-width: calc(var(--logo-bg-ratio) * 100vh * (1920 / 1080));
	/* Portrait tuning: side margin (in vw) reserved on each side of the logo.
	   Adjust to match Unity's portrait camera framing once that's dialed in. */
	--logo-portrait-side-margin: 4vw;
	--logo-portrait-width: calc(100vw - 2 * var(--logo-portrait-side-margin));
}

#loadingOverlay .logo-progress {
	position: absolute;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	--logo-effective-width: var(--logo-landscape-width);
	width: var(--logo-effective-width);
	aspect-ratio: 1195 / 163;
	pointer-events: none;
}

/* Loading background: sized relative to .logo-progress, NOT the viewport.
   Width = logo width × (1920/1195) so the cutout area in the BG is exactly the
   logo's width. Height auto-derives from BG aspect ratio. Centered on the logo. */
#loadingOverlay .logo-progress .loading-bg {
	position: absolute;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	width: calc(100% * (1920 / 1195));
	height: auto;
	max-width: none;
	z-index: 0;
	pointer-events: none;
}

/* Clip wrapper — overflow:hidden masks the full-size logo image.
   JS sets width from 0% to 100% to reveal left-to-right. */
#loadingOverlay .logo-progress .logo-fill-wrapper {
	position: absolute;
	top: 0;
	left: 0;
	height: 100%;
	width: 100%; /* TEMP: set to 0% for production */
	overflow: hidden;
	z-index: 1;
}

/* Logo image: always rendered at the full logo-progress size, never scaled by the wrapper.
   Width references the grandparent (.logo-progress) via the inherited CSS variable,
   so the image stays at its final width while the wrapper's overflow:hidden clips
   it from the right — producing the left-to-right wipe. */
#loadingOverlay .logo-progress .logo-fill {
	display: block;
	width: var(--logo-effective-width);
	height: auto;
	max-width: none;
	position: absolute;
	top: 0;
	left: 0;
}

/* ---- Portrait orientation override ----
   Switch the logo's width basis from viewport-height to viewport-width.
   Everything else (BG sizing, logo-fill, clip wrapper) is relative to
   .logo-progress and follows automatically.
   The :not(.simulated-landscape) guard disables this rule when ViewportManager
   has rotated the page — in that case the device is physically portrait but the
   content is visually landscape, so we want the landscape-style sizing. */
@media (orientation: portrait) {
	body:not(.simulated-landscape) #loadingOverlay .logo-progress {
		--logo-effective-width: var(--logo-portrait-width);
	}
}

/* ---- Simulated landscape override ----
   When ViewportManager rotates the page 90deg via CSS transform, the rotated
   content's visual width equals the device's portrait HEIGHT (i.e. 100vh).
   The default landscape formula multiplies 100vh by (1920/1080), which is correct
   when 100vh is the short dimension of a real landscape viewport — but here 100vh
   IS the visual width, so we drop the multiplier to keep the logo proportionally
   sized within the rotated frame. */
body.simulated-landscape #loadingOverlay .logo-progress {
	--logo-effective-width: calc(var(--logo-bg-ratio) * 100vh);
}

/* ---- Info modal ----
   Hidden by default. Opened via controls-overlay.js when #infoBtn is clicked.
   Sits inside #rotationRoot so it rotates with the rest of the UI in
   simulated landscape mode — this is what keeps the info button pinned to the
   landscape's bottom-left and the panel aligned with the canvas contents
   regardless of the device's physical orientation.
   It follows #controlsBar in the DOM, so it paints above the buttons (covering
   them while open) without needing a z-index of its own. */
#infoModal {
	/* ABSOLUTE, not fixed — position: fixed creates a stacking context, which
	   would isolate the panel's luminosity blend from the canvas behind it
	   (exactly the trap #controlsBar hit). The offset parent is #rotationRoot,
	   itself fixed and covering the viewport, so inset: 0 resolves to the same
	   rectangle either way. Do not change this back to fixed. */
	position: absolute;
	inset: 0;
	display: none;
	align-items: center;
	justify-content: center;
	pointer-events: none;
	/* NO opacity/transition on this element. Any opacity between 0 and 1
	   creates a stacking context, which isolates the panel's luminosity blend
	   — during a fade on this container the splash rendered flat grey and the
	   blend only snapped on when opacity reached exactly 1. The fade is done
	   on the CHILDREN instead (see .info-modal-content / .info-modal-backdrop
	   below), so this element's opacity stays 1 and the blend is live for the
	   whole animation. */
}

#infoModal.visible {
	display: flex;
	pointer-events: auto;
}

/* Use .open-anim once display:flex has applied to trigger the fade. JS adds
   .visible first (display flips), then on the next frame adds .open-anim. */
/* The fade is applied to the panel's individual LAYERS, never to the panel or
   the modal container.

   Why: an element at opacity between 0 and 1 creates a stacking context, and
   that isolates mix-blend-mode. Measured on a test page — an element carrying
   the blend reads saturation 171 at opacity 1 but only 86 at opacity 0.5,
   while a CHILD at opacity 0.5 leaves the parent's blend untouched (177).
   Fading the container therefore made the splash render flat grey for the
   whole animation and snap into colour at the end.

   The ::before splash layer is exempt: it carries mix-blend-mode itself, so
   giving it its own opacity would isolate the very blend we want. It's faded
   via the panel's background-color-free ::before opacity below, which is safe
   because the layer IS the blended element rather than an ancestor of it. */
#infoModal .info-modal-content::before,
#infoModal .info-panel-inner,
#infoModal .info-modal-close,
#infoModal .info-modal-backdrop {
	opacity: 0;
	transition: opacity 0.25s ease;
}

#infoModal.open-anim .info-modal-content::before,
#infoModal.open-anim .info-panel-inner,
#infoModal.open-anim .info-modal-close,
#infoModal.open-anim .info-modal-backdrop {
	opacity: 1;
}

/* Invisible full-viewport click-catcher. The visible dim is drawn by
   .info-modal-backdrop, which spans the viewport and needs no alignment to the
   artwork. */
/* Full-viewport dim, and the click-catcher for dismiss-on-outside-click.

   Plain black at 10% — no cutout. The panel's splash sits ON TOP of this, so
   the two greys compound: the panel reads as a darker grey than the
   surrounding dim, which is the separation Diego wanted. The earlier approach
   (a PNG with the splash shape knocked out, so the panel blended against
   undimmed canvas) is gone, along with its asset and its scale-locking.

   Consequence worth knowing: the luminosity blend now reads the DIMMED mural
   rather than the original, so the panel is slightly less saturated than the
   cutout version (measured 111 vs 129 on the earlier test). That desaturation
   is exactly what produces the darker panel grey here — it's the intent, not
   a regression. */
.info-modal-backdrop {
	position: absolute;
	inset: 0;
	background: rgba(0, 0, 0, 0.10);
	cursor: pointer;
}


/* The panel is an aspect-ratio-locked box matching InfoPanel.png (1708x935).
   Locking the ratio is what lets the close button and the text sit at
   percentage coordinates derived straight from the artwork — they track the
   splash shape at any rendered size.

   Sizing: ~58% of viewport width on a 16:9 desktop, clamped so it never
   exceeds the source resolution (no upscaling blur) and never gets so small
   the text becomes unreadable. The height term in the min() keeps the whole
   panel on screen in short/wide viewports, where width alone would overflow
   vertically. */
.info-modal-content {
	position: relative;
	/* Two-stage width — PURELY PROPORTIONAL, no floor (changed 2026-09-01):

	     >= 1280px viewport : holds 1190.4px (the size every text offset in
	                          this panel was measured against — DO NOT change
	                          this number, or the width at which the panel stops
	                          holding it, without re-measuring them all)
	     < 1280px           : scales down with the viewport at 93vw

	   ONLY the 818px floor was removed (2026-09-01). Everything above it is
	   unchanged from the 2026-08-27 tuning and must stay that way.

	   Why the floor went: it existed to stop the type falling below 13px, and
	   expressed that limit as a panel WIDTH (13 / 0.0159 = 818). Fine on
	   desktop, but it made the panel wider than the whole viewport on a phone —
	   an S7 in landscape gives 640 CSS px, so an 818px panel overflowed 89px off
	   each side and clipped the credits. Below 880px the panel now keeps
	   shrinking at 93vw instead of stopping and overflowing.

	   A CAUTION, learned the hard way: it is tempting to "simplify" this to a
	   single proportional term like 62vw (= 1190.4/1920, the panel's ratio on
	   the reference screen). DO NOT. That makes the panel start shrinking the
	   moment the viewport is under 1920, so every window narrower than the
	   reference — including a 1920 monitor at 125% Windows scaling, which
	   reports a 1536px CSS viewport — renders the panel ~20% small. The hold
	   down to 1280px is deliberate and load-bearing.

	   Height guard (2026-09-25): the third term is the width at which the panel
	   is exactly as tall as the visible viewport (height x 1708/935). It only
	   wins when the panel would otherwise overflow vertically, so the close
	   button can never end up off screen. The vh line is the fallback for
	   browsers without dvh. (Not the old 62vh term removed 2026-09-01 — that
	   one shrank the panel to about a third of the height, even when it fit.) */
	width: min(93vw, 1190.4px, calc(100vh * 1708 / 935));
	width: min(93vw, 1190.4px, calc(100dvh * 1708 / 935));
	/* #infoModal is a flex container, and a flex item may shrink below its own
	   width. The panel must be allowed to reach exactly the width above and no
	   less, so the flex squeeze still has to be disabled — but there is no
	   min-width any more, since shrinking with the viewport is now the point. */
	flex-shrink: 0;
	aspect-ratio: 1708 / 935;
	/* The splash artwork is NOT painted here — it lives on the ::before
	   pseudo-element below so it can carry mix-blend-mode without dragging the
	   text into the blend. */
	color: #000;
	font-family: var(--modal-font);
	line-height: 1.5;
	text-align: center;
	/* Establishes the container for the cqw units used by .info-panel-inner.
	   Note container-type applies to DESCENDANTS, not to this element itself —
	   so the font-size lives on the inner wrapper, not here. */
	container-type: inline-size;
}

/* The grey splash artwork, on its own layer so it can blend.

   mix-blend-mode: luminosity — the splash contributes its LIGHTNESS while the
   mural behind keeps its hue and saturation, reproducing the artist's
   Photoshop layer. Verified against the alternative: background-blend-mode
   blends the image against the element's own background colour, not against
   what is behind it, and renders flat grey (saturation 0) — mix-blend-mode
   keeps the colour (saturation ~162 in the same test).

   It sits on ::before rather than on .info-modal-content directly because
   mix-blend-mode applies to the WHOLE element: putting it on the panel would
   blend the black text too. On a pseudo-element the artwork blends and the
   text, a real child, stays solid.

   z-index -2 keeps it below the dim cutout (-1) and the text (auto). */
.info-modal-content::before {
	content: '';
	position: absolute;
	inset: 0;
	background-image: url('assets/InfoPanel.png');
	background-size: 100% 100%;
	background-repeat: no-repeat;
	mix-blend-mode: luminosity;
	/* z-index 0, NOT negative. Negative values would paint the layer behind
	   .info-modal-content's whole stacking context — and since that element no
	   longer creates one (it must not, or the blend can't reach the canvas),
	   the layer would disappear behind the page entirely. Paint order among
	   these siblings is set by z-index 0 / 1 / 2 instead. */
	z-index: 0;
	pointer-events: none;
}

/* 2026-09-26: once Unity draws the backdrop itself (InfoPanelBackdrop.cs,
   handed over by controls-overlay.js after Unity says it is ready), the CSS
   copy goes transparent. Only the image goes: the layer keeps its place, so
   nothing in the panel's layout or layer order changes. During loading the
   class is absent and this layer draws the backdrop as before. */
body.unity-panel-backdrop .info-modal-content::before {
	background-image: none;
}

/* Inner text column. Inset from the panel edges so text never collides with
   the organic ragged boundary of the splash art — the shape's left/right
   edges are irregular, so the safe text zone is narrower than the image box.
   Percentages are of the panel, so the insets scale with it. */
/* Insets derived from the artwork's actual alpha channel, not eyeballed. The
   splash is opaque from ~1-4% on the left but its right edge never passes
   ~88-92%, and below y=80% the shape collapses fast (only 39% wide at y=85%,
   13% at y=90%) as it tapers off to the bottom-right. So the safe text zone
   stops well short of the image box on the right and bottom. */
.info-panel-inner {
	position: absolute;
	/* Above the splash (0) and the dim (1) so the text is never blended or
	   dimmed — it stays solid black. */
	z-index: 2;
	left: 8%;
	right: 15%;
	top: 14%;
	bottom: 26%;
	display: flex;
	flex-direction: column;
	justify-content: space-between;
	/* SINGLE SOURCE OF TRUTH for panel type size. Every string in the panel
	   inherits this — the PSD sets them all to Arial Regular 14pt, so nothing
	   below should redeclare font-size.

	   The size is the original panel base (1.87cqw) taken to 85%, which was
	   the size the credit roles already rendered at — everything in the panel
	   now matches that. cqw is a container-query unit (1cqw = 1% of
	   .info-modal-content's width), so the type stays locked to the artwork's
	   proportions at any rendered panel size.
	   The vw line is a fallback for engines without container queries, tuned
	   to match at the desktop panel width (62vw). */
	   The max() floor is now a BACKSTOP, not a layout driver (changed
	   2026-09-01). It used to be 13px, which paired with the panel's 818px
	   floor so the two bottomed out together. That pairing is gone: the panel
	   is purely proportional now, so the type must be free to scale with it all
	   the way down or the text-to-splash proportion breaks — which is the one
	   invariant this panel depends on.

	   6px is deliberately low enough never to engage on any real device (an S7
	   in landscape gives a 640px viewport → a 396.7px panel → 6.3px type), so
	   the composition stays proportional everywhere. It exists only to stop the
	   type vanishing entirely in a pathologically narrow viewport.

	   KNOWN TRADE-OFF: at small viewports this type is genuinely small — 6.3px
	   CSS on an S7. It renders sharply (DPR 4 → ~25 physical px) but its
	   apparent size is small. This is the honest consequence of scaling a
	   1190px composition into a 640px viewport, and it is the baseline against
	   which any future non-linear type curve should be judged. Do not raise
	   this floor to "fix" legibility — that silently breaks the proportion.
	   Change the type's SCALING CURVE instead. */
	font-size: 0.99vw;
	font-size: max(1.59cqw, 6px);
}

/* Close button: the artwork places the X at (1402, 90) on the 1708x935 panel,
   with a 45x60 footprint → left 82.08%, top 9.63%, width 2.63%, height 6.42%.
   The <img> keeps those exact coordinates; the <button> is padded outward so
   the hit area clears the 44px touch-target minimum even though the glyph
   renders smaller than that. The negative margins keep the padding from
   shifting the glyph off its measured position. */
.info-modal-close {
	position: absolute;
	/* Above the splash and dim layers, like the text. */
	z-index: 2;
	/* Artwork position was left 82.08% / top 9.63% (the X sits at 1402,90 on
	   the 1708x935 source). Diego's measured correction against the reference
	   is 44px right and 14px up at the 1190.4x651.65 desktop panel, i.e.
	   +3.696% / -2.148% — folded into the offsets here rather than added as a
	   transform, which would fight the negative margins below.
	   A later pass moved it 4px back down (+0.614%), giving the 8.096% here.
	   NOTE these percentages are of the PANEL, not the viewport: at the
	   1190.4x651.65 desktop panel, 1px = 0.084% horizontally and 0.154%
	   vertically. Smaller `top` moves the button up. */
	left: 85.776%;
	top: 8.096%;
	width: 2.63%;
	height: 6.42%;
	padding: 0;
	background: transparent;
	border: none;
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
	/* Expand the clickable area around the glyph without moving it. The glyph
	   renders ~31x42 at a 1190px panel — under the 44px touch minimum on the
	   short axis — so the box is padded outward and the negative margin pulls
	   the layout back so the artwork stays on its measured coordinates. */
	box-sizing: content-box;
	padding: 14px 18px;
	margin: -14px -18px;
	transition: opacity 0.15s ease;
}

.info-modal-close img {
	display: block;
	width: 100%;
	height: 100%;
	object-fit: contain;
	pointer-events: none;
}

.info-modal-close:hover {
	opacity: 0.6;
}

.info-section {
	margin: 0;
}

.info-section p {
	margin: 0;
	color: #000;
}

.info-section-title {
	margin: 0 0 0.6em;
	/* These are <h3> elements, so the browser default stylesheet would apply
	   font-size: 1.17em and bold. Both are reset here so the titles render at
	   exactly the panel's base size, like every other string. */
	font-size: 1em;
	font-weight: 400;
	letter-spacing: 0.02em;
	text-transform: uppercase;
	color: #000;
}

/* ---- Measured offsets ----
   Every nudge below was measured by Diego against the Photoshop reference: he
   screenshotted the panel in fullscreen on a 1920x1080 monitor and overlaid the
   original artwork on it. So the source numbers are SCREEN pixels at the
   desktop panel size (62vw of 1920 = 1190.4 x 651.65 px).

   They're stored as percentages of the panel so they scale with it:
       x%  =  px / 1190.4 * 100
       y%  =  px / 651.65 * 100
   Percentage translate() resolves against the ELEMENT's own box, not the
   panel, so the offsets are expressed in cqw instead — a container-query unit
   that is always 1% of the PANEL's width, whatever the element's own size.
   Vertical offsets also use cqw (not cqh): the panel's aspect-ratio is locked
   at 1708/935, so a share of its width is a fixed share of its height too, and
   cqh would additionally require container-type: size rather than inline-size.
   Conversion used: y_cqw = px / 1190.4 * 100.

   If the panel's base width (62vw) ever changes, these stay correct: they're
   proportional, not absolute. */

/* Controls: pan on the left, zoom on the right, sharing one centered row. */
.info-controls-row {
	display: flex;
	justify-content: center;
	align-items: baseline;
	gap: 12%;
	color: #000;
}

/* Intro line. Vertical offset is the measured 12px down plus Diego's further
   12px (total 24px). Horizontally it is now aligned to "CONTROLS:" by centre
   point — same reasoning as the CREDITS title: both are full-width centred
   blocks, so matching their x offsets matches their centres.

   All three centred blocks (this line, CONTROLS:, CREDITS:) and the pan/zoom
   row were then shifted -2.487cqw (-29.6px) so they centre on the CREDITS
   BLOCK's bounding box rather than the panel's midpoint. The credits span
   87.2px to 998.6px, centring at 542.9px = 45.608% of the panel, while the
   centred blocks were sitting at 572.5px.
   The same delta is applied to every element, so the internal spacing between
   the controls title and its two instruction strings is unchanged. */
.info-section-recommendations {
	transform: translate(-0.891cqw, 2.016cqw);
}

/* "CONTROLS:" title. Measured 24px right / 66px up, then +6px right and
   +24px down, then the global -11px left, then a further +10px down
   (-3.528 + 0.840 = -2.688cqw).
   NOTE: the title and its two instruction strings no longer move as one unit —
   the strings are now positioned by the centring rule below instead. */
.info-section-controls .info-section-title {
	transform: translate(-0.891cqw, -2.688cqw);
}

/* Control strings: measured 59px up (pan 1px right, zoom 43px right), then
   +24px down with the CONTROLS title, then the global -11px left.

   The vertical offset is now DERIVED, not measured: this row sits halfway
   between the "CONTROLS:" and "CREDITS:" lines, matched on the ink centre of
   each (not the element box, whose half-leading differs). At the 1190.4px
   reference panel those centres render at 176.14px and 322.28px, so the
   geometric midpoint is 249.21px; the row measured 212.86px, i.e. +36.35px =
   +3.054cqw, giving -2.940 + 3.054 = +0.114cqw.

   Then -5px (-0.420cqw) on top of that, for +0.114 - 0.420 = -0.306cqw.
   The reason: a true geometric centre reads as slightly LOW, because these
   lines are mostly lowercase and their ink sits below the line box's centre.
   Diego judged the optical correction at roughly half a lowercase character's
   height. Measured, that is half the x-height = 4.73px (Segoe UI x-height
   9.45px at the panel's 18.93px type); 5px was chosen as the round value.
   So the row is now deliberately 68.4px below CONTROLS: and 77.8px above
   CREDITS: — optically centred, not geometrically.

   This is a dependent value. If either title's translateY changes, or the
   panel's font-size or line-height changes, re-derive it — it will not
   self-correct. */
.info-control-pan {
	display: inline-block;
	transform: translate(-3.327cqw, -0.306cqw);
}

.info-control-zoom {
	display: inline-block;
	transform: translate(0.201cqw, -0.306cqw);
}

/* "CREDITS:" title. Horizontally aligned to "CONTROLS:" by centre point (both
   are full-width centred h3s, so matching their x offsets aligns their centres);
   then the global -11px left.
   Vertically it is spaced from the first credit row by the SAME gap that
   separates "CONTROLS:" from its instruction line. Both titles share identical
   CSS (font-size, margin-bottom: 0.6em), so the gap is governed purely by the
   difference between the two transforms: CONTROLS opens up +0.588cqw, so
   CREDITS matches it at row1_y - 0.588 = -3.528 - 0.588 = -4.116cqw.
   Was -5.460cqw, i.e. this moved the title 16px DOWN; the credit rows
   themselves were deliberately left untouched.

   Then the whole CREDITS block — this title and all three rows below it —
   moved +20px down together (+1.680cqw), so the internal spacing within the
   block is unchanged: -4.116 + 1.680 = -2.436cqw. The same +1.680 is applied
   to .credits-row-heading and rows 2 and 3; keep them in step. */
.info-section-credits .info-section-title {
	transform: translate(-0.891cqw, -2.436cqw);
}

/* Credits: two ROWS, each holding a left and a right entry. Structuring it by
   row (rather than by column) is what guarantees the vertical alignment Diego
   asked for — the two entries in a row share one baseline because they're in
   the same flex line, so they cannot drift apart no matter what horizontal
   nudge each one carries. The vertical offset is therefore applied to the ROW,
   never to the individual entries.
   (His per-entry measurements had 1px and 4px vertical discrepancies between
   the members of a row — measurement noise from reading a screenshot. Those
   are resolved to a single per-row value here.) */
.credits-rows {
	display: flex;
	flex-direction: column;
}

/* Two fixed-width columns rather than space-between. space-between sizes each
   entry to its own content and pushes it to the row's edge, which left the
   right column's start edge ragged (it varied with each string's length).
   Equal columns give both sides a constant left edge, so every row in a column
   starts at the same x. */
.credits-row {
	display: grid;
	/* minmax(0, 1fr), not plain 1fr: a grid item's min-width defaults to auto,
	   which lets a long nowrap string force its own column wider. That made each
	   row split at a different point, so the right column's start edge drifted
	   (measured 45.2% / 46.5% / 48.7%). Flooring the min at 0 gives every row an
	   identical 50/50 split, so both columns have a constant left edge. */
	grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
	align-items: baseline;
}

/* Left entries align on their FIRST character: both rows share one x offset
   so their left edges are flush. Includes the global -11px left. */
.credit-left {
	text-align: left;
	/* 0.168cqw originally, then -20px with the right column, then +10px on its
	   own: 0.168 - 1.680 + 0.840 = -0.672cqw. */
	transform: translateX(-0.672cqw);
}

/* Right entries align on their FIRST character, so all three rows of the right
   column start from a common left edge (previously they were flush-right).
   Both share one x offset.

   -1.932cqw was the original (global -11px left), then +180px right at Diego's
   request to clear the longest left-column line:
   -1.932 + (180 / 1190.4 * 100) = 13.189cqw, then -20px together with the
   left column: 13.189 - 1.680 = 11.509cqw, then a further -20px to clear the
   splash's right edge: 11.509 - 1.680 = 9.829cqw. */
.credit-right {
	text-align: left;
	transform: translateX(9.829cqw);
}

/* The two people rows keep the exact offsets Diego measured against the
   reference. A heading row was added ABOVE them, so their nth-child indices
   shifted from 1/2 to 2/3 — the measured RELATIVE spacing is unchanged and must
   stay that way unless the panel is re-measured.

   All three rows and the "CREDITS:" title were then shifted +20px down
   (+1.680cqw) as one block, so the spacing WITHIN the block is untouched.
   Later passes then moved the three ROWS on their own, leaving the "CREDITS:"
   title where it was: -10px, then +5px back down. Net shift is therefore +20px
   on the title and +15px on the rows, which TIGHTENS the title-to-rows gap by
   5px overall. The three rows still share one delta, so their spacing relative
   to each other is still the measured original.
   The pre-shift values are kept in each comment below so the measured baseline
   is still recoverable. */

/* Row 2 (artistic research / visual art): 16px higher than measured.
   Was -3.528cqw, +1.680 group shift, -0.840 then +0.420 rows-only shifts. */
.credits-row:nth-child(2) {
	transform: translateY(-2.268cqw);
}

/* Row 3 (artistic collaboration / creative technology).
   Its gap from row 2 is line-height plus the difference between the two
   translateY values. Target 36px: 36 - 28.39 = 7.61px = 0.639cqw, so this sits
   0.639cqw below row 2 — a relationship the group shift preserves, since both
   rows moved by the same amount. Was -2.889cqw, +1.680 group shift,
   -0.840 then +0.420 rows-only shifts. */
.credits-row:nth-child(3) {
	transform: translateY(-1.629cqw);
}

/* Heading row ("Radio Gardening" / "radiogardening.space"). It names the scope
   of each column: the art project on the left, the website on the right.
   Sits one line-height above row 2 so the block reads as a unit; it inherits
   row 2's horizontal alignment through .credit-left / .credit-right, so the
   columns stay flush all the way down.
   Mixed case, unlike the all-caps role lines below it.
   Was -3.528cqw (matching row 2), +1.680 group shift, -0.840 then +0.420
   rows-only shifts (net -0.420 on the rows).
   Still matches row 2 exactly, as it must — the two share a horizontal
   alignment and read as one block. */
.credits-row-heading {
	transform: translateY(-2.268cqw);
	/* Baseline-to-baseline spacing to the first role row is line-height (1.5em)
	   plus this margin. Target 48px at the 18.93px base: 48 - 28.39 = 19.6px,
	   i.e. 1.036em. */
	margin-bottom: 1.036em;
}

.credit-entry {
	margin: 0;
	white-space: nowrap;
	color: #000;
}

/* The all-caps role label, one point smaller than the rest of the panel text
   (0.93em = 13.2pt against the 14.2pt base) at Diego's request, to claw back
   line length so the two credit columns can separate.

   Typed in caps in the HTML rather than uppercased by CSS — the person's name
   beside it keeps normal capitalisation, so the two halves of each line are
   genuinely different strings rather than one string with a transform. */
.credit-entry .credit-role {
	font-size: 0.93em;
	letter-spacing: 0.01em;
	color: #000;
}

/* ---- Info panel: mobile / narrow-viewport tier ----
   Removed. It used to override the panel width and re-inset the text below a
   700px viewport, which broke the one invariant this panel depends on: the
   text and the splash must keep a FIXED proportion to each other at every
   size. The type is sized in cqw (a percentage of the panel's own width), so
   the two scale as a single unit automatically — but only as long as nothing
   re-declares the panel width or the text insets at a breakpoint.

   Both now bottom out together at an 818px panel and overflow past that.
   Mobile will get a deliberate design later; when it does, change the panel
   width and the text insets TOGETHER or the proportions drift. */


/* ---- Small-panel tier ----
   Intentionally EMPTY on desktop now.

   This block used to re-inset the text (right/bottom) and tighten the grid
   gaps below a 700px panel. That repositioning was the visible "jump" where
   the credits rearranged themselves as the window narrowed. Now that the panel
   and the type bottom out together at 723px, the layout never has to adapt:
   it holds its proportions and overflows instead.

   Mobile gets its own treatment in a later pass; do not reinstate per-width
   text repositioning here without checking it against the reference. */

/* ---- Simulated-landscape override ----
   When ViewportManager rotates #rotationRoot 90deg, the rotated frame's visual
   WIDTH is the device's viewport HEIGHT (100dvh) and its visual height is the
   viewport width. Plain vw/vh (and the max-width media query above) refer to
   the UNROTATED viewport, so both are wrong here: on a 390x844 phone the
   visual frame is 844px wide — roomy — yet vw-based rules see 390px and
   cramp the panel. Declared after the mobile tier so it wins the cascade. */
body.simulated-landscape .info-modal-content {
	/* After rotation the visual width is the device's viewport HEIGHT and the
	   visual height is its viewport WIDTH, so the vw/vh roles swap relative to
	   the unrotated rules above. Percentages resolve against #infoModal, which
	   is already sized to the rotated frame — more robust than restating the
	   swap in viewport units.

	   Floor REMOVED here too (2026-09-01), matching the unrotated rule — see the
	   long note there. A rotated phone is exactly the case the 818px floor broke
	   worst, since the rotated frame is only as wide as the device viewport
	   height. The 88% aiming term is UNCHANGED from the 2026-08-27 tuning.
	   Height guard as in the unrotated rule, axes swapped: the rotated frame's
	   visible height is the viewport WIDTH. */
	width: min(88%, 1190.4px, calc(100vw * 1708 / 935));
	width: min(88%, 1190.4px, calc(100dvw * 1708 / 935));
}

/* Accessibility-only — hides text from sight but keeps it readable by
   screen readers. Used for the modal's title (visible heading is implicit
   from the content). */
.visually-hidden {
	position: absolute;
	width: 1px;
	height: 1px;
	padding: 0;
	margin: -1px;
	overflow: hidden;
	clip: rect(0, 0, 0, 0);
	white-space: nowrap;
	border: 0;
}

/* ===== Debug report button + panel (debug-report.js) =====
   Diagnostic UI for remote testers. Sits above every other overlay
   (soft-warnings tops out at 99999) so it is reachable even when a
   splash or banner is showing. Self-contained: no dependency on the
   controls-bar styling, so it survives changes there. */

#rg-debug-button {
	position: fixed;
	/* TOP CENTRE. Every corner is spoken for: the bandwidth meter takes
	   top-left, the stall-test button top-right, and the fullscreen button
	   bottom-right. Centring is what lets all three diagnostics be on screen
	   at once, which matters because a remote tester gets one session and
	   cannot be asked to try again with a different flag. */
	left: 50%;
	transform: translateX(-50%);
	top: 10px;
	z-index: 100000;
	padding: 7px 14px;
	font: 12px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
	color: rgba(255, 255, 255, 0.85);
	background: rgba(0, 0, 0, 0.55);
	border: 1px solid rgba(255, 255, 255, 0.35);
	border-radius: 5px;
	cursor: pointer;
	-webkit-appearance: none;
	appearance: none;
}

#rg-debug-button:hover {
	background: rgba(0, 0, 0, 0.75);
	color: #fff;
}

#rg-debug-overlay {
	position: fixed;
	inset: 0;
	z-index: 100001;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 16px;
	background: rgba(0, 0, 0, 0.6);
	box-sizing: border-box;
}

.rg-debug-panel {
	display: flex;
	flex-direction: column;
	width: 100%;
	max-width: 720px;
	max-height: 100%;
	background: #1c1c1e;
	border: 1px solid rgba(255, 255, 255, 0.2);
	border-radius: 8px;
	box-sizing: border-box;
	overflow: hidden;
}

.rg-debug-title {
	flex: 0 0 auto;
	padding: 10px 14px;
	font: 600 14px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
	color: #fff;
	border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}

/* The report is a fixed-width table, so it must scroll rather than wrap:
   wrapping would destroy the column alignment that makes it readable. */
.rg-debug-pre {
	flex: 1 1 auto;
	margin: 0;
	padding: 12px 14px;
	overflow: auto;
	font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
	color: #e6e6e6;
	white-space: pre;
	-webkit-overflow-scrolling: touch;
	/* Allow the user to select the text manually if the Copy button's
	   clipboard call is blocked (non-secure origin, older WebKit). */
	-webkit-user-select: text;
	user-select: text;
}

.rg-debug-row {
	flex: 0 0 auto;
	display: flex;
	gap: 8px;
	padding: 10px 14px;
	border-top: 1px solid rgba(255, 255, 255, 0.15);
}

.rg-debug-btn {
	padding: 9px 16px;
	font: 13px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
	color: #fff;
	background: rgba(255, 255, 255, 0.14);
	border: 1px solid rgba(255, 255, 255, 0.25);
	border-radius: 5px;
	cursor: pointer;
	-webkit-appearance: none;
	appearance: none;
}

.rg-debug-btn-primary {
	background: #2f7d5a;
	border-color: #3b9a6f;
}

.rg-debug-btn:disabled {
	opacity: 0.5;
	cursor: default;
}

/* ---- Watchdog stall test (TEST SCAFFOLDING) ----
   Remove with stall-test.js, its <script> tag, and the __debugGetStations
   accessors on the three manager facades. The button sits top-RIGHT; the
   debug-report button owns top-left and the fullscreen button owns
   bottom-right. Reuses .rg-debug-panel / -pre / -row / -btn for the dialog. */
#rg-stall-button {
	position: fixed;
	right: 10px;
	top: 10px;
	z-index: 100000;
	padding: 7px 14px;
	font: 12px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
	color: rgba(255, 255, 255, 0.85);
	background: rgba(0, 0, 0, 0.55);
	border: 1px solid rgba(255, 255, 255, 0.35);
	border-radius: 5px;
	cursor: pointer;
	-webkit-appearance: none;
	appearance: none;
}

#rg-stall-button:hover {
	background: rgba(0, 0, 0, 0.75);
	color: #fff;
}

#rg-stall-overlay {
	position: fixed;
	inset: 0;
	z-index: 100001;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 16px;
	background: rgba(0, 0, 0, 0.6);
	box-sizing: border-box;
}
