/* style.css: Hook, Line and Sentence.
   R2 PALETTE (Art & Animation Refresh). This replaces the locked M7.5 "dusk
   pond" scheme, which was a saturated cool dusk: right for pixel art, wrong
   for the warm painterly storybook `ART_DIRECTION.md` now calls for. Every UI
   colour is below or derived from it.

   Two rules from ART_DIRECTION.md that this file has to keep obeying:
     - muted and warm, never saturated or neon;
     - NO PURE BLACK anywhere. Shadows and outlines are warm dark browns, which
       is why --umber replaced the old near-black --abyss and why every
       rgba(var(--shadow-rgb),x) in here is now rgba(var(--shadow-rgb),x).

   The ghost-hands keyboard is the one deliberate exception (CLAUDE.md): it is
   off limits, so its colours are frozen below as --kb-* at their pre-refresh
   values and it references nothing else. Changing the palette must never drag
   the keyboard with it. The same goes for its type: the monospace stack is a
   frozen --kb-font token that only `.key` reads, because body is Baloo 2 now
   and the old arrangement (carry no font-family, inherit --mono from body)
   would have swept the keyboard up with it. Never point a .key rule at
   --display. */

:root {
  /* Sky and light. REFERENCE VALUES: nothing consumes these yet: the sky and
     the sun are painted into the background art, not drawn in CSS. They are
     here because R3's three background paintings have to match this palette,
     and a prompt is easier to write against a number than a description. */
  --sky-high:   #b7cfd8;
  --sky-low:    #f2ddbe;
  --glow:       #f7e6bd;

  /* warm accents */
  --amber:      #e3b477;
  --gold:       #dcab63;
  --ember:      #d4886a;
  --rose:       #c08d88;
  --moss:       #93ac78;

  /* water: muted teal-green, with a darker band for depth */
  --water-teal: #7aa89b;
  --water-mid:  #4f7d76;
  --water-deep: #375c58;
  --foam:       #e6eee4;

  /* warm darks: these replace every near-black blue the old scheme used.
     --umber is the deepest tone in the game and stands in for the old
     --abyss (#122736); it is a warm brown, not a black. */
  --bark:       #7a6350;
  --bark-deep:  #4b3d33;
  --umber:      #33291f;

  /* channel triples, for the translucent panels and shadows that need an alpha.
     Same colours as above: CSS can't take an alpha off a hex token without
     color-mix, and these are read often enough that one place to retune them
     is worth the pair. */
  --umber-rgb:  51,41,31;
  --shadow-rgb: 47,38,32;
  --gold-rgb:   220,171,99;
  --ink-rgb:    250,242,228;
  --ember-rgb:  212,136,106;   /* --ember, for the start-again button's quiet border */

  /* type */
  --ink:        #f5ece0;
  --dim:        #b9a894;

  /* ---- frozen: the ghost-hands keyboard, exempt from the refresh ---- */
  --kb-dim:     #9db4b8;
  --kb-gold:    #f0c060;
  --kb-gold-rgb: 240,192,96;
  --kb-ink:     242,237,227;
  --kb-panel:   8,18,26;
  --kb-key:     18,39,54;
  --kb-line:    157,180,184;
  /* Its type is frozen the same way and for the same reason. This used to be a
     scene token (--mono) that the keyboard picked up by inheriting it from
     body; body is Baloo 2 now, and the keyboard is the only thing in the game
     that wants a monospace, so the stack lives here with the rest of what is
     off limits. */
  --kb-font:    ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;

  /* F2: the display face. Silkscreen was the last pixel-era thing in the
     game after R2 repainted the palette, and it was carrying every pun,
     chip, banner and shop label at 10-13px over warm painterly art. This
     is BUILD_PLAN_REFRESH.md open question 1, answered: it goes.

     Baloo 2 was picked by rendering five candidates into the real game on
     the same beat and looking (Baloo 2, Fredoka, Nunito, Grandstander,
     Quicksand). It has weight, which matters when type is laid straight
     over a painting (Quicksand vanished at 11px) and the longest string
     the game shows, a catch message with a size class and a best flag,
     fits on one line where Grandstander wrapped it.

     SELF-HOSTED rather than pulled from Google Fonts, which is what
     Silkscreen did. Three reasons and they all point the same way: it
     takes a third-party request off every page load (BACKLOG.md keeps
     "no analytics, no third-party scripts" as a value), it cannot
     silently fall back to monospace when the CDN is unreachable, and it
     is one 33KB file in assets/ instead of a network round trip.
     Zero, as of 2026-09-05: the Firebase SDK import that used to make it
     "one fewer, not zero" went with cloud saves, so the page now loads
     nothing from anyone else's server. SIL OFL 1.1,
     licence in assets/baloo2-OFL.txt. Latin subset only; the game is
     English and the variable weight axis covers 400-800 in the one file.

     The ghost-hands keyboard is untouched by this and must stay that way:
     it carries its own frozen --kb-font, which is the only monospace left
     in the game. */
  --display: "Baloo 2", ui-rounded, system-ui, sans-serif;
}
@font-face {
  font-family: "Baloo 2";
  src: url("assets/baloo2-latin.woff2") format("woff2");
  font-weight: 400 800;
  font-display: swap;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
  /* Baloo 2, game-wide. The 29 explicit `font-family: var(--display)` rules
     below predate this: F2 swapped the panels one at a time and left body on
     --mono, so anything it missed (the word box, the profile picker) stayed
     monospace by inheritance rather than by choice. Nothing in this game is
     meant to be monospace except the keyboard, which carries its own frozen
     --kb-font. */
  font-family: var(--display);
  background: var(--umber);
  color: var(--ink);
  overflow: hidden;
}
/* The title sits in #topleft above the pun bubble. It used to be separately
   fixed and needed two media queries to keep it off the HUD; the top bar's
   layout does that job now, so it only shrinks for its own sake. */
h1 {
  font-family: var(--display); font-size: 10px; letter-spacing: 0.1em; color: var(--dim);
  background: rgba(var(--umber-rgb),0.45); padding: 5px 9px; border-radius: 6px;
  align-self: flex-start; pointer-events: none;
}
@media (max-width: 560px) {
  h1 { font-size: 8px; letter-spacing: 0.04em; padding: 4px 7px; }
}
/* the pun is the voice of the game and the title is decoration: on a phone the
   room goes to the pun */
@media (max-width: 430px) {
  h1 { display: none; }
}

/* ============ THE POND (full-screen) ============ */
/* #scene keeps its original 720x360 design-space coordinate system: every
   bobber/line/fish/particle position tuned earlier assumes it. #scene-frame
   scales+centers that fixed canvas to cover the viewport ("background-size:
   cover" style); #scene-viewport crops whatever spills past the real edges.
   Internal game code never has to know the viewport size. */
#scene-viewport {
  position: fixed; inset: 0; z-index: 1; overflow: hidden; background: var(--umber);
}
/* anchored bottom-left, not centered: cropping should only ever eat into
   the decorative right edge (far reeds) or top edge (sky), never the boat
   (design x=26, left edge) or the tension meter (design y=360, bottom edge) */
#scene-frame {
  position: absolute; left: 0; bottom: 0; width: 720px; height: 360px;
  transform-origin: left bottom;
}
#scene {
  position: relative;
  width: 720px;
  height: 360px;
  overflow: hidden;
  /* R3: no background image of its own. Every level is painted by the three
     #bg-* planes below, which swap their images per .loc-*; this colour is only
     what shows if an image ever fails to load. */
  background: var(--water-deep);
}

/* ── R3: the three painted planes (ART_DIRECTION.md, "Background layers per
   level"). These carry the Pond by default; the .loc-* rules further down swap
   their images for the Stream and the Ocean. ─────────────────────────────────
   Separate elements, not three background-images on #scene, because each drifts
   on its own timer and one element can only carry one animation: the water
   layer is specified to animate *independently*.

   The drift is deliberately tiny and slow: the milestone asks for parallax that
   "reads at 1x without drawing attention to itself". At cover scale the art is
   849px wide inside a 720px canvas, so there is ~64px of slack each side and a
   ±7px drift can never expose an edge. The three periods are mutually prime-ish
   so the planes never visibly resynchronise. */
#bg-far, #bg-water, #bg-fore {
  position: absolute; inset: 0; pointer-events: none;
  background-position: center center;
  background-size: cover;
  background-repeat: no-repeat;
}
#bg-far   { background-image: url("assets/background-pond-far.png");
            animation: drift-far 121s ease-in-out infinite alternate; }
#bg-water { background-image: url("assets/background-pond-water.png");
            animation: drift-water 47s ease-in-out infinite alternate; }
/* z-index 3 matches #surface; it comes later in the DOM, so it paints over the
   water wash and the rig, but stays under the z-index 4 splashes and text. */
#bg-fore  { background-image: url("assets/background-pond-fore.png"); z-index: 3;
            animation: drift-fore 73s ease-in-out infinite alternate; }

/* far barely moves, water moves most (it is water), foreground counter-drifts
   so the planes separate rather than sliding as one sheet */
@keyframes drift-far   { from { background-position: calc(50% - 3px) center; }
                         to   { background-position: calc(50% + 3px) center; } }
@keyframes drift-water { from { background-position: calc(50% - 7px) center; }
                         to   { background-position: calc(50% + 7px) center; } }
@keyframes drift-fore  { from { background-position: calc(50% + 5px) center; }
                         to   { background-position: calc(50% - 5px) center; } }

/* R3: the Stream, repainted as three layers. The planes just swap their images,
   no per-location framing, no scale, no offset, because the new art was
   generated (and then fitted) to put its waterline on the same row as the
   Pond's.

   This replaces the A3 workaround that stood here: the old stream PNG came back
   as a forest pool seen from *above*, water in a low diagonal band, so the scene
   was held together by scaling the art 1.246x and offsetting it to -520px,-368px
   to seat the near bank on y=198. It was playable but it cost the sky and ran
   the art chunkier than the other biomes. Deleting it was R3's done-when
   criterion for this level. */
