/* =====================================================
   kid-hub.css — the illustrated hub that replaced the picker,
   and the one place the self-hosted faces are spent.

   Loaded LAST on abby-games.html and bella-games.html, after css/fonts.css
   (the three --font-* tokens), css/style.css (every colour token, .game-area,
   the header shell), css/kid-games.css (the page chrome, --kg-target and the
   --kg-gap floor) and css/kid-motion.css (the easings, the durations and every
   class js/kidgames/motion.js puts on a node).

   This sheet is linked from those two pages and nowhere else, which is why the
   handful of unprefixed selectors below — `body`, `h2`, `.story-title` — are
   safe: they cannot reach the reader or the Arcade. Everything this file
   INTRODUCES is prefixed kg-, per the same rule kid-games.css follows.

   REDUCED MOTION IS NOT HANDLED HERE, ON PURPOSE. css/style.css:559-562
   already flattens every animation-duration and transition-duration globally,
   so the trail's ink lands instantly, the traveling character teleports, and
   every state below is still exactly as visible. A second media block here
   would be a second place for that rule to drift. The one thing a stylesheet
   cannot flatten — an infinite keyframe, which style.css parks at whatever
   100% happens to be — is the idle bob, and js/kidgames/hub.js never adds
   .kg-float at all when a child has asked for less motion.
   ===================================================== */


/* ---------------- The faces ----------------
   Display for headings and game titles, body for everything a child taps or
   reads as UI. --font-literacy is deliberately absent: it belongs to letters
   and words a child is sounding out, which is game content, not chrome.
   Every stack in fonts.css ends in a generic family, so a 404 on a .woff2
   costs the look and nothing else. */
body {
  font-family: var(--font-body, system-ui, sans-serif);
}
h1, h2, h3,
.story-title,
.kg-picker-title,
.kg-hub-spot-name,
.kg-win-title,
.g-title {
  font-family: var(--font-display, ui-rounded, system-ui, sans-serif);
  letter-spacing: .01em;
}

/* Text that only a screen reader gets: the game a spot opens, and whether it
   is finished. Both live INSIDE the button so the accessible name is built
   from its contents and contains the visible caption (WCAG 2.5.3) — an
   aria-label would replace the visible name instead of extending it. */
.kg-sr {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden; white-space: nowrap;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
}


/* ---------------- The stage ----------------
   The hub and the mounted game share one grid cell, so js/kidgames/motion.js's
   transitionScreens() can have both on screen for the 100ms they overlap
   without the page growing to the height of the two of them and snapping back.
   A cross-fade would need neither — and would read as a slideshow. */
.kg-stage-shell {
  position: relative;
  width: 100%;
  max-width: 900px;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  align-items: start;
}
.kg-stage-shell > * {
  grid-column: 1;
  grid-row: 1;
  min-width: 0;
}


/* ---------------- How the leftover height is spent ----------------
   A phone is 2.16:1 and this scene is 1.6:1, so on a 390x844 there is always
   ~180px more page than there is anything to put on it. There are only three
   places that slack can go, and two of them are wrong.

   IT MAY NOT GO INSIDE THE CARD. The first attempt at this gave the card a
   min-height and centred the hub in what it won, which moved the hole rather
   than closing it: ~90px of blank paper between the subtitle and the picture
   and ~110px below the caption. White voids inside a card read as a layout
   that broke; the same pixels outside it read as page background. So the card
   HUGS: no min-height, no flexible child, its height is exactly its contents.

   IT MAY NOT ALL GO UNDER THE CARD EITHER. That was the state before the
   min-height, and a 270px band of bare gradient under a card that stops 70%
   of the way down reads as a page that failed to finish loading.

   SO IT IS SPLIT, ABOVE AND BELOW, and the picture is grown first so there is
   less of it to split. .kg-main is centred (it grew a `flex: 1` in
   kid-games.css to have anything to centre), which puts half the leftover
   above the progress jar and half under the card — two even bands of sky
   around a card that is exactly as tall as what is in it.

   WHY THE PICTURE CANNOT SIMPLY BE TALLER. Every anchor in the manifest is
   normalised against the PICTURE and .kg-hub-scene is object-fit: cover, so a
   frame taller than the image crops its sides and slides every spot off its
   landmark — at 390px even a 1.46 frame walks Bella's paint pool into the
   edge of the screen. The frame keeps the art's own aspect and grows the only
   way it can: sideways, over the card's side padding (below) and, on a
   tablet, up to the full width of the card.

   EVERY RULE HERE ONLY EVER ADDS OR CENTRES. `justify-content` has nothing to
   distribute unless there is free space, so a screen with none — 375x667,
   and both landscapes, which already scroll — is left exactly as it was. */