#scene.loc-stream #bg-far   { background-image: url("assets/background-stream-far.png"); }
#scene.loc-stream #bg-water { background-image: url("assets/background-stream-water.png"); }
#scene.loc-stream #bg-fore  { background-image: url("assets/background-stream-fore.png"); }

/* R3: the Ocean, repainted as three layers, the last of the nine, so every
   level now works the same way and no scene carries a framing patch. */
#scene.loc-ocean #bg-far   { background-image: url("assets/background-ocean-far.png"); }
#scene.loc-ocean #bg-water { background-image: url("assets/background-ocean-water.png"); }
#scene.loc-ocean #bg-fore  { background-image: url("assets/background-ocean-fore.png"); }
#scene.shake { animation: shake 0.3s; }
@keyframes shake {
  20% { transform: translate(-5px, 2px); } 40% { transform: translate(4px, -2px); }
  60% { transform: translate(-3px, 1px); } 80% { transform: translate(2px, -1px); }
}

/* The water used to carry an animated SVG wave stack and a glitter strip, both
   sized to the pond's flat painted waterline. The stream and ocean paintings
   have their own surf, and the overlay just banded them with dark horizontal
   stripes at the wrong height, so it's gone, and the painted water is the
   water. Ambient ripples and fish shadows still play on top (from app.js). */

.fish-shadow {
  position: absolute; height: 12px; border-radius: 50%;
  background: rgba(var(--umber-rgb),0.5); filter: blur(3px);
  animation: shadowDrift linear forwards;
}
@keyframes shadowDrift { from { transform: translateX(0); } to { transform: translateX(820px); } }

/* boat rig bobs as one unit: sits at the waterline (#water starts at top: 55%) */
/* V1: the boat ROCKS rather than slides. The pivot is the hull's centre at the
   waterline (rig-relative 59,30), so it tips the way a hull tips instead of
   translating the whole rig up and down over a static water plane. The heave is
   deliberately small: the sitting-in-water read comes from #surface cutting
   the hull, not from movement. */
/* R5: position, pivot and whether it rocks all come from the pose now
   (CONFIG.rig.poses.<loc>.anchor / .bob), set by renderRig(). The bob is a class
   so prefers-reduced-motion's `animation: none !important` still wins. */
#rig { position: absolute; }
#rig.bobs { animation: bob 5.2s ease-in-out infinite; }
@keyframes bob {
  0%, 100% { transform: translateY(0) rotate(-0.9deg); }
  50%      { transform: translateY(2px) rotate(0.9deg); }
}
/* boat/kid are now real sprites (M9 art pass); sizes/positions in design-space
   px, tuned so the boat sits on the waterline and the kid rides in it. The
   kid sprite has the rod baked in, so #line originates at the sprite's rod tip
   (upper-right of the kid image). */
/* R5: the vessel is built by renderRig() from the pose, in two halves, `far`
   behind the angler and `near` in front, so the kid sits down IN the hull. That
   is V1's front-plane trick (the one that made #surface work) applied to the
   boat. Geometry comes from the pose; this only says how they paint. */
.vessel { position: absolute; background: center / contain no-repeat; }
/* G1: the angler is a stack of these (body, hat, rod) built by renderRig()
   from CONFIG.rig.layers, which owns the geometry so one config edit swaps a
   hat. Paint order is DOM order. (R1: the line is no longer one of these
   children: it's #line-svg below, in scene space, so it can curve.) */
.rig-layer {
  position: absolute;
  background: center / contain no-repeat;
}
/* R1: the line is an SVG path in scene design space (720x360), redrawn every
   frame by app.js from the rod tip's live position to whatever it's attached to:
   the lure in flight, the bobber, or a hooked fish. It's a quadratic Bezier
   whose control point sags, and during a reel that sag tightens with tension.
   No z-index: it sits between #rig and #surface in DOM order, so the stretch of
   line below the waterline is tinted by the water like everything else. */
#line-svg {
  position: absolute; left: 0; top: 0; width: 720px; height: 360px;
  pointer-events: none; overflow: visible;
}
/* The alpha is paired with CONFIG.anim.line.widthPx and the two are one
   decision: see the note there. A 0.5px line at 0.72 disappears against the
   Ocean's pale sky; at 0.9 it reads more clearly than the old 1.6px did. */
#line-path { fill: none; stroke: rgba(245,236,224,0.9); }
/* R1: the lure/hook on the end of the line while it's in the air. The bobber
   takes over once it lands, so this is only ever visible mid-cast. */
#lure {
  position: absolute; width: 7px; height: 7px; border-radius: 50%;
  background: var(--gold); opacity: 0; pointer-events: none;
}
#lure.on { opacity: 1; }
/* R1: the rod rotates about the grip (CONFIG.anim.rod.pivot) for the cast's
   backswing and the per-keystroke tug. renderRig() sets the origin from config;
   the angle is set per frame in JS, so there's no transition to fight. */
.rig-layer[data-id="rod"] { will-change: transform; }

/* T2: terminal tackle, what sits where the line meets the water during the
   wait. ONE element with one look per spot, chosen from CONFIG.tackle: the Pond
   floats a cork, the Stream drifts a dry fly, and the Ocean names nothing so
   this never turns on there. The size belongs to the look, which is why
   bobberIn() applies the kind before it reads offsetWidth to centre it. */
#bobber {
  /* left/top are set from CONFIG.anim.cast.landing by app.js (R1): the lure
     lands and the tackle takes its place, so the two can't drift apart. */
  position: absolute;
  opacity: 0; transition: opacity 0.3s;
}
#bobber.on { opacity: 1; }

/* The Pond's cork float. Red over cream with a warm dark outline rather than a
   black one (ART_DIRECTION.md), taller than it is wide, and the colour break
   sits BELOW the middle so the pale half reads as the bit riding in the water
   rather than as half a bicolour ball. The old one was a flat 50/50 circle. */
#bobber.tackle-bobber {
  width: 9px; height: 12px;
  border-radius: 50% 50% 46% 46% / 55% 55% 45% 45%;
  background: linear-gradient(178deg, var(--ember) 0 44%, #d9c9b6 44% 52%, #f0e7db 52% 100%);
  box-shadow: inset 0 0 0 0.8px var(--bark-deep);
  animation: bobberBob 3s ease-in-out infinite;
}
/* the wet shine, one notch above the waterline */
#bobber.tackle-bobber::after {
  content: ""; position: absolute; left: 1.8px; top: 2px;
  width: 2.4px; height: 3px; border-radius: 50%;
  background: rgba(255,255,255,0.5);
}

/* The Stream's dry fly. A hackled body riding ON the surface with a pale wing
   standing up off it: at this size that silhouette is the whole read, so the
   wing is the thing worth the pixels. It does not bob: a dry fly sits in the
   film and drifts, which is also what tells a kid this spot fishes differently
   from the Pond. */
#bobber.tackle-fly {
  width: 11px; height: 4px;
  border-radius: 60% 40% 45% 55% / 65% 65% 35% 35%;
  background: linear-gradient(90deg, var(--bark-deep) 0 24%, #6f6a44 24% 100%);
  box-shadow: inset 0 0 0 0.6px var(--umber);
  animation: flyDrift 4.2s ease-in-out infinite;
}
/* The upright wing, and it is the whole read at this size: a pointed pale
   wedge is what says "fly" where a rounded lump said "pebble with a stone on
   it", which is what the first attempt looked like on screen. It sits at the
   head end, on the left, because that is the end the line comes down to. */
#bobber.tackle-fly::after {
  content: ""; position: absolute; left: 1.6px; top: -4.4px;
  width: 4.4px; height: 5.2px;
  background: rgba(243,236,226,0.95);
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
  transform: rotate(-13deg);
}
/* the tail wisp, trailing behind the hook bend */
#bobber.tackle-fly::before {
  content: ""; position: absolute; right: -2.6px; top: 0.9px;
  width: 3.2px; height: 1.1px; border-radius: 50%;
  background: var(--bark);
}
/* F4: one word's worth of twitch on the bait. Short and sharp on purpose, the
   whole mechanic rests on a kid seeing their typing move something in the
   water, and a gentle wobble would not read as "I did that". It replaces the
   idle bob for its third of a second and the bob resumes underneath. */
#bobber.twitch { animation: bobberTwitch 0.34s ease-out; }
@keyframes bobberTwitch {
  0%   { transform: translate(0, 0) rotate(0deg); }
  28%  { transform: translate(-3px, 5px) rotate(-11deg); }
  58%  { transform: translate(2px, -3px) rotate(8deg); }
  80%  { transform: translate(-1px, 1px) rotate(-3deg); }
  100% { transform: translate(0, 0) rotate(0deg); }
}
#bobber.plunge { animation: plunge 0.35s ease-in forwards; }
@keyframes bobberBob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
/* a fly is not lifted by the swell, it is carried by the surface: sideways and
   a touch of yaw, no vertical travel */
@keyframes flyDrift {
  0%,100% { transform: translateX(0) rotate(0deg); }
  35%     { transform: translateX(2.2px) rotate(3deg); }
  70%     { transform: translateX(-1.4px) rotate(-2deg); }
}
@keyframes plunge { to { transform: translateY(14px); opacity: 0; } }

/* ============ V1: WATER DEPTH ============
   The scene used to be one flat plane: every sprite painted on top of the
   backdrop, nothing ever in front of anything. That's why the boat floated and
   the fish never looked submerged. #surface is the front plane: translucent
   water from the waterline down, painted over the rig and the fish. The 55%
   matches where every biome's art puts its waterline (design y=198). */
#surface {
  position: absolute; left: 0; right: 0; top: 55%; bottom: 0;
  z-index: 3; pointer-events: none;
  /* Not an even wash: the read comes from the first 30px. A bright lip on the
     line, a darker band just under it, then it opens back up before deepening
     with distance: the way a real surface catches light at the waterline and
     shades whatever is sitting in it. An even gradient just fogs the scene. */
  background: linear-gradient(180deg,
    rgba(168, 206, 190, 0.30) 0px,
    rgba(58, 92, 84, 0.34) 6px,
    rgba(48, 80, 74, 0.18) 26px,
    rgba(42, 70, 64, 0.34) 60%,
    rgba(36, 58, 52, 0.62) 100%);
}
/* contact: without a shadow the hull has nothing to sit on. It lives outside
   #rig so it stays flat on the water while the boat rocks, and behind #surface
   so it dims like anything else underwater. */
/* left/top/width come from the pose's vessel.shadow: see renderRig() */
#hull-shadow {
  position: absolute; height: 10px;
  z-index: 2; pointer-events: none;
  background: radial-gradient(ellipse at center,
    rgba(45, 62, 56, 0.45) 0%, rgba(45, 62, 56, 0) 70%);
  animation: wake 5.2s ease-in-out infinite;
}
/* the shadow stretches and eases as the hull rocks over it */
@keyframes wake {
  0%, 100% { transform: scaleX(1) translateX(0); opacity: 0.9; }
  50%      { transform: scaleX(1.06) translateX(2px); opacity: 1; }
}

/* expanding ripple rings on the water */
/* z-index 4: these happen ON the surface, so they sit in front of #surface */
.ripple {
  position: absolute; z-index: 4; width: 8px; height: 8px;
  border: 1.5px solid rgba(230,238,228,0.45); border-radius: 50%;
  transform: translate(-50%, -50%); pointer-events: none;
  animation: rippleGrow 1.6s ease-out forwards;
}
@keyframes rippleGrow {
  from { opacity: 0.7; width: 8px; height: 8px; }
  to   { opacity: 0;   width: 64px; height: 26px; }
}

/* ---- L1: what lives here ----
   Ambient actors, spawned by app.js when a spot's voice speaks and removed on a
   timer. Drawn in CSS rather than generated, because at this size (a dragonfly
   is 15 design px, a gull 18) the silhouette and the motion are the entire
   read: the same call T2 made for the bobber. #life shares #surface's z-index
   and comes after it in the markup, so an actor paints over the water wash and
   under the near foreground, which is how a dragonfly passes behind a reed.
   Positions come from CONFIG.life and are all in design space.

   None of these spawn under prefers-reduced-motion (app.js), so they are
   deliberately absent from the animation: none block at the foot of this file:
   an actor that cannot travel is not shown at all. */
#life { position: absolute; inset: 0; z-index: 3; pointer-events: none; }
.actor { position: absolute; pointer-events: none; will-change: transform; }

/* The crossing: right to left, sagging by --dip in the middle because nothing
   alive flies in a straight line. --face keeps the art pointing the way it is
   travelling if a from/to is ever flipped in config. */
@keyframes actorCross {
  0%   { transform: translate(0, 0) scaleX(var(--face)); opacity: 0; }
  10%  { opacity: 1; }
  50%  { transform: translate(calc(var(--dx) / 2), calc(var(--dy) / 2 + var(--dip)))
                    scaleX(var(--face)); }
  88%  { opacity: 1; }
  100% { transform: translate(var(--dx), var(--dy)) scaleX(var(--face)); opacity: 0; }
}

/* The Pond's frog: two eyes and a nose on the film, which is both what a frog
   in a pond actually shows and all that reads at 16px. It rises, puffs its
   throat on the croak, and sinks. app.js rings the water where it comes up. */
.actor-frog {
  width: 16px; height: 7px;
  border-radius: 50% 50% 40% 40% / 80% 80% 30% 30%;
  background: linear-gradient(180deg, var(--moss) 0%,
              color-mix(in srgb, var(--moss) 62%, var(--water-deep)) 100%);
  border: 1px solid color-mix(in srgb, var(--umber) 70%, var(--moss));
  border-bottom: none;
  transform-origin: center bottom;
  animation-name: actorSurface;
  animation-timing-function: ease-in-out;
  animation-fill-mode: both;
}
/* the eyes: two bumps proud of the head, which is the whole silhouette */
.actor-frog::before, .actor-frog::after {
  content: ""; position: absolute; top: -3px; width: 4px; height: 4px;
  border-radius: 50%;
  background: var(--moss);
  border: 1px solid color-mix(in srgb, var(--umber) 70%, var(--moss));
  box-shadow: inset -0.5px -0.5px 0 color-mix(in srgb, var(--umber) 40%, transparent);
}
.actor-frog::before { left: 2px; }
.actor-frog::after  { right: 2px; }
@keyframes actorSurface {
  0%   { transform: translateY(4px) scale(0.7, 0.5);  opacity: 0; }
  14%  { transform: translateY(0)   scale(1, 1);      opacity: 1; }
  24%  { transform: translateY(0)   scale(1.1, 1.25); }   /* the croak */
  38%  { transform: translateY(0)   scale(1, 1); }
  82%  { transform: translateY(0)   scale(1, 1);      opacity: 1; }
  100% { transform: translateY(4px) scale(0.7, 0.5);  opacity: 0; }
}

/* The Pond's dragonfly: a dark needle with two pale wings, crossing the open
   water. The wings beat far too fast to count, which is the read. */
.actor-dragonfly {
  width: 19px; height: 2px;
  border-radius: 1px;
  background: linear-gradient(90deg,
              color-mix(in srgb, var(--umber) 78%, var(--water-deep)) 0%,
              color-mix(in srgb, var(--umber) 62%, var(--water-deep)) 55%,
              color-mix(in srgb, var(--umber) 30%, var(--water-teal)) 100%);
  /* the head, as a fatter dark tip on the leading end: without it the body is a
     dash, and a dash with a pale smear beside it reads as debris on the water */
  box-shadow: -1px 0 0 0.5px color-mix(in srgb, var(--umber) 72%, var(--water-deep));
  animation-name: actorCross;
  animation-timing-function: ease-in-out;
  animation-fill-mode: both;
}
/* Both wings pivot at the thorax and sweep BACK over the body, which is the
   shape that reads: wings hung off one end made it look like a comet. */
.actor-dragonfly::before, .actor-dragonfly::after {
  content: ""; position: absolute; left: 2px; top: -1px; width: 9px; height: 3px;
  border-radius: 50%;
  /* a beating wing is a blur, not a paddle: at 62% opacity these read as two
     white oars and the whole thing became a moth. The abdomen has to stay the
     longest thing on screen, which is the entire dragonfly silhouette. */
  background: color-mix(in srgb, var(--foam) 38%, transparent);
  border: 0.4px solid color-mix(in srgb, var(--umber) 14%, transparent);
  transform-origin: 1.5px center;
}
.actor-dragonfly::before { animation: dragonflyWingUp 110ms linear infinite alternate; }
.actor-dragonfly::after  { animation: dragonflyWingDown 110ms linear infinite alternate; }
@keyframes dragonflyWingUp   { from { transform: rotate(-27deg); } to { transform: rotate(-9deg); } }
@keyframes dragonflyWingDown { from { transform: rotate(9deg); }   to { transform: rotate(25deg); } }

/* The Ocean's gull: "one bird a long way off", so it is two arcs and nothing
   else, and it crosses the sky slowly enough to read as distant. */
.actor-gull {
  width: 18px; height: 7px;
  animation-name: actorCross;
  animation-timing-function: linear;
  animation-fill-mode: both;
}
/* Each wing droops at its OUTER tip only and stays flat where it meets its
   partner, so the two make one shallow arch. Rounding both top corners (the
   obvious way to draw an arc) curved each wing down at the middle as well, and
   two humps with a notch between them read as a pair of croquet hoops rather
   than as a bird. They pivot on the join, so the flap cannot open a gap. */
.actor-gull::before, .actor-gull::after {
  content: ""; position: absolute; top: 0; width: 9px; height: 4px;
  border-top: 1.4px solid color-mix(in srgb, var(--bark-deep) 72%, transparent);
}
.actor-gull::before { left: 0;  border-radius: 100% 0 0 0 / 100% 0 0 0;
                      transform-origin: right center; animation: gullFlap 1.5s ease-in-out infinite; }
.actor-gull::after  { right: 0; border-radius: 0 100% 0 0 / 0 100% 0 0;
                      transform-origin: left center;  animation: gullFlapMirror 1.5s ease-in-out infinite; }
@keyframes gullFlap       { 0%, 100% { transform: rotate(-7deg); } 50% { transform: rotate(9deg); } }
@keyframes gullFlapMirror { 0%, 100% { transform: rotate(7deg); }  50% { transform: rotate(-9deg); } }

/* foreground reeds sway at the pond edges */
/* V1's CSS-drawn stand-in foreground, now unused: all three levels have real
   painted foregrounds in #bg-fore. Kept hidden rather than deleted only because
   the markup and the sway keyframes still reference them; they come out with
   the next markup pass. */
.reeds { position: absolute; bottom: 8px; display: none; gap: 7px; align-items: flex-end; pointer-events: none; }
#reeds-left { left: 12px; }
#reeds-right { right: 16px; }
.reed {
  width: 5px; border-radius: 3px 3px 0 0;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--moss) 40%, var(--water-deep)) 0%,
    var(--water-deep) 95%);
  transform-origin: bottom center;
  animation: sway ease-in-out infinite alternate;
}
@keyframes sway { from { transform: rotate(-2.4deg); } to { transform: rotate(2.6deg); } }

/* fish is now a real sprite (M9 art pass), swapped by rarity tier. The art
   faces left (head toward the boat), matching the swim/landing direction.
   Three tier images cover four tiers: common+uncommon share the common
   sprite, rare and legendary get their own. */
/* left/top are driven every frame by the reel RAF (so the line can stay glued
   to the fish), so NO left/top transition here: a transition would smear the
   fish behind its per-frame target. Only opacity eases. */