/* CENTRED, NOT space-evenly. Distributing it evenly measures better — three
   78px bands at 390x844 instead of two of 110 — and looks worse: it floats
   the jar in the middle of the sky with nothing either side of it, and the
   page reads as three unrelated objects rather than a card with its meter on
   top. The band that matters is the one a person notices, not the largest
   number, and one grouped block between two skies is the quieter page. */
.kg-main { justify-content: center; }

/* THE PICTURE TAKES THE CARD'S FULL WIDTH, AND THIS IS ARITHMETIC RATHER THAN
   TASTE. Three targets in a row need 3 x size + 2 x 30px of frame to hold the
   spacing rule. Bella's floor is 80px, so she needs 300px of frame — and
   inside .game-area's own clamp(18px, 5vw, 28px) padding a 360px phone leaves
   only 300px of card, which lands exactly on the boundary and dips under it
   by 390px. It is also the better picture at every size: at 820x1180 a 640px
   scene used to float in the middle of a 780px card with paper all the way
   round it.

   The card's SIDE padding is therefore zero and the three text runs carry
   their own, which is what a negative margin on the frame used to fake. The
   difference matters once the frame hits a cap: a bled frame with a
   max-width is pinned 28px left of where it should be, because the negative
   margins that widened it are still there when the width stops growing.
   `margin: 0 auto` on a plain block centres correctly at every size.

   AND IN LANDSCAPE THE CARD STOPS WHERE THE PICTURE DOES. The frame is capped
   at 560 there to keep the page off the scrollbar (see .kg-hub-frame), and a
   560px scene floating in the middle of an 820px card is the same
   paper-all-the-way-round the tablet used to have, turned on its side.
   Portrait puts .game-area's own 820 back. */
.kg-picker {
  padding-left: 0;
  padding-right: 0;
  max-width: 560px;
}
.kg-picker > h2,
.kg-picker-sub,
.kg-hub-live { padding-left: var(--kg-card-pad); padding-right: var(--kg-card-pad); }


/* ---------------- The hub ----------------
   Both spot sizes are ABOVE their floors (brief §4: Bella 76, Abby 57) and
   both are clamped in vw, never %. A percentage here would resolve against
   the frame's width in `left` and its height in `top`, so the same token
   would mean two different numbers and every spot would sit off its landmark
   by tens of pixels — with nothing on screen to say so. */
body.theme-bella { --kg-hub-spot: clamp(80px, 22vw, 128px); }
body.theme-abby  { --kg-hub-spot: clamp(76px, 21vw, 120px); }

/* The empty div the page ships; js/kidgames/hub.js appends .kg-hub into it. */
.kg-hub-host { width: 100%; }

.kg-hub {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  /* Room for the lowest spot's FOCUS RING, which is drawn 9px outside the
     button and contributes no height of its own. It used to be 22px, from
     when the captions were the thing at risk; they are inside the frame at
     every size measured, and the live line below already sits between the
     frame and this edge. The rest of that 22px was white paper at the bottom
     of the card with nothing in it. */
  padding-bottom: 8px;
}