/* R6: a species with its own art renders as cut layers INSIDE #fish (renderFish
   sets `.rigged` and the box), and a species without one keeps the tier
   placeholder below. Every placeholder rule is scoped `:not(.rigged)` rather
   than ordered after the art, so the two can never fight over specificity: the
   glow and the submerged treatment stay unscoped, because rarity and being
   underwater are true of a fish whatever it is drawn from. */
#fish {
  position: absolute; top: 244px; width: 62px; height: 41px;
  opacity: 0;
  transition: opacity 0.3s ease;
  /* 0 = the shape that rose out of the depths, 1 = a fish you can name. Written
     every frame by drawFish() from how far the fish has been reeled; the
     default of 1 is for a landed fish, which is out of the water and clear. */
  --reveal: 1;
}
#fish:not(.rigged) { background: url("assets/fish-common.png") center / contain no-repeat; }
.fish-layer { position: absolute; inset: 0; background: center / contain no-repeat; }
/* the tail sweeps about the peduncle it was cut at: transform-origin and both
   custom properties are set inline from CONFIG.fish (the pivot is per species,
   the swing is not). `alternate` is why the duration is half a cycle. */
.fish-layer[data-id="tail"] {
  animation: tailSweep var(--tail-ms) ease-in-out infinite alternate;
}
@keyframes tailSweep {
  from { transform: rotate(calc(var(--tail-deg) * -1)); }
  to   { transform: rotate(var(--tail-deg)); }
}
/* V1: while it's below the waterline the fish is seen THROUGH water, softer,
   bluer, less contrasty. Removed when it's landed, so breaking the surface is
   the moment the fish snaps into focus. Written out per tier rather than
   composed from custom properties, because `filter` doesn't cascade: naming the
   glow again is duller to read and impossible to get subtly wrong.

   It is also seen through DISTANCE. Each term interpolates on --reveal between
   the approach silhouette's own value at 0 and the through-water value at 1, so
   a hooked fish at the far end of the scene is still the shape that rose out of
   the depths and only becomes a nameable species as it is reeled in. The gold
   rides the same ramp: a legendary that announced itself the moment it bit gave
   away more than its body did. CONFIG.fish.reveal.startAt sets how late this
   begins; drawFish() writes --reveal. */
#fish.submerged {
  filter: saturate(calc(0.35 + 0.35 * var(--reveal)))
          brightness(calc(0.42 + 0.4 * var(--reveal)))
          contrast(calc(0.85 + 0.07 * var(--reveal)))
          blur(calc(1.4px - 1px * var(--reveal)));
}
#fish.tier-rare.submerged {
  filter: saturate(calc(0.35 + 0.35 * var(--reveal)))
          brightness(calc(0.42 + 0.4 * var(--reveal)))
          contrast(calc(0.85 + 0.07 * var(--reveal)))
          blur(calc(1.4px - 1px * var(--reveal)))
          drop-shadow(0 0 8px rgba(var(--gold-rgb), calc(0.45 * var(--reveal))));
}
#fish.tier-legendary.submerged {
  filter: saturate(calc(0.35 + 0.35 * var(--reveal)))
          brightness(calc(0.42 + 0.4 * var(--reveal)))
          contrast(calc(0.85 + 0.07 * var(--reveal)))
          blur(calc(1.4px - 1px * var(--reveal)))
          drop-shadow(0 0 12px rgba(var(--gold-rgb), calc(0.6 * var(--reveal))));
}
/* uncommon shares the common sprite but hue-shifted so it reads as a
   different fish. This is a placeholder trick and nothing else: real art is
   already the colour the species should be, so it is scoped away from it. */
#fish:not(.rigged).tier-uncommon { filter: hue-rotate(150deg) saturate(1.2); }
#fish:not(.rigged).tier-rare { background-image: url("assets/fish-rare.png"); }
#fish.tier-rare { filter: drop-shadow(0 0 8px rgba(var(--gold-rgb),0.7)); }
#fish:not(.rigged).tier-legendary { background-image: url("assets/fish-legendary.png"); }
#fish.tier-legendary { filter: drop-shadow(0 0 12px rgba(var(--gold-rgb),0.9)); }
/* R6: before it bites, the fish is a shape in the water rather than a fish,
   dark, soft-edged and low-contrast, seen through #surface. Warm dark, never
   black (ART_DIRECTION.md): brightness on a warm sprite stays warm, which a
   grayscale or a flat fill would not. Declared after the tier rules so it wins
   any tie; the approach deliberately carries no tier class of its own. */
#fish.silhouette { filter: brightness(0.42) saturate(0.35) contrast(0.85) blur(1.4px); opacity: 0.8; }
/* the drift itself. --drift is set inline from CONFIG.fish.approach.leadMs, and
   the opacity fade keeps its own timing: a fish that took a second to appear
   would arrive already half way across. */
#fish.approaching { transition: left var(--drift) ease-out, top var(--drift) ease-out, opacity 0.3s ease; }
/* the escape, and the mirror of the approach: it accelerates AWAY (ease-in,
   where the drift in eased out) and the filter goes with it, so the reveal the
   reel had earned drains back to a silhouette as the fish makes deep water.
   --flee comes from CONFIG.fish.escape.ms. This is a transition rather than the
   swim RAF because land() has already stopped that loop. */
#fish.fleeing { transition: left var(--flee) ease-in, top var(--flee) ease-in, filter var(--flee) ease-in; }
#fish.landing { animation: fishArc 0.7s ease forwards; }
@keyframes fishArc {
  40% { transform: translate(-30px, -90px) rotate(-30deg); }
  100% { transform: translate(-60px, -40px) rotate(-70deg); opacity: 0; }
}
/* momentary snap when the fish gets hooked at bite; idle swim wobble (JS-driven
   transform) resumes once this finishes since it has no fill-mode */
#fish.hooked { animation: hookJolt 0.35s ease; }
@keyframes hookJolt {
  0%   { transform: scale(1) rotate(0deg); }
  30%  { transform: scale(1.15) rotate(-8deg); }
  60%  { transform: scale(0.95) rotate(6deg); }
  100% { transform: scale(1) rotate(0deg); }
}

/* particles & floaters */
.p {
  position: absolute; z-index: 4; width: 7px; height: 7px; border-radius: 50%;
  background: var(--foam); pointer-events: none;
  animation: splash 0.65s ease-out forwards;
}
@keyframes splash {
  to { transform: translate(var(--dx), var(--dy)) scale(0.1); opacity: 0; }
}
.coinfloat {
  position: absolute; z-index: 4; font-family: var(--display); font-size: 15px; color: var(--gold);
  text-shadow: 0 2px 6px rgba(var(--shadow-rgb),0.6); pointer-events: none;
  animation: rise 1.1s ease-out forwards;
}
@keyframes rise { to { transform: translateY(-52px); opacity: 0; } }

/* Text/UI moments: fixed-size viewport overlays (not scaled with the
   #scene-frame canvas; that was blowing status/word text up 2-3x and
   colliding with the hud/guide overlays). */
/* ---- the pun bubble (top-left) ----
   The game's voice used to be a 12px line of bare text tucked under the title,
   which is a strange size for the only thing on screen that talks to the kid.
   It is a card now, at roughly twice the type size, and it POPS in: a new line
   is a small event, not a silent text swap. The bubble is only as wide as it
   needs to be (fit-content) so a four-word line does not draw a banner across
   the sky, and it disappears entirely when there is nothing to say. */
#pun {
  display: flex; align-items: flex-start; gap: 6px;
  width: fit-content; max-width: min(100%, 34ch);
  padding: 9px 12px 10px 14px; border-radius: 16px 16px 16px 4px;
  background: rgba(var(--umber-rgb),0.72);
  border: 1px solid rgba(var(--ink-rgb),0.14);
  border-left: 3px solid rgba(var(--gold-rgb),0.75);
  box-shadow: 0 8px 22px rgba(var(--shadow-rgb),0.38);
  backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
  transform-origin: 12% 0;
}
#pun.empty { display: none; }
/* The card is the payoff and it says "type to cast again" itself, so while one
   is up the bubble stands down: on a phone the two were landing on top of each
   other (the bubble covered the card's NEW SPECIES ribbon), and even where they
   fit, two instructions on one screen is one too many. */
#pun.behind-card { display: none; }
#status {
  font-family: var(--display); font-weight: 600;
  font-size: clamp(17px, 2.4vw, 22px); line-height: 1.28;
  color: var(--ink); letter-spacing: 0.02em;
  text-shadow: 0 2px 8px rgba(var(--umber-rgb),0.8);
}
/* the way out, for a kid who has heard enough. It only ever appears on a line
   that is safe to lose (app.js, setPun): never on the cast prompt. */
#pun-dismiss {
  flex: none; margin: -2px -4px 0 0;
  width: 26px; height: 26px; padding: 0; border-radius: 50%;
  font-family: var(--display); font-size: 13px; line-height: 1;
  color: var(--dim); background: rgba(var(--ink-rgb),0.08);
  border: 1px solid rgba(var(--ink-rgb),0.14); cursor: pointer;
}
#pun-dismiss:hover { color: var(--ink); background: rgba(var(--ink-rgb),0.18); }
#pun-dismiss:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
#pun-dismiss[hidden] { display: none; }
/* the pop: overshoot, a little tilt, and a settle. Re-triggered per line by
   popPun() in app.js, and skipped entirely under prefers-reduced-motion (which
   app.js checks, so the class is simply never added). */
#pun.pop { animation: punpop 460ms cubic-bezier(0.22,1,0.36,1); }
@keyframes punpop {
  0%   { opacity: 0; transform: translate(-10px,-12px) scale(0.62) rotate(-5deg); }
  45%  { opacity: 1; transform: translate(0,2px) scale(1.08) rotate(1.6deg); }
  70%  { transform: translate(0,0) scale(0.97) rotate(-0.8deg); }
  100% { opacity: 1; transform: none; }
}
#word {
  position: fixed; left: 50%; bottom: 250px; transform: translateX(-50%);
  /* above the top bar (6): on a short landscape screen the pun bubble and the
     word box are within a few px of each other, and the word is the one thing
     that has to stay readable */
  z-index: 7;
  font-size: 36px; letter-spacing: 0.14em; min-width: 120px; min-height: 54px;
  /* Pinned, because the box's height is load-bearing: #card-slot's `bottom`
     is 250 + this box + a margin, and Baloo 2's default line box is ~12px
     taller than the monospace one this was measured against, which pushed the
     word up into the card's band at three viewport shapes. 45 + 8px padding
     keeps the 54px box that number was derived from. */
  line-height: 45px;
  padding: 4px 22px; border-radius: 27px; text-align: center;
  background: rgba(var(--umber-rgb),0.5);
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
}
#word .done { color: var(--moss); }
#word .todo { color: var(--ink); }
/* the space you're about to type in a phrase (A1): a soft, pulsing ␣ cue so the
   spacebar is obvious; letters use the plain .todo colour above */
#word .cur.space { color: var(--gold); opacity: 0.9; animation: spacecue 1s ease-in-out infinite; }
@keyframes spacecue { 50% { opacity: 0.4; } }
#word.shakeword { animation: shakeword 0.16s; }
@keyframes shakeword { 25% { transform: translateX(calc(-50% - 6px)); } 75% { transform: translateX(calc(-50% + 6px)); } }
#word.pop { animation: wordpop 0.22s ease; }
@keyframes wordpop { 40% { transform: translateX(-50%) scale(1.07); } }
#word:empty { background: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; }

/* ============ F3: THE CATCH CARD ============
   The payoff, and the one surface a kid actually stops and reads. It replaces
   a 12px line in the top-left corner that was overwritten 1500ms later.

   Two things about the geometry, both of them rules from CLAUDE.md rather than
   taste. Nothing may reach the bottom-center finger-guide panel, and #word sits
   at bottom: 250px, so the card is not positioned, it is CENTRED IN A SLOT
   that spans exactly the free band between the HUD and the word. A fixed `top`
   would collide with one or the other on some window shape; a slot cannot.

   The unlock banner (z-index 6) deliberately still sits ABOVE this (z-index 5).
   The two land on the same catch, the banner is a 2.6s celebration and the card
   waits for the kid, so the banner plays over the card and the card is still
   there when it clears. There is nothing to sequence. */
#card-slot {
  position: fixed; left: 0; right: 0;
  /* Clear of the top bar, MEASURED rather than guessed: app.js (fitTopbar)
     keeps --card-top in step with what the bar actually is, because its height
     moves with the chips' wrapping and with the stacked layout under 620px. A
     fixed 52px was right for one HUD row and put the card under the tackle box
     on a 320px phone. The fallback is that old value. */
  top: var(--card-top, 52px);
  bottom: 312px;     /* clear of #word (bottom 250 + its 54px box + a margin) */
  z-index: 5;
  display: flex; align-items: center; justify-content: center;
  pointer-events: none;
}
#catch-card {
  width: min(348px, 88vw); max-height: 100%;
  padding: 15px 20px 12px; border-radius: 18px; text-align: center;
  background: rgba(var(--umber-rgb),0.84);
  border: 1px solid rgba(var(--ink-rgb),0.16);
  box-shadow: 0 12px 34px rgba(var(--shadow-rgb),0.5);
  backdrop-filter: blur(7px); -webkit-backdrop-filter: blur(7px);
  opacity: 0; transform: translateY(16px) scale(0.95);
}
/* in: it rises under the catch. out: it is YANKED off the top, the word Matt
   used, and the reason this isn't a fade. A fade reads as the card giving up;
   a yank reads as the kid clearing it to get on with fishing. */
#catch-card.in {
  opacity: 1; transform: none;
  transition: opacity var(--card-in) ease, transform var(--card-in) cubic-bezier(0.2,0.9,0.3,1.25);
}
#catch-card.out {
  opacity: 0; transform: translateY(-26px) scale(0.97);
  transition: opacity var(--card-out) ease-in, transform var(--card-out) ease-in;
}
.card-fish {
  margin: 2px auto 10px; background: center / contain no-repeat;
  filter: drop-shadow(0 6px 10px rgba(var(--shadow-rgb),0.45));
}
/* An escape has no species: the reveal (CONFIG.fish.reveal) never tells you
   what a fish is until it is close, and the one that got away is the one you
   never found out about. So the card shows the shared placeholder body under
   the same treatment #fish.silhouette uses: the shape that rose out of the
   depths, and nothing more. Not a blank space, which is what an empty box
   looked like, and not the species, which would give away the secret the whole
   reel is built on. */
.escape .card-fish {
  background-image: url("assets/fish-common.png") !important;
  filter: brightness(0.42) saturate(0.35) contrast(0.85) blur(1.4px);
  opacity: 0.75;
}
.card-name {
  font-family: var(--display); font-size: 21px; font-weight: 600;
  color: var(--ink); letter-spacing: 0.02em;
}
.card-sub { font-family: var(--display); font-size: 13px; color: var(--gold); margin-top: 2px; }
.card-pun {
  font-family: var(--display); font-size: 14px; color: var(--ink);
  opacity: 0.92; margin-top: 9px; line-height: 1.35;
}
.card-coins { font-family: var(--display); font-size: 14px; color: var(--gold); margin-top: 8px; }
.card-coins:empty { display: none; }
.card-hint {
  font-family: var(--display); font-size: 11px; color: var(--dim);
  margin-top: 12px; padding-top: 9px; border-top: 1px solid rgba(var(--ink-rgb),0.12);
}
/* the ribbon only exists on a plaque, so the ordinary card has no gap for it */
.card-ribbon { display: none; }

/* ---- the plaque: the same card, raised, when a species is new or a record
   falls. A second surface queued behind the first was the alternative and this
   is deliberately not that: one card, one dismissal, more gold. ---- */
#catch-card.plaque {
  border: 2px solid var(--gold); padding-top: 8px;
  background: rgba(var(--umber-rgb),0.92);
  box-shadow: 0 12px 34px rgba(var(--shadow-rgb),0.5), 0 0 28px rgba(var(--gold-rgb),0.28);
}
#catch-card.plaque .card-ribbon {
  display: block; margin: -8px -20px 10px; padding: 7px 20px 6px;
  font-family: var(--display); font-size: 12px; font-weight: 700;
  letter-spacing: 0.14em; color: var(--umber);
  background: linear-gradient(180deg, var(--amber), var(--gold));
  border-radius: 15px 15px 4px 4px;
}
#catch-card.plaque .card-name { color: var(--gold); font-size: 23px; }
#catch-card.plaque .card-fish { filter: drop-shadow(0 0 14px rgba(var(--gold-rgb),0.5)); }

/* the "new letters!" celebration banner */
#unlock-banner {
  position: fixed; left: 50%; top: 40%; transform: translate(-50%, -50%);
  z-index: 6;
  text-align: center; padding: 14px 26px; border-radius: 12px;
  background: rgba(var(--umber-rgb),0.78); border: 1px solid rgba(var(--gold-rgb),0.5);
  opacity: 0; pointer-events: none; transition: opacity 0.25s;
}
#unlock-banner.show { opacity: 1; animation: bannerpop 0.45s ease; }
@keyframes bannerpop { 0% { transform: translate(-50%, -50%) scale(0.6); } 70% { transform: translate(-50%, -50%) scale(1.08); } }
#unlock-banner .banner-title { font-family: var(--display); font-size: 12px; color: var(--ink); letter-spacing: 0.08em; }
#unlock-banner .letters { font-family: var(--display); font-size: 34px; color: var(--gold); letter-spacing: 0.3em; margin-top: 4px; text-shadow: 0 0 14px rgba(var(--gold-rgb),0.6); }
/* A8: the Muskie capstone wears the same banner, turned up, a gold frame and a
   slow glow, so the finish line doesn't look like just another letter unlock */
#unlock-banner.prestige {
  border-color: var(--gold); border-width: 2px; padding: 20px 34px;
  background: rgba(var(--umber-rgb),0.9);
  animation: prestigeGlow 1.4s ease-in-out infinite alternate;
}
#unlock-banner.prestige .letters { font-size: 42px; }
@keyframes prestigeGlow {
  from { box-shadow: 0 0 18px rgba(var(--gold-rgb),0.35); }
  to   { box-shadow: 0 0 46px rgba(var(--gold-rgb),0.85); }
}
/* the rank line under the journal's badge count */
.jrank { margin-top: 6px; font-family: var(--display); font-size: 13px; color: var(--gold); }

/* tension = the bottom edge of the pond itself */
#meter { position: absolute; left: 0; right: 0; bottom: 0; height: 8px; background: rgba(var(--shadow-rgb),0.4); }
#meter-fill { height: 100%; width: 0%; background: var(--moss); transition: width 0.15s, background 0.15s; }
#meter-fill.danger { animation: meterpulse 0.45s ease-in-out infinite alternate; }
@keyframes meterpulse { from { filter: brightness(1); } to { filter: brightness(1.6); } }

/* ============ OVERLAY CHROME (floats above the full-screen pond) ============ */
/* ---- the top bar: both corners, one row ----
   Nothing in here is positioned against the viewport individually any more.
   The row hands the left column whatever the right column does not need, and
   below 620px it stacks (column-reverse: the HUD keeps the corner, the pun
   drops underneath it with the full width). An overlap is not something this
   layout can express, which is the point: the old one could, and did. */
#topbar {
  position: fixed; top: 12px; left: 14px; right: 14px; z-index: 6;
  display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
  pointer-events: none;      /* the sky behind it stays clickable */
}
/* the chips and the title stay click-through (the sky behind them is scenery
   worth not blocking); everything you can press does not */
#topbar button, #controls { pointer-events: auto; }
/* the left column takes what is left over, and the bubble's own max-width caps
   it. Splitting the row by percentage instead wrapped the four chips onto two
   rows at 740px, where the second row then sat on #word. */