.kg-hub-frame {
  position: relative;
  width: 100%;
  /* THE LANDSCAPE CAP IS A SCROLL BUDGET, NOT A TASTE. A phone in landscape
     is 390px tall and this column is ~780px whatever we do, so every pixel
     the frame gains there is another pixel of scroll on a page that already
     has some. 560 is the smallest cap that still clears the min-height below
     — at 128px spots that floor is 307px of frame, which needs 491px of width
     at 1.6 — and it buys back more than the progress jar spends, so both
     landscapes come out SHORTER than they were. Portrait, where the height is
     free, widens it below. */
  max-width: 560px;
  margin: 0 auto;
  /* Set from the manifest, then re-set from the loaded image's own natural
     size, so the anchors land on the art whatever it ships at. */
  aspect-ratio: var(--kg-hub-aspect, 1.6);
  /* If aspect-ratio is unsupported the frame would collapse to nothing and
     stack all three spots on the same pixel. */
  min-height: calc(var(--kg-hub-spot) * 2.4);
  border-radius: var(--radius-lg);
}

/* Everything painted is clipped to the frame. The spots are NOT in here: a
   focus ring on an edge spot must not be sliced in half. */
.kg-hub-art {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: inherit;
  box-shadow: var(--shadow-lg), inset 0 0 0 4px rgba(255, 255, 255, .55);
  background: var(--kg-tint, #eee);
}

.kg-hub-scene {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  opacity: 0;
  transition: opacity .35s var(--kg-ease-enter, ease);
}
.kg-hub[data-scene="on"] .kg-hub-scene { opacity: 1; }


/* ---------------- The scene with no art ----------------
   Not a placeholder box and not an error: a painted-enough place, drawn in
   gradients and two clip-paths, that the spots sit on correctly. A child must
   never meet a broken page because an image failed. */
.kg-hub-fallback {
  position: absolute;
  inset: 0;
  transition: opacity .35s var(--kg-ease-enter, ease);
}
.kg-hub[data-scene="on"] .kg-hub-fallback { opacity: 0; }
.kg-hub-fallback::before,
.kg-hub-fallback::after {
  content: "";
  position: absolute;
  inset: 0;
}

/* Abby: a sunset valley — cinematic light, deep saturated ground, one warm
   key. docs/characters.md §8. */
body.theme-abby .kg-hub-fallback {
  background:
    radial-gradient(circle at 20% 34%, rgba(255, 214, 138, .95) 0 6%, rgba(255, 214, 138, 0) 34%),
    linear-gradient(180deg, #6b5aa6 0%, #8a5f9e 20%, #a5709b 34%, #ef9a63 58%, #ffd9a3 74%);
}
body.theme-abby .kg-hub-fallback::before {
  background: linear-gradient(180deg, #4a5b83, #34456b);
  clip-path: polygon(0 74%, 16% 50%, 32% 64%, 52% 36%, 70% 58%, 86% 30%, 100% 50%, 100% 100%, 0 100%);
}
body.theme-abby .kg-hub-fallback::after {
  background: linear-gradient(180deg, #55744f, #2c4433);
  clip-path: polygon(0 86%, 22% 72%, 46% 84%, 72% 68%, 100% 80%, 100% 100%, 0 100%);
}

/* Bella: a high-key pastel meadow — near shadowless, true-white clouds,
   lavender-tinted shade. docs/characters.md §8. */
body.theme-bella .kg-hub-fallback {
  background:
    radial-gradient(circle at 82% 20%, rgba(255, 233, 168, .95) 0 7%, rgba(255, 233, 168, 0) 26%),
    radial-gradient(circle at 24% 22%, #fff 0 5%, rgba(255, 255, 255, 0) 6%),
    radial-gradient(circle at 32% 25%, #fff 0 6.5%, rgba(255, 255, 255, 0) 7.5%),
    radial-gradient(circle at 62% 15%, #fff 0 4.5%, rgba(255, 255, 255, 0) 5.5%),
    linear-gradient(180deg, #d9f0ff 0%, #eaf7ff 48%, #fff6e5 66%);
}
body.theme-bella .kg-hub-fallback::before {
  background: linear-gradient(180deg, #cfe6bd, #b7d9ab);
  clip-path: polygon(0 64%, 26% 54%, 54% 66%, 78% 56%, 100% 66%, 100% 100%, 0 100%);
}
body.theme-bella .kg-hub-fallback::after {
  background: linear-gradient(180deg, #aecfa0, #8fbd88);
  clip-path: polygon(0 82%, 30% 74%, 60% 84%, 86% 74%, 100% 82%, 100% 100%, 0 100%);
}


/* ---------------- The trail ----------------
   preserveAspectRatio="none" maps the 0-100 user space straight onto the
   frame, so a 0.5 anchor is half way across whatever the frame turns out to
   be. vector-effect keeps the stroke from being stretched with it. */
.kg-hub-trail {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}
.kg-hub-track {
  stroke: rgba(255, 255, 255, .72);
  stroke-width: 5;
  stroke-linecap: round;
  stroke-dasharray: 1.4 4;
  filter: drop-shadow(0 1px 2px rgba(53, 49, 94, .45));
}
.kg-hub-ink {
  stroke: var(--accent);
  stroke-width: 8;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0;
  filter: drop-shadow(0 1px 3px rgba(53, 49, 94, .5));
  transition: stroke-dashoffset 900ms var(--kg-ease-emphasis, ease), opacity .3s;
}
/* Belt and braces for the JS reduced-motion path: the ink is already flat
   under style.css:559, and this makes it flat even if that rule is ever
   scoped tighter. Not a media query — a class js/kidgames/hub.js sets. */
.kg-hub-flat .kg-hub-ink { transition: none; }


/* ---------------- The child ----------------
   Positioned with a transform, unlike the spots: she has no width until her
   image loads, so there is no number to subtract. The idle bob therefore
   lives on the inner element — two transforms on one node is one transform. */
.kg-hub-child {
  position: absolute;
  left: clamp(6%, var(--kg-child-x, 20%), 94%);
  top: clamp(8%, var(--kg-child-y, 70%), 92%);
  height: var(--kg-child-h, 36%);
  /* She always leans toward the middle, so she stands BESIDE the spot rather
     than over it, and can never be pushed off the edge of a scene. The offset
     is a fraction of the SPOT, not of her own width: her width is whatever
     her image turned out to be, and half of that is not the distance that
     clears a circle she must not stand in front of. */
  transform: translate(calc(-50% + var(--kg-child-lean, 1) * var(--kg-hub-spot, 80px) * .78), -76%);
  transition: left .55s var(--kg-ease-emphasis, ease), top .55s var(--kg-ease-emphasis, ease);
  pointer-events: none;
  z-index: 3;
}
.kg-hub-child-inner {
  height: 100%;
  display: flex;
  align-items: flex-end;
  justify-content: center;
}
.kg-hub-child-art {
  display: none;
  height: 100%;
  width: auto;
  filter: drop-shadow(0 8px 12px rgba(53, 49, 94, .35));
}
.kg-hub-child-token {
  font-size: clamp(30px, 8vw, 52px);
  line-height: 1;
  filter: drop-shadow(0 6px 10px rgba(53, 49, 94, .4));
}
.kg-hub[data-child="on"] .kg-hub-child-token { display: none; }
.kg-hub[data-child="on"] .kg-hub-child-idle { display: block; }
.kg-hub[data-child="on"][data-cheer="on"][data-mood="cheer"] .kg-hub-child-idle { display: none; }
.kg-hub[data-child="on"][data-cheer="on"][data-mood="cheer"] .kg-hub-child-cheer { display: block; }
/* The celebration that works whether or not a cheer pose ever shipped. It is
   a filter, not a transform: .kg-float owns the transform on this element and
   an animation beats a plain declaration for the same property. */
.kg-hub[data-mood="cheer"] .kg-hub-child-art,
.kg-hub[data-mood="cheer"] .kg-hub-child-token {
  filter: drop-shadow(0 0 14px var(--accent-2)) drop-shadow(0 8px 12px rgba(53, 49, 94, .35));
}


/* ---------------- The spots ----------------
   Real buttons, sized from --kg-hub-spot, positioned by their own left/top so
   nothing here competes with the transforms motion.js animates. The clamp is
   what keeps a target whole: an anchor at 0.02 would otherwise hang half of
   the button outside the frame and push the page sideways. */
/* Above the character: the spots are the interface and she is the scenery.
   Without this she stands in front of the circle she is standing at, and in
   front of its caption. */
.kg-hub-spots {
  position: absolute;
  inset: 0;
  z-index: 4;
}
.kg-hub-spot {
  position: absolute;
  width: var(--kg-hub-spot);
  aspect-ratio: 1;
  left: clamp(0px, calc(var(--kg-spot-x, 50%) - var(--kg-hub-spot) / 2), calc(100% - var(--kg-hub-spot)));
  top: clamp(0px, calc(var(--kg-spot-y, 50%) - var(--kg-hub-spot) / 2), calc(100% - var(--kg-hub-spot)));
  display: grid;
  place-items: center;
  padding: 0;
  background: none;
  border: none;
  color: var(--ink);
}
.kg-hub-spot-dot {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  display: grid;
  place-items: center;
  position: relative;
  background: radial-gradient(circle at 50% 32%, #fff, var(--kg-tint, #fff));
  border: 4px solid #fff;
  box-shadow: 0 10px 22px rgba(53, 49, 94, .38), 0 0 0 3px rgba(255, 255, 255, .5);
  font-size: calc(var(--kg-hub-spot) * .44);
  line-height: 1;
  transition: border-color .2s var(--kg-ease-emphasis, ease), box-shadow .2s var(--kg-ease-emphasis, ease);
}
.kg-hub-spot-emoji { line-height: 1; }

/* ---------------- The painted prop, and the disc it does not need ----------
   A system emoji in a hard white circle on top of a painted valley is two
   drawings from two different worlds, and the circle is what makes the join
   obvious: a flat white disc with a 4px white rim reads as a sticker somebody
   put on the picture. So the disc goes with the emoji. When the manifest's
   icon actually loads, the button says data-icon="on" and the same target
   wears a BLOOM instead — white at the centre, gone by the rim — which lifts
   the prop off whatever it is standing on without drawing an edge around it.

   The size does not change, in either state: .kg-hub-spot-dot is 100% of the
   button and the button is --kg-hub-spot. Only the paint changes.

   CONTRAST. The bloom is still ~90% white under the prop itself, which is the
   part that has to separate from the scene, and the icon carries its own drop
   shadow for the pale half of Bella's beach. What it stops being is a hard
   edge. The `here` and `done` rings below are unaffected: they are drawn on
   the same 4px border, which is transparent here rather than removed. */
.kg-hub-spot-icon {
  display: none;
  width: 80%;
  height: 80%;
  object-fit: contain;
  /* The prop's own shadow, not the disc's. It is what separates a cream puppy
     from cream sand on Bella's beach, where a white bloom has nothing to do. */
  filter: drop-shadow(0 3px 6px rgba(53, 49, 94, .5));
}
.kg-hub-spot[data-icon="on"] .kg-hub-spot-icon { display: block; }
.kg-hub-spot[data-icon="on"] .kg-hub-spot-emoji { display: none; }
.kg-hub-spot[data-icon="on"] .kg-hub-spot-dot {
  background: radial-gradient(circle at 50% 46%,
    rgba(255, 255, 255, .95) 0 38%,
    rgba(255, 255, 255, .74) 58%,
    rgba(255, 255, 255, .3) 78%,
    rgba(255, 255, 255, 0) 93%);
  border-color: transparent;
  /* Soft, offset and far bigger than the circle, so it grounds the prop
     without becoming the rim this rule just took away. */
  box-shadow: 0 10px 20px rgba(53, 49, 94, .32);
}

/* Earned once, kept forever. Two visual states and nothing to read, which is
   the whole progression surface for a pre-reader. */
.kg-hub-spot-token {
  position: absolute;
  top: -6px;
  right: -6px;
  font-size: calc(var(--kg-hub-spot) * .3);
  line-height: 1;
  opacity: 0;
  transform: scale(.6);
  transition: opacity .2s var(--kg-ease-back, ease), transform .2s var(--kg-ease-back, ease);
}
.kg-hub-spot.kg-hub-done .kg-hub-spot-dot {
  border-color: var(--accent);
  box-shadow: 0 10px 22px rgba(53, 49, 94, .38), 0 0 0 3px var(--accent-2);
}
.kg-hub-spot.kg-hub-done .kg-hub-spot-token { opacity: 1; transform: scale(1); }
/* Where she is standing right now. */
.kg-hub-spot.kg-hub-here .kg-hub-spot-dot { border-color: var(--accent-2); }

.kg-hub-spot-name {
  position: absolute;
  top: calc(100% + 6px);
  left: 50%;
  transform: translateX(-50%);
  width: max-content;
  max-width: min(30vw, 132px);
  padding: 3px 10px;
  border-radius: 999px;
  background: rgba(53, 49, 94, .66);
  color: #fff;
  font-size: clamp(.74rem, 3vw, .95rem);
  font-weight: 700;
  line-height: 1.2;
  text-align: center;
  text-shadow: 0 1px 2px rgba(0, 0, 0, .45);
}
/* A spot low in the frame wears its caption above instead, so the label of
   the nearest thing to the bottom edge is never the thing that hangs off it.
   The same for the two ends: a caption centred under the first spot starts
   off the left edge of the picture, so the outer ones hang inward from the
   target instead of being centred on it. */
.kg-hub-spot.kg-hub-spot-up .kg-hub-spot-name {
  top: auto;
  bottom: calc(100% + 6px);
}
.kg-hub-spot.kg-hub-spot-start .kg-hub-spot-name {
  left: 0;
  transform: none;
  text-align: left;
}
.kg-hub-spot.kg-hub-spot-end .kg-hub-spot-name {
  left: auto;
  right: 0;
  transform: none;
  text-align: right;
}

/* 4px, high contrast, offset clear of the circle — kid-games.css:171 already
   covers everything inside #kg-picker; this only rounds it to the target. */
.kg-hub-spot:focus-visible {
  outline: 4px solid var(--accent);
  outline-offset: 5px;
  border-radius: 50%;
}

/* One live region for the whole screen, and it is this one. The tokens meter
   above the hub deliberately does NOT carry aria-live any more: two polite
   regions updating on the same tap is the same sentence twice. */
.kg-hub-live {
  min-height: 1.2em;
  margin: 0;
  width: 100%;
  color: var(--ink-soft);
  font-size: .95rem;
  font-weight: 700;
  text-align: center;
}


/* ---------------- Breakpoints ----------------
   Only the ones style.css and kid-games.css already use. */
@media (hover: hover) {
  .kg-hub-spot:hover .kg-hub-spot-dot { border-color: var(--accent); }
}

@media (max-width: 560px) {
  .kg-hub-live { font-size: .88rem; }

  /* FOUR SPOTS, AND A PHONE'S FRAME IS 212px TALL.
     Four targets cannot sit in one row here — 4 x 80 + 3 x 30 is 410px of
     frame and a 360px phone has 336 — so on both scenes two of them share a
     column, and the two captions between them have to live inside whatever
     the 30px tap gap leaves. Measured at 360: 49px of clear frame, against
     the 52px that a 6px stand-off and 3px of padding need. The pills touched.
     4 and 2 is what fits, with room over, from 360 up — which is where the
     30px tap gap holds at all, three spots or four. Above 560 the frame is
     tall enough that the roomier numbers stay.

     This changes the LABEL and never the target: --kg-hub-spot, the 30px gap
     and the focus ring are all untouched, and shrinking a target to make a
     caption fit would be the wrong trade in the other direction. */
  .kg-hub-spot-name { top: calc(100% + 4px); padding-block: 2px; }
  .kg-hub-spot.kg-hub-spot-up .kg-hub-spot-name { bottom: calc(100% + 4px); }

  /* ONE NAME AT A TIME, AND THE PAINTING BACK.
     The rule above made four captions FIT. Fitting was never the whole
     problem: four 82px discs and four dark pills cover most of a 366x231
     scene, so a phone showed a diagram of a map rather than the map. The
     spots are the product rule and cannot shrink; the labels are not, and
     three of the four are answering a question nobody asked.

     So under 560 a place says its name when it is worth saying: where she is
     STANDING (the caption follows her, which is what tells a reader the
     figure and the pill are one fact), and wherever the keyboard IS. Every
     other spot is its painted prop, which is the cue that was doing the work
     anyway — Bella cannot read hers, and Abby's four props are four
     different objects before they are four different words. Above 560 the
     frame is big enough that all four fit without crowding, and nothing here
     applies.

     THIS IS A VISUAL DECLUTTER AND NOTHING ELSE. The caption is hidden the
     way .kg-sr hides the game title and "Finished." — clipped to a pixel,
     never `display: none` and never `visibility: hidden`, both of which
     would take the words out of the accessible name and cost a screen
     reader the place. The name a button computes is byte-for-byte the same
     at 360 as at 820; test/hub.test.js pins that, and the browser pass
     re-reads it through the real accname algorithm at every width.

     A browser that has never heard of :focus-visible drops this whole rule
     as unparseable and shows all four captions — the behaviour of five
     minutes ago, which is a fallback and not a fault. */
  .kg-hub-spot:not(.kg-hub-here):not(:focus-visible) .kg-hub-spot-name {
    position: absolute;
    width: 1px; height: 1px;
    margin: -1px; padding: 0; border: 0;
    overflow: hidden; white-space: nowrap;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    background: none;
  }
}

@media (min-width: 720px) {
  .kg-hub { gap: 14px; }
  /* 132 is a phone number: it is what keeps two captions off each other when
     the spots are 47px apart. On a tablet they are 138px apart and the same
     cap wraps "The puppy party" onto two lines for no reason. 190 still
     leaves 76px between two captions centred on adjacent spots at the
     narrowest tablet this rule applies to. */
  .kg-hub-spot-name { max-width: min(30vw, 190px); }
}

/* Portrait is the one orientation where widening the frame cannot cost a
   scrollbar — there is always more page than column — so the cap comes off
   here and only here. 780 is .kg-main's own content width at the 820px
   tablet, which is the widest card this layout ever draws; above that the
   card is the limit, not this. A phone never reaches either number: at 390
   the card is 366px wide, so portrait phones are governed by the card and
   this rule changes nothing below ~600px. */
@media (orientation: portrait) {
  .kg-hub-frame { max-width: 780px; }
  .kg-picker { max-width: 820px; }

  /* AND THE REST OF THE LEFTOVER IS SPENT ON THE THINGS THAT ARE ALREADY
     THERE, rather than on more sky. Portrait is the only orientation with
     height to give away — a landscape phone and a 1024x768 tablet are both
     within a few pixels of scrolling — so every one of these is scoped here
     and none of them can cost a scrollbar. dvh, not a breakpoint: each moves
     continuously with the screen, so there is no width or height at which the
     page jumps. A 375x667, which has nothing spare, lands on the floor of
     every clamp below and comes out as it was. */
  /* A taller card gets a taller rhythm — the alternative is the same rhythm
     with more sky around it, which is the hole again, one step further out. */
  .kg-picker {
    gap: clamp(14px, 3dvh, 34px);
    padding-top: clamp(18px, 3.4dvh, 44px);
    padding-bottom: clamp(18px, 3.4dvh, 44px);
  }
  .kg-hub { gap: clamp(10px, 1.8dvh, 24px); }
  .kg-picker-title { font-size: clamp(1.4rem, min(7.4vw, 4.2dvh), 2.5rem); }
  .kg-picker-sub { font-size: clamp(.95rem, min(4.2vw, 1.85dvh), 1.2rem); }
  /* The compact height kid-games.css sets while a game is mounted still wins
     over this: two classes beat one, and a media query adds no specificity. */
  .kg-vessel-svg { height: clamp(48px, 12dvh, 130px); }
}