#topleft {
  display: flex; flex-direction: column; align-items: flex-start; gap: 7px;
  flex: 1 1 auto; min-width: 0;
}
#topright {
  position: relative;        /* #controls hangs off this, so the tray follows
                                the tackle box instead of guessing a `top` */
  display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
  flex: none;
}
#hud {
  display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end;
}
@media (max-width: 620px) {
  #topbar { flex-direction: column-reverse; align-items: stretch; gap: 10px; }
}
/* A phone on its side: the whole game is squeezed between the HUD and the
   keyboard, and #word (at bottom: 250px) is only ~55px from the top. The
   bubble gives the room back rather than growing into the one thing a kid has
   to be able to read. Where they still meet on a long sentence, #word wins:
   see its z-index. */
@media (max-height: 520px) {
  h1 { display: none; }
  #topbar { top: 8px; }
  #pun { padding: 6px 10px 7px 12px; border-radius: 12px 12px 12px 4px; max-width: min(100%, 26ch); }
  #status { font-size: 15px; }
  #pun-dismiss { width: 22px; height: 22px; font-size: 11px; }
}
.chip {
  font-family: var(--display); font-size: 11px; color: var(--dim);
  background: rgba(var(--umber-rgb),0.55); border: 1px solid rgba(var(--ink-rgb),0.12);
  padding: 6px 12px; border-radius: 8px;
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
}
.chip b { color: var(--ink); font-size: 14px; }
.chip.coins b { color: var(--gold); }

/* the tackle box: a single always-visible button that opens #controls */
#tacklebox {
  /* 44px is the smallest thing a thumb hits reliably, and this is the only
     way into the shop, the journal and every spot in the game */
  width: 44px; height: 44px; padding: 0; font-size: 24px; line-height: 1;
  background: rgba(var(--umber-rgb),0.55); border: 1px solid rgba(var(--ink-rgb),0.15);
  border-radius: 8px; cursor: pointer;
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  transition: transform 0.12s ease, background 0.12s ease;
}
#tacklebox:hover { background: rgba(var(--umber-rgb),0.75); }
#tacklebox[aria-expanded="true"] { background: var(--gold); transform: rotate(-4deg); }
#tacklebox:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

/* the menu tray drops out of the tackle box, top-right, as a vertical stack */
#controls {
  /* absolutely positioned inside #topright rather than fixed at a guessed
     `top`: it now opens under the tackle box wherever the chips have pushed
     it to, and opening it never shoves the pun bubble down the screen. */
  position: absolute; top: calc(100% + 6px); right: 0; z-index: 6;
  display: flex; flex-direction: column; align-items: stretch; gap: 7px;
  width: 208px; max-height: calc(100vh - 190px); overflow-y: auto; padding: 11px;
  background: rgba(var(--umber-rgb),0.82); border: 1px solid rgba(var(--ink-rgb),0.15);
  border-radius: 10px; font-size: 12px; color: var(--dim);
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
}
#controls[hidden] { display: none; }
/* While the tray is open the bar outranks #word, and only then. #word is
   z-index 7 so that a phone held sideways lets the word box win over the top
   bar, which is right: the bar is chrome and the word is the game. But the tray
   is not chrome, it is a menu the kid opened, and at z-index 6 inside this bar
   #word painted straight over the middle of it: a button under the word box
   could not be tapped at all, and which button that was depended on how long
   the randomly-picked cast word happened to be. Raising #controls itself
   cannot fix that, because this bar's own z-index makes a stacking context its
   children can never climb out of, so the bar is what has to move. */
#topbar:has(#controls:not([hidden])) { z-index: 8; }
/* the tray is a menu on a touchscreen, so its rows are sized for a finger
   rather than for the corner of a desktop window */
#controls .toggle-btn.nav { text-align: left; font-size: 12px; padding: 9px 12px; }
#controls .ctrl-row .toggle-btn { min-width: 46px; padding: 7px 10px; font-size: 11px; }
.ctrl-row {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  font-family: var(--display); font-size: 12px; color: var(--ink); padding-left: 2px;
}
#who {
  font-family: var(--display); font-size: 11px; color: var(--gold);
  padding: 2px 2px 6px; border-bottom: 1px solid rgba(var(--ink-rgb),0.12); margin-bottom: 2px;
}
#who:empty { display: none; }
.toggle-btn {
  font-family: var(--display); font-size: 10px; color: var(--dim);
  background: rgba(var(--umber-rgb),0.55); border: 1px solid rgba(var(--ink-rgb),0.15);
  border-radius: 6px; padding: 6px 12px; cursor: pointer;
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
}
.toggle-btn.active { color: var(--umber); background: var(--gold); border-color: var(--gold); }
.toggle-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* dev/test-only affordance (CONFIG.dev.testShortcuts): dashed so it reads as
   "not a real player button"; the whole thing goes away when the flag is off */
.toggle-btn.dev { border-style: dashed; border-color: rgba(var(--gold-rgb),0.55); color: var(--gold); }

/* ============ FINGER GUIDE (semi-transparent overlay, pinned to the bottom) ============ */
/* R2: the keyboard is EXEMPT from the palette refresh (CLAUDE.md, it is the
   best part of the game and stays exactly as it is). Everything in this block
   therefore uses the frozen --kb-* tokens and never the scene palette, so a
   future palette change cannot reach it by accident. */
/* The panel's ALPHA is the whole reason a kid could not tell which letters were
   locked, and it is a measurement rather than a taste call. `.key.locked` is
   opacity alone, so a locked key shows whatever is painted behind the panel. At
   0.55 that is mostly the water, and the water is brighter than a live key's own
   --kb-key fill at two spots out of three: SIX locked keys rendered BRIGHTER
   than the unlocked ones (the Stream's y p q w, the Ocean's z q). Not a faint
   signal, an inverted one.

   Measured by sampling the rendered pixels of every letter key at all three
   spots, as the gap in luminance between a live key's cell and a locked one:

     alpha   pond          stream            ocean
     0.55    +2.8 worst    -8.3, 4 inverted  -15.4, 2 inverted   <- shipped
     0.80    +14.5         +8.3              +3.6, none
     0.88    +17.6         +12.9             +9.9, none          <- here
     1.00    +23.6         +20.9             +18.9, none

   The floor is the Pond's +2.8, because the Pond is the spot that reads today.
   0.80 is the least that removes the inversion everywhere; 0.88 clears the
   working floor by 3.5x at the WORST spot, which leaves room for a background
   that has not been painted yet, and still lets a little water through so the
   board sits in the scene rather than on it. A data test holds the floor.

   This is inside the R2 exemption, not a break of it: --kb-panel is unchanged
   and no key colour moved. Only how much scene is allowed to leak through. */
#guide-panel {
  position: fixed; left: 50%; bottom: 14px; transform: translateX(-50%); z-index: 5;
  background: rgba(var(--kb-panel),0.88); border: 1px solid rgba(255,255,255,0.1);
  border-radius: 14px; padding: 10px; max-width: calc(100vw - 20px); overflow: hidden;
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
}
#guide { position: relative; max-width: 100%; }   /* size set in JS (scales with GUIDE_SCALE) */
.key {
  position: absolute;
  /* Type is part of the exemption, and it has to be stated now rather than
     left to fall through from body. Never point a .key rule at --display. */
  font-family: var(--kb-font);
  border: 1px solid rgba(var(--kb-line),0.25); border-radius: 6px;
  display: flex; align-items: center; justify-content: center;
  color: var(--kb-dim); background: rgba(var(--kb-key),0.6);
}
.key.ghost-key { opacity: 0.35; }
.key.locked { opacity: 0.18; }
/* Holding Shift capitalises the caps, so the key under a kid's finger shows the
   shape the word box is asking for. text-transform rather than a JS relabel
   because it touches exactly the letters and nothing else: the ghost keys
   (`; , . /`) and the shift glyph have no uppercase form and are left alone. */
#guide.shift-held .key { text-transform: uppercase; }
.key.fresh {
  border-color: var(--kb-gold); color: var(--kb-gold);
  animation: freshpulse 0.7s ease-in-out infinite alternate;
}
@keyframes freshpulse { from { box-shadow: 0 0 4px rgba(var(--kb-gold-rgb),0.3); } to { box-shadow: 0 0 16px rgba(var(--kb-gold-rgb),0.85); } }
.key.target { border-color: var(--kb-gold); color: var(--kb-gold); box-shadow: 0 0 10px rgba(var(--kb-gold-rgb),0.45); }
/* A2: the two Shift keys sit quietly like the ghost anchor until a capital lights them */
.key.shift-key { opacity: 0.35; }
.key.shift-key.target { opacity: 1; }
/* A5-guide: same for the ghost keys, the ";" anchor and the `,` `.` `/`
   punctuation keys stay quiet at the Pond and come up to full when a sentence
   actually calls for one. Needs its own rule because .key.target sets colour,
   not opacity, so the 0.35 above would otherwise win. */
.key.ghost-key.target { opacity: 1; }
.finger {
  position: absolute;
  border: 1.5px solid rgba(var(--kb-ink),0.28); background: rgba(var(--kb-ink),0.05);
  border-radius: 9px; pointer-events: none;
  transition: transform 0.18s ease, border-color 0.18s, background 0.18s;
}
.finger.active { border-color: var(--kb-gold); background: rgba(var(--kb-gold-rgb),0.16); }

/* ============ OVERLAY SCREENS (collection, shop) ============ */
.overlay {
  position: fixed; inset: 0; z-index: 10;
  /* R2 deferred this value until real art existed to judge it against; R3's
     backgrounds landed, so it was judged and lightened 0.75 -> 0.45. The scrim
     buys focus, not legibility: .overlay-panel below is fully opaque, so every
     word in a modal sits on solid umber and nothing reads better at 0.75. At
     that weight it was halving a scene that is now worth seeing: the profile
     picker is the first thing a kid looks at. */
  background: rgba(var(--umber-rgb),0.45);
  display: flex; align-items: center; justify-content: center;
}
.overlay[hidden] { display: none; }
.overlay-panel {
  background: var(--umber); border: 1px solid rgba(var(--gold-rgb),0.35);
  border-radius: 14px; padding: 20px 24px; max-width: 680px; max-height: 90vh;
  overflow-y: auto; box-shadow: 0 16px 50px rgba(var(--shadow-rgb),0.6);
  text-align: center;
}
.overlay-title { font-family: var(--display); font-size: 13px; color: var(--gold); letter-spacing: 0.12em; margin-bottom: 16px; }

/* ---- The browsable panels: a fixed head, a row of tabs, one scrolling body.
   `paneled` is opt-in rather than applied to every .overlay-panel because the
   flow panels (the profile picker, the rod nudge, quick cast) are a single
   column that scrolls as one and would lose their scrolling to the flex
   layout below. The panel itself stops scrolling: .overlay-body does it, which
   is what keeps the close button and the tabs on screen. */
.overlay-panel.paneled {
  display: flex; flex-direction: column; overflow: hidden;
  padding: 14px 18px 16px; width: min(680px, calc(100vw - 24px));
}
.overlay-head {
  display: flex; align-items: center; gap: 12px;
  padding-bottom: 12px; margin-bottom: 12px;
  border-bottom: 1px solid rgba(var(--gold-rgb),0.22);
  flex: 0 0 auto;
}
.overlay-head .overlay-title { margin-bottom: 0; text-align: left; }
/* Top-LEFT, and first in the DOM: it is the first thing a tab lands on and the
   first thing a thumb finds, and it never scrolls away under a long shop. */
.overlay-x {
  flex: 0 0 auto; width: 34px; height: 34px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 15px; line-height: 1; color: var(--gold); cursor: pointer;
  background: rgba(var(--gold-rgb),0.1); border: 1px solid rgba(var(--gold-rgb),0.35);
}
.overlay-x:hover { background: rgba(var(--gold-rgb),0.22); }
.overlay-x:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

.tabs {
  display: flex; gap: 6px; flex: 0 0 auto; margin-bottom: 12px;
  overflow-x: auto; scrollbar-width: none;   /* a narrow phone scrolls the row rather than wrapping it */
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
  font-family: var(--display); font-size: 10px; letter-spacing: 0.1em;
  color: var(--dim); background: rgba(var(--ink-rgb),0.04);
  border: 1px solid rgba(var(--ink-rgb),0.12); border-radius: 8px;
  padding: 8px 14px; cursor: pointer; white-space: nowrap; flex: 0 0 auto;
}
.tab:hover { color: var(--ink); }
.tab.active { color: var(--umber); background: var(--gold); border-color: var(--gold); }
.tab:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

.overlay-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 2px; }
.tabpane[hidden] { display: none; }
.nudge-text { font-size: 13px; line-height: 1.5; color: var(--ink); max-width: 380px; margin: 0 auto 18px; }

/* Fishing journal: badge grid */
#journal-summary { font-size: 12px; color: var(--ink); margin-bottom: 16px; }
#journal-summary b { color: var(--gold); }
#journal-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
.badge-card {
  padding: 12px 10px 14px; border-radius: 10px; text-align: center;
  background: rgba(var(--ink-rgb),0.04); border: 1px solid rgba(var(--ink-rgb),0.1);
  opacity: 0.5;
}
.badge-card.earned { opacity: 1; border-color: rgba(var(--gold-rgb),0.5); background: rgba(var(--gold-rgb),0.08); }
.badge-medal { font-size: 26px; line-height: 1; margin-bottom: 6px; }
.badge-name { font-family: var(--display); font-size: 11px; color: var(--ink); margin-bottom: 4px; }
.badge-card.earned .badge-name { color: var(--gold); }
.badge-desc { font-size: 10px; color: var(--dim); line-height: 1.4; }

/* T3: the junk shelf, under the badges. Deliberately the same card language as
   .badge-card next to it (dim and locked until found) because that is what it
   sits beside, and a second visual idiom in one panel would read as a second
   kind of thing. The sprite fills the medal's slot when it is found. */
#junk-summary { font-size: 12px; color: var(--ink); margin-bottom: 10px; }
#junk-summary b { color: var(--gold); }
#junk-summary .jpulls { color: var(--dim); margin-left: 10px; font-size: 11px; }
#junk-shelf { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 16px; }
.junk-cell {
  padding: 10px 6px 12px; border-radius: 10px; text-align: center;
  background: rgba(var(--ink-rgb),0.04); border: 1px solid rgba(var(--ink-rgb),0.1);
  opacity: 0.5;
}
.junk-cell.found { opacity: 1; border-color: rgba(var(--gold-rgb),0.5); background: rgba(var(--gold-rgb),0.08); }
.junk-art {
  height: 34px; margin-bottom: 6px; font-size: 22px; line-height: 34px;
  background: center / contain no-repeat;
}
.junk-name { font-family: var(--display); font-size: 10px; color: var(--dim); margin-bottom: 3px; }
.junk-cell.found .junk-name { color: var(--gold); }
.junk-sub { font-size: 10px; color: var(--dim); }

/* Badge-earned toast (non-modal, slides down under the HUD) */
#badge-toast {
  position: fixed; left: 50%; top: 12px; transform: translate(-50%, -140%); z-index: 12;
  background: var(--umber); border: 1px solid var(--gold); border-radius: 10px;
  padding: 8px 16px; font-size: 12px; color: var(--ink); white-space: nowrap;
  box-shadow: 0 8px 24px rgba(var(--shadow-rgb),0.5); opacity: 0; pointer-events: none;
  transition: transform 0.3s ease, opacity 0.3s ease;
}
#badge-toast.show { transform: translate(-50%, 0); opacity: 1; }
/* Top-centre only works while there is room between the centre of the screen
   and a right-aligned HUD: the pill is ~262px and the chips ~332px, so they
   meet below ~954px of viewport. Under that it goes to the band between the
   keyboard and #word and rises into place instead of dropping. The numbers are
   measured, not guessed: #word's box ends 304px up and the guide panel's top
   edge is 201px up at full size (it scales down below ~719px wide, which only
   opens the gap further), so 208 clears both. It has to stay OUT of the card's
   band, which ends 312px up: the first attempt at 316 was inside it. */
@media (max-width: 980px) {
  #badge-toast {
    top: auto; bottom: 208px;
    transform: translate(-50%, 140%);
    /* max-content, or a `left: 50%` box wraps at HALF the viewport (that is all
       the room a fixed element has to its right) and the second line runs into
       #word. It may still wrap, but only when the text really is wider than the
       screen. */
    width: max-content; max-width: calc(100vw - 28px);
    white-space: normal; text-align: center;
  }
}
#badge-toast b { color: var(--gold); }
#badge-toast .badge-medal { font-size: 15px; display: inline; margin: 0; }

/* Parent progress view: per-key accuracy heatmap */
#progress-summary { font-size: 12px; color: var(--ink); line-height: 1.7; margin-bottom: 14px; }
#progress-summary b { color: var(--gold); }
#progress-kb { display: flex; flex-direction: column; gap: 6px; align-items: center; margin-bottom: 14px; }
.prow { display: flex; gap: clamp(3px, 1vw, 6px); }
/* The keys shrink with the screen instead of the ten-key top row hanging 67px
   off the side of a 320px phone, which is where `o` and `p` used to live: the
   panel scrolled sideways to reach them, and now it does not scroll at all. */
.pkey {
  width: clamp(22px, 7vw, 34px); height: clamp(22px, 7vw, 34px); border-radius: 6px;
  display: flex; align-items: center; justify-content: center;
  font-family: var(--display); font-size: clamp(9px, 2.2vw, 12px); color: var(--ink); text-transform: uppercase;
  border: 1px solid rgba(var(--ink-rgb),0.12);
}
.pkey.nodata { background: rgba(var(--ink-rgb),0.05); color: var(--dim); }
#progress-legend { font-size: 10px; color: var(--dim); margin-bottom: 14px; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
.pscale { display: inline-block; width: 120px; height: 10px; border-radius: 5px;
  background: linear-gradient(90deg, hsl(6,42%,48%), hsl(42,46%,50%), hsl(112,30%,44%)); }
.nudge-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
#nudge-shop { color: var(--umber); background: var(--gold); border-color: var(--gold); }
/* One grid per spot, inside that spot's tab. It used to be a single grid with
   full-width .cgroup headings inside it (A3); the tab row is that heading now,
   so the headings went with it rather than being said twice. */
/* auto-fill rather than a fixed five columns, and the cell no longer sets its
   own width: at five fixed 116px cells the grid was 628px wide inside a 366px
   phone panel and simply hung off the right-hand edge (visible the moment the
   panel stopped scrolling sideways). It is five across on the desktop panel
   and three on a phone, from the same rule. */
.cgrid {
  display: grid; gap: 12px; margin-bottom: 4px;
  grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
}
.cell {
  min-width: 0; padding: 10px 6px 12px; border-radius: 10px;
  background: rgba(var(--ink-rgb),0.04); border: 1px solid rgba(var(--ink-rgb),0.1);
}
/* R2: rare and legendary catches carry the same gold the scene gives them when
   they're on the line, so the grid says rarity in the same language the water
   does. Deliberately quiet: a warm rim, not a neon badge. */
.cell.tier-rare { border-color: rgba(var(--gold-rgb),0.32); }
.cell.tier-legendary {
  border-color: rgba(var(--gold-rgb),0.55);
  box-shadow: 0 0 14px rgba(var(--gold-rgb),0.14) inset;
}
/* same shape language as the in-scene #fish, kept in sync by hand since
   this one's built from JS-created markup with no child elements */
.cfish {
  position: relative; width: 56px; height: 26px; margin: 10px auto 12px;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--fish-color, #6f8f86) 100%, var(--foam) 30%) 0%,
    var(--fish-color, #6f8f86) 55%,
    color-mix(in srgb, var(--fish-color, #6f8f86) 100%, var(--umber) 22%) 100%);
  border-radius: 50% 40% 40% 50% / 60% 60% 40% 40%;
}
.cfish::after {
  content: ""; position: absolute; right: -14px; top: 2px; width: 18px; height: 22px;
  background: var(--fish-color, #6f8f86); filter: brightness(0.92);
  clip-path: polygon(0% 20%, 100% 0%, 62% 50%, 100% 100%, 0% 80%);
}
.cell:not(.unknown) .cfish::before {
  content: ""; position: absolute; left: 10px; top: 6px; width: 8px; height: 8px;
  border-radius: 50%;
  background: radial-gradient(circle at 65% 35%, var(--umber) 0 35%, var(--foam) 40% 100%);
}
/* R6: once a species has its own art AND has been caught, the cell shows the
   real body sprite instead of the shape above, which is the whole point of
   "the collection reads as 33 different fish". An UNCAUGHT cell keeps the blob
   whatever art exists: the silhouette is the tease, and spoiling it would cost
   more than the art buys. The box keeps the blob's total footprint (40 + 3 + 5
   against 26 + 10 + 12) so a half-landed roster doesn't ripple the grid. */
.cfish.art {
  width: 70px; height: 40px; margin: 3px auto 5px;
  background: center / contain no-repeat;
  border-radius: 0;
}
/* the blob's tail and its drawn-on eye both have to go: a painted fish has
   its own. The eye rule needs the extra class to outrank
   `.cell:not(.unknown) .cfish::before` on specificity rather than on order. */
.cfish.art::after, .cell:not(.unknown) .cfish.art::before { content: none; }
.cname { font-family: var(--display); font-size: 10px; color: var(--ink); min-height: 24px; }
.cell.unknown .cname { color: var(--dim); }
.csub { font-size: 10px; color: var(--dim); margin-top: 2px; }

/* ============ PROFILE PICKER ============ */
#profile-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 6px; }
.profile-cell {
  position: relative; display: flex; flex-direction: column; align-items: center; gap: 4px;
  width: 128px; padding: 16px 8px; border-radius: 12px; cursor: pointer;
  background: rgba(var(--ink-rgb),0.04); border: 1px solid rgba(var(--ink-rgb),0.12);
  color: var(--ink);
}
.profile-cell:hover { border-color: rgba(var(--gold-rgb),0.5); background: rgba(var(--gold-rgb),0.06); }
.profile-cell:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
.pavatar { font-size: 34px; line-height: 1; }
.pname { font-family: var(--display); font-size: 11px; }
.pmeta { font-size: 10px; color: var(--dim); }
.profile-cell.add { color: var(--dim); border-style: dashed; justify-content: center; }
.profile-cell.add .pavatar { color: var(--dim); }
/* The two marks in a cell's corners. They are faintly visible at rest rather
   than revealed on hover: editing your own name is a thing you go looking for,
   and a control nobody can see is a control nobody has. Delete keeps the
   right-hand corner it has always had. */
.pdelete, .pedit {
  position: absolute; top: 5px; font-size: 11px; color: var(--dim); line-height: 1;
  opacity: 0.3; transition: opacity 0.15s;
}
.pedit { left: 7px; }
.pdelete { right: 7px; }
.profile-cell:hover .pdelete, .profile-cell:hover .pedit { opacity: 0.7; }
.pedit:hover { color: var(--gold); opacity: 1; }
.pdelete:hover { color: var(--ember); opacity: 1; }
#profile-new { margin-top: 12px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
#profile-new[hidden] { display: none; }   /* ID specificity beats [hidden]; restore it */
#profile-form-title { margin-bottom: 0; }

/* Starting a player again: the button, and the question it becomes. Both are
   inside the form, so cancelling the form cancels the question with it. */
#profile-reset {
  margin-top: 4px; padding-top: 14px; width: 100%;
  border-top: 1px solid rgba(var(--ink-rgb),0.1);
  display: flex; flex-direction: column; align-items: center; gap: 10px;
}
#profile-reset[hidden], #reset-confirm[hidden], #reset-start[hidden] { display: none; }
#reset-confirm { display: flex; flex-direction: column; align-items: center; gap: 10px; }
#reset-warning { margin-bottom: 0; max-width: 340px; }
.toggle-btn.danger { color: var(--ember); border-color: rgba(var(--ember-rgb),0.45); }
.toggle-btn.danger:hover { color: var(--umber); background: var(--ember); border-color: var(--ember); }
#profile-name {
  font-size: 17px; text-align: center; padding: 9px 14px;
  border-radius: 8px; border: 1px solid rgba(var(--ink-rgb),0.22);
  background: rgba(var(--shadow-rgb),0.28); color: var(--ink); width: 200px;
}
#profile-name:focus-visible { outline: 2px solid var(--gold); outline-offset: 1px; }
/* A hundred animals rather than twelve, so this scrolls instead of pushing the
   buttons off the panel. Wider than it was for the same reason: at 300px the
   roster ran to thirteen rows. */
#avatar-row {
  display: flex; gap: 6px; flex-wrap: wrap; justify-content: center;
  max-width: 420px; max-height: 168px; overflow-y: auto;
  padding: 4px; border-radius: 10px; background: rgba(var(--shadow-rgb),0.18);
}
.avatar-opt {
  font-size: 22px; padding: 4px 6px; border-radius: 8px; cursor: pointer;
  background: transparent; border: 1px solid transparent; line-height: 1;
}
.avatar-opt.sel { border-color: var(--gold); background: rgba(var(--gold-rgb),0.15); }
.avatar-opt:focus-visible { outline: 2px solid var(--gold); outline-offset: 1px; }
.profile-actions { display: flex; gap: 8px; }
#sync-bar {
  margin-top: 16px; padding-top: 12px; border-top: 1px solid rgba(var(--ink-rgb),0.1);
  display: flex; align-items: center; justify-content: center; gap: 12px; flex-wrap: wrap;
}
#sync-status { font-size: 11px; color: var(--dim); }

/* ============ SHOP ============ */
#shop-coins { font-family: var(--display); font-size: 11px; color: var(--dim); margin-left: auto; white-space: nowrap; }
#shop-coins b { color: var(--gold); font-size: 14px; }
/* The row wraps rather than squeezing, because a gate rod's hint ("opens the
   Stream · luck ★★") is twice the length of "luck ★★" and a 320px phone has
   about 270px of row to spend. Wrapping drops the button onto its own line and
   keeps the hint whole; without it the hint broke into a ragged five-line
   column mid-clause. `margin-left: auto` keeps every button on the right edge
   whether or not its row wrapped, so the column of BUY buttons stays a column.
   Measured at 320/360/390/768/1280 on all four tabs. */
.shop-row {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 9px 12px; margin-bottom: 6px; border-radius: 8px;
  background: rgba(var(--ink-rgb),0.04); border: 1px solid rgba(var(--ink-rgb),0.1);
}
.shop-name { font-family: var(--display); font-size: 11px; color: var(--ink); flex: 1; text-align: left; }
.shop-hint { font-size: 11px; color: var(--dim); white-space: nowrap; }
.shop-btn { min-width: 104px; margin-left: auto; }
.shop-btn:disabled { opacity: 0.4; cursor: default; }
.shop-btn.equipped { color: var(--umber); background: var(--moss); border-color: var(--moss); }

@media (prefers-reduced-motion: reduce) {
  #rig, .cloud, .reed, #bobber, #meter-fill.danger, #hull-shadow,
  #bg-far, #bg-water, #bg-fore, .fish-layer { animation: none !important; }
  .finger, #fish { transition: none; }
  #scene.shake, #word.shakeword, #word.pop, #unlock-banner.show, #unlock-banner.prestige, .key.fresh, #fish.hooked, #word .cur.space { animation: none; }
  /* the card still appears and still waits for a keystroke; it just doesn't
     travel to get there. Losing the card would lose the catch's whole payoff. */
  #catch-card.in, #catch-card.out { transition: none; }
  .ripple, .p, .coinfloat { display: none; }
}

/* ── Quick Cast: the timed speed test (always available, outside progression) ── */
#st-best { font-size: 12px; color: var(--dim); margin-bottom: 18px; letter-spacing: 0.04em; }

#st-clock {
  font-family: var(--display); font-size: 30px; color: var(--gold);
  letter-spacing: 0.08em; margin-bottom: 14px;
}
#st-clock.ready { color: var(--foam); }
#st-clock.low   { color: var(--ember); }

#st-word {
  font-size: 34px; letter-spacing: 0.14em; min-height: 48px;
  margin-bottom: 10px; word-break: break-all;
}
#st-word .done { color: var(--moss); }
#st-word .todo { color: var(--ink); }
#st-word .cur  { color: var(--gold); }
/* its own shake: #word's keyframes bake in the translateX(-50%) that centres
   the fixed in-game word, which would fling this one out of the panel */
#st-word.shakeword { animation: stshake 0.16s; }
@keyframes stshake { 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } }

#st-next {
  font-size: 15px; color: var(--dim); letter-spacing: 0.1em;
  min-height: 20px; opacity: 0.75;
}
#st-hint { font-size: 11px; color: var(--dim); margin-top: 14px; opacity: 0.7; }

#st-wpm { font-family: var(--display); font-size: 52px; color: var(--gold); line-height: 1.1; }
#st-wpm-label { font-size: 11px; color: var(--dim); letter-spacing: 0.14em; margin-bottom: 14px; }
#st-detail { font-size: 13px; color: var(--ink); margin-bottom: 10px; }
#st-pb { font-family: var(--display); font-size: 12px; color: var(--moss); margin-bottom: 16px; }
#st-pb[hidden] { display: none; }

@media (prefers-reduced-motion: reduce) {
  #st-word.shakeword { animation: none; }
}
