/* ==========================================================================
   AQUAKids Swim School — design system  ·  SUNLIT SHALLOWS
   Smithfield NSW  ·  home page only, prototype

   Third colour direction, forked from main.css rather than from main-deep.css,
   and the reason determined everything else: this version puts DARK ink on
   BRIGHT water. Deep Water put light ink on dark water. Those are opposite
   architectures, and main.css already had the right one — its ink ladder, its
   white-card-with-tinted-head level cards, its dark-on-pale hierarchy. Starting
   from the dark build would have meant inverting all of it back and re-deriving
   every pair. This is a re-ground of version one, not a dimming of version two.

   ---------------------------------------------------------------------------
   WHY DARK INK. Not a preference — the brief's own hero gradient decided it:

     radial-gradient(130% 95% at 12% -10%, #9fd8df 0%, transparent 58%)

   That pale pool of light sits at 12% -10%, which is exactly where the headline
   sits. Against #9fd8df, white text measures 1.57:1 and dark ink measures
   10.62:1. There was never a choice. The lighting design dictated the type
   colour, and once the hero is dark-on-light the rest of the page has to follow
   or the nav flips ink colour on scroll.

   ---------------------------------------------------------------------------
   THE DEAD ZONE. Between relative luminance 0.183 and 0.233, neither white nor
   dark ink clears 4.5:1 — white needs a ground at or below 0.183, dark ink
   needs 0.233 or above, and between them is a band where body copy is simply
   not possible. Mid-turquoise lands near it, so the ladder below steps over it
   deliberately: --water at 0.363, --water-mid at 0.234 (just clear, dark ink),
   --trench at 0.170 (just clear, white ink). --water-shade at 0.198 is INSIDE
   the zone and is restricted to atmosphere layers. It carries no text anywhere.
   The brief happened to place it at 92% 8%, top right, behind the photo — the
   one spot on the page where that restriction costs nothing.

   For large text (3:1) there is no dead zone; the ranges overlap from 0.139 to
   0.300. Headlines have freedom the body copy does not.

   ---------------------------------------------------------------------------
   THE PIVOT LEVELS. Three of the seven level colours ARE this water. CIE76
   ΔE against --water #2bb0d4: swordfish 17.4, shark 19.2, dolphin 27.9,
   against 68–111 for the four warm levels. Same hue as the ground, so
   lightness alone cannot separate them — swept across the usable range, shark
   never clears ΔE 38 against any aqua ground, including near-white (24.4 at
   #eaf7f9). Deep Water hit the mirror image of this with the same two levels.
   They are the pivot levels in any teal scheme: whichever way the water moves,
   their value has to be pushed the other way.

   The resolution is structural, not chromatic, and it is version one's own: the
   cards are WHITE objects sitting on the water, not tinted grounds cut into it.
   A white card separates from #2bb0d4 by a margin no level colour could, the
   pale tinted head lets the artwork read, and the saturated level colour appears
   on the badge and rule where it sits on white. Nothing on this page asks a
   saturated mascot colour to hold its own against water of the same hue.

   Palette is still lifted from the AQUAKids logo, and the seven level colours
   are byte-identical to both other builds. Only grounds and inks moved.
   ========================================================================== */

/* --------------------------------------------------------------- 0. Fonts */
/* Self-hosted. These used to come from fonts.googleapis.com, which cost two
   extra hosts on the critical path — a DNS lookup, TLS handshake and round trip
   for the stylesheet on googleapis, then the whole lot again on gstatic before a
   single glyph could start downloading. On a good desktop connection that is
   invisible; on 4G in Smithfield it is most of the reason text takes a moment to
   settle. Now the fonts resolve from the same origin and the same already-open
   connection as this stylesheet.

   These are the *variable* builds, one file per subset spanning weight 400-800,
   rather than the eight fixed-weight files the old link requested. The page uses
   400, 600, 700 and 800; served as statics that was ~283KB of latin woff2, and
   as two variable files it is ~71KB for the same four weights plus every weight
   in between, should the scale ever want a 550.

   Subsets kept: latin, latin-ext and vietnamese. The first two are the obvious
   ones. Vietnamese is here on purpose and is not padding — Fairfield LGA has one
   of the largest Vietnamese-Australian populations in the country, and this is a
   site whose testimonials and enquiry form will carry parents' real names. A
   name that renders in a fallback face while everything around it is Nunito is
   a small, specific way of telling that parent the site was not built for them.
   Google's own unicode-range declarations are preserved exactly as served, so a
   visitor only ever downloads the subsets their content actually needs.

   font-display: swap throughout — text paints immediately in the fallback and
   reflows when the webfont lands. For a page whose headline is the first thing
   on screen, invisible text is the worse failure.

   To regenerate: see the fonts note in README.md. */

/* vietnamese */
@font-face {
  font-family: 'Baloo 2';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url(../fonts/baloo2-vietnamese.woff2) format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
  font-family: 'Baloo 2';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url(../fonts/baloo2-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'Baloo 2';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url(../fonts/baloo2-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
  font-family: 'Nunito';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url(../fonts/nunito-vietnamese.woff2) format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
  font-family: 'Nunito';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url(../fonts/nunito-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
  font-family: 'Nunito';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url(../fonts/nunito-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* --------------------------------------------------------------- 1. Tokens */
:root {
  /* Brand — sampled from the logo artwork */
  --purple: #8e2d8e;
  --orange: #f2871f;
  --magenta: #ec0f8c;
  --green: #7dbe3c;
  --teal: #29b8c6;
  --yellow: #ffc629;
  --dolphin: #3baee0;
  --pink: #f5539f;

  /* Darkened variants — used only where white text sits on the colour, so
     that every text/background pair clears WCAG AA 4.5:1. */
  --purple-ink: #6f2270;
  --magenta-ink: #b8046d;
  /* #4e7d1f measured 4.32:1 against the seal level's own age badge — the badge
     ground is color-mix(--lv 17%, #fff) and green is the level whose 17% tint is
     lightest, so it is the one pair in the set that fails. Version one ships the
     same 4.32; this is not a consequence of re-grounding. Darkened the minimum
     distance to clear it at 4.73. */
  --green-ink: #4a761c;
  /* Nudged down from #10707b so the eyebrow still clears 4.5:1 on the deepest
     section ground (pool-200), where the old value sat on exactly 4.51. */
  --teal-ink: #0e6570;
  --dolphin-ink: #1b6f97;
  --orange-ink: #a35208;

  /* Neutrals — shifted from version one's navy into teal. #123049 was a blue
     ink on a blue-white page and it was right there; on saturated turquoise it
     reads as a borrowed colour, faintly violet against its ground. Same three
     tiers, hue pulled to the water's own ~196deg, so the type belongs to the
     palette rather than sitting on top of it.

     Measured, white / --pool-100 / --pool-200 / --water / hero floor:
       --ink        17.35  15.85  13.74  6.83  4.69
       --ink-soft    7.53   6.88   5.96  2.96  (never used on water)
       --ink-faint   6.16   5.63   4.88  2.13  (never used on water)

     --ink is #051d26 rather than the #0e2c38 this started as, and the hero floor
     column is why. The first pass shifted version one's navy to teal by hue and
     kept roughly its lightness, which measured 3.96:1 against the bottom of the
     brief's hero gradient — a fail, on the largest block of body copy on the
     page. Dark-on-bright needs the ink to clear the DARKEST ground it will ever
     sit on, and here that is #2791a5 at relative luminance 0.234, which caps the
     ink at 0.0131. There is no room to be tasteful about it. The brief caps how
     dark the grounds may go; it says nothing about the ink, so the ink is where
     the contrast had to come from.
     --ink-faint was #4a6f7f until it measured 4.29 on --pool-200. The palest
     ground a faint tier has to survive is not white, it is the tinted band —
     that is where it fails first, and where version one had already been caught
     out once for the same reason (see the note this replaces). */
  --ink: #051d26;
  --ink-soft: #34596b;
  --ink-faint: #446676;
  --white: #ffffff;

  /* Pool tints — re-grounded. Every existing usage in this file follows these
     four tokens, so the whole canvas moves by editing them rather than by
     hunting rules. pool-100 and pool-200 are the two pale reading bands,
     pool-50 is the near-white wash, pool-300 is decorative only. */
  --pool-50:  #f2fafb;   /* relLum 0.939 */
  --pool-100: #eaf7f9;   /* relLum 0.909  — SURF band */
  --pool-200: #cdeaf0;   /* relLum 0.781  — SHALLOW band */
  --pool-300: #8ed4e2;   /* decorative; too light for body copy either way */

  /* ---------------------------------------------------------------- The sky
     The Our Programs ground, and the mid-blue this palette was missing. The
     ladder ran --pool-200 #cdeaf0 (relLum 0.781) straight to --water #2bb0d4
     (0.363) with nothing in between, so every section was either almost-white
     or fully saturated. --sky sits at 0.582, which is where a swimming pool
     actually looks like a swimming pool.

     Programs was --water and the four mascot-less cards were disappearing into
     it: measured as CIELAB ΔE (under ~10 reads as the same colour at a glance),
     --teal against #2bb0d4 is 13.8. On --sky it is 20.7. The one colour --sky
     is close to is shark #8fd4e8 at ΔE 3.8, and that is fixed on the card
     rather than on the band — see the vignette note at .lvl__art, which gives
     every panel its own edge and stops any of them depending on the ground
     being pale.

     --teal-ink measures 4.06:1 here and fails, so anything on --sky that would
     have used it takes --sky-ink instead (4.67 on --sky, and still 7.76 on
     white, so it is safe to inherit into a card). */
  --sky:     #98d2e1;   /* relLum 0.582 — PROGRAMS. ink 10.45, ink-soft 4.54 */
  --sky-ink: #0d5b66;

  /* ------------------------------------------------------------- The water
     Four of these five are the brief's own hero stops, unaltered. --trench is
     the one value added, and the one place this file goes below the stated
     floor: #288698 measures white at 4.24:1, which fails, and the closing
     section needs white body copy. #267e8f is 2.1 lightness points deeper and
     clears it at 4.70:1. Side by side the two are indistinguishable; one of
     them is legible. */
  --water:       #2bb0d4;  /* relLum 0.363 — signature band. dark ink 5.75 */
  /* One clean step up from --water, not a new hue — a 30% mix toward
     --water-lit. Exists solely so Getting Started's floor and Parent Reviews
     below it read as two connected but distinct bodies of water instead of
     one continuous fill; see the note at .steps-water__bot. */
  --water-shallow: #4ebcd7; /* relLum 0.425 — Getting Started floor. dark ink 7.85 */
  --water-lit:   #9fd8df;  /* relLum 0.618 — hero light pool. dark ink 10.62 */
  --water-mid:   #2791a5;  /* relLum 0.234 — hero floor. dark ink 4.69, tight */
  --water-shade: #288698;  /* relLum 0.198 — DEAD ZONE. atmosphere only */
  --trench:      #267e8f;  /* relLum 0.170 — closing surface. white 4.70 */

  /* -------------------------------------------------------- Below the floor
     The closing section used to START and END on --trench, and the footer sat
     on --trench as well: three adjacent blocks at one value, joined by a wave
     that had no tonal work to do. That is what made the base of the page read
     as a single flat slab — the CTA and the footer were the same body of water
     with a decorative squiggle drawn across it.

     These two continue the ladder instead of stopping it. They are not new
     colours: --trench is hsl(190, 58%, 35.5%) and these are the same hue and
     the same saturation at 28.5% and 21% lightness, so the descent is three
     even steps down ONE column of the palette rather than a drift into navy.

       --trench       relLum 0.170   white 4.70   closing surface
       --trench-deep  relLum 0.110   white 6.55   closing floor
       --abyss        relLum 0.058   white 9.68   the footer

     Going DOWN was always available — the legibility note at .deep explains
     there is no room to move the closing section UP (white needs 0.183 or
     below and the floor is already 0.170), and that argument was quietly
     applied in both directions. Downward there is a full range, every step of
     it safer for white type than the value above it. The brief's floor caps
     how dark the WATER may go; the footer is not water, it is the page's
     furniture, and it is the one block on the site that carries no photography,
     no cards and no colour — so it can take the depth the bands cannot.

     What this buys, beyond the divide: at 9.68:1 the footer gets a THREE-tier
     ink ladder back (see section 14). On --trench only pure white cleared, so
     every word in the footer — headings, links, address, copyright — was the
     same colour and the same weight of voice. */
  --trench-deep: #1e6673;  /* relLum 0.110 — closing floor.  white 6.55 */
  --abyss:       #164b55;  /* relLum 0.058 — the footer.     white 9.68 */

  /* Semantic */
  /* The page base is the SURF band, not white. A plain `.section` with no tint
     modifier has no background of its own and falls through to the body, so on
     this build "no modifier" has to mean the palest water rather than paper —
     otherwise the Why section renders white, the pale wave divider above it
     (filled --pool-100) becomes invisible against it, and the programs band
     appears to simply stop, which is exactly how it looked on the first pass.

     Cards stay white via --surface, so the white-object-on-water logic that the
     level cards depend on is unaffected.

     The one section with no tint modifier is Why Parents Choose Us, so this
     token has exactly one visible consumer: it is that section's ground. */
  --bg: var(--pool-100);
  --bg-alt: var(--pool-50);
  --surface: var(--white);
  --border: #d8ecf5;
  --focus: #0b6ea8;

  /* Type */
  --font-display: "Baloo 2", "Trebuchet MS", system-ui, sans-serif;
  --font-body: "Nunito", system-ui, -apple-system, "Segoe UI", sans-serif;

  /* Spacing — spacious scale, this is a marketing site not a dashboard */
  --space-2xs: 0.5rem;
  --space-xs: 0.75rem;
  --space-sm: 1.25rem;
  --space-md: 2rem;
  --space-lg: 3rem;
  --space-xl: 4.5rem;
  --space-2xl: 6rem;

  /* Radii — chunky, matches the logo's rounded lettering */
  --r-sm: 12px;
  --r-md: 20px;
  --r-lg: 28px;
  --r-xl: 40px;
  --r-pill: 999px;

  /* Claymorphic shadows: a soft ambient layer + a tight contact layer */
  --shadow-sm: 0 2px 6px rgba(18, 48, 73, 0.06), 0 8px 20px rgba(18, 48, 73, 0.05);
  --shadow-md: 0 4px 10px rgba(18, 48, 73, 0.07), 0 16px 36px rgba(18, 48, 73, 0.08);
  --shadow-lg: 0 8px 18px rgba(18, 48, 73, 0.08), 0 28px 60px rgba(18, 48, 73, 0.1);
  /* One step past --shadow-lg, for the hover pop below: a card moving toward
     the viewer casts a bigger, softer shadow than one just lifting in place. */
  --shadow-xl: 0 16px 30px rgba(18, 48, 73, 0.11), 0 42px 84px rgba(18, 48, 73, 0.14);
  --shadow-clay: inset 0 -4px 0 rgba(18, 48, 73, 0.08), 0 6px 16px rgba(18, 48, 73, 0.1);

  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-back: cubic-bezier(0.34, 1.56, 0.64, 1);
  /* Harder overshoot than --ease-back, for the one gesture on the page that
     should read as a physical bounce rather than a settle. */
  --ease-bounce: cubic-bezier(0.16, 1.9, 0.3, 1);

  --container: 1180px;
  /* Page gutter. Fluid, because a fixed 20px was fine at 1180 (where
     .container's own max-width supplies the rest of the margin) and cramped at
     1200–1360, where .container--wide has stopped being centred by its
     max-width and the 20px *is* the entire white space. The wide hero and nav
     were running 20px from the window edge while every ordinary section below
     them sat 60px+ in, which is what reads as "the sides get tight when I
     resize". Scales to 52px and stops. */
  --gutter: clamp(1.25rem, 0.55rem + 2.4vw, 3.25rem);
  --nav-h: 92px;
  /* Height of a wave divider. Shared, because the deep end has to reserve
     exactly this much top padding for the surface it now carries. */
  --wave-h: clamp(48px, 7vw, 96px);

  /* ------------------------------------------------------------------------
     Level identity colours.

     Sampled from the mascot artwork itself, so a level's colour and its
     character are literally the same colour. The vivid value is for fills,
     rules and mascot discs; the -ink value is the darkened text-safe pair
     (all clear 4.5:1 on white — seal is the tightest at 4.91:1).

     House rule: these seven colours only ever appear when they are naming a
     level, and only ever alongside that level's mascot. Colour is identity
     here, never decoration — that is what keeps a rainbow logo from turning
     into a rainbow website.
     ------------------------------------------------------------------------ */
  /* #c9186f landed on exactly 4.50:1 against its age-chip tint — passing, but
     with nothing in hand against renderer rounding. #b8046d gives 5.28:1. */
  --lv-seahorse: #f5539f;   --lv-seahorse-ink: #b8046d;
  --lv-starfish: #f2871f;   --lv-starfish-ink: #a35208;
  /* Seal's ink is a touch darker than the old --green-ink: at #4e7d1f it only
     reached 4.32:1 on its own age-chip tint. #456f1b clears it at 5.21:1. */
  --lv-seal: #7dbe3c;       --lv-seal-ink: #456f1b;
  --lv-stingray: #7b4ba8;   --lv-stingray-ink: #5f338c;
  --lv-dolphin: #3fbfb4;    --lv-dolphin-ink: #10707b;
  --lv-shark: #8fd4e8;      --lv-shark-ink: #1b6f97;
  --lv-swordfish: #29a8e8;  --lv-swordfish-ink: #12678f;
}

/* ----------------------------------------------------------- 2. Reset/base */
*,
*::before,
*::after { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--nav-h) + 16px);
  /* The actual fix for the homepage-only nav/hamburger shift: <html>, not
     <body>, is the root scroller here, so body's own overflow-x:hidden below
     never contained anything — a fixed element sizes against <html>'s
     scrollable width, not body's. The home hero is the one section on the
     site with bleeding decorative layers (the frame's caustic overlay, the
     mascot pair), each already clipped by its own ancestor's overflow:hidden,
     but the last few subpixels of that clipping were still enough to widen
     <html>'s scrollable area past the viewport — which is what a fixed nav
     then sized itself against. Every other page has nothing that bleeds, so
     nothing else showed it. */
  overflow-x: hidden;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink-soft);
  font-family: var(--font-body);
  font-size: 1.0625rem;
  line-height: 1.65;
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

img,
picture,
svg,
video { display: block; max-width: 100%; height: auto; }

button,
input,
select,
textarea { font: inherit; color: inherit; }

a { color: var(--dolphin-ink); text-decoration-thickness: 2px; text-underline-offset: 3px; }
a:hover { color: var(--magenta-ink); }

/* One visible, consistent focus ring everywhere. Never removed. */
:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
  border-radius: 6px;
}

.skip-link {
  position: absolute;
  left: 50%;
  top: 0;
  transform: translate(-50%, -120%);
  z-index: 200;
  padding: 0.75rem 1.5rem;
  background: var(--ink);
  color: var(--white);
  border-radius: 0 0 var(--r-md) var(--r-md);
  font-weight: 700;
  text-decoration: none;
  transition: transform 0.2s var(--ease-out);
}
.skip-link:focus { transform: translate(-50%, 0); color: var(--white); }

.visually-hidden {
  position: absolute !important;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* ------------------------------------------------------------ 3. Typography */
h1, h2, h3, h4, .display {
  font-family: var(--font-display);
  color: var(--ink);
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: -0.01em;
  margin: 0 0 var(--space-sm);
  text-wrap: balance;
}

/* The scale is built to hold its *ratios* down to 360px, not just its sizes.
   The previous version didn't: every heading clamped to a floor, and because the
   floors were set independently they converged. At 375px an h1 landed on 33.6px
   against an h2 of 30.4px — a ratio of 1.11 where the desktop end of the same
   scale runs 1.55. A headline that barely outranks a section head is the phone
   version of a flat wall of content, and it is the specific way a responsive
   type ramp fails silently: nothing overflows, nothing overlaps, the page just
   stops having a hierarchy at the size most people read it.

   Each step now uses a `rem + vw` middle term instead of a bare vw. A bare vw
   term is worth almost nothing on a phone (4.2vw is 15.75px at 375), so the
   floor was doing all the work and the floor is exactly what doesn't scale
   relative to anything else. Mixing in a rem base keeps the middle term live at
   every width, so the floors are now a genuine last resort rather than the
   default state on mobile. Desktop values are unchanged — every cap is the same
   number it was. */
h1, .h1 { font-size: clamp(2.35rem, 1.15rem + 4.6vw, 4.1rem); }
h2, .h2 { font-size: clamp(1.6rem, 1.1rem + 2.4vw, 2.9rem); }
h3, .h3 { font-size: clamp(1.18rem, 0.98rem + 0.95vw, 1.6rem); }
h4, .h4 { font-size: 1.125rem; }

p { margin: 0 0 var(--space-sm); text-wrap: pretty; }
p:last-child { margin-bottom: 0; }

.lead {
  /* Floor raised from 1.075rem. Body copy is 17px, so the old floor made a lead
     paragraph 17.2px on a phone — the same size as the text it is supposed to
     be introducing. It now sits ~19.4px at 375px, a real 1.14x step. */
  font-size: clamp(1.2rem, 1.05rem + 0.7vw, 1.3rem);
  line-height: 1.6;
  color: var(--ink-soft);
}

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.875rem;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--teal-ink);
  margin-bottom: var(--space-xs);
}
.eyebrow::before {
  content: "";
  width: 26px;
  height: 4px;
  border-radius: var(--r-pill);
  background: currentColor;
}

.text-center { text-align: center; }
.measure { max-width: 62ch; }
.measure-center { max-width: 62ch; margin-inline: auto; }

/* --------------------------------------------------------------- 4. Layout */
.container {
  width: 100%;
  max-width: var(--container);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
.container--wide { max-width: 1360px; }
.container--narrow { max-width: 820px; }

.section { padding-block: var(--space-xl); position: relative; }
.section--tight { padding-block: var(--space-lg); }
/* ----------------------------------------------------------------- The bands
   Every band is water. The page is a pool, so the palette is the depth of the
   pool and nothing else — the only variable is how deep you are.

     hero      #2bb0d4 → #2791a5   the brief's gradient, verbatim
     programs  --sky      0.582    the ten mascot cards
     why       --pool-100 0.909    densest body copy on the page
     facility  --pool-200 0.781    photo + checklist
     steps     --pool-100 0.909    with its own water body inside it
     reviews   --water    0.363    deep-teal quote cards
     faq       --pool-200 0.781    accordion
     close     --water-mid → --trench, the floor

   Programs was --water and is now --sky. Two things were wrong with a saturated
   ground under the mascot wall. The four cards with no mascot of their own use
   --teal, which is ΔE 13.8 from #2bb0d4 — Aquababes, Aquatots, Pufferfish and
   No Limits were dissolving into the band. And --water forced the ink ladder to
   collapse to a single tier, so the one section that most needs hierarchy had
   the least of it. --sky fixes both: --teal separates at 20.7, and the full
   three-tier ladder comes back at 10.45 / 4.54 / 4.67.

   It also puts a real mid-tone into a ladder that didn't have one. --pool-200
   to --water was a jump from relLum 0.781 to 0.363 with nothing between, which
   is why every band read as either almost-white or fully saturated. --sky at
   0.582 lands in the middle, and the descent Programs → Why → Facility → Steps
   → Reviews now has a step where it used to have a cliff.

   Saturation is still on the page — hero, Parent Reviews and the deep end are
   all --water or below, and Reviews is no longer a duplicate of Programs, so
   the loudest blue now appears in three places that each mean something rather
   than four where one was an accident. */
.section--alt { background: var(--pool-200); }
.section--pool {
  background: var(--grain) repeat, var(--surface-glow), var(--sky);
  /* --teal-ink is 4.06:1 on --sky and fails; --ink-faint is 3.71 and fails
     harder. Redefining the tokens rather than restyling the eyebrow, the filter
     label and the group sub-head means every descendant follows automatically.
     Cards restore the full ladder below, because they bring their own white. */
  --teal-ink: var(--sky-ink);
  --ink-faint: var(--ink-soft);
}
.section--pool .lvl,
.section--pool .card,
.section--pool .finder,
.section--pool .reach,
.section--pool .lvl-filter__chip {
  --ink-faint: #446676;
  --teal-ink: #0e6570;
}

.section-head { max-width: 66ch; margin-bottom: var(--space-lg); }
.section-head--center { margin-inline: auto; text-align: center; }

/* Mobile-first: breakpoints MUST stay in ascending order. Equal-specificity
   rules are resolved by source order, so a min-width:640px block placed after
   a min-width:900px block silently wins on desktop. */
.grid { display: grid; gap: var(--space-sm); }

@media (min-width: 640px) {
  .grid--2 { grid-template-columns: repeat(2, 1fr); }
  .grid--3 { grid-template-columns: repeat(2, 1fr); }
  .grid--4 { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 900px) {
  .grid--3 { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1024px) {
  .grid--4 { grid-template-columns: repeat(4, 1fr); }
}

.split {
  display: grid;
  gap: var(--space-lg);
  align-items: center;
}
@media (min-width: 900px) {
  .split { grid-template-columns: 1fr 1fr; gap: var(--space-xl); }
  /* Centring is right when the two columns are copy and a photo of similar
     height. It is wrong when one of them is a five-step form: the card runs
     twice the height of the column beside it, and centring leaves that column
     floating in the middle of the band with air above and below it, attached to
     nothing. These two align at the top, so both headings start on the same
     line and the section reads as two columns rather than two objects. */
  .split--top { align-items: start; }
  /* The enquiry section, and the one .split on the site whose two columns are
     not the same shape. Source order is intro -> FORM -> aside, because that is
     the order a phone needs and a phone has no layout but order. Desktop puts
     the two supporting blocks back in one column and lets the form span beside
     them, which is the arrangement the section was designed in.

     Explicit line numbers rather than auto-placement: auto-flow would put the
     form in column 2 row 1 and then drop the aside into column 1 row 2 by
     itself, which is nearly right and leaves the aside's placement dependent on
     the form's height rather than stated. Naming all three means the layout is
     the same whatever the form does. */
  /* Row gap and column gap are not the same distance here, and they should not
     be. --space-xl between the columns is what keeps the copy off the form
     card. The same 72px BETWEEN the intro and the aside was measuring a gap
     between two things that belong to each other — the heading, the
     reassurance and the two direct channels are one column of supporting
     material, and at 72px they read as two unrelated blocks with the second
     one floating. At --space-md the column reads as one piece and its bottom
     edge lands within 14px of the card's, so the section closes level. */
  .split--form { grid-template-rows: auto 1fr; align-items: start; row-gap: var(--space-md); }
  .split--form .contact__intro { grid-column: 1; grid-row: 1; }
  .split--form .contact__aside { grid-column: 1; grid-row: 2; }
  .split--form .contact__form  { grid-column: 2; grid-row: 1 / span 2; }

  .split--media-first > :first-child { order: -1; }
  /* Sends the LAST child (the media column, in every .split on the site) to the
     left on desktop only. .split--media-first above cannot do this: it puts
     :first-child at order -1, which is where :first-child already is, so it is a
     no-op on markup written copy-first — which all of ours is.

     Desktop only, and only ever applied to a column whose contents are
     decorative and aria-hidden, so the visual order never diverges from the
     reading order for anything a screen reader or a keyboard will reach. On a
     phone the columns stack and the copy stays first, which is the order that
     matters on the device where order is the only layout there is. */
  .split--flip > :last-child { order: -1; }

  /* Media-dominant split. Every other .split on the site pairs copy against a
     photo of roughly the same height, and 1fr 1fr is right for those. The
     founder section is the one case where it isn't: a 16:9 photograph in half a
     container is ~512px wide sitting beside a column running three times its
     height, and the eye reads the smaller object as subordinate no matter how
     good it is. Ratio alone doesn't fix that — the copy has to come down to
     meet it, which is why the section splits its text across two rows and only
     the short intro sits in this one. At 0.82/1.18 the photo takes ~700px and
     the two columns land within a few pixels of each other in height, which is
     the condition for a landscape image holding its own beside type. */
  .split--media-lead { grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr); }
}

/* On a phone the three blocks stack in source order — intro, form, aside — and
   the gap between them is .split's own var(--space-lg). The aside wants a little
   more than that: it changes subject twice (a reassurance, then two ways to
   reach a person that are not this form), and at the same gap as the heading
   above the form it reads as a fourth step of the form rather than as what
   follows it. */
@media (max-width: 899px) {
  .split--form .contact__aside { margin-top: var(--space-md); }
}

.stack > * + * { margin-top: var(--space-sm); }
.cluster {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs);
  align-items: center;
}

/* -------------------------------------------------------------- 5. Buttons */
.btn {
  --btn-bg: var(--orange);
  --btn-fg: var(--ink);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  min-height: 52px;              /* comfortably over the 44px touch target */
  padding: 0.85rem 1.75rem;
  border: 0;
  border-radius: var(--r-pill);
  background: var(--btn-bg);
  color: var(--btn-fg);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.0625rem;
  line-height: 1;
  text-decoration: none;
  cursor: pointer;
  box-shadow: var(--shadow-clay);
  transition: transform 0.2s var(--ease-back), box-shadow 0.2s var(--ease-out),
              background-color 0.2s var(--ease-out);
}
/* `hidden` is a UA-stylesheet rule and ANY author rule that sets `display`
   beats it. .btn is display:inline-flex, so `btnNext.hidden = true` set the
   attribute, passed every DOM assertion, and left the button on screen. Same
   bug bit the level cards (.lvl is display:flex) — worth remembering that in
   this stylesheet `hidden` is only honoured where something restores it. */
.btn[hidden] { display: none; }

.btn:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); color: var(--btn-fg); }
.btn:active { transform: translateY(1px); box-shadow: var(--shadow-sm); }

.btn--secondary { --btn-bg: var(--teal); --btn-fg: var(--ink); }
.btn--magenta   { --btn-bg: var(--magenta-ink); --btn-fg: var(--white); }
/* The secondary tier: every quiet action on the site wears this — "View
   timetable", "See what squad involves", "Read all questions". It stays quiet
   on purpose; the orange is reserved for the one action that earns money, and
   on the home page the FAQ link sits a couple of hundred pixels above exactly
   that button in the deep end.

   The ring is --pool-300, not --border. --border (#d8ecf5) is *lighter* than
   --pool-200 (#c4e9f7), so on the tinted sections the outline inverted into a
   pale halo and the button stopped reading as a button at all — same component,
   two different objects depending on the ground under it. --pool-300 is darker
   than every ground the button lands on, so the pill holds its edge on white,
   pool-100 and pool-200 alike. */
.btn--ghost {
  --btn-bg: transparent;
  --btn-fg: var(--ink);
  box-shadow: inset 0 0 0 2.5px var(--pool-300);
}
/* Hover has to move *away* from the resting ring now that the resting ring is
   pool-300 — teal is the site's interaction accent and reads as a deliberate
   step up rather than as the same edge slightly darker. */
.btn--ghost:hover { --btn-bg: var(--pool-100); box-shadow: inset 0 0 0 2.5px var(--teal); }
.btn--white { --btn-bg: var(--white); --btn-fg: var(--ink); }
.btn--lg { min-height: 60px; padding: 1rem 2.25rem; font-size: 1.15rem; }
.btn--block { width: 100%; }
.btn svg { width: 20px; height: 20px; flex: none; }

/* ---------------------------------------------------------------- 6. Cards */
.card {
  position: relative;
  background: var(--surface);
  border-radius: var(--r-lg);
  padding: var(--space-md);
  box-shadow: var(--shadow-md);
  height: 100%;
  display: flex;
  flex-direction: column;
}
.card--tint { background: var(--pool-50); box-shadow: var(--shadow-sm); }
/* Was a 6px lift. A card that only rises reads as a button depressing in
   reverse; a card that grows reads as it coming toward you, which is the
   stronger cue for "this is the one to click." Scale carries that read, so the
   lift underneath it is small — just enough to keep the two working together
   rather than the vertical motion competing with the size change.

   Gated to hover-capable pointers: on touch, :hover can stick after a tap
   until something else is touched, which would leave a card visibly enlarged
   with nothing pressing it. Touch gets the tap's own feedback instead; hover
   is a mouse-only affordance here.

   Enter and leave deliberately use different curves. --ease-back overshoots
   past 1 before settling, so arriving reads as a small spring rather than a
   linear grow — but the same curve on the way out would make the card
   *undershoot* past its resting size, which looks like a wobble, not a
   settle. The base rule's --ease-out (no overshoot) is what runs on
   mouseleave, because a transition's curve is read from whichever rule is
   active at the moment the property changes.

   The :hover rule itself lives in section 15, after [data-reveal].is-revealed
   — see the comment on that rule for why. This card also carries
   [data-reveal], and a :hover transform declared up here would tie that
   rule's specificity and lose on source order. */
.card--link { transition: transform 0.3s var(--ease-out), box-shadow 0.3s var(--ease-out); }

.card__icon {
  width: 58px;
  height: 58px;
  display: grid;
  place-items: center;
  border-radius: var(--r-md);
  background: var(--accent, var(--pool-200));
  color: var(--ink);
  margin-bottom: var(--space-sm);
  box-shadow: inset 0 -3px 0 rgba(18, 48, 73, 0.1);
}
.card__icon svg { width: 30px; height: 30px; }
.card h3 { margin-bottom: 0.5rem; }

/* Level cards — each inherits its colour from --accent set inline */
.level {
  --accent: var(--teal);
  --accent-ink: var(--teal-ink);
  position: relative;
  overflow: hidden;
  border-radius: var(--r-lg);
  background: var(--surface);
  box-shadow: var(--shadow-md);
  display: flex;
  flex-direction: column;
  height: 100%;
  transition: transform 0.3s var(--ease-out), box-shadow 0.3s var(--ease-out);
}
/* Same "closer, not just higher" hover as .card--link, and the same reason it
   has to live in section 15 rather than here — see both notes there. */
.level__band {
  background: var(--accent);
  padding: var(--space-sm) var(--space-md);
  color: var(--ink);
  position: relative;
}
.level__band::after {
  content: "";
  position: absolute;
  inset: auto 0 -1px 0;
  height: 14px;
  background: var(--surface);
  -webkit-mask: radial-gradient(16px at 50% 0, transparent 98%, #000) repeat-x 0 0 / 32px 14px;
          mask: radial-gradient(16px at 50% 0, transparent 98%, #000) repeat-x 0 0 / 32px 14px;
}
/* Sits on a white chip rather than directly on the band. Small uppercase text
   on a saturated brand colour only reached ~4.3:1; on the chip it clears 12:1
   and reads as a deliberate label. */
.level__age {
  display: inline-block;
  padding: 0.25rem 0.7rem;
  margin-bottom: 0.45rem;
  border-radius: var(--r-pill);
  background: rgba(255, 255, 255, 0.94);
  color: var(--ink);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.8125rem;
  letter-spacing: 0.07em;
  text-transform: uppercase;
}
.level__name {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.6rem;
  line-height: 1.1;
  margin: 0.15rem 0 0;
  color: var(--ink);
}
.level__body { padding: var(--space-md); display: flex; flex-direction: column; flex: 1; }
.level__body > .btn { margin-top: auto; }

.checklist { list-style: none; margin: 0 0 var(--space-sm); padding: 0; }
.checklist li {
  position: relative;
  padding-left: 2rem;
  margin-bottom: 0.6rem;
  font-size: 1rem;
}
.checklist li::before {
  content: "";
  position: absolute;
  left: 0; top: 0.35em;
  width: 20px; height: 20px;
  border-radius: 50%;
  background: var(--accent-ink, var(--teal-ink));
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M10 1a9 9 0 100 18 9 9 0 000-18zm4.3 6.6l-5 5.6a1 1 0 01-1.5 0L5.6 10.9a1 1 0 111.5-1.3l1.5 1.7 4.2-4.8a1 1 0 111.5 1.3z'/%3E%3C/svg%3E") center/contain no-repeat;
          mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M10 1a9 9 0 100 18 9 9 0 000-18zm4.3 6.6l-5 5.6a1 1 0 01-1.5 0L5.6 10.9a1 1 0 111.5-1.3l1.5 1.7 4.2-4.8a1 1 0 111.5 1.3z'/%3E%3C/svg%3E") center/contain no-repeat;
}

.badge {
  display: inline-flex;
  align-items: center;
  /* .card is a flex column, so a bare inline-flex child stretched to the full
     card width and the pill rendered as a full-bleed bar. */
  align-self: flex-start;
  gap: 0.4rem;
  padding: 0.4rem 0.9rem;
  border-radius: var(--r-pill);
  background: var(--pool-100);
  color: var(--ink);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.8125rem;
  letter-spacing: 0.04em;
}
.badge--solid { background: var(--magenta-ink); color: var(--white); }
.badge--yellow { background: var(--yellow); color: var(--ink); }

/* Pale tints rather than the saturated brand colour. Small badge text on full
   -strength pink only reached 4.3:1; mixed back toward white it clears 4.5:1
   comfortably while still reading as that level's colour. Browsers without
   color-mix() fall back to the base --pool-100 background. */
.badge--pink    { background: color-mix(in srgb, var(--pink) 26%, #fff); }
.badge--orange  { background: color-mix(in srgb, var(--orange) 30%, #fff); }
.badge--green   { background: color-mix(in srgb, var(--green) 32%, #fff); }
.badge--teal    { background: color-mix(in srgb, var(--teal) 32%, #fff); }
.badge--dolphin { background: color-mix(in srgb, var(--dolphin) 32%, #fff); }

/* ------------------------------------------------------------ 7. Navigation */
.nav {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 100;
  height: var(--nav-h);
  display: flex;
  align-items: center;
  /* Tinted rather than white, and more transparent than version one's 0.86.
     A white bar at 86% over saturated turquoise reads as an opaque slab laid on
     the water — the hero's light shafts and caustics all stop dead at its lower
     edge, which is exactly the join the shafts exist to avoid drawing attention
     to. Pulling it to 0.72 and tinting it toward --pool-100 lets the water move
     behind it; saturate(1.25) puts back the vibrancy blur() flattens, so the
     turquoise reading through stays turquoise instead of going chalky. */
  background: rgba(234, 247, 249, 0.72);
  backdrop-filter: blur(16px) saturate(1.25);
  -webkit-backdrop-filter: blur(16px) saturate(1.25);
  border-bottom: 1px solid transparent;
  transition: height 0.3s var(--ease-out), box-shadow 0.3s var(--ease-out),
              border-color 0.3s var(--ease-out);
}
.nav.is-stuck {
  height: 74px;
  box-shadow: 0 4px 24px rgba(18, 48, 73, 0.09);
  border-bottom-color: var(--border);
}
/* Same gutter as .container--wide, so the logo stays flush with the hero copy
   underneath it at every width rather than drifting out to the window edge. */
.nav__inner {
  width: 100%;
  max-width: 1360px;
  margin-inline: auto;
  padding-inline: var(--gutter);
  display: flex;
  align-items: center;
  gap: var(--space-sm);
}
.nav__logo { flex: none; margin-right: auto; display: block; }
/* The shipped logo PNGs carried ~20% baked-in white margin, which is most of
   why the mark read as weightless. assets/img/logo-*-280 are keyed and
   trimmed, so the same box now shows a materially larger mark. */
.nav__logo img {
  height: 62px;
  width: auto;
  transform-origin: left center;
  transition: height 0.35s var(--ease-out);
}
.nav.is-stuck .nav__logo img { height: 50px; }

.nav__links {
  display: none;
  align-items: center;
  gap: 0.25rem;
  list-style: none;
  margin: 0;
  padding: 0;
}
.nav__links a {
  display: block;
  padding: 0.6rem 0.85rem;
  border-radius: var(--r-pill);
  color: var(--ink);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1rem;
  text-decoration: none;
  transition: background-color 0.2s var(--ease-out), color 0.2s var(--ease-out);
}
.nav__links a:hover { background: var(--pool-100); color: var(--ink); }
.nav__links a[aria-current="page"] { background: var(--pool-200); color: var(--ink); }

.nav__cta { display: none; }

.nav__toggle {
  display: grid;
  place-items: center;
  width: 48px; height: 48px;
  border: 0;
  border-radius: var(--r-md);
  background: var(--pool-100);
  color: var(--ink);
  cursor: pointer;
}
.nav__toggle svg { width: 26px; height: 26px; }
.nav__toggle .icon-close { display: none; }
.nav__toggle[aria-expanded="true"] .icon-close { display: block; }
.nav__toggle[aria-expanded="true"] .icon-menu { display: none; }

@media (min-width: 1024px) {
  .nav__links { display: flex; }
  .nav__cta { display: inline-flex; }
  .nav__toggle { display: none; }
}

/* Mobile drawer */
.drawer {
  position: fixed;
  inset: var(--nav-h) 0 0 0;
  z-index: 99;
  background: var(--white);
  padding: var(--space-md) var(--space-sm) var(--space-xl);
  overflow-y: auto;
  transform: translateX(100%);
  visibility: hidden;
  transition: transform 0.32s var(--ease-out), visibility 0.32s;
}
.drawer.is-open { transform: translateX(0); visibility: visible; }
.drawer ul { list-style: none; margin: 0 0 var(--space-md); padding: 0; }
.drawer li + li { border-top: 1px solid var(--border); }
.drawer a {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.15rem 0.25rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.3rem;
  color: var(--ink);
  text-decoration: none;
}
.drawer a[aria-current="page"] { color: var(--magenta-ink); }
@media (min-width: 1024px) { .drawer { display: none; } }

body.nav-open { overflow: hidden; }

/* Sticky mobile call/book bar */
.mobile-bar {
  position: fixed;
  inset: auto 0 0 0;
  z-index: 98;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.6rem;
  padding: 0.7rem 0.9rem calc(0.7rem + env(safe-area-inset-bottom));
  background: rgba(255, 255, 255, 0.94);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border-top: 1px solid var(--border);
  transform: translateY(110%);
  transition: transform 0.3s var(--ease-out);
}
.mobile-bar.is-visible { transform: translateY(0); }
.mobile-bar .btn { min-height: 48px; padding-inline: 1rem; font-size: 0.98rem; }
@media (min-width: 768px) { .mobile-bar { display: none; } }
@media (max-width: 767px) { body { padding-bottom: 78px; } }

/* ------------------------------------------------- 8. Hero & water effects */
/* The inner-page hero. It is NOT the home hero and should not be — that one is
   the arrival, and it is the only one carrying a photograph, the stats, the
   mascots, the caustics and a scroll cue. If every page opened on the same
   saturated water the home page stops being the front door, and the site's
   whole art direction (a descent, surface to floor) has nothing left to
   descend from.

   What was actually wrong is the opposite problem. This ran --pool-100 (relLum
   0.909) to white over a body that is --pool-100, so the masthead of every
   inner page was the same value as the page underneath it — a heading in a
   region with no edges, which is not a hero, it is the top of the document.

   So it deepens without going near the home hero: --pool-200 at the top with a
   --sky light pool bled in from the upper left, fading to --pool-50 at the
   base. Measured, worst case is --sky at 0.582 under the eyebrow and the h1 —
   --ink 10.45, --ink-soft 4.54, both clear. The gradient still resolves pale at
   the bottom so the band hands off to the page rather than stopping at a line.

   Home sits at 0.363→0.234. This sits at 0.781→0.939 with a 0.582 pool. Two
   clearly different registers, and a real one where there was almost nothing. */
.hero {
  position: relative;
  padding-top: calc(var(--nav-h) + var(--space-lg));
  padding-bottom: var(--space-xl);
  background:
    radial-gradient(120% 85% at 15% -5%, var(--sky) 0%, transparent 62%),
    linear-gradient(180deg, var(--pool-200) 0%, var(--pool-50) 88%);
  overflow: hidden;
}
.hero__grid {
  display: grid;
  gap: var(--space-lg);
  align-items: center;
}
@media (min-width: 960px) {
  .hero__grid { grid-template-columns: 1.02fr 1fr; gap: var(--space-xl); }
}

/* Removed: .hero__tagline (the tilted LIVE/LAUGH/SWIM chips) and .hero__stat
   (the 32°C / 4:1 / 6-levels bar). Both were cut from the home page by request
   — the chips read as taxonomy rather than a motto, and the stat bar sat over
   the children's faces in the photo. See .motto for the replacement. No page
   references either class any more. */

/* The photo is 1248px wide at source, so it sits in a contained rounded
   frame rather than full-bleed — that keeps it pin-sharp on large screens. */
.hero__media {
  position: relative;
  border-radius: var(--r-xl);
  overflow: hidden;
  box-shadow: var(--shadow-lg);
  background: var(--pool-200);
  aspect-ratio: 1248 / 765;   /* reserved up front — no layout shift */
}
.hero__media img { width: 100%; height: 100%; object-fit: cover; }

/* Caustic light shimmer drifting over the water */
.hero__media::after {
  content: "";
  position: absolute;
  inset: -30%;
  pointer-events: none;
  background:
    radial-gradient(closest-side, rgba(255,255,255,0.4), transparent 70%) 20% 30% / 45% 45% no-repeat,
    radial-gradient(closest-side, rgba(255,255,255,0.28), transparent 70%) 70% 60% / 38% 38% no-repeat;
  mix-blend-mode: soft-light;
  animation: caustics 14s ease-in-out infinite alternate;
}
@keyframes caustics {
  from { transform: translate3d(-2%, -1%, 0) scale(1); opacity: 0.85; }
  to   { transform: translate3d(3%, 2%, 0) scale(1.08); opacity: 1; }
}

/* --------------------------------------------------------------- Bubbles
   Two nested elements so rise and sway are independent animations rather than
   one over-specified keyframe: the outer <span> carries the vertical travel,
   the inner <i> sways side to side on its own period. The two run at
   unrelated durations, so no two bubbles ever trace the same path.

   Still transform/opacity only, so the whole field composites on the GPU. */
.bubbles {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
  z-index: 0;
}
.bubbles span {
  position: absolute;
  bottom: -8%;
  animation: rise var(--dur, 16s) linear var(--delay, 0s) infinite;
  will-change: transform;
}
.bubbles i {
  display: block;
  width: var(--size, 20px);
  height: var(--size, 20px);
  border-radius: 50%;
  /* A specular hotspot plus a rim highlight — the previous flat wash was
     invisible against the pale hero, which is why the effect read as "too
     subtle". This reads as an actual bubble on light and dark alike. */
  background:
    radial-gradient(circle at 30% 26%, rgba(255,255,255,0.98) 0 10%,
                    rgba(255,255,255,0.55) 20%, rgba(255,255,255,0) 44%),
    radial-gradient(circle at 68% 74%, rgba(255,255,255,0.5) 0 6%,
                    rgba(255,255,255,0) 30%),
    radial-gradient(circle at 50% 50%, rgba(41,184,198,0.2),
                    rgba(41,184,198,0.07) 62%, rgba(41,184,198,0) 74%);
  box-shadow:
    inset 0 0 0 1.5px rgba(255, 255, 255, 0.9),
    inset -3px -5px 11px rgba(41, 184, 198, 0.3),
    0 2px 9px rgba(18, 48, 73, 0.07);
  animation: sway var(--sway, 5s) ease-in-out var(--delay, 0s) infinite alternate;
  will-change: transform;
}

/* Depth of field: a few bubbles sit "behind" the others and read softer.
   The blur belongs here and only here. It used to live on .bubbles i as
   `filter: blur(var(--blur, 0px))`, which meant all 24 bubbles carried a
   filter — and a zero-radius blur is not free, it still costs the element a
   full filter render pass every frame. Only the third that are actually "far"
   set --blur, so two thirds were paying for a no-op. */
.bubbles span[data-depth="far"] { opacity: 0.55; }
.bubbles span[data-depth="far"] i { filter: blur(var(--blur, 1.6px)); }

@keyframes rise {
  0%   { transform: translate3d(0, 0, 0) scale(0.55); opacity: 0; }
  8%   { opacity: 1; }
  88%  { opacity: 1; }
  100% { transform: translate3d(var(--drift, 30px), calc(-100vh - 140px), 0) scale(1.12); opacity: 0; }
}
@keyframes sway {
  from { transform: translate3d(calc(var(--sway-x, 14px) * -1), 0, 0); }
  to   { transform: translate3d(var(--sway-x, 14px), 0, 0); }
}

/* Deep-water variant — used on the closing CTA, where the canvas is navy. */
.bubbles--deep i {
  background:
    radial-gradient(circle at 30% 26%, rgba(255,255,255,0.95) 0 10%,
                    rgba(255,255,255,0.4) 22%, rgba(255,255,255,0) 46%),
    radial-gradient(circle at 50% 50%, rgba(255,255,255,0.14),
                    rgba(255,255,255,0.04) 62%, rgba(255,255,255,0) 74%);
  box-shadow:
    inset 0 0 0 1.5px rgba(255, 255, 255, 0.75),
    inset -3px -5px 12px rgba(255, 255, 255, 0.22);
}

/* Wave dividers between sections.

   Deliberately not every join. The middle of the page is a continuous tonal
   descent — Why, the pool, Getting Started, Parent Reviews, Common Questions,
   each one step deeper than the last — and a wave at every boundary would chop
   that descent into slices and turn the page's nicest gesture into wallpaper.
   The rule is that a wave marks a change of *character*, not a change of step,
   and there are exactly two of those:

     Hero → Our Programs          the saturated hero lifting to open water
     Common Questions → the CTA   the pool dropping away into the deep end

   The first one MOVED here. It used to sit at Programs → Why, which was the
   right boundary when Programs was --water and Why was almost white. Now that
   Programs is --sky, that join is one gentle step in a ladder — and the big
   edge is the one above it, where the hero's floor at relLum 0.234 meets --sky
   at 0.582. That is the only place on the page where two grounds meet across a
   real gap, and it is the join that needs the curve.

   Programs → Why → Facility → Steps are all single steps in the same ladder and
   take flat butt joins, which is what a ladder should look like. Adding a third
   wave means finding a third boundary that genuinely earns it. */
.wave {
  position: relative;
  display: block;
  width: 100%;
  height: var(--wave-h);
  /* Overlap both neighbours by a pixel, the same thing .footer-wave already does
     and this rule never did. Both edges are same-colour-on-same-colour, so the
     overlap is invisible; what it removes is the antialiased half-row that a
     fill ending exactly on an element boundary leaves behind. */
  margin-block: -1px;
  color: var(--white);
  /* The paths overhang the viewBox so they can drift; without this the
     overhang would be painted outside the divider's own band. */
  overflow: hidden;
}
/* Taller than the band at BOTH ends and pulled up by the overshoot, so neither
   of the svg's own horizontal edges lands inside the divider's clip.

   The bottom overshoot has always been here: a fill that ends on the element's
   own boundary gets antialiased there, and that half-transparent row is enough
   to show a hairline of the wrong colour against the section below.

   The top overshoot is new and it is the fix for the line that was visible in
   the teal a few pixels above the crest on the home page. Measured off a phone
   screenshot, one device pixel across the full width, rgb(67,161,180) — which
   is --water-mid blended 24% toward --sky, the divider's OWN fill. It sat
   exactly on the svg's top edge.

   The path drifts sideways on scroll, so it is a composited layer, and the
   bottom of that layer is solid fill. Its top row samples the fill, and the
   result is a pale rule sitting in the middle of the water above the wave —
   nothing to do with the join it straddles, which is why matching the
   neighbouring colours never touched it. Two pixels of overshoot put that row
   outside the clip, where it cannot be seen.

   The curve rises about 1.4px at --wave-h's 48px floor as a result, because
   the viewBox now maps to a 4px taller box. That is under a pixel of visible
   change and it is the same crest either way. */
.wave svg {
  width: 100%;
  height: calc(100% + 4px);
  margin-top: -2px;
  display: block;
}
/* And the belt to that pair of braces. The overflow above moves the fill's own
   edge out of the way, but the CLIP is now the edge, and a clip on a box whose
   bottom lands mid-device-pixel is itself antialiased — through that half-row
   you get the divider's own background, which is the colour of the section
   ABOVE, showing as a hairline against the section below. On the home page that
   background is --water-mid, which is exactly the faint blue line you can catch
   under the hero divider on a phone, where fractional layout positions are the
   norm rather than the exception.

   Two solid rows of the destination colour, inside the clip so they survive it.
   currentColor is already the section-below colour on every instance, set
   inline beside the background, so this needs no per-page value. */
.wave::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: currentColor;
}
/* The same belt at the top, in the colour of the section ABOVE, and it wears
   both braces for the same reason the bottom does: the overshoot moves the
   svg's edge out of the clip, this covers the clip's own edge. Anything the
   divider paints in its first two rows — a stray sampled row, an antialiased
   clip — is under an opaque strip of the colour that edge is supposed to be.

   `background: inherit` rather than a token, because the colour above is set
   inline per page and differs on all three instances that use this divider
   (--water-mid on home, --pool-100 on about and timetable). Inheriting the
   parent's background picks up whichever one is in force without any of them
   needing to know this rule exists.

   Invisible in normal rendering: the crest never rises above ~14px, so these
   two rows are the divider's own background painted over the divider's own
   background. They only do work in the row that was misbehaving. */
.wave::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 2px;
  background: inherit;
}
.wave--up { transform: rotate(180deg); }

/* The drift itself is scroll-scrubbed from main.js via [data-parallax-x], not
   a CSS loop. It used to be an 18s alternating timer, and a timer is the one
   thing that can't read as water — it keeps moving when the reader has
   stopped, and it drifts out of step with everything else on the page. Tied
   to scroll it answers the reader instead, which is the whole effect.
   No JS, no GSAP or prefers-reduced-motion: the wave simply sits still, which
   is the correct resting state rather than a degraded one. */

/* LIVE · LAUGH · SWIM marquee */
.marquee {
  overflow: hidden;
  padding-block: 0.9rem;
  background: var(--ink);
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.marquee__track {
  display: flex;
  width: max-content;
  gap: 2.5rem;
  animation: marquee 32s linear infinite;
  will-change: transform;
}
.marquee__track span {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(1.05rem, 2.2vw, 1.5rem);
  letter-spacing: 0.08em;
  color: var(--white);
  white-space: nowrap;
  display: flex;
  align-items: center;
  gap: 2.5rem;
}
.marquee__track span::after {
  content: "";
  width: 12px; height: 12px;
  border-radius: 50%;
  background: var(--yellow);
  flex: none;
}
@keyframes marquee { to { transform: translate3d(-50%, 0, 0); } }

/* Decorative floating mascot / blobs */
.float { animation: float 6s ease-in-out infinite; will-change: transform; }
.float--slow { animation-duration: 9s; }
@keyframes float {
  0%, 100% { transform: translate3d(0, 0, 0); }
  50%      { transform: translate3d(0, -14px, 0); }
}

/* ------------------------------------------------------------- 9. Timetable */
.table-wrap {
  overflow-x: auto;
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-md);
  background: var(--surface);
  -webkit-overflow-scrolling: touch;
}
/* ---------------------------------------------------------------- .sched
   The timetable. Day at the top, time windows inside it, characters down
   the left edge.

   Four earlier versions are worth recording, because each was wrong in a
   different way and the last two are the interesting failures.

   The first was the client's grid transcribed faithfully: ten rows, five
   columns, and forty of the fifty cells holding one of four strings, because
   every level is 30 minutes and every level has the identical weekend.
   Accurate and unreadable.

   The second was a gantt on a shared 8am-8pm axis. It removed the repetition
   and it photographed well, and it was harder to use than the table it
   replaced: a bar edge landing between two gridlines makes the reader
   interpolate a number printed nowhere, then travel back up to an axis above a
   twelve-row chart to check it. Handsome, and it moved work onto the parent.

   The third split the page into one card per time window, each headed by its
   hours. The times were finally readable and the mental model was inverted.
   Nobody plans a week by time first — a parent knows it is a Tuesday, or that
   Saturday is free, and only then asks what time.

   The fourth put the day at the top and the windows inside it. Right structure,
   and flat: one white rectangle of type where the level colours did nothing but
   tint eleven words. Colour applied to a glyph is the weakest possible use of
   an identity system this strong.

   So the colour arrives as the characters now. Each level carries its mascot in
   a disc washed with its own hue — the same construction the pathway's stations
   use, deliberately, because a parent who has met these discs on the programs
   page should recognise them here. Read down the left edge they are the pathway
   in order, which is the one thing a list of names could never show.

   Every level in the list carries its own character, Aquababies, Aquatots and
   Pufferfish included — their artwork arrived later than the rest and the line
   motifs that stood in for it have been retired.

   Both cards open with a water surface: a tinted masthead with the site's own
   wave curve breaking into the body below. The weekend plate repeats it one
   depth down, so the section descends pool → white → trench exactly the way the
   page's bands do. */
/* --space-lg between the two cards, where the rest of the page uses --space-md
   between siblings. These are not two items in a list — they are the week and
   the weekend, and at 2rem they read as one object that happens to have a seam
   across it. The extra rem is the pause a reader needs to register that the
   ground has changed from white to deep water. */
.sched { display: grid; gap: var(--space-lg); }

.sched__card {
  position: relative;
  background: var(--surface);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-md);
  /* The masthead bleeds to the card's edges, so padding lives on the children
     rather than here. */
  overflow: hidden;
}

/* --------------------------------------------------------- water surface */
/* --pool-200, not --pool-100. The body of this page IS pool-100, so a
   pool-100 masthead sat at exactly the value of the ground behind the card and
   the wave under it had nothing to break against — the surface was invisible
   and the card read as one flat sheet. Three distinct values now: page
   pool-100, masthead pool-200, body white. */
/* Horizontal padding lives on the heading, never on the masthead. With it here
   the wave sat inset from the card by --space-md while the masthead colour
   bled the full width, so each card ended with two stepped notches where the
   curve stopped short of its own corners. The wave has to run edge to edge or
   the surface reads as broken glass. */
.sched__head {
  position: relative;
  z-index: 1;
  background: var(--pool-200);
  padding: var(--space-lg) 0 0;
}
/* Paints the card body's own ground over the curve, on the masthead's ground,
   which is the same two-colour contract every .wave on the site works to. */
/* THE SEAM, AND WHY THIS ONE DIVIDER HAD IT AND THE OTHERS DIDN'T.
   Every other wave on the site overlaps its neighbours — .wave carries
   margin-block:-1px, .footer-wave the same, .deep__surface a 2px overhang — and
   the reason is written up at each of them: a fill that ends exactly on an
   element's boundary gets antialiased there, and that half-transparent row
   shows a hairline of whatever is behind it. This divider was built without any
   of that. Its svg is exactly as tall as its box, so the path's filled bottom
   edge lands precisely on the boundary, and what sits behind it is the
   masthead's --pool-200. Result: a pale blue line straight across the card at
   the top of the first slot.

   It only became obvious on a phone. The svg is preserveAspectRatio:none over a
   60-unit viewBox, so at the 26px mobile end it is squashed to 0.43 of its
   drawn height and the boundary almost never lands on a whole device pixel;
   at the 44px desktop end it very often does.

   The fix paints the destination colour back over the seam rather than trying
   to land the edge cleanly, because there is no landing it cleanly at arbitrary
   fractional heights. currentColor is already the body's own ground on both
   variants — --surface on the weekday cards, --trench on the weekend plate — so
   this needs no second rule to follow the modifier. */
.sched__wave {
  position: relative;
  color: var(--surface);
  margin-top: var(--space-md);
}
.sched__wave svg {
  display: block;
  width: 100%;
  height: clamp(26px, 3.4vw, 44px);
}
.sched__wave::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;
  height: 2px;
  background: currentColor;
}

/* The heading a parent actually arrives with. Display scale, and the only
   thing in the card allowed to be this size — the hours below read as its two
   halves precisely because they are set smaller. */
.sched__day {
  font-size: clamp(1.75rem, 1.2rem + 2.1vw, 2.5rem);
  line-height: 1.05;
  letter-spacing: -0.015em;
  margin: 0;
  padding-inline: var(--space-md);
}

.sched__body { position: relative; z-index: 1; padding: 0 var(--space-md) var(--space-md); }

/* Two halves of the day, side by side from tablet up. The columns are
   deliberately unequal in content — four levels swim in the morning and seven
   after school — and that asymmetry is the schedule telling the truth. */
.sched__slots { display: grid; gap: var(--space-lg) var(--space-xl); }
@media (min-width: 760px) {
  .sched__slots { grid-template-columns: 1fr 1fr; align-items: start; }
}

/* The part of the day, in words, is what a parent scans for first — they know
   they need a morning, not that they need 9am. So the word takes the display
   scale here and the hours sit under it as its expansion. The pill this used to
   be pushed the word down to caption size and buried the distinction between
   the two columns inside two rounded rectangles that looked identical at a
   glance. */
.sched__label {
  display: block;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(1.3rem, 1.05rem + 1vw, 1.65rem);
  line-height: 1.15;
  letter-spacing: -0.01em;
  color: var(--ink);
  margin: 0;
}

/* Set against the word above it: same family, one weight and several steps
   down, and in the softer ink so the hours read as that word's expansion
   rather than as a competing headline. */
.sched__time {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.04rem;
  line-height: 1.2;
  letter-spacing: 0.01em;
  color: var(--ink-soft, var(--ink-faint));
  margin: 0.2rem 0 0;
}
.sched__time span { color: inherit; opacity: 0.62; margin: 0 0.1em; }

/* ---------------------------------------------------------- level rows */
.sched__levels {
  --disc: 42px;
  list-style: none;
  margin: var(--space-sm) 0 0;
  padding: 0;
}
.sched__levels li {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  padding: 0.45rem 0.5rem;
  margin-inline: -0.5rem;
  border-radius: 14px;
  transition: background-color 0.32s var(--ease-out, ease);
}

/* Same construction as .plevel__disc on the find-your-class pathway: a 15%
   wash of the level's hue with a 38% ring. Deliberate reuse — a parent who
   picked their level over there meets the identical object here, and the two
   pages stop looking like two projects. */
.sched__disc {
  position: relative;
  flex: 0 0 var(--disc);
  display: grid;
  place-items: center;
  width: var(--disc);
  height: var(--disc);
  border-radius: 50%;
  overflow: hidden;
  background-color: color-mix(in srgb, var(--lv) 15%, #fff);
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--lv) 38%, #fff);
  transition: transform 0.42s var(--ease-back, ease), box-shadow 0.42s var(--ease-out, ease);
}
.sched__disc img { width: 78%; height: 78%; object-fit: contain; }
/* 64%, where the pathway's stations use 56%. A full-colour mascot PNG fills its
   disc with mass; a three-stroke line drawing does not, and at the pathway's
   ratio the three motif levels read as empty discs next to eight full ones.
   Bigger and in the level ink evens the weight without inventing artwork. */
.sched__motif { width: 64%; height: 64%; color: var(--lv-ink); }

/* The character floats inside its own bubble rather than the bubble floating.
   Durations and delays are set per row from the markup so eleven of them never
   line up into a single pulse, which is what makes an infinite loop read as
   water instead of as a loading state. */
@keyframes sched-float {
  0%, 100% { transform: translate3d(0, 0, 0); }
  50%      { transform: translate3d(0, -3px, 0); }
}
.sched__disc img,
.sched__motif {
  animation: sched-float var(--float-dur, 7s) ease-in-out var(--float-delay, 0s) infinite;
  will-change: transform;
}

.sched__lvl {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.04rem;
  color: var(--lv-ink, var(--ink));
  white-space: nowrap;
}
/* Every level here has a panel on the programs page, and "what is a Seahorse 2"
   is the obvious next question once a parent has found their time.

   No underline and no colour change on hover. The global a:hover repaints any
   link --magenta-ink, which outranks this rule on specificity and turned all
   eleven names the same pink the moment a cursor entered the row — overwriting
   the one colour on the line that carries meaning. The row's ground tinting in
   the level's own hue already says "this is a thing you can press", and it says
   it across the whole row rather than under one word. */
a.sched__lvl {
  text-decoration: none;
}
a.sched__lvl:hover,
a.sched__lvl:focus-visible { color: var(--lv-ink, var(--ink)); }

/* The mascot is what a parent's eye and finger both go to, so the whole row is
   the target. The link's box is stretched over the row rather than a second
   anchor wrapped around the disc: one link per level for a screen reader, and a
   56px-tall press area in place of a 17px word. */
.sched__levels li { position: relative; }
a.sched__lvl::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 14px;
}
/* The ring belongs on the row now, since the row is what's being pressed — a
   3px outline hugging the word while the whole line highlights would point at
   the wrong object. Removed from the anchor only because it is redrawn one
   level up, never because it is unwanted. */
.sched__levels li:has(a:focus-visible) {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
  border-radius: 14px;
}
a.sched__lvl:focus-visible { outline: none; }

/* The age belongs to the level, so it sits with the level. Leader dots ran it
   out to a flush right edge instead, which set up a second column of type the
   eye had to travel to — and a rule of dots between two things is the visual
   grammar of a contents page, chapter on the left, page number on the right.
   This is a list of eleven levels, not eleven chapters. Nothing leads
   anywhere now; name and age are one phrase divided by a middot. */
.sched__text {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 0.42rem;
  min-width: 0;
}
.sched__sep {
  color: var(--ink-faint);
  opacity: 0.55;
  font-size: 0.9rem;
}
.sched__age {
  font-size: 0.86rem;
  color: var(--ink-faint);
}
.sched__note {
  margin: var(--space-sm) 0 0;
  font-size: 0.88rem;
  color: var(--ink-faint);
}

/* ------------------------------------------------------------ hover state
   Two things move and they move as one gesture: the row's ground tints in the
   level's own hue and the disc grows, its ring deepening at the same time. */
@media (hover: hover) {
  .sched__levels li:hover {
    background-color: color-mix(in srgb, var(--lv) 11%, transparent);
  }
  .sched__levels li:hover .sched__disc {
    transform: scale(1.09);
    box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--lv) 62%, #fff);
  }
}
/* Keyboard parity. :focus-within on the row gives a tabbing user the same
   disc growth a mouse gets, which is the whole point of building the state on
   the row rather than on the link. */
.sched__levels li:focus-within .sched__disc { transform: scale(1.09); }

/* ---------------------------------------------------------- weekend plate
   Deep water closes the block. Every glyph on it is pure white: 4.70:1 on
   --trench, which is why the tone exists. Hierarchy comes from size and
   tracking alone, since tinting the labels down would drop them under AA. */
.sched__card--wknd { background: var(--trench); color: var(--white); }
.sched__card--wknd .sched__head {
  background: color-mix(in srgb, var(--trench) 76%, var(--ink));
}
.sched__card--wknd .sched__wave { color: var(--trench); }
.sched__card--wknd .sched__day { color: var(--white); }
.sched__card--wknd .sched__label { color: var(--white); }
.sched__card--wknd .sched__time { color: rgba(255, 255, 255, 0.88); }
.sched__card--wknd .sched__time span { opacity: 0.7; }
/* Two days, not two halves of a twelve-hour day, so they sit together instead
   of spanning the full plate. The hairline between them is what makes them
   read as two separate days without giving each one its own card — Saturday
   closes three hours later than Sunday, and that difference was getting lost
   in a plain gap. */
@media (min-width: 760px) {
  .sched__card--wknd .sched__slots {
    grid-template-columns: repeat(2, max-content);
    gap: var(--space-lg);
  }
  .sched__card--wknd .sched__slot + .sched__slot {
    border-inline-start: 1px solid rgba(255, 255, 255, 0.22);
    padding-inline-start: var(--space-lg);
  }
}

/* The rule crosses the full plate, over the top of the divider between the two
   days. That is the whole job it does: anything sitting under a line that
   spans both columns belongs to both columns, so the line-up cannot be read as
   Saturday's. */
.sched__band {
  padding-top: var(--space-lg);
}
.sched__every {
  margin: 0;
  color: var(--white);
  font-size: 0.98rem;
}

/* Every level, shown rather than claimed.
   Three ghosted mascots used to drift behind this plate at 0.1 opacity, which
   said "water" and nothing else; two of them were levels that also appear on
   the weekday card, so the ghosts were arbitrary as well as mute. The same
   discs the weekday lists are built from, laid out in pathway order, do the
   actual work: a parent counts ten characters and knows the weekend holds all
   of them. Aquababes through Shark, left to right, the order they'll swim it.

   Discs run one step smaller than the weekday rows. Ten of them at 42px would
   be the widest object on the plate and would outrank the two times, which are
   still the reason anyone opened this card. */
.sched__lineup {
  --disc: 38px;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: var(--space-md);
}
/* On the trench the discs' near-white wash is doing the lifting, so the ring
   goes brighter than the level tint would give it on white — a 38% mix of a
   deep hue like Stingray disappears against dark water. */
.sched__lineup .sched__disc {
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--lv) 45%, #fff);
}
/* These pop harder than the weekday discs do. Over there the row's ground
   tints as well, so the disc only has to join in; here the disc is the entire
   control and has to carry the state on its own. Lift plus scale, because
   rising out of dark water is the gesture this plate is already making. */
.sched__lineup .sched__disc {
  transform: translate3d(0, 0, 0);
}
@media (hover: hover) {
  .sched__lineup .sched__disc:hover {
    transform: translate3d(0, -5px, 0) scale(1.16);
    box-shadow:
      inset 0 0 0 2px color-mix(in srgb, var(--lv) 80%, #fff),
      0 8px 18px -6px rgba(0, 0, 0, 0.45);
  }
}
.sched__lineup .sched__disc:focus-visible {
  transform: translate3d(0, -5px, 0) scale(1.16);
  outline: 3px solid var(--white);
  outline-offset: 3px;
}

.sched__foot {
  margin-top: var(--space-md);
  max-width: 66ch;
  font-size: 0.95rem;
  color: var(--ink-faint);
}

@media (max-width: 560px) {
  .sched__head { padding: var(--space-md) 0 0; }
  .sched__day { padding-inline: var(--space-sm); }
  .sched__body { padding: 0 var(--space-sm) var(--space-md); }
  .sched__levels { --disc: 38px; }
  /* Held at 38px rather than shrunk with the weekday discs: these are ten
     touch targets now, and a phone is where they'll actually be tapped. */
  .sched__lineup { --disc: 38px; gap: 0.45rem; }
  /* Ages like "Intermediate to advanced" stop fitting beside the level name on
     a narrow phone. The pair wraps to two lines there and the middot goes with
     the first, so the age still reads as belonging to the name above it rather
     than starting a line of its own. The disc stays centred on the block. */
  .sched__levels li { align-items: center; }
  .sched__lvl { white-space: normal; }
  .sched__text { row-gap: 0.05rem; }
  .sched__sep { display: none; }
  .sched__age { flex: 1 0 100%; }

  /* THE WINDOW ON THE LEFT, WHAT SWIMS IN IT ON THE RIGHT.
     Stacked, "Mornings / 9:00am – 1:00pm" was a heading with a list under it,
     and on a phone the second window's heading arrived so far down the scroll
     that the two windows read as two unrelated blocks rather than as two halves
     of one day. Side by side they read as a timetable again: the time is the
     row label, the levels are what's in that row, and the eye can hold both.

     No rule between the columns. The gap is the divider — a hairline here would
     draw a table, and a table is exactly the dense grid this card was built to
     avoid. Column one is capped as a fraction so a long window label wraps
     rather than squeezing the level names, which are the longer strings and the
     ones that must stay on one line where they can.

     Only slots that actually carry levels. The weekend plate's Saturday and
     Sunday slots are a label and a time with nothing to sit beside, and a
     two-column grid there would leave a hole. */
  .sched__slot:has(.sched__levels) {
    display: grid;
    /* Fixed, not a fraction: 0.42fr was resolving to exactly 94px regardless
       of content, which fit "Mornings" but wrapped "After school" and every
       time range ("9:00am – 1:00pm") onto two lines. 124px is measured to
       the widest label/time pair across the card, so both stay on one line;
       the level column keeps every name on the page — "Seahorse 1–2" is the
       longest — on one line too, with the disc at its 38px mobile size. */
    grid-template-columns: 124px minmax(0, 1fr);
    /* THE THIRD ROW IS THE POINT OF THIS. The level list spans the left
       column's rows, and an auto-sized row grows to carry its share of a
       spanning item — so with only two rows the list's height was split between
       them and the hours were pushed a hundred pixels clear of the word they
       belong to. A 1fr row underneath takes all of that slack instead, leaving
       rows one and two at min-content so the label and the hours stay a pair. */
    grid-template-rows: auto auto 1fr auto;
    column-gap: var(--space-sm);
    align-items: start;
  }
  .sched__slot:has(.sched__levels) .sched__label { grid-column: 1; grid-row: 1; }
  .sched__slot:has(.sched__levels) .sched__time  { grid-column: 1; grid-row: 2; }
  /* Starts level with the word rather than with the hours, and runs down past
     both into the slack row. */
  .sched__slot:has(.sched__levels) .sched__levels {
    grid-column: 2;
    grid-row: 1 / 4;
    margin-top: 0;
  }
  /* Back to full width: the note is about the window as a whole, so hanging it
     under either column would attach it to the wrong thing. */
  .sched__slot:has(.sched__levels) .sched__note { grid-column: 1 / -1; grid-row: 4; }
  /* The hours are the smaller of the two lines in a narrow column, so they get
     the tighter measure and are allowed to break at the dash. */
  .sched__slot:has(.sched__levels) .sched__time { font-size: 0.95rem; }
}

/* Every loop here is decorative. None of it carries meaning, so all of it
   goes. */
@media (prefers-reduced-motion: reduce) {
  .sched__disc img,
  .sched__motif { animation: none !important; }
}

/* ------------------------------------------------------------- .schedtip
   A level's whole week, beside its own mascot.

   White, with a hairline. Two grounds were tried before it and both were
   wrong for the same reason: --ink made it the only dark object on the page,
   and --pool-200 made it a coloured field competing with eleven level hues
   that are the only colour this page is supposed to carry. A white plate on a
   white card separates by hairline and shadow alone, which is how every other
   floating object on this site behaves, and on the trench plate it simply
   glows.

   The hairline takes the level's hue at the same strength the mascot discs
   ring themselves with. That is one thin line of colour tying the bubble to
   the character it points at, and it is the whole of the argument.

   Built in two layers: a plate that carries the ground, border, radius and
   shadow, and content that sits above it. Only the plate is ever scaled, so
   the type inside never stretches during the open. */
.schedtip {
  /* 72%, where the mascot discs ring themselves at 38%. A disc's ring sits on
     a tinted fill of the same hue and only has to separate two close values;
     this one is a single hairline on pure white, and at the disc's strength the
     pale levels — Shark, Dolphin — drew no visible line at all. */
  --ring: color-mix(in srgb, var(--lv, var(--teal)) 72%, #fff);
  position: fixed;
  left: 0;
  top: 0;
  z-index: 60;
  width: max-content;
  max-width: min(280px, calc(100vw - 24px));
  padding: 0.85rem 0.95rem 0.9rem;
  visibility: hidden;
  pointer-events: none;
}
.schedtip[data-open] { visibility: visible; }

.schedtip__bg {
  position: absolute;
  inset: 0;
  border-radius: 16px;
  background: var(--surface);
  box-shadow:
    inset 0 0 0 1.5px var(--ring),
    0 2px 6px -2px rgba(5, 29, 38, 0.1),
    0 22px 44px -18px rgba(5, 29, 38, 0.34);
}
.schedtip__in { position: relative; z-index: 1; }

/* Only used when GSAP is absent. With it loaded the timeline owns every
   property below and a transition underneath would fight it every frame. */
.schedtip--css .schedtip__bg,
.schedtip--css .schedtip__in {
  opacity: 0;
  transition: opacity 0.16s var(--ease-out);
}
.schedtip--css[data-open] .schedtip__bg,
.schedtip--css[data-open] .schedtip__in { opacity: 1; }

/* The level ink, unmixed, on white — which is the ground these colours were
   drawn against. Shark's is the palest at 5.57:1 and the rest run past 7. This
   is the bubble naming its level, which is the one thing the house rule lets a
   level's hue do. */
.schedtip__lvl {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1rem;
  line-height: 1.2;
  margin: 0;
  color: var(--lv-ink, var(--ink));
}
/* The one uppercase label in the bubble, and it earns it: three or four day
   names stacked need to be distinguishable from the hours at a glance without
   taking any of their weight. */
.schedtip__day {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.66rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
  margin: 0.7rem 0 0.2rem;
}
.schedtip__list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.schedtip__list li {
  padding: 0.06rem 0;
}
.schedtip__list b {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.94rem;
  color: var(--ink);
}

/* A rotated square carrying two of the ring's four sides — whichever two face
   outward once it has turned 45°, so the hairline runs unbroken around the
   point instead of stopping at the plate's edge. It lives inside the plate, so
   the open scales the two of them as one shape.

   Its cross-axis offset is set from the mascot's centre, not the bubble's, so
   it keeps pointing at the character even when the bubble has been clamped
   against a viewport edge and the two centres no longer agree. */
.schedtip__tail {
  position: absolute;
  box-sizing: border-box;
  width: 13px;
  height: 13px;
  background: var(--surface);
  border-radius: 2px;
  transform: rotate(45deg);
}
.schedtip[data-side="right"] .schedtip__tail {
  left: -6px;
  margin-top: -6.5px;
  border-left: 1.5px solid var(--ring);
  border-bottom: 1.5px solid var(--ring);
}
.schedtip[data-side="left"] .schedtip__tail {
  right: -6px;
  margin-top: -6.5px;
  border-top: 1.5px solid var(--ring);
  border-right: 1.5px solid var(--ring);
}
.schedtip[data-side="top"] .schedtip__tail {
  bottom: -6px;
  margin-left: -6.5px;
  border-bottom: 1.5px solid var(--ring);
  border-right: 1.5px solid var(--ring);
}
.schedtip[data-side="bottom"] .schedtip__tail {
  top: -6px;
  margin-left: -6.5px;
  border-top: 1.5px solid var(--ring);
  border-left: 1.5px solid var(--ring);
}

.price {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 2.4rem;
  line-height: 1;
  color: var(--ink);
}
.price small { font-size: 0.95rem; font-weight: 600; color: var(--ink-faint); }

/* ------------------------------------------------------------ 10. Accordion */
.accordion { border-radius: var(--r-lg); overflow: hidden; box-shadow: var(--shadow-md); background: var(--surface); }
.accordion__item + .accordion__item { border-top: 1px solid var(--border); }

/* Closed, the accordion is the largest flat white slab on the page and opening
   a row earns you no visible reward beyond the text appearing. The open item
   now tints and grows a teal rule down its edge.

   Teal rather than a level colour because this is state feedback, not
   decoration — it's the site's existing UI accent (eyebrows, checklists, focus
   ring), so the one-hue-per-level rule is untouched. The rule wipes downward
   as the panel opens; the accordion's own overflow:hidden trims it to the
   rounded corners on the first and last rows. */
.accordion__item { position: relative; transition: background-color 0.25s var(--ease-out); }
.accordion__item::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 5px;
  background: var(--teal);
  transform: scaleY(0);
  transform-origin: top;
  transition: transform 0.32s var(--ease-out);
}
.accordion__item.is-open { background: var(--pool-50); }
.accordion__item.is-open::before { transform: scaleY(1); }
.accordion__btn {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  padding: 1.25rem var(--space-md);
  background: none;
  border: 0;
  text-align: left;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.08rem;
  color: var(--ink);
  cursor: pointer;
  min-height: 60px;
  transition: background-color 0.2s var(--ease-out);
}
.accordion__btn:hover { background: var(--pool-50); }
.accordion__btn svg {
  width: 24px; height: 24px;
  flex: none;
  color: var(--teal-ink);
  transition: transform 0.28s var(--ease-out);
}
.accordion__btn[aria-expanded="true"] svg { transform: rotate(45deg); }
.accordion__panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.32s var(--ease-out);
}
.accordion__panel > div { overflow: hidden; }
.accordion__item.is-open .accordion__panel { grid-template-rows: 1fr; }
.accordion__panel p { padding: 0 var(--space-md) 1.35rem; margin: 0; }

/* -------------------------------------------------- 10b. The "more" row
   Four questions on the home page was too few — a parent whose question was the
   fifth most common had to leave the page to find out we had an answer at all,
   at the exact point in the scroll where they are deciding whether to enquire.
   Ten is the set now: five resting, five behind this.

   It is a ROW OF THE ACCORDION, not a button under it. Same hairline, same
   height, same left inset as every question above it, so the list visibly
   continues rather than being followed by a control. The chevron is the only
   thing distinguishing it, and it points the way the list is about to grow.

   Everything is in the markup — the extra rows are display:none, not `hidden`
   and not fetched — so a crawler and a reader with no scripting both get all
   ten answers and never see a button that cannot work. */
.accordion__item--extra { display: none; }
.accordion__item--extra.is-shown { display: block; }

.accordion__more {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  min-height: 58px;
  padding: 1rem var(--space-md);
  border: 0;
  border-top: 1px solid var(--border);
  background: none;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.98rem;
  color: var(--teal-ink);
  cursor: pointer;
  transition: background-color 0.2s var(--ease-out), color 0.2s var(--ease-out);
}
.accordion__more:hover { background: var(--pool-50); }
.accordion__more svg {
  width: 20px; height: 20px;
  flex: none;
  /* A 3px nudge on hover, down, because that is the direction the list grows.
     The same idea as the arrow links' 5px sideways travel — a control should
     point at what it is about to do. */
  transition: transform 0.28s var(--ease-out);
}
.accordion__more:hover svg { transform: translateY(3px); }
.accordion__more[hidden] { display: none; }

/* No scripting: every row is shown and the control that would have revealed
   them is not. main.js clears `no-js` on its first line. */
.no-js .accordion__item--extra { display: block; }
.no-js .accordion__more { display: none; }

/* ------------------------------------------------------- 11. Testimonials */
.quote {
  background: var(--surface);
  border-radius: var(--r-lg);
  padding: var(--space-md);
  box-shadow: var(--shadow-md);
  height: 100%;
  display: flex;
  flex-direction: column;
}
.quote__stars { display: flex; gap: 2px; color: var(--yellow); margin-bottom: var(--space-xs); }
.quote__stars svg { width: 20px; height: 20px; }
.quote blockquote { margin: 0 0 var(--space-sm); font-size: 1.05rem; color: var(--ink-soft); }
.quote figcaption { margin-top: auto; display: flex; align-items: center; gap: 0.75rem; }
.quote__avatar {
  width: 46px; height: 46px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: var(--accent, var(--pool-200));
  font-family: var(--font-display);
  font-weight: 800;
  /* 20px bold counts as large text under WCAG, so 3:1 applies here rather
     than 4.5:1 — which the brand colours clear comfortably. */
  font-size: 1.25rem;
  color: var(--ink);
  flex: none;
}
.quote__name { font-weight: 800; color: var(--ink); font-family: var(--font-display); line-height: 1.2; }
.quote__meta { font-size: 0.86rem; color: var(--ink-faint); }

/* ------------------------------------------------------------- 12. Forms */
.field { margin-bottom: var(--space-sm); }
.field label {
  display: block;
  margin-bottom: 0.4rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.98rem;
  color: var(--ink);
}
.field .hint { display: block; font-weight: 400; font-size: 0.86rem; color: var(--ink-faint); margin-top: 0.1rem; }

.input,
.select,
.textarea {
  width: 100%;
  min-height: 52px;
  padding: 0.8rem 1rem;
  border: 2px solid var(--border);
  border-radius: var(--r-md);
  background: var(--white);
  color: var(--ink);
  font-size: 1rem;
  transition: border-color 0.2s var(--ease-out), box-shadow 0.2s var(--ease-out);
}
.textarea { min-height: 120px; resize: vertical; }
.input:hover, .select:hover, .textarea:hover { border-color: var(--pool-300); }
.input:focus, .select:focus, .textarea:focus {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 4px rgba(11, 110, 168, 0.16);
}
.input[aria-invalid="true"], .textarea[aria-invalid="true"] { border-color: #c62828; }

.error {
  display: none;
  margin-top: 0.4rem;
  color: #b3261e;
  font-size: 0.9rem;
  font-weight: 600;
}
.error.is-shown { display: block; }

/* Chunky selectable option tiles used by the class finder */
.options { display: grid; gap: 0.75rem; }
@media (min-width: 620px) { .options--2 { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 620px) { .options--3 { grid-template-columns: repeat(3, 1fr); } }

.option {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.9rem;
  min-height: 68px;
  padding: 0.9rem 1.1rem;
  border: 2.5px solid var(--border);
  border-radius: var(--r-md);
  background: var(--white);
  cursor: pointer;
  text-align: left;
  transition: border-color 0.2s var(--ease-out), background-color 0.2s var(--ease-out),
              transform 0.15s var(--ease-out), box-shadow 0.2s var(--ease-out);
}
.option:hover { border-color: var(--pool-300); background: var(--pool-50); transform: translateY(-2px); }
.option input { position: absolute; opacity: 0; pointer-events: none; }
.option__dot {
  width: 26px; height: 26px;
  border-radius: 50%;
  border: 2.5px solid var(--pool-300);
  flex: none;
  display: grid;
  place-items: center;
  transition: border-color 0.2s, background-color 0.2s;
}
.option__dot::after {
  content: "";
  width: 12px; height: 12px;
  border-radius: 50%;
  background: var(--white);
  transform: scale(0);
  transition: transform 0.2s var(--ease-back);
}
.option__text { display: block; }
.option__title { display: block; font-family: var(--font-display); font-weight: 700; color: var(--ink); line-height: 1.25; }
.option__sub { display: block; font-size: 0.88rem; color: var(--ink-faint); }

.option:has(input:checked) {
  border-color: var(--teal-ink);
  background: var(--pool-100);
  box-shadow: var(--shadow-sm);
}
.option:has(input:checked) .option__dot { border-color: var(--teal-ink); background: var(--teal-ink); }
.option:has(input:checked) .option__dot::after { transform: scale(1); }
.option:has(input:focus-visible) { outline: 3px solid var(--focus); outline-offset: 3px; }

/* Compact day/time chips */
.chips { display: flex; flex-wrap: wrap; gap: 0.6rem; }
.chip { position: relative; }
.chip input { position: absolute; opacity: 0; pointer-events: none; }
.chip span {
  display: grid;
  place-items: center;
  min-width: 56px;
  min-height: 48px;
  padding: 0.5rem 1rem;
  border: 2.5px solid var(--border);
  border-radius: var(--r-pill);
  background: var(--white);
  font-family: var(--font-display);
  font-weight: 700;
  color: var(--ink);
  cursor: pointer;
  transition: all 0.2s var(--ease-out);
}
.chip span:hover { border-color: var(--pool-300); background: var(--pool-50); }
.chip input:checked + span { background: var(--teal); border-color: var(--teal-ink); }
.chip input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 3px; }

/* ------------------------------------------------------ 13. Class finder UI */
/* The most valuable object on the site — every "Book now" on every page lands
   on it — and until now it was styled like any other white card. It is now
   built as a DEVICE: a deep masthead, a white body, and a footprint that says
   this is the thing you came here to use.

   The head is --trench, the same water the closing CTA sits on. That is the one
   pairing on this site that already means "this is the ask", so the form's
   masthead and the page's final CTA are the same body of water rather than two
   unrelated dark blues. It is also the only deep tone in the palette that names
   no level — the house rule (a level's hue appears only when it names that
   level) rules out teal, green and the rest for a chrome element like this. */
.finder {
  background: var(--surface);
  border-radius: var(--r-xl);
  /* Was --shadow-lg. On a saturated ground the card needs to look like it is
     floating ON the water rather than printed onto it, and that is a matter of
     how far the shadow spreads, not how dark it is. */
  box-shadow: 0 2px 6px rgba(5, 29, 38, 0.06), 0 22px 50px -18px rgba(5, 29, 38, 0.4);
  overflow: hidden;
}
.finder__progress {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  padding: 0.95rem var(--space-md);
  background: var(--trench);
  color: var(--white);
}
.finder__bar {
  position: relative;
  flex: 1;
  height: 8px;
  border-radius: var(--r-pill);
  background: rgba(255, 255, 255, 0.22);
  overflow: hidden;
}
.finder__bar i {
  display: block;
  height: 100%;
  width: 25%;
  border-radius: var(--r-pill);
  /* Was --teal → --green. Both of those name levels, and on --trench they
     turned muddy besides. Light on water instead — the same idea as the
     caustics in the hero, at 3mm tall. */
  background: linear-gradient(90deg, var(--water-lit), var(--white));
  transition: width 0.55s var(--ease-out);
}
/* Segment ticks. A continuous bar tells a parent they are "somewhere"; four
   segments tell them there are four questions and they have done two, which is
   the fact that decides whether they finish. Drawn as gaps punched out of the
   track in the head's own colour, so the count is a property of the component
   rather than something the two scripts driving it have to know about — both
   still just set a percentage width. --seg is here to be overridden if a form
   ever has a different number of questions. */
.finder__bar::after {
  content: "";
  position: absolute;
  inset: 0;
  --seg: 4;
  background: repeating-linear-gradient(90deg,
    transparent 0 calc(100% / var(--seg) - 5px),
    var(--trench) calc(100% / var(--seg) - 5px) calc(100% / var(--seg)));
}
.finder__count {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.9rem;
  letter-spacing: 0.04em;
  color: var(--white);
  white-space: nowrap;
}

.finder__body { padding: var(--space-md); }
@media (min-width: 720px) { .finder__body { padding: var(--space-lg); } }

/* ---------------------------------------------------------- Step transitions
   The wrapper exists so the card can animate its own height. Without it the
   steps are siblings in the <form> and a swap resizes the card in one frame —
   which is most of why the page appeared to jump when an option was clicked.

   overflow-anchor is the other half. Chrome's scroll anchoring watches for
   exactly this kind of height change above the viewport and scrolls to
   compensate, so the browser was actively fighting the animation. Turning it
   off here is the difference between "the card grew" and "the page moved". */
.finder__steps {
  position: relative;
  overflow-anchor: none;
}
/* Clipping is applied only while a swap is in flight. Permanent overflow:hidden
   would cut the 3px focus ring off the option cards at the wrapper's edge, and
   a keyboard user losing the focus indicator on the site's only conversion path
   is not a trade worth making for a transition. */
.finder__steps.is-swapping { overflow: hidden; }

.step { display: none; }
.step.is-active { display: block; }
/* The outgoing step keeps painting while it leaves, taken out of flow so the
   incoming one can occupy the same space and the two genuinely cross rather
   than queue. Higher specificity than .step above, which is why it is written
   as a compound. */
.step.step--leaving {
  display: block;
  position: absolute;
  inset: 0 0 auto 0;
  pointer-events: none;
}
.step h2 { margin-bottom: 0.35rem; }
.step > .lead { margin-bottom: var(--space-md); }

.finder__nav {
  display: flex;
  gap: var(--space-xs);
  justify-content: space-between;
  align-items: center;
  margin-top: var(--space-md);
  padding-top: var(--space-md);
  border-top: 1px solid var(--border);
}

/* ------------------------------------------------- Direct channels (contact)
   The phone number and the email address, beside the Smart Form. Deliberately
   NOT cards, and the distinction is the whole design: two cards side by side
   are an invitation to compare two options, and these are not options — they
   are one escape hatch, for the parent who does not want to fill in a form, and
   the form is already the recommendation. So they are two rows of a single
   plate, divided by a hairline.

   It is also pitched a full step below the form card on purpose. Same white,
   but a smaller radius, a shadow a quarter the depth, and no lift on hover:
   near the site's most important object, the second-most important thing a
   component can do is decline to compete with it. */
.reach__lead {
  font-family: var(--font-display);
  font-weight: 700;
  color: var(--ink);
  margin: var(--space-md) 0 0.6rem;
}
.reach {
  list-style: none;
  margin: 0;
  padding: 0;
  background: var(--surface);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-sm);
  overflow: hidden;
}
.reach li + li { border-top: 1px solid var(--border); }
.reach a {
  display: flex;
  align-items: flex-start;
  gap: 0.9rem;
  padding: var(--space-sm);
  text-decoration: none;
  color: inherit;
  transition: background-color 0.2s var(--ease-out);
}
.reach a:hover { background: var(--pool-50); }
.reach a:focus-visible { outline: 3px solid var(--focus); outline-offset: -3px; }
.reach__icon {
  flex: none;
  width: 46px;
  height: 46px;
  display: grid;
  place-items: center;
  border-radius: var(--r-md);
  background: var(--pool-100);
  color: var(--teal-ink);
}
.reach__icon svg { width: 24px; height: 24px; }
.reach__text { display: block; min-width: 0; }
.reach__label {
  display: block;
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-faint);
}
.reach__value {
  display: block;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.2rem;
  line-height: 1.2;
  color: var(--ink);
}
/* The address is 31 characters of unbreakable string. At the display size above
   it overflows a 400px column on a phone, and shrinking the whole tier to suit
   the longest value would quieten the phone number as well — so only the long
   one steps down, and only where it has to. */
.reach__value--long { font-size: 1rem; word-break: break-word; }
.reach__note {
  display: block;
  margin-top: 0.3rem;
  font-size: 0.9rem;
  color: var(--ink-soft);
}
/* The chevron tracks the pointer without the row moving. A lift here would put
   this plate in the same motion vocabulary as the cards it replaced, which is
   the comparison the layout is trying not to invite. */
.reach__go {
  flex: none;
  align-self: center;
  color: var(--teal-ink);
  transition: transform 0.25s var(--ease-out);
}
.reach__go svg { width: 20px; height: 20px; display: block; }
.reach a:hover .reach__go { transform: translateX(4px); }

/* ----------------------------------------------------- Visit us (contact) */
/* THE PLATE. One white object holding the three facts that used to sit loose on
   the band — hours, address, directions. See the note in contact.html for why
   the ground moved and why the facts had to become an object once it did: a
   white plate on --pool-200 separates by a margin no tint can buy, and it is
   the same device the level cards are built on.

   Deliberately NOT a .card: it is not one of a set, nothing is being compared,
   and it does not lift on hover. A card's whole grammar implies siblings. */
.visit-card {
  margin-top: var(--space-md);
  background: var(--surface);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-md);
  overflow: hidden;
}
/* The one saturated element in the column, and it takes --teal. It used to take
   --trench, on the theory that a header strip in a colour the page uses nowhere
   else would be decoration, while the closing tone was a preview of where the
   reader was heading. That argument inverted the day the section itself became
   the closing tone: a --trench strip on a --trench-family ground stops
   previewing anything and just blends into it. --teal is the palette's other
   reserved accent — ink on teal clears 6.9:1, comfortably past the white on
   trench it replaces, and the strip goes back to reading as an object held
   against the water rather than part of it. */
.visit-card__head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem var(--space-sm);
  padding: 0.9rem var(--space-sm);
  background: var(--teal);
}
.visit-card__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.05rem;
  letter-spacing: 0.02em;
  color: var(--ink);
}

/* THE LIVE STATUS. A swim school's opening hours are the one fact on a contact
   page with a right answer that changes hourly, and printing the table alone
   makes the reader do the arithmetic — on a Sunday afternoon, on a phone, while
   deciding whether it is worth driving over.

   NOT A PILL, AND THE PILL IS WHAT WAS WRONG WITH IT. A capsule is the badge
   grammar of a dashboard: it says "system status", it implies a set of other
   capsules somewhere, and in its resting state — closed, which is most of any
   given week — it renders as a grey lozenge of small type. On a plate whose
   other three elements are a heading, a table and a button, it was the only
   object pretending to be a component. The information is one short sentence,
   so it is set as one short sentence, sitting opposite the heading on the same
   baseline. Nothing to draw, nothing to contain it, and it now reads as the
   card telling you something rather than as a widget reporting.

   Colour is constrained and worth recording. The obvious "open" green is
   --green #7dbe3c, which is Seal's identity colour exactly, and the house rule
   is that a level's hue appears only when it is naming that level. So the two
   states are separated by the DOT's construction rather than by hue — filled
   and breathing for open, an open ring for closed — which is also why it
   survives being read by someone who cannot separate the states by colour.
   Ink on --teal is 6.9:1, so both states carry full-strength type; the dimming
   happens on the mark, never on the words. */
.visit-status {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.85rem;
  letter-spacing: 0.01em;
  color: var(--ink);
}
/* Closed: an open ring. Present, clearly a status mark, and visibly not lit. */
.visit-status::before {
  content: "";
  width: 9px;
  height: 9px;
  flex: none;
  border-radius: 50%;
  box-shadow: inset 0 0 0 2px currentColor;
  opacity: 0.55;
}
/* Open: the ring fills in and breathes. */
.visit-status.is-open::before {
  background: currentColor;
  opacity: 1;
  animation: visit-pulse 2.4s var(--ease-out) infinite;
}
/* A dot that fades in place rather than one that scales: a growing ring on a
   9px dot at this size renders as a flicker. */
@keyframes visit-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.28; }
}
@media (prefers-reduced-motion: reduce) {
  .visit-status.is-open::before { animation: none; }
}

/* Left-aligned, unlike the footer's version. The footer sets the values right
   because the column there is 200px wide and the days and times would otherwise
   drift apart; here there is room for the two to sit as one readable block. */
/* The gap is ZERO and the padding does the spacing instead. .hours is a
   two-column grid, so a dt and its dd are separate boxes — with a column gap
   between them the today highlight below renders as two disconnected pills with
   a stripe of white running down the middle of one row. Padding inside boxes
   that touch gives the same optical spacing and one continuous bar. */
.visit__hours {
  padding: var(--space-sm) var(--space-sm) 0;
  color: var(--ink-soft);
  gap: 0;
}
.visit__hours dt,
.visit__hours dd {
  padding: 0.55rem 0.8rem;
  text-align: left;
}
.visit__hours dt { color: var(--ink); }
.visit__hours dd { color: var(--ink); }
/* Today's row, lifted out of the list. Three rows is not much to search, but
   the whole point of the pill above is that the reader should not have to
   search at all — the highlight is what connects "open now" to the line the
   claim was derived from, so the table stays the source of truth instead of
   being replaced by an assertion. Marked on both halves, and the radius is
   split between them so the pair reads as one bar. */
.visit__hours .is-today {
  background: var(--pool-100);
  color: var(--ink);
  font-weight: 700;
}
.visit__hours dt.is-today {
  border-radius: var(--r-sm) 0 0 var(--r-sm);
  box-shadow: inset 4px 0 0 var(--teal);
  padding-left: 1.1rem;
}
.visit__hours dd.is-today { border-radius: 0 var(--r-sm) var(--r-sm) 0; }

.visit-card__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  margin-top: var(--space-sm);
  padding: var(--space-sm);
  border-top: 1px solid var(--border);
  background: var(--pool-50);
}
.visit__address {
  font-family: var(--font-display);
  font-style: normal;
  font-weight: 800;
  font-size: clamp(1.15rem, 1rem + 0.6vw, 1.4rem);
  line-height: 1.25;
  color: var(--ink);
  margin: 0;
}

/* ---------------------------------------------------------------- The map
   A Google embed is a rectangle of somebody else's design system, and bled to
   the container edge it was the one surface on the site with no art direction
   on it. Two moves fix that without touching the map's usefulness.

   MOUNTED, NOT BLED. A white frame with the map inset inside it, which reads as
   a print in a mount rather than as an iframe — and it makes the map a white
   object on water like everything else in the section. */
/* Bottom alignment with the hours plate opposite. #visit's split is
   align-items:start (see .split--top above) because the two columns are not
   the same height — the left one carries an eyebrow, a head and a lead before
   the plate even starts. Left at start, the map's fixed-height frame floats
   with its own air below it that has nothing to do with the plate beside it.
   Stretching the column and pushing the frame+caption to the end of it lines
   the two bottom edges up without touching the map's own proportions. */
@media (min-width: 900px) {
  #visit .split--top { align-items: stretch; }
  #visit .visit-map {
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
  }
}
.visit-map__frame {
  position: relative;
  padding: 10px;
  background: var(--surface);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-md);
  overflow: hidden;
  /* Fences the veil's blend to this frame. Without it the blend group is
     whatever stacking context happens to be closest, and the tint would reach
     past the mount onto the section's own ground — a bug that only appears
     once some unrelated rule changes what creates a stacking context above it.
     One property, and the frame is now the whole world the veil can see. */
  isolation: isolate;
}
.visit-map__frame iframe {
  border-radius: calc(var(--r-lg) - 10px);
  /* Google's base map is a set of near-neutrals; saturate() alone has almost
     nothing to act on, so the lift is small and the veil below does the work. */
  filter: saturate(1.12) contrast(1.02);
}
/* THE VEIL. mix-blend-mode:color takes the hue and saturation of this layer and
   the LUMINANCE of the map underneath, so every road, label and boundary keeps
   exactly the lightness it was drawn with — the map is recoloured, not dimmed,
   and nothing that was legible stops being legible. At 0.24 the beige goes to a
   pale house blue and the parks read as deep water.

   If a browser ignores the blend mode this degrades to a flat 24% --water wash,
   which is a tinted map rather than a broken one. There is no version of this
   that fails badly. */
.visit-map__veil {
  position: absolute;
  inset: 10px;
  border-radius: calc(var(--r-lg) - 10px);
  background: var(--water);
  mix-blend-mode: color;
  opacity: 0.24;
  pointer-events: none;
}
.visit-map__cap {
  margin: 0.9rem 0 0;
  padding-left: 0.2rem;
  font-size: 0.9rem;
  font-weight: 700;
  /* --ink-soft was for the pool-200 ground this sat on originally; on the deep
     ground it now sits on, the same muted-but-legible job falls to the tone
     .deep .lead already uses one column over. */
  color: #cfe7f4;
}

/* Contextual FAQ that surfaces as the parent answers */
.helper {
  margin-top: var(--space-md);
  padding: var(--space-sm);
  border-radius: var(--r-md);
  background: var(--pool-50);
  border-left: 5px solid var(--teal);
}
.helper h4 {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.4rem;
  font-size: 1rem;
}
.helper h4 svg { width: 20px; height: 20px; color: var(--teal-ink); flex: none; }
.helper p { font-size: 0.95rem; margin-bottom: 0.6rem; }
.helper p:last-child { margin-bottom: 0; }

.result__hero {
  padding: var(--space-md);
  border-radius: var(--r-lg);
  background: var(--accent, var(--teal));
  margin-bottom: var(--space-md);
}
.result__hero .level__name { font-size: 2rem; }

/* --------------------------------------------------------------- 14. Footer */
/* The deepest block on the site, and the only one that ends the page rather
   than continuing it. It is two steps below the closing section's floor (see
   --abyss) for one structural reason: the CTA is the last thing being SOLD and
   the footer is the last thing being FILED, and a reader should be able to feel
   that change of register before reading a word of it. When both sat on
   --trench they were one block with a wave drawn through it, and the CTA's
   headline and buttons were competing with a directory of links on identical
   ground.

   Depth also buys back hierarchy. On --trench, white was the only ink that
   cleared 4.5:1, so the whole footer spoke in one voice. On --abyss there is
   room for three, and they are used the way a masthead uses them:

     --white    9.68:1   column headings, opening hours days, the wordmark
     #a9cbd4    5.61:1   links, address, description — the body of the footer
     #93bcc8    4.73:1   the legal line, which should recede and now can

   The middle tier is what actually makes this look expensive. A footer where
   the copyright notice is as loud as the phone number is a footer nobody
   designed. */
.footer {
  background: var(--abyss);
  color: var(--footer-ink);
  padding-block: var(--space-xl) var(--space-md);
  font-size: 0.97rem;
  /* Set here rather than in :root — these two tones are legible on --abyss and
     nowhere else on the site, so scoping them to the footer stops them being
     picked up as general-purpose greys. */
  --footer-ink: #a9cbd4;
  --footer-faint: #93bcc8;
}
.footer h4 {
  color: var(--white);
  font-size: 1.05rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  margin-bottom: var(--space-xs);
}
.footer a { color: var(--footer-ink); text-decoration: none; }
.footer a:hover { color: var(--yellow); text-decoration: underline; }
.footer ul { list-style: none; margin: 0; padding: 0; }
/* Footer list links measured 21px tall, under the 24px WCAG 2.2 target-size
   minimum. Padding rather than min-height, so multi-line entries (the street
   address) still grow instead of overflowing. */
.footer li { margin-bottom: 0.1rem; }
.footer li a { display: inline-block; padding-block: 0.42rem; }
.footer__grid { display: grid; gap: var(--space-lg); }
@media (min-width: 720px) { .footer__grid { grid-template-columns: 1.4fr 1fr 1fr 1.2fr; } }
.footer__logo { height: 90px; width: auto; margin-bottom: var(--space-sm); }
.footer__bottom {
  margin-top: var(--space-lg);
  padding-top: var(--space-sm);
  /* Up from 0.14 — the same alpha over a ground this much darker composited to
     a line you had to look for. */
  border-top: 1px solid rgba(255, 255, 255, 0.18);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs) var(--space-sm);
  justify-content: space-between;
  font-size: 0.87rem;
  /* The faint tier, and the one place on the page that gets to be quiet again.
     This was forced to pure white while the footer sat on --trench — a
     copyright line shouting at the same volume as the phone number above it.
     #93bcc8 clears 4.73:1 on --abyss. */
  color: var(--footer-faint);
}
.footer__social { display: flex; gap: 0.6rem; }
/* White rather than the link tier these would otherwise inherit. The glyph sits
   on a disc that is itself a 10% white lift, so the soft tone composites to
   4.16:1 against it — legal for a non-text graphic, but these are the only two
   brand marks in the footer and they should be the loudest thing in the column,
   not the quietest. */
.footer__social a {
  color: var(--white);
  width: 44px; height: 44px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.1);
  transition: background-color 0.2s var(--ease-out), transform 0.2s var(--ease-out);
}
/* Was --teal-ink, which is now LIGHTER than the ground it sits on — the hover
   read as the disc going pale, not as it lifting. A white lift keeps the
   direction of travel the same as the transform. */
.footer__social a:hover { background: rgba(255, 255, 255, 0.24); transform: translateY(-3px); }
.footer__social svg { width: 22px; height: 22px; }

/* .hours is used in two opposite places and only one of them was ever styled.
   The day column was hard-set to --white, which is right in the footer and
   catastrophic everywhere else: the contact page and About both run this list
   on a pale ground, where "Monday – Friday" was white on near-white and simply
   absent. .visit__hours dt did try to set --ink, but it is the same specificity
   and declared 120 lines earlier, so it lost on source order — which is why the
   override looked correct in the file and did nothing on the page.

   The default is now the dark tier, and the white is scoped to the one ground
   that needs it. Both call sites are fixed by the scoping rather than by adding
   another override to out-order the first one. */
.hours { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 1rem; }
.hours dt { font-weight: 700; color: var(--ink); }
.hours dd { margin: 0; text-align: right; }
.footer .hours dt { color: var(--white); }

/* -------------------------------------------------- 15. Motion primitives */
/* GSAP animates these; the class only supplies the pre-animation state, and
   .no-motion (set by JS when reduced motion is on) neutralises it. */
/* 26px of travel over 0.55s was too small a gesture to register — the reveal
   finished before the eye arrived. Travel and duration are both up, and the
   trigger fires later (see START in main.js) so movement happens while the
   element is actually being looked at rather than below the fold. */
[data-reveal] { opacity: 0; transform: translate3d(0, 44px, 0); }
[data-reveal="scale"] { transform: translate3d(0, 30px, 0) scale(0.94); }
[data-reveal="left"]  { transform: translate3d(-38px, 0, 0); }
[data-reveal="right"] { transform: translate3d(38px, 0, 0); }
/* Dealt-card settle: arrives with a slight tilt and straightens. --deal is set
   per element so alternating cards lean opposite ways. */
[data-reveal="deal"]  { transform: translate3d(0, 46px, 0) rotate(var(--deal, -3deg)); }

/* GSAP finishes a tween by leaving `transform` inline on the element, and an
   inline transform cannot be overridden by a stylesheet :hover rule — which
   silently killed every card-lift hover on the page (.lvl, .quote, .card--link).
   main.js therefore adds .is-revealed and clears the inline props on complete,
   handing the element back to CSS. Anything with a :hover transform must be
   declared after this rule to win the specificity tie. */
[data-reveal].is-revealed { opacity: 1; transform: none; }

/* .card--link and .level's hover rules, exiled here from section 6 for the
   reason directly above: both carry [data-reveal], both tie that rule's
   specificity (one class + one pseudo-class either way), and a tie is decided
   by source order. .lvl's and .quote's own :hover rules already lived below
   this point and never had the problem — these two are the ones that did.

   Gated to hover-capable pointers, same as .lvl's mascot leap: on touch,
   :hover can stick after a tap until something else is touched, which would
   leave a card visibly enlarged with nothing pressing it.

   Enter and leave use different curves. --ease-back overshoots past 1 before
   settling, so arriving reads as a small spring; the same overshoot on the
   way out would undershoot past resting size and read as a wobble rather than
   a settle. Each base rule's own --ease-out (no overshoot) is what runs on
   mouseleave, since a transition's curve is read from whichever rule is
   active at the moment the property changes — see .card--link and .level in
   section 6 for those. */
@media (hover: hover) {
  .card--link:hover {
    transform: translateY(-3px) scale(1.045);
    box-shadow: var(--shadow-xl);
    transition: transform 0.5s var(--ease-back), box-shadow 0.35s var(--ease-out);
    position: relative;
    z-index: 2;
  }
  .level:hover {
    transform: translateY(-3px) scale(1.045);
    box-shadow: var(--shadow-xl);
    transition: transform 0.5s var(--ease-back), box-shadow 0.35s var(--ease-out);
    z-index: 2;
  }
}

.no-motion [data-reveal] { opacity: 1 !important; transform: none !important; }

/* If JS fails to load, nothing should stay invisible. */
.no-js [data-reveal] { opacity: 1; transform: none; }

/* Stroke-drawn SVG detail. Pre-animation state only; main.js measures each
   path and animates the offset to 0. Never applied to anything load-bearing —
   if the tween never runs, the failsafe below still paints the stroke. */
[data-draw] path,
[data-draw] circle,
[data-draw] line { stroke-dasharray: var(--len, 0); stroke-dashoffset: var(--len, 0); }
.no-js [data-draw] path,
.no-js [data-draw] circle,
.no-js [data-draw] line,
.no-motion [data-draw] path,
.no-motion [data-draw] circle,
.no-motion [data-draw] line { stroke-dashoffset: 0 !important; }

/* Continuous buoyancy for mascots and decorative art. Each instance gets its
   own duration and delay from JS so nothing ever bobs in lockstep. */
.bob { animation: bob var(--bob-dur, 6s) ease-in-out var(--bob-delay, 0s) infinite; will-change: transform; }
@keyframes bob {
  0%, 100% { transform: translate3d(0, 0, 0) rotate(0deg); }
  50%      { transform: translate3d(0, -12px, 0) rotate(var(--bob-rot, 1.5deg)); }
}

/* Companion path for the second half of a mascot pair. Same family, same
   duration pool, same randomised offsets — it just sculls sideways on the way
   up and comes down by a different route, so two mascots side by side read as
   two animals in the same water rather than one animation used twice.
   Overrides only animation-name, so it must stay after .bob in source order. */
.bob--drift { animation-name: bob-drift; }
@keyframes bob-drift {
  0%, 100% { transform: translate3d(0, 0, 0) rotate(0deg); }
  35%      { transform: translate3d(-5px, -6px, 0) rotate(calc(var(--bob-rot, 1.5deg) * -0.55)); }
  70%      { transform: translate3d(4px, -13px, 0) rotate(var(--bob-rot, 1.5deg)); }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  [data-reveal] { opacity: 1 !important; transform: none !important; }
  [data-draw] path,
  [data-draw] circle,
  [data-draw] line { stroke-dashoffset: 0 !important; }
  .bob { animation: none !important; }
  .bubbles, .hero__media::after, .hero__frame::after, .hero__water::before { display: none; }
}

/* ------------------------------------------------------------ 16. Utilities */
.pill-list { display: flex; flex-wrap: wrap; gap: 0.5rem; list-style: none; margin: 0; padding: 0; }
.pill-list li {
  padding: 0.35rem 0.85rem;
  border-radius: var(--r-pill);
  background: var(--pool-100);
  font-size: 0.88rem;
  font-weight: 700;
  color: var(--ink);
}
/* The scale had --space-sm but no .mt-sm, so anything wanting the tight gap
   reached for .mt-md and got the medium one. That is how a page ends up with one
   spacing value doing three different jobs — see the level blocks on
   programs.html, where the rhythm now runs md / sm / lg. */
.mt-sm { margin-top: var(--space-sm); }
.mt-md { margin-top: var(--space-md); }
.mt-lg { margin-top: var(--space-lg); }
/* The top of the scale was missing for the same reason .mt-sm was: the founder
   section puts two rows inside one band, and the gap between them has to be
   bigger than the gap between a heading and its list, or the second row reads as
   a continuation of the first rather than as the next movement. */
.mt-xl { margin-top: var(--space-xl); }
.mb-0 { margin-bottom: 0; }
.rounded-media { border-radius: var(--r-lg); overflow: hidden; box-shadow: var(--shadow-md); }

/* Placeholder imagery — clearly a stand-in, not a stock photo pretending
   to be the real pool. Documented in README for swap-out. */
.ph {
  position: relative;
  display: grid;
  place-items: center;
  background:
    linear-gradient(135deg, var(--pool-200) 0%, var(--pool-100) 100%);
  color: var(--teal-ink);
  border-radius: var(--r-lg);
  aspect-ratio: 4 / 3;
  overflow: hidden;
}
.ph::after {
  content: attr(data-label);
  position: absolute;
  bottom: 12px;
  left: 12px;
  padding: 0.3rem 0.7rem;
  border-radius: var(--r-pill);
  background: rgba(18, 48, 73, 0.72);
  color: #fff;
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}
.ph svg { width: 40%; max-width: 130px; height: auto; opacity: 0.55; }
.ph--wide { aspect-ratio: 16 / 9; }
.ph--square { aspect-ratio: 1; }

/* A real photo in the same rounded, shadowed frame the placeholders use, but
   with no aspect-ratio of its own — the image's own width/height attributes set
   the box, so a photo is never cropped to fit a slot that was chosen before the
   photo existed. The intrinsic ratio also reserves the right amount of space
   before the file loads, so a lazy image lower down the page can't shift the
   layout under the reader. */
.frame {
  margin: 0;
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--shadow-lg);
  background: var(--pool-100);
}
.frame img { width: 100%; height: auto; }
/* Same contract as the img above. Without it the video is an inline replaced
   element and picks up the line-box descender gap, which shows as a few pixels
   of --pool-100 along the bottom inside the radius. */
.frame video { display: block; width: 100%; }

/* Caption. .visit-map__cap does this job one component over, but is coloured
   for the deep ground it ended up on; this is the same idea on a light band, so
   the muted-but-legible tone is --ink-soft, which .hours already uses on
   --pool-200 elsewhere on this page.

   The caption sits outside .frame rather than inside it — .frame clips to a
   28px radius and paints its own ground, so a figcaption within it would be
   boxed in with the photograph instead of sitting under it. Hence the wrapper:
   .framed carries the semantics, .frame carries the crop. */
.framed { margin: 0; }
.frame__cap {
  margin: 0.9rem 0 0;
  padding-left: 0.2rem;
  font-size: 0.9rem;
  font-weight: 700;
  color: var(--ink-soft);
}

/* ----------------------------------------------------- Our Smithfield Pool
   A --purple slab sat offset behind the photo here and it read as unfinished,
   correctly. The reason is worth keeping, because it is a general one: a block
   of colour that holds nothing has no way to look finished. The eye reads a
   filled rectangle as a container, goes looking for what it contains, finds an
   edge of empty purple, and concludes the layout is broken rather than styled.
   Decoration at that scale always reads as a mistake.

   So the colour stays and gets a job. The four facility facts used to be a
   plain checklist in the copy column — the most skimmable content in the
   section set as the least interesting thing in it — and they now live inside
   the block, which turns the same area of colour from a slab into a card.

   --yellow, and not one of the deeper hues, because this one sits ON the
   photograph. The pool image is overwhelmingly blue and cyan, so a warm
   complementary is the only thing that will separate from it at a glance, and
   yellow is the warmest the logo has. It also takes --ink at 11.04:1, the
   highest of any brand colour, so the card can be fully saturated and still
   carry four lines of body copy — which none of the deep hues can do.

   The overlap is the other half of the fix. Butted below the photo this would
   be a caption block; pulled up into it, the two become one object, and the
   card reads as deliberately placed rather than left over. */
.pool-media { position: relative; }
.pool-card {
  position: relative;
  z-index: 2;
  border-radius: var(--r-lg);
  background: var(--yellow);
  color: var(--ink);
  /* Tighter top and bottom than the 2rem it used to carry on all four sides:
     the rows now hold their own vertical rhythm, and stacking the card's
     padding on top of the first row's would push the masthead away from the
     rule that belongs to it. The horizontal measure is untouched. */
  padding: 1.5rem var(--space-md) 1.35rem;
  box-shadow: inset 0 -6px 0 rgba(18, 48, 73, 0.13), var(--shadow-lg);
}
.pool-card__title {
  font-family: var(--font-display);
  font-weight: 800;
  /* Up from 0.86rem, and given a rule. At 512px wide the old label was a
     caption sitting on top of a block rather than a masthead belonging to one —
     too small to hold the card's whole width, so the first thing the eye landed
     on was the middle of the list. The hairline under it is what makes it a
     header: it draws the width, and it starts the run of rules the spec rows
     continue, so the card reads as one ruled object instead of a label plus
     some text. */
  font-size: 0.94rem;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  margin: 0;
  padding-bottom: 0.85rem;
  border-bottom: 1px solid rgba(5, 29, 38, 0.22);
  /* Deliberately not --ink: the label and the four items would otherwise be the
     same colour, and on a ground this bright that reads as one undifferentiated
     block of dark type.

     Yellow is a brutal ground to put a coloured label on, because it is the
     lightest thing in the palette — measured against it, orange-ink is 3.55,
     green-ink 3.43, magenta-ink 4.08 and even teal-ink only 4.29. All four
     fail. --purple-ink is 6.25 and is the only accent with real headroom.

     Which is the right answer for a second reason. --purple is the one logo hue
     nothing else on this page can use: --ink is 2.41:1 on it, so it can never
     be a tile or a disc, and it had no home anywhere in the design. On yellow
     it is not the ground, it is the ink — and purple on yellow is a complement
     pairing, so the hue that fitted nowhere ends up in the one place it is
     strongest. */
  color: var(--purple-ink);
}
/* THE SPEC PLATE.
   Three levels of ink doing three different jobs, which is the whole reason
   this stopped reading flat. --purple-ink names the object once, at the top.
   The keys sit at 70% ink — present enough to be read as a column, quiet enough
   that they never compete with the fact beside them, and still 5.4:1 on yellow,
   which is well clear of AA. The facts are full-strength ink at 11:1. Nothing
   here is decoration; each step down the scale is a step down in importance,
   and the eye can take the column of keys or the column of facts on its own.

   Rules rather than gaps. A tick list separates its items with whitespace and
   therefore does not separate them at all — four lines of similar length read
   as a paragraph. A hairline is unambiguous, costs no vertical space, and at
   14% on yellow it is a seam rather than a border. */
.spec { margin: 0; }
.spec__row {
  display: grid;
  /* Measured, not guessed: the widest key sets to 67px and the widest fact to
     235px, so the columns are cut to just clear them. A rule that runs 175px
     past the end of its own row is what makes a table look like text floating
     in a box — the line has to feel like it is holding something. */
  grid-template-columns: 5.4rem 1fr;
  gap: 0 1rem;
  align-items: baseline;
  padding: 0.68rem 0;
  border-bottom: 1px solid rgba(5, 29, 38, 0.14);
}
/* The link's own rule closes the table, so the last row must not draw one
   underneath it as well — two lines a millimetre apart is the tell of a
   component assembled rather than drawn. */
.spec__row:last-child { border-bottom: 0; }
.spec dt {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: rgba(5, 29, 38, 0.7);
}
.spec dd {
  margin: 0;
  font-size: 0.98rem;
  line-height: 1.45;
  color: var(--ink);
}
/* The figure is the thing anybody actually came for, so the display face gets
   it. 32, 30, four to six, six months — set one step up and in Baloo they read
   before the words around them do, which is exactly the order a parent scans
   in. It is also the only place on the page the display face appears inside a
   line of body copy, and it earns that by marking data rather than emphasis. */
.spec b {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.08em;
  letter-spacing: -0.005em;
}

/* The door. A rule above it heavier than the seams between the rows, so it
   closes the table rather than continuing it, and the arrow parked at the far
   edge — the card is 512px wide and a link hugging its own text in the
   bottom-left would go back to floating. */
.pool-card__link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-top: 0.2rem;
  padding-top: 1rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.02rem;
  color: var(--ink);
  text-decoration: none;
  border-top: 1px solid rgba(5, 29, 38, 0.24);
}
/* RINGED, because a bare arrow at the end of a rule is a chevron in a footer
   and every site has one. A ring turns the end of the plate into an object you
   could put your thumb on — which on a phone is literally true, and it is also
   the one element in the card that is not a line or a word, so it closes the
   composition instead of trailing off it. */
.pool-card__link svg {
  width: 36px; height: 36px;
  padding: 8px;
  flex: 0 0 auto;
  box-sizing: border-box;
  border: 2px solid rgba(5, 29, 38, 0.26);
  border-radius: 50%;
  transition: transform 0.34s cubic-bezier(0.2, 0.8, 0.25, 1),
              background-color 0.24s ease, border-color 0.24s ease, color 0.24s ease;
}
/* The ring FILLS rather than the label changing colour: on --yellow there is
   nowhere for a text colour to go that is not either invisible or a second
   accent, so the state change lives in the one element that can carry a solid.
   The nudge is small — the ring is the event, the travel just confirms it. */
/* Outside the hover query, because a device with a keyboard and no pointer
   would otherwise get the focus outline and none of the response. */
.pool-card__link:focus-visible svg,
.pool-card__link:hover svg {
  background: var(--ink);
  border-color: var(--ink);
  color: var(--yellow);
  transform: translateX(3px);
}
@media (hover: hover) {
  .pool-card__link:hover { text-decoration: underline; text-underline-offset: 5px; text-decoration-thickness: 2px; }
}
.pool-card__link:focus-visible {
  outline: 3px solid var(--ink);
  outline-offset: 4px;
  border-radius: 4px;
}

/* The table survives the phone rather than being replaced by one — the columns
   are narrow enough to hold at 375px, and a key-above-fact stack would double
   the plate's height for no gain in legibility. Only the gutter gives way. */
@media (max-width: 479px) {
  /* 4.5rem clears the widest key ("CLASS SIZE", 62px) with room to spare and
     hands the difference to the facts, which is where it is needed — at 375px
     the longest of them measures 172px against 189px of column, and a spec that
     wraps stops being a spec. */
  .spec__row { grid-template-columns: 4.5rem 1fr; gap: 0 0.7rem; }
  .spec dt { font-size: 0.66rem; }
}

/* BUDGET THE PARALLAX, not just the resting position. The figure carries
   [data-parallax="-26"], which animates y from 0 to -26 across the whole
   scroll-through — so the photo rises by up to 26px while the card, which is
   not animated, stays put. The visual overlap is therefore
   (margin - 26) at the far end of the travel, not `margin`.

   Miss that and the number you set is the BEST case rather than the worst one.
   The first pass used -28px on phones, which measured a healthy 28px overlap at
   rest and collapsed to 2px by the end of the scroll — the two elements
   effectively touching, which is the caption-bar reading this layout exists to
   avoid. Both values below are chosen so the overlap never drops under ~20px.

   The breathing is worth keeping, though: on desktop the overlap moves between
   46 and 72px as you scroll, so the photo slides against the card rather than
   the pair travelling as one block. */
/* THE COMPOSITION SWITCHES WHERE THE PLATE STOPS FITTING AS AN OBJECT, which is
   640px — the point at which the container is wide enough to hold a 440px plate
   AND leave frame showing to the left of it. Above that the overlap works and is
   the better idea; below it there is no room to inset anything and the treatment
   at the foot of this file takes over. */
@media (min-width: 640px) {
  /* Overlap the photo's lower-left corner. Inset from the right so the card is
     visibly narrower than the frame — matched edges would read as a caption bar
     bolted to the bottom, which is the generic version of this idea. */
  .pool-card { max-width: 27.5rem; margin: -48px var(--space-lg) 0 0; }
}
@media (min-width: 900px) {
  /* Deeper, because the frame is deeper: -72 of 580 and -48 of 400 are the same
     proportion of the media, which is what keeps the composition constant
     rather than the number. */
  .pool-card { margin: -72px var(--space-xl) 0 0; }
}
/* ON A PHONE THE OVERLAP HAS TO GO, AND THE IDEA BEHIND IT DOES NOT.
   The overlap works on desktop because the card is 440px of plate placed on the
   corner of a 1160px frame: a small object on a big one, offset, so the two read
   as composed. None of that survives at 375px. The card is exactly as wide as
   the frame — there is no room to inset it — and it stands 297px against a
   210px video, so the pull-up stopped being a plate on a corner and became a
   caption bar covering a fifth of the footage, which is the exact thing the
   desktop rule was written to avoid.

   So the two become ONE OBJECT instead of two overlapping ones. The frame keeps
   the top corners, the card keeps the bottom, they meet on a hard seam and a
   single silhouette carries the shadow. That is the same argument the overlap
   was making — these belong together — expressed in the way that is available
   at this width. Nothing is covered, and the video gets all 210px of itself
   back.

   The parallax has to stop for it. Drift is why the overlap was budgeted at -48
   in the first place, and an object cannot shear along its own seam: 26px of
   travel on the frame alone would open a gap under it. It is a cheap loss —
   26px on a 210px frame, over a whole scroll-through, on the one device where
   the reader is moving the page themselves anyway.

   639.98 and not 639. A viewport is not an integer — on a 2x display it lands on
   639.5 often enough — and `max-width: 639px` paired with `min-width: 640px`
   leaves a half-pixel band where NEITHER matches and the section falls back to
   the unstyled base. Caught it by measuring at exactly 639: the plate detached
   from the frame and floated 8px below it. */
@media (max-width: 639.98px) {
  .pool-media {
    border-radius: var(--r-lg);
    overflow: hidden;
    box-shadow: var(--shadow-lg);
  }
  .pool-media .reel {
    border-radius: 0;
    box-shadow: none;
    /* Beats the inline transform ScrollTrigger writes each frame. */
    transform: none !important;
  }
  .pool-card {
    margin: 0;
    border-radius: 0;
    /* The inset highlight at the top is what sells the seam: the video reads as
       RESTING on the plate rather than being butted against it, which is the
       difference between one object and two stacked ones. */
    box-shadow: inset 0 9px 14px -11px rgba(5, 29, 38, 0.55),
                inset 0 -6px 0 rgba(18, 48, 73, 0.13);
  }

  /* RULES TO THE EDGES. Inside the padding they are four lines floating in a
     yellow box; run to the plate's own edges they become a panel — the single
     cheapest move there is for making a stack of text read as designed rather
     than typed, and the right reference for a swim school, which is a timetable
     on a wall. The head and the foot get it too, so the card resolves into three
     zones you can see at a glance instead of one block you have to read. */
  .pool-card { padding: 1.35rem var(--space-md) 0; }
  .pool-card__title {
    margin-inline: calc(var(--space-md) * -1);
    padding-inline: var(--space-md);
    font-size: 1rem;
  }
  .spec { margin-inline: calc(var(--space-md) * -1); }
  .spec__row { padding-inline: var(--space-md); }
  .spec dd { font-size: 1.02rem; }
  .pool-card__link {
    margin: 0 calc(var(--space-md) * -1);
    padding: 1rem var(--space-md) 1.15rem;
    font-size: 1.06rem;
  }
}

/* ------------------------------------------------------------- 17.7b The note
   A tinted box with a fat rule down its left edge is the house style of every
   documentation site on the internet, and it belonged to none of them less than
   it belonged here: it carried no idea this site uses anywhere else, so on a
   page of mascots and water it read as a component borrowed from a different
   product.

   What replaces it is the deep end. The panel is the one place on a page that
   goes darker rather than lighter, which is what makes a reader stop at it —
   and it is the same descent the page itself makes, from the surface bands at
   the top down to the trench at the closing section. Light plays across it the
   way it plays across the hero.

   No edge rule, and none anywhere else on it either. What separates the note
   from the page is that it is a different depth of water. */
.note {
  padding: var(--space-md);
  border-radius: var(--r-lg);
  color: #eafcff;
  font-size: 1rem;
  /* Light in deep water, as a wash rather than as objects.

     This began as three soft circles on a layer of their own, and they read as
     three circles — discrete enough that the eye found them, counted them and
     went back to them instead of to the sentence. Their SOFTNESS was the part
     worth keeping, so it is kept and the shapes are thrown away.

     What makes this a wash and not a set of orbs is that every source is
     elliptical and centred OFF the panel: at 12%/0% and 88%/100% the light
     enters from beyond the corners, so no stop ever closes into a visible edge
     inside the panel. The third is a darkening at the foot rather than a
     lightening — water loses light with depth, and it is what stops the two
     highlights reading as a flat gloss laid over a rectangle. */
  background:
    radial-gradient(115% 80% at 12% -10%, rgba(255, 255, 255, 0.17), rgba(255, 255, 255, 0) 62%),
    radial-gradient(95% 75% at 92% 108%, rgba(255, 255, 255, 0.11), rgba(255, 255, 255, 0) 66%),
    radial-gradient(80% 120% at 55% 135%, rgba(2, 34, 40, 0.30), rgba(2, 34, 40, 0) 60%),
    var(--teal-ink);
}
/* The lead phrase of every one of these is a question or a claim — "What age
   can children start swimming lessons?", "Lessons are not a substitute for
   supervision." Bolded inline it looked like emphasis mid-paragraph. On its own
   line in the display face it is what it actually is: the thing the note is
   about, with the answer under it. */
/* :first-of-type, not :first-child — the light layer is inserted ahead of the
   copy, so the lead phrase is no longer the panel's first child and a
   :first-child hook would silently stop matching the moment the script runs. */
.note > strong:first-of-type,
.note > p:first-of-type > strong:first-of-type {
  display: block;
  margin-bottom: 0.45rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.25rem;
  line-height: 1.3;
  color: var(--white);
}
.note > :last-child { margin-bottom: 0; }
/* Links have to carry on a ground this dark. #a8ecf5 measures 8.6:1 on
   --teal-ink; the underline stays, because colour alone is not a link. */
.note a { color: #a8ecf5; text-underline-offset: 0.15em; }
.note a:hover { color: var(--white); }

/* The reading-width variant. Everything the modifier used to carry — its own
   ground, its own rule colour, its own shadow — is the base component now, so
   what is left is the one thing it was really for: holding a lifted answer to a
   line length you can read, and centring it under a centred section head. */
.note--flat {
  max-width: 62ch;
  margin-inline: auto;
}

/* ------------------------------------------------------ 17.7c Pull statement
   One sentence, lifted out of a paragraph and given the page's largest type.

   Not a blockquote — nobody said it — and not an h2, because it is an assertion
   rather than a section title and putting it in the outline would make the
   document structure lie. It is a <p> that has been allowed to be loud, which is
   exactly what it is.

   Line length is capped in ch rather than px: the whole effect depends on the
   sentence breaking into two or three balanced lines, and a px cap re-breaks it
   badly the moment the copy changes. text-wrap:balance does the rest where it
   is supported and is silently ignored where it isn't. */
.pull-statement {
  max-width: 20ch;
  margin: var(--space-lg) auto;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(1.9rem, 1.1rem + 3.1vw, 3.1rem);
  line-height: 1.12;
  letter-spacing: -0.015em;
  text-align: center;
  text-wrap: balance;
  color: var(--ink);
}

/* ==========================================================================
   17. Home page

   Art direction: "the pool from below". The page opens at the surface —
   bright, pale, airy — and gets deeper as you scroll, closing on a navy deep
   end above the footer. Every motion on the page belongs to one of three
   water behaviours: things RISE (bubbles, reveals), things FLOAT (mascots,
   card buoyancy) or things REFRACT (caustics, the drifting surface). Nothing
   moves for a reason outside that vocabulary, which is what stops a page full
   of micro-interactions from turning into a page full of noise.

   Scoped to .hero--home / new class names so the other eight pages, which
   share this stylesheet, are untouched.
   ========================================================================== */

/* --------------------------------------------------------- 17.1 Hero shell */
/* The hero was the tightest block on a site whose whole character is space.
   Every other section breathes on --space-xl (72px); this one ran --space-md
   above the copy and --space-lg below, so the one screen that has to feel
   confident was the one squeezed hardest. Both ends now clear --space-xl or
   better, and the floor keeps it commanding on short-content viewports without
   pushing the CTA under the fold on a laptop. */
.hero--home {
  padding-top: calc(var(--nav-h) + var(--space-xl));
  padding-bottom: var(--space-xl);
  min-height: min(94svh, 960px);
  /* The brief's gradient, kept stop for stop. The only additions are the grain
     tile and token names — #9fd8df is --water-lit, #288698 --water-shade,
     #2bb0d4/#2791a5 --water/--water-mid.

     Both radials are load-bearing and neither can move. The first is the light
     pool the headline reads against (dark ink 10.62:1). The second is the
     dead-zone value, parked behind the photo where nothing has to be legible.
     Swapping their positions breaks the hero in both directions at once. */
  background:
    var(--grain) repeat,
    radial-gradient(130% 95% at 12% -10%, var(--water-lit) 0%, transparent 58%),
    radial-gradient(90% 70% at 92% 8%, var(--water-shade) 0%, transparent 62%),
    linear-gradient(180deg, var(--water) 0%, var(--water-mid) 88%);
}

/* Caustic light drifting across the whole hero, not just the photo.
   ---------------------------------------------------------------------------
   PERFORMANCE — this block used to be the most expensive thing on the site.
   Profiled at 1280x860 the hero sat at ~36ms/frame (≈27fps) while *stationary*;
   the rest of the page idled at ~17ms. It read as "the hero lags when I
   scroll" only because scrolling is when you notice a frame budget that was
   already gone. Two things were wrong, and both were about compositing rather
   than about the artwork:

   1. There were two of these layers, each `inset: -25%` — 150% of the hero in
      both axes, so ~2.25x the hero's area, twice over, alpha-blended on top of
      the hero gradient, the photo and 24 bubbles every single frame. Pure
      fill-rate: swapping the gradients for a flat colour cost exactly the same,
      so it was never the painting.
   2. `mix-blend-mode: soft-light` on a layer that size forces the compositor to
      read the backdrop back and re-blend it on every frame the layer moves —
      and it never stops moving. Removing just the blend recovered ~5ms.

   One layer, sized to the hero instead of half again bigger, blending normally:
   ~22ms/frame, against a ~19ms floor with every hero effect switched off. The
   drift is what sells this, not the blend maths, and against a ground this pale
   a low-opacity white wash is very close to what soft-light was producing
   anyway. The lost second layer bought "the interference pattern never
   repeats"; the four-waypoint keyframe below buys the same irregularity from
   one element, because the path no longer just runs back and forth. */
.hero__water {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
  z-index: 0;
}
.hero__water::before {
  content: "";
  position: absolute;
  /* Must stay at inset:0. Every percent of overhang here is hero-sized area
     being composited per frame for no visible gain — the gradients already
     fade out well inside their own boxes. */
  inset: 0;
  background:
    radial-gradient(closest-side, rgba(255, 255, 255, 0.85), transparent 72%) 18% 22% / 46% 42% no-repeat,
    radial-gradient(closest-side, rgba(255, 255, 255, 0.6), transparent 72%) 74% 62% / 38% 34% no-repeat,
    radial-gradient(closest-side, rgba(148, 216, 240, 0.5), transparent 72%) 48% 84% / 50% 38% no-repeat,
    radial-gradient(closest-side, rgba(255, 255, 255, 0.5), transparent 72%) 88% 18% / 30% 28% no-repeat;
  opacity: 0.55;
  animation: caustics-drift 31s ease-in-out infinite;
  will-change: transform;
}
/* Four waypoints rather than a two-state `alternate`, and the return leg takes
   a different route from the outbound one — which is what stops a single layer
   reading as one blob sliding back and forth on a timer. Translate only: the
   scale() the old pair carried made no measurable difference either way, and
   without it the layer is a plain composited transform. */
@keyframes caustics-drift {
  0%   { transform: translate3d(-2.2%, -1.4%, 0); }
  27%  { transform: translate3d(2.6%,  1.1%, 0); }
  54%  { transform: translate3d(1.2%,  2.4%, 0); }
  78%  { transform: translate3d(-2.8%, 0.6%, 0); }
  100% { transform: translate3d(-2.2%, -1.4%, 0); }
}

.hero__inner { position: relative; z-index: 1; }
.hero--home .hero__grid { align-items: center; }
@media (min-width: 960px) {
  .hero--home .hero__grid { grid-template-columns: 1.05fr 1fr; gap: var(--space-xl); }
}

/* ------------------------------------------------------- 17.2 Motto (logo) */
/* The logo draws LIVE / LAUGH / SWIM as three offset colour tags — that device
   is the brand's own, not a generic badge row, so the motto uses it directly
   instead of translating it into letterspaced running text.

   Matched to the artwork itself rather than invented: LIVE is pink, LAUGH is
   orange, SWIM is green (the -ink variant of each, for the same reason the
   avatar discs use them — white text on the vivid tone would fail 4.5:1).
   LAUGH rides higher than its two neighbours, which sit level with each other,
   the same arc the logo draws — not three independent tilts.

   No overflow:hidden on the row any more — it was there only to clip a
   one-time shine sweep, and clipping a box exactly at its own edge also
   clipped every tag's drop shadow in a straight line the instant a tag's
   entrance bounce pushed it even 1px past that edge, which read as a seam
   between the motto and the hero ground behind it. The shine wasn't worth
   that: the logo itself is static art with no such sweep, and removing it
   gets the row closer to "exactly like the logo," not further. */
/* Every number below is measured off the logo artwork itself
   (assets/img/logo-horizontal-420.png), sampled per pixel, rather than
   estimated by eye — the tags in the lockup are 20px tall there, which is what
   the ratios are expressed against:

     colour     LIVE #eb2693   LAUGH #f68e1f   SWIM #6eba44
     tilt       -5deg          +2.7deg         +4.8deg
     baseline   level          15px lower      level        (15/20 = 0.75 tall)
     corners    square — they are plain rectangles, not pills
     keyline    ~1px white between overlapping tags (1/20 of the tag)

   The tilts are the reason each tag needs a real angle rather than a shared
   one: they are not a mirrored pair, and reading them off the artwork is the
   difference between "three coloured labels" and the logo's own hand-lettered
   row. The tags also overlap horizontally in the lockup — they are not a gapped
   row — which is what the negative margins are for. */
.motto {
  display: flex;
  align-items: center;
  margin: 0 0 var(--space-sm);
  /* Room for the ±0.6em stagger and the tags' shadows. Nothing clips here (the
     row no longer hides its overflow), so this is purely about not letting the
     lowest tag crowd the headline underneath. */
  padding-block: 0.75rem 0.9rem;
}

/* The animation target, and the only reason this wrapper exists. GSAP writes an
   inline transform here for the hero entrance, and an inline transform beats any
   stylesheet rule — so when the pose and the entrance shared one element, the
   tag animated to a flat, untilted position and then visibly SNAPPED into its
   tilt the instant GSAP cleared its transform. Splitting them means the drop and
   the pose compose instead of overwrite, and there is nothing to hand back. */
.motto__tag { position: relative; display: inline-flex; }
/* Later tags sit over earlier ones, matching the artwork: orange overlaps
   pink's right edge there, not the other way round. */
.motto__tag:nth-child(2) { margin-left: -0.27em; z-index: 2; }
.motto__tag:nth-child(3) { margin-left: -0.68em; z-index: 3; }

.motto__word {
  --rot: 0deg;
  --dy: 0em;
  display: inline-flex;
  align-items: center;
  /* Square. The logo's tags have no corner radius at all, and rounding them was
     the single thing that most made this read as a generic badge row. */
  border-radius: 0;
  /* The white keyline the artwork draws around each tag. It earns its keep at
     the overlaps, where it is the only thing separating two saturated colours. */
  border: 1.5px solid var(--white);
  /* Tight vertically, wide horizontally, and this asymmetry is the whole shape.
     The logo's tags run about 2.8–4.1× wider than they are tall; a first pass at
     0.32em/0.6em came out at 1.9×, which is why the row read as three chunky
     lozenges rather than the lockup's slim ribbons. Padding-block is what was
     wrong — in the artwork the letters nearly fill the tag's height, so there is
     very little air above and below them. */
  padding: 0.12em 0.72em;
  font-family: var(--font-display);
  font-weight: 800;
  /* Bigger than it was (0.72–0.82rem) so the row carries some weight of its own
     next to a 4.5rem headline instead of reading as a caption above it. The
     slimmer tags above are what make the extra size affordable. */
  font-size: clamp(1rem, 1.9vw, 1.2rem);
  line-height: 1;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--white);
  /* Flat colour, no inset gloss: the artwork's tags are flat, and the highlight
     that used to be here was inventing a moulded-plastic finish the logo does
     not have. The shadow stays — it is what keeps three saturated rectangles
     from looking pasted onto the hero. */
  box-shadow: 0 2px 7px rgba(18, 48, 73, 0.2);
  transform: translateY(var(--dy)) rotate(var(--rot));
  transition: transform 0.3s var(--ease-back);
}
/* --dy is half the stagger each way, so the pair of them makes the 0.75-of-a-tag
   drop measured off the artwork. */
/* The -ink variants of the logo tag colours, which is what the note above this
   rule always claimed the motto used and what the shipped code never did. White
   on the raw #f68e1f and #6eba44 measures 2.38:1 and 2.39:1, and the tags clamp
   to 16px on a phone where the large-text exemption does not apply. Hue and
   saturation are the artwork's; only lightness moves, the minimum distance that
   clears 4.5:1 with white on top — LIVE 7.2:1, LAUGH 4.66:1, SWIM 4.80:1.
   Version one carries the original failure; this is version one's own documented
   intent, implemented. */
.motto__word--1 { --rot: -5deg;  --dy: -0.5em; background: #a80f64; }
.motto__word--2 { --rot: 2.7deg; --dy: 0.5em;  background: #b15f07; }
.motto__word--3 { --rot: 4.8deg; --dy: -0.5em; background: #4b7f2e; }
/* A small, cheap flourish for pointer devices: each tag lifts slightly on
   hover, as if it were a real pressed badge. Composed from the same two
   variables so a tag keeps its own tilt instead of straightening out. */
@media (hover: hover) {
  .motto__word:hover {
    transform: translateY(calc(var(--dy) - 3px)) rotate(var(--rot)) scale(1.06);
  }
}

/* ------------------------------------------------ 17.3 Headline + emphasis */
.hero--home h1 {
  font-size: clamp(2.65rem, 6vw, 4.5rem);
  letter-spacing: -0.025em;
  /* Tighter leading as the size goes up — at 4.5rem the default 1.15 opens
     gaps between the lines that read as three separate statements. */
  line-height: 1.04;
  margin-bottom: var(--space-md);
}

/* Hand-drawn underline that draws itself in behind a single word. The word
   stays a normal text node, so selection, search and screen readers are
   unaffected — the stroke is purely decorative. */
.mark { position: relative; display: inline-block; white-space: nowrap; }
.mark svg {
  position: absolute;
  left: -2%;
  bottom: -0.16em;
  width: 104%;
  height: 0.34em;
  overflow: visible;
  pointer-events: none;
}
.mark path {
  fill: none;
  stroke: var(--orange);
  stroke-width: 9;
  stroke-linecap: round;
}

.hero--home .lead { max-width: 54ch; }

/* ------------------------------------------------------- 17.4 Hero proof */
/* Sits directly under the button it de-risks. Small and quiet on purpose —
   it's the sentence that removes the last objection, not a third CTA. */
.hero__reassure {
  margin: 0.9rem 0 0;
  font-size: 0.95rem;
  color: var(--ink-faint);
  max-width: 46ch;
}

/* Three numbers instead of three ticks.
   The ticked list this replaced ("Free water assessment", "Max 4 per class",
   "Make-up lessons included") had no shared shape — one was an offer, one a
   limit, one a policy — so it scanned as a grab-bag, and a grab-bag of ticks
   is the single most generic component on the web. Numbers give the row one
   grammar and force each claim to be specific.

   A <dl> because that is what this is: three terms and their definitions. The
   figure is the term, so a screen reader hears "4 — children per class".
   Divided by hairlines rather than boxed, so it stays a strip of facts rather
   than becoming another row of cards on a page that already has plenty. */
.hero-stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-sm);
  margin: var(--space-lg) 0 0;
  padding: 0;
}
.hero-stats > div { padding-left: var(--space-sm); border-left: 2px solid var(--pool-200); }
.hero-stats > div:first-child { padding-left: 0; border-left: 0; }
.hero-stats__fig {
  display: block;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(2rem, 3.2vw, 2.75rem);
  line-height: 1;
  letter-spacing: -0.035em;
  color: var(--ink);
}
.hero-stats__label {
  display: block;
  margin: 0.4rem 0 0;
  font-size: 0.88rem;
  line-height: 1.45;
  color: var(--ink-faint);
  text-wrap: pretty;
}

/* ---------------------------------------------------------- 17.5 Hero media */
.hero--home .hero__media {
  position: relative;
  border-radius: 0;
  overflow: visible;
  box-shadow: none;
  background: none;
  aspect-ratio: auto;
}

/* A tilted tinted panel offset behind the photo. One extra plane is the
   difference between "an image on a page" and "a composition". */
.hero__frame {
  position: relative;
  border-radius: var(--r-xl);
  overflow: hidden;
  box-shadow: var(--shadow-lg);
  background: var(--pool-200);
  aspect-ratio: 1248 / 765;   /* reserved up front — no layout shift */
  z-index: 1;
}
.hero__frame img { width: 100%; height: 100%; object-fit: cover; }
/* This one lost its blend too, and it turned out to matter more than the size
   of the layer did. The instinct was to keep soft-light here — it is a small
   element, clipped by the frame's own overflow:hidden, and it sits on a
   photograph rather than a flat tint, which is the one place soft-light
   actually does something a white wash can't. But with the hero-wide pair
   fixed this became the single most expensive thing left in the hero: ~8ms a
   frame, more than the full-bleed caustic layer above it now costs. The cost
   isn't area, it's the backdrop readback, and this element never stops moving.

   As a plain overlay it's a light bloom drifting over the water instead of a
   true blend. Over a bright pool photo that is a very small difference, and it
   is worth 8ms. Opacity carries what the blend used to; the gradient alphas are
   unchanged so the shape of the highlight is the same. */
.hero__frame::after {
  content: "";
  position: absolute;
  inset: -12%;
  pointer-events: none;
  background:
    radial-gradient(closest-side, rgba(255, 255, 255, 0.42), transparent 70%) 22% 28% / 46% 46% no-repeat,
    radial-gradient(closest-side, rgba(255, 255, 255, 0.3), transparent 70%) 72% 62% / 38% 38% no-repeat;
  opacity: 0.5;
  animation: caustics-frame 16s ease-in-out infinite alternate;
  will-change: transform;
}
@keyframes caustics-frame {
  from { transform: translate3d(-2.5%, -1.5%, 0) scale(1); }
  to   { transform: translate3d(3%, 2%, 0) scale(1.08); }
}
/* A tinted card rotated behind the photo so its corners peek out — the photo is
   meant to sit ON this, not under it.

   It is positioned with z-index 0, and a positioned element paints above
   non-positioned in-flow content regardless of source order. On the home page
   the picture happens to win anyway; on the About page it did not, and the
   result was a translucent teal panel lying across two thirds of the pool photo.
   The panel can't simply go behind with a negative z-index — that would drop it
   behind .hero__media's own background too — so the photo is lifted instead. */
.hero__media::before {
  content: "";
  position: absolute;
  inset: 8% -3.5% -7% 5%;
  border-radius: var(--r-xl);
  background: linear-gradient(140deg, var(--lv-dolphin) 0%, var(--pool-300) 100%);
  transform: rotate(3deg);
  opacity: 0.5;
  z-index: 0;
}
.hero__media picture,
.hero__media > img { position: relative; z-index: 1; }

/* The two logo dolphins bookend the photo and give its rectangle something to
   break against. They face inward — pink's head points right, blue's points
   left — so the pair frames the picture rather than swimming away from it.

   These are the logo's own characters rather than level mascots, so they don't
   breach the one-colour-per-level rule: pink and blue here are the brand, not
   a class name.

   Scoped through .hero--home to outrank `.hero__media img { width: 100% }`,
   which about.html still relies on — unscoped, that rule wins on specificity
   and stretches the mascot to the full column. */
.hero--home .hero__mascot {
  position: absolute;
  z-index: 2;
  /* Narrower than the near-square dolphin this replaced: these two are tall
     portraits, and at the old 168px they'd have stood 300px and swamped the
     photo. Matched on height instead, they carry the same visual weight. */
  width: clamp(74px, 10.5vw, 122px);
  height: auto;
  /* One shared offset, so their feet line up however tall each one happens to
     be. It also sets how much photo they cover: at -14% their heads sit on the
     pool's water line and the mural behind — the busy part of the picture —
     stays clear. Past about -20% they hang mostly off the frame onto the pale
     hero ground and go weightless. */
  bottom: -14%;
  filter: drop-shadow(0 12px 22px rgba(18, 48, 73, 0.24));
}
/* Not a mirrored pair: the asymmetry lives in these two offsets and in the two
   bob paths, not in their heights. Pink hangs further out because it bleeds
   into the 4.5rem column gap, where there's room. Blue only has the
   container's own padding to play with, so at ~1280px anything past -2% gets
   clipped by .hero's overflow:hidden. */
.hero--home .hero__mascot--pink { left: -15%; }
.hero--home .hero__mascot--blue { right: -13%; }

/* ------------------------------------------------------- 17.6 Scroll cue */
.hero__cue {
  position: relative;
  z-index: 1;
  display: none;
  align-items: center;
  gap: 0.6rem;
  width: max-content;
  margin: var(--space-lg) auto 0;
  padding: 0.55rem 0.6rem 0.55rem 1.1rem;
  border-radius: var(--r-pill);
  color: var(--ink-soft);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.9rem;
  text-decoration: none;
  transition: color 0.2s var(--ease-out), background-color 0.2s var(--ease-out);
}
.hero__cue:hover { color: var(--ink); background: rgba(255, 255, 255, 0.7); }
.hero__cue span {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--white);
  box-shadow: var(--shadow-sm);
  animation: cueBob 2.6s ease-in-out infinite;
}
.hero__cue svg { width: 18px; height: 18px; color: var(--teal-ink); }
@keyframes cueBob {
  0%, 100% { transform: translate3d(0, 0, 0); }
  50%      { transform: translate3d(0, 5px, 0); }
}
@media (min-width: 960px) { .hero__cue { display: flex; } }

/* ------------------------------------------ 17.7 Hero → programs transition */
/* Deliberately nothing here. A two-layer drifting water surface lived in this
   slot and was cut for reading as busy and inconsistent with the calmer
   sections below. The hero's gradient already resolves to white at its base
   and the programs band opens on pool-100, so the two meet as a soft tonal
   step with no graphic seam — which is the same way every other section
   boundary on the page now works. */

/* --------------------------------------------------------- 17.8 Level cards */
/* The row gap is wider than the column gap specifically to give the mascot
   somewhere to bounce into. Budget at the peak of the bounce, not at the
   resting target: --ease-bounce overshoots its endpoint by ~21%, so a 30px
   rise momentarily reaches ~36px, and the -4deg rotation adds a further ~5px
   of bounding box. That totals ~39px of escape, which --space-md (32px) would
   clip against the card above. --space-lg (48px) leaves real headroom. */
.levels { display: grid; column-gap: var(--space-md); row-gap: var(--space-lg); }
@media (min-width: 620px) { .levels { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 980px) { .levels { grid-template-columns: repeat(3, 1fr); } }

/* THE GRID IS THE FALLBACK, AND IT IS LOAD-BEARING — worth stating plainly,
   because the levels row has three renderings and only one of them is authored
   in this rule.

     ≥980   the deck, added by script as .levels--deck (section 20)
     620–979 this grid, two across
     <620   the snap rail, .rail below in section 17.21
     any width, no JS / reduced motion / a script error: this grid

   The last line is the one that matters. Every enhancement above is ADDITIVE —
   the deck adds a class and wraps each card in a slot, the rail is pure CSS —
   so if main.js never arrives, or GSAP 404s, or the deck throws, what remains is
   this: three columns of whole, readable cards in level order. Nothing here
   depends on script having run, and nothing script does is required for the row
   to be legible.

   Card HEIGHTS in that fallback are not set here and must not be. See 17.27:
   the cards are floored by the min-height equalise() measures, and the grid is
   align-items:start, precisely so that opening one card's Read more grows that
   card alone instead of dragging its two row-mates up to 600px with it. An
   `align-items: stretch` or a `grid-auto-rows: 1fr` added here would look like
   tidying and would reinstate that bug — which is why the rule lives at the
   bottom of the file with its reasoning attached, and why there is nothing to
   add in this block. */

.lvl {
  --lv: var(--teal);
  --lv-ink: var(--teal-ink);
  position: relative;
  display: flex;
  flex-direction: column;
  height: 100%;
  /* Visible, not hidden — the mascot has to be able to leave the card. Only
     .lvl__art needed its own border-radius as a result: it paints the tinted
     background. .lvl__body is transparent, so the card's own rounded white
     background still gives it clean bottom corners. */
  overflow: visible;
  border-radius: var(--r-lg);
  background: var(--white);
  box-shadow: var(--shadow-md);
  transition: transform 0.4s var(--ease-out), box-shadow 0.4s var(--ease-out);
}
/* Same "closer, not just higher" hover as .card--link — see the note there.
   :focus-within alongside :hover for the same reason the mascot leap below
   gets both: a keyboard user tabbing to the "Level details" link inside the
   card should see the same card respond, not just the mascot jump on its own. */
@media (hover: hover) {
  .lvl:hover,
  .lvl:focus-within {
    transform: translateY(-3px) scale(1.035);
    box-shadow: var(--shadow-xl);
    transition: transform 0.5s var(--ease-back), box-shadow 0.35s var(--ease-out);
    z-index: 2;
  }
}

/* The mascot stands in tinted water and the white wave crops it at the
   waterline. On hover it launches out of the water and clears the top edge of
   the card entirely — the frame-break is the point, it's what stops the card
   reading as a flat tile. */
.lvl__art {
  position: relative;
  height: 190px;
  overflow: visible;
  border-radius: var(--r-lg) var(--r-lg) 0 0;
  /* Version one was 30/11/4. This is 100/84/66 — the centre of the head is the
     level's colour at full strength, undiluted, falling to 66% at the edges.

     Worth being explicit about why it can go all the way to 100%: the head
     carries no text. The age badge sits below the waterline on the white card
     body, not on this ground, so there is no contrast pair here to satisfy — the
     only constraint on this tint was ever artwork separation, and the halo below
     handles that. The first pass at 72/52/34 was still hedging against a
     requirement that does not exist, and because the radial spends most of its
     area near the outer stops, a 34% edge made the whole head read pale no
     matter what the centre was doing.

     The reason it can go this far is the halo on .lvl__art img below. The
     constraint on this tint was never contrast, it was that the mascot stands ON
     this ground and three of the levels are drawn in the same cyan as the water
     — measured, shark separated from a 30% tint by only ΔE 20.5 and got worse
     from there, so saturation and legibility were in direct conflict. Giving the
     artwork its own edge breaks that conflict: the character no longer relies on
     the ground being pale to be readable, so the ground is free to be vivid.

     The outer stop now goes DEEPER than --lv instead of paler, and that is what
     lets the band be any blue it likes. Fading to a 66% tint meant the panel was
     at its palest exactly where it met the section — so on a pale ground the
     edge dissolved, and the paler the ground the worse it got. Shark is the
     proof: its #8fd4e8 is ΔE 3.8 from --sky at full strength, and its old 66%
     edge was paler still. A ground can't fix that, because the collision is
     between the panel and the section AT THE BOUNDARY, and moving the ground
     just moves which card it happens to.

     Mixing 12% toward --ink at the outer stop turns the fade into a vignette:
     the centre is still the level's colour undiluted, and the rim is a defined
     edge. That alone takes tots from ΔE 10.9 to 23.2 against --sky and
     swordfish from 15.9 to 33.4 — but it moves shark only 7.6 to 7.9, because
     shark is so pale that a 12% mix barely separates it from a ground sitting
     at nearly the same lightness.

     Deepening the vignette until shark cleared was the obvious next move and it
     is the wrong one: at the 66% mix that gets shark to ΔE 21.9, stingray's rim
     falls to L 30 and starfish to L 48. One global mix cannot serve a colour as
     pale as shark and one as dark as stingray — it can only trade a dissolving
     panel for a muddy one.

     So the boundary is a 1px inset rim instead, at a fixed 58% toward ink. A
     hairline does not care how much area it covers, so it can go deep enough to
     define the palest panel without darkening any of the others: it reads as a
     drawn edge on shark and as a crisp seam everywhere else. The bottom run of
     the ring sits under the two waterline waves, which paint over it. */
  background:
    radial-gradient(120% 100% at 50% 8%,
      var(--lv) 0%,
      color-mix(in srgb, var(--lv) 96%, #fff) 55%,
      color-mix(in srgb, var(--lv) 88%, var(--ink)) 100%);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lv) 58%, var(--ink));
}
.lvl__art img {
  position: absolute;
  left: 50%;
  bottom: 14px;
  width: auto;
  height: 168px;
  /* The height is fixed and the width is free, which is the right way round
     until the width the aspect ratio asks for exceeds the panel: the global
     img{max-width:100%} then caps the box while the height stays put, and the
     mascot is squashed rather than scaled. It is the widest characters that hit
     it — the three swimming horizontally, and Stingray on the programs page.
     `contain` makes the cap scale the artwork instead of distorting it, and the
     bottom anchor keeps its feet on the waterline while it does. */
  object-fit: contain;
  object-position: center bottom;
  /* A white edge, drawn as four offset drop-shadows so it wraps the silhouette
     on all sides rather than pooling under one. This is what lets the head tint
     above be vivid: it separates the mascot from its ground by construction
     instead of by luminance, so it works identically on the four warm levels and
     on the three that share the water's hue.

     Static — the hover rule further down replaces this filter for the leap, so
     nothing composites a four-shadow filter on an animating element. */
  filter:
    drop-shadow(1.5px 0 0 rgba(255, 255, 255, 0.92))
    drop-shadow(-1.5px 0 0 rgba(255, 255, 255, 0.92))
    drop-shadow(0 1.5px 0 rgba(255, 255, 255, 0.92))
    drop-shadow(0 -1.5px 0 rgba(255, 255, 255, 0.92));
  transform: translate3d(-50%, 0, 0);
  /* Was 0.5s/--ease-back and read as a slow drift. Shorter, with a harder
     overshoot, so it snaps up and rebounds. */
  transition: transform 0.4s var(--ease-bounce), filter 0.4s var(--ease-out);
  /* Decorative and aria-hidden. pointer-events:none also stops the escaped
     head from stealing hover off the card, which would make it flicker as the
     cursor followed it up. */
  pointer-events: none;
  /* Below .lvl__wave (2) on purpose: the wave crops the artwork at the
     waterline. Raising this above the wave removes the crop and the mascot
     reads as pasted on top of the water instead of standing in it. The card
     itself doesn't clip, so the head still escapes upward past the top edge —
     and cards later in the DOM paint over earlier ones, so a second-row mascot
     rises over the card above rather than behind it. */
  z-index: 1;
}
/* Gated to hover-capable pointers. Two reasons, and the second is the bug:

   1. On touch there is no hover, so the launch only ever fired via
      :focus-within — which happens when you TAP the "What they'll learn"
      summary. A decorative leap triggered by opening an accordion is a side
      effect, not a design intent.
   2. On phones the cards live in a horizontal scroll container, and a scroll
      container clips BOTH axes — you cannot have overflow-x:auto with
      overflow-y:visible. So the mascot couldn't escape the card the way it does
      on desktop; it just had its head sliced off at the card's top edge.

   (hover: hover) excludes touch devices entirely, which fixes both. */
@media (hover: hover) {
  .lvl:hover .lvl__art img,
  .lvl:focus-within .lvl__art img {
    transform: translate3d(-50%, -30px, 0) scale(1.06) rotate(-4deg);
    /* Halo repeated here, plus the lift shadow — the shorthand replaces the
       resting filter entirely, so dropping the four edge shadows would make the
       mascot lose its outline at exactly the moment it leaves the card. */
    filter:
      drop-shadow(1.5px 0 0 rgba(255, 255, 255, 0.92))
      drop-shadow(-1.5px 0 0 rgba(255, 255, 255, 0.92))
      drop-shadow(0 1.5px 0 rgba(255, 255, 255, 0.92))
      drop-shadow(0 -1.5px 0 rgba(255, 255, 255, 0.92))
      drop-shadow(0 16px 20px rgba(14, 44, 56, 0.3));
  }
}

/* pointer-events:none — these SVGs cover the bottom strip of .lvl__art and
   would otherwise swallow clicks meant for the card-wide link overlay. */
.lvl__wave { position: absolute; inset: auto 0 -1px 0; z-index: 2; height: 30px; width: 100%; pointer-events: none; }
/* The back wave tracks the head's edge tone, so the two-wave transition still
   reads as one body of water stepping down to the card rather than as a stripe
   of unrelated colour between the head and the body. */
.lvl__wave--back { color: color-mix(in srgb, var(--lv) 72%, #fff); height: 34px; opacity: 0.9; }
.lvl__wave--front { color: var(--white); }

/* ------------------------------------------- 17.8c The level plate, on a page
   .lvl__art is the HEAD OF A CARD. Every decision in it assumes a white card
   body directly underneath: the top-only radius, the 190px height, the front
   wave filled --white so the two waves read as a waterline stepping down onto
   that body.

   programs.html borrowed the component into a two-column .split with nothing
   beneath it, and all three assumptions broke at once. The panel became a 190px
   band floating in the vertical middle of a 450-pixel column — roughly 130px of
   leftover space above it and 130 below, which is not whitespace, it is a
   component that hasn't been composed with the column beside it. Its bottom
   corners stayed square against no card. And the white front wave painted a
   ragged white strip along the foot, hanging in mid-air on a tinted section: on
   the card that white IS the card, here it was a fault.

   Ten of these in a row is the flattest stretch on the site, and it is the
   stretch where a parent is actually choosing a level.

   The page variant is a plate rather than a card head: full radius, full column
   height, and the mascot at the scale commissioned artwork deserves instead of
   168px marooned in a band. The waves stay, but the front one is now a pale
   tint of the level's own colour, so the foot of the plate reads as the mascot
   standing in shallow water — which is what the two waves were always drawing.
   Nothing about the vignette, the rim or the white silhouette halo changes;
   those solved a real problem and they solve it here too. */
.lvl__art--page {
  height: clamp(300px, 33vw, 400px);
  border-radius: var(--r-lg);
  /* The card version is overflow:visible so the mascot can leap out on hover.
     There is no card and no hover here — these sections are not .lvl — so the
     plate contains its own artwork. */
  overflow: hidden;
  box-shadow: var(--shadow-lg);
}
.lvl__art--page img {
  height: clamp(180px, 19vw, 236px);
  bottom: 22%;
}
/* Percentages, not the card's fixed 30px: the plate's height is fluid, so a
   fixed waterline would sit near the foot at 400px and halfway up at 300. */
.lvl__art--page .lvl__wave { height: 24%; }
.lvl__art--page .lvl__wave--back {
  height: 28%;
  color: color-mix(in srgb, var(--lv) 60%, #fff);
}
.lvl__art--page .lvl__wave--front { color: color-mix(in srgb, var(--lv) 32%, #fff); }
/* Kept for No Limits, the one card with no character of its own: a line icon
   has to carry the plate alone, and at the card's own size it is a 64px mark
   stranded in 400px of colour. Aquababies, Aquatots and Pufferfish used this
   too until their artwork arrived. */
.lvl__art--page.lvl__art--motif svg:not(.lvl__wave) {
  width: clamp(140px, 16vw, 190px);
  height: clamp(140px, 16vw, 190px);
  /* 0.75 was tuned for an 84px mark on a 190px card head, where the icon fills
     enough of the frame to survive being faint. Scaled up onto a 400px plate the
     same opacity reads as a watermark rather than as artwork — and these three
     levels have no mascot to fall back on, so the mark IS the picture. */
  opacity: 0.92;
  stroke-width: 2.4;
}

/* Phone: the plate stops competing with the copy for the fold. */
@media (max-width: 899px) {
  .lvl__art--page { height: clamp(240px, 52vw, 300px); }
  .lvl__art--page img { height: clamp(150px, 32vw, 190px); }
}

.lvl__body {
  display: flex;
  flex: 1;
  flex-direction: column;
  padding: 0 var(--space-md) var(--space-md);
}
.lvl__age {
  align-self: flex-start;
  padding: 0.28rem 0.75rem;
  margin-bottom: 0.5rem;
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--lv) 17%, #fff);
  color: var(--lv-ink);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.lvl__name {
  margin: 0 0 0.35rem;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.55rem;
  line-height: 1.1;
  color: var(--ink);
}
/* The only place the level's colour is used as a graphic mark, and it sits
   directly under the level's name — colour as identity, not decoration. */
.lvl__rule {
  width: 44px;
  height: 5px;
  margin-bottom: 0.8rem;
  border-radius: var(--r-pill);
  background: var(--lv);
  transition: width 0.4s var(--ease-out);
}
.lvl:hover .lvl__rule { width: 72px; }
.lvl__body p { font-size: 0.98rem; }
.lvl__body .checklist { --accent-ink: var(--lv-ink); margin-top: auto; padding-top: var(--space-sm); }
.lvl__body .checklist li { font-size: 0.94rem; }
.lvl__link {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  min-height: 44px;
  align-self: flex-start;
  color: var(--lv-ink);
  font-family: var(--font-display);
  font-weight: 700;
  text-decoration: none;
}
.lvl__link svg { width: 18px; height: 18px; transition: transform 0.25s var(--ease-out); }
.lvl__link:hover { color: var(--lv-ink); }
.lvl__link:hover svg { transform: translateX(5px); }
/* Card-wide hit area, so the whole card is clickable without nesting links. */
.lvl__link::after { content: ""; position: absolute; inset: 0; z-index: 3; }

/* --------------------------------------- 17.9 Invitation-only level banner */
/* Seven levels in a three-column grid would leave an orphan on the last row.
   Making the invitation-only pathway a full-width band instead turns that
   into hierarchy: six levels you enrol in, one you get invited to. */
.lvl-feature {
  position: relative;
  display: grid;
  gap: var(--space-md);
  align-items: center;
  margin-top: var(--space-sm);
  padding: var(--space-md);
  overflow: hidden;
  border-radius: var(--r-lg);
  background:
    radial-gradient(90% 140% at 88% 50%,
      color-mix(in srgb, var(--lv-swordfish) 24%, #fff) 0%,
      color-mix(in srgb, var(--lv-swordfish) 7%, #fff) 60%,
      var(--white) 100%);
  box-shadow: var(--shadow-md);
}
@media (min-width: 780px) {
  .lvl-feature { grid-template-columns: 1fr auto; padding: var(--space-lg) var(--space-lg) var(--space-lg) var(--space-xl); }
}
.lvl-feature__art { justify-self: center; width: clamp(150px, 24vw, 216px); }
.lvl-feature__art img { width: 100%; height: auto; filter: drop-shadow(0 16px 26px rgba(18, 48, 73, 0.2)); }
.lvl-feature h3 { font-size: clamp(1.6rem, 3.4vw, 2.1rem); margin-bottom: 0.5rem; }
.lvl-feature .checklist { --accent-ink: var(--lv-swordfish-ink); }

/* ------------------------------------------------ 17.9b Section identity */
/* The stretch from "Why parents choose us" to "Answers before you ask" read as
   flat: five consecutive near-white grounds, so nothing told you that you'd
   moved from one section to the next.

   The fix is value, not hue. Each section sits one step deeper than the last,
   which gives every band its own ground *and* is the page's stated art
   direction — you are descending through water toward the navy deep end. No new
   colours are introduced, so the rule that a hue only appears when it names a
   level still holds.

     Why          white
     Facility     pool-50    (.section--alt)
     Steps        pool-100   (.section--tint-2)
     Testimonials pool-200   (.section--tint-3, see below)
     FAQ          pool-100   (.section--tint-4)
     Deep end     #0e6570 → navy

   Depth is capped at pool-200: --ink-faint drops to 3.88:1 on pool-300, so
   anything deeper would need the body palette re-tuned. Cards on these grounds
   stay white for the same reason. */
.section--tint-2 { background: var(--pool-100); }

/* Parent Reviews. This band has now been three different things and each
   failure is worth keeping.

   Raw #2791a5 meeting #2bb0d4: a hard line, because the two hues didn't
   actually match at the join. Fixed by making the steps water's floor equal
   var(--water) exactly — the grounds matched to the byte and the line
   disappeared, but so did the section: Getting Started's floor and the whole
   of Parent Reviews read as one continuous fill.

   Then --water-shallow → --water with a surface glow. That solved the seam and
   introduced a worse problem: it was the ONLY full-saturation slab in the page
   body, arriving between a near-white band above and a pale band below. The
   page's stated art direction is a descent, and the real sequence ran
   sky → pool-100 → pool-200 → pool-100 → SATURATED CYAN → pool-200 → navy.
   One band at four times the chroma of its neighbours doesn't read as "deeper
   water", it reads as a component pasted in from a different site — which is
   exactly how the client described it.

   Now it is pool-200: the deepest of the pale reading bands, the same family as
   every other ground on the page. The drama the saturated fill was reaching for
   moves into the cards instead (see .quote--plain), where it belongs — an
   object you can hold, not a wall you have to walk past.

   Consequence: FAQ was also pool-200, so the two would have merged into one
   1300px slab with no boundary between "what families say" and "common
   questions". FAQ lifts to pool-100.

   pool-100 and not pool-50, which was the first attempt: the accordion is a
   white surface and pool-50 (#f2fafb) is within a whisker of white, so the
   largest component in that section would have lost its own edge against its
   own ground. pool-100 keeps it. It also settles the back half of the page into
   a legible 100 / 200 / 100 / 200 / 100 alternation — a rhythm rather than a
   staircase — and drops from the palest of those straight into navy, which is
   the hardest close available. */
.section--tint-3 { background: var(--pool-200); }
.section--tint-4 { background: var(--pool-100); }

/* -------------------------------------------------------- 17.10 Why cards */
/* THE HOUSE RULE, AMENDED. It used to read "a colour has to be naming a level
   to earn its place", and these icons were monochrome because of it. The rule
   was right about the failure it was preventing — the original teal/yellow/
   green/pink icons named nothing — but it was too broad, and the page it
   produced proves it: one section alive with colour and the next three grey.

   The rule is now scoped rather than dropped, because the thing worth
   protecting is that a LEVEL's colour always means that level:

     --lv-* tokens name a level. They appear only on that level's card, only
     beside its mascot, and nowhere else on the page. Unchanged.

     The logo colours are the brand's, and they mark a SET or a SEQUENCE — four
     pillars here, three steps in Getting Started. Position in a set is a real
     thing for colour to encode; it is what the original icons lacked.

   The two never collide, because the decorative set is the logo's non-blue
   hues and the page's canvas is blue. Nothing here can be mistaken for a level
   badge: levels are colour-topped white cards in the Programs wall, and these
   are filled tiles and discs.

   Vivid fill, --ink glyph — the same construction as .lvl__art, where a mascot
   with dark outlines sits on its level's colour at full strength. That is also
   what forced the palette: --ink measures 6.83 on --orange, 7.69 on --green and
   11.04 on --yellow, but only 2.41 on --purple, so the deep hues can't carry a
   dark glyph and are left out. Four warm saturated tiles on a blue page is the
   loudest this section can be while every glyph still clears 4.5:1.

   The tiles themselves are built in §17.31, which is where this section's
   layout is decided — everything here would be overridden by it. */
.feature { background: none; box-shadow: none; padding: 0; }
.feature h3 { font-size: 1.2rem; }
.feature p { font-size: 0.97rem; }

/* ------------------------------------------- 17.10b Section mascots (removed)
   Two decorative mascots used to live here: a Seahorse in the left margin of
   the steps section and a Stingray in the FAQ. Both are gone, and the reason is
   worth keeping because it will come up again the next time a section looks
   flat.

   They were hired to fix a real problem — the run from Why to the FAQ was four
   sections deep with no colour in it. But a mascot could only fix it by being
   read as decoration, because neither section named the level it was showing.
   The house rule says a level colour appears only when it is naming a level;
   pink next to "Three steps and you're in the water" names nothing, and purple
   next to answers about Seahorses and Starfish names the wrong thing.

   The tell was that neither was load-bearing: both were display:none below
   1180px / 900px and neither section had a hole in it without them. Anything
   that can vanish at a breakpoint was never composed into the layout, it was
   placed on top of it — and the .mascot-disc that used to sit behind them was
   the admission, a ground invented for objects that had nothing to stand on.

   What replaced them, in both cases, is structure or content rather than
   placement: see .steps (a descent that *is* the sequence) and .lv-name (colour on
   the words that actually name a level). Both are in the flow, both survive to
   mobile, and neither needs a disc to look anchored.

   The bobbing mascots that remain on this page all straddle something — the
   hero pair the photo's edge, the seal the facility frame's corner, the deep-end
   school the water itself. That is the test worth applying to the next one. */

/* Asymmetric section head — breaks the run of five identical centred heads. */
.head-split { display: grid; gap: var(--space-sm); margin-bottom: var(--space-lg); }
@media (min-width: 900px) {
  .head-split { grid-template-columns: 1fr 1fr; gap: var(--space-xl); align-items: end; }
  .head-split .lead { margin-bottom: 0.35rem; }
}

/* ------------------------------------------------- 17.11 Pool entry steps */
/* Third version of this section, and the two dead ends are worth recording.

   It began as three equal cards with a big numeral each — the most templated
   section pattern there is, flat by construction, and with no colour of its own,
   which is why a seahorse ended up parked in the margin beside it.

   Then it became a lane rope threaded through three floats. That fixed the
   flatness and it was structural rather than decorative, but it was the wrong
   object: a lane rope is lap-swimming furniture, and this page sells the school
   two sections earlier by promising "no lap squad thrashing past in the next
   lane". The graphic contradicted the copy. Being well-built didn't save it.

   What works is the thing the headline already said. The shallow-end steps are
   where every small child actually enters a pool, so the three cards step down
   and the water rises behind them until the third is in it. No new vocabulary —
   the whole page is a descent from surface to deep end, and this is that same
   descent happening inside a single section. */
.section--steps { position: relative; overflow: hidden; }

/* The water. An SVG surface, not a gradient — see the note in index.html for
   why the gradient version was invisible. preserveAspectRatio="none" lets one
   path stretch to any section width, and the wave is authored so its lowest
   point sits under step 01 and its highest clears step 03. */
.steps-water {
  position: absolute;
  inset: auto 0 0 0;
  /* Low enough that all three cards sit above the surface and step down toward
     it, and the CTA underneath lands in the water. */
  height: 42%;
  pointer-events: none;
}
/* +2px, clipped by .section--steps' overflow:hidden — the same trick .wave and
   .deep__surface already use, and the reason is spelled out in the .wave note:
   a fill whose edge lands exactly on its element's boundary gets antialiased
   there, and that half-transparent row lets the ground behind show through as a
   hairline. Here the ground behind is --pool-100, nearly white, against water —
   about the highest-contrast pair on the page — so a single blended row was
   plainly visible as a light line between Getting Started and Parent Reviews.
   Overshooting puts the antialiased edge outside the clip. */
.steps-water svg { width: 100%; height: calc(100% + 2px); display: block; }
/* And the belt, which this was the last section-ending wave on the site still
   going without. The overshoot above moves the FILL's own edge out of the way,
   but the clip then becomes the edge, and a clip whose bottom lands
   mid-device-pixel is itself antialiased — which on a phone is the normal case
   rather than the odd one, because momentum scrolling puts every box on a
   fractional position on every frame. Through that half-row you get
   .section--steps' own ground, #eaf7f9, blended into the waterline.

   Two solid rows of the gradient's own bottom stop, inside the clip so they
   survive it. Same fix as .wave::after, .deep__surface::after and
   .footer-wave::after, arriving by the same door. */
.steps-water::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: var(--water-shallow);
}
/* The steps section is a PALE band with a saturated water body inside it — the
   inverse of version one (pale water on a pale band), and the reason this
   section is not itself a water band. Step 03's paragraph crosses the surface
   line: dark ink measures 6.54:1 on the top stop, so the copy stays legible as
   it enters the water. Any deeper here would force an ink flip halfway through
   a sentence. */
/* The bottom stop must not equal --water-mid (the hero floor) or the raw
   #2791a5 this shipped as first — either draws a hard line at the boundary
   below, which was the original bug here. It also, on a later pass, matched
   --water exactly: that erased the line but erased the section with it, since
   Getting Started's floor and the whole of Parent Reviews became one
   indistinguishable fill. --water-shallow is the middle case — one clean step
   lighter than --water, so the two bands are legible as separate water rather
   than one uninterrupted sheet, without reintroducing a mismatched-hue seam.
   See --water-shallow's own note for the value. */
/* Top stop is --sky now. The rising water in this section is the first real
   water after the warm half, and starting it at the brief's pale sky rather
   than at another mid-saturation teal makes the surface read as shallow and lit
   — which is what a wave that rises to meet you should be. It still falls to
   --water-shallow, so the band below Parent Reviews is unchanged and the two
   bodies of water stay distinguishable. */
.steps-water__top { stop-color: var(--sky); }
.steps-water__bot { stop-color: var(--water-shallow); }
.steps-water__glint { stroke: var(--white); }

.steps {
  position: relative;
  z-index: 1;
  display: grid;
  gap: var(--space-md);
  margin: 0;
  padding: 0;
  list-style: none;
}

.steps__item { position: relative; }

/* Was 3.4rem of #cf680c text — a muted burnt orange, and muted because it had
   to be: a bare coloured numeral IS text, so it was capped by the 3:1 bar
   against this ground and plain --orange failed it at 2.27:1. Every saturated
   colour on a pale band hits that same ceiling, which is why colour-as-text
   always comes out dull here.

   Putting the numeral in a filled disc removes the ceiling entirely. The fill
   is a shape, not text, so it can be the brand colour at full strength; the
   numeral inside it is --ink, which clears 6.8–11:1 on all three. Same
   construction as the .feature tiles above and the .lvl__art panels above
   that — one device, three sections, so the colour reads as a system rather
   than as three unrelated flourishes.

   Yellow → orange → magenta, in that order and not by chance: the steps warm up
   as you move through them, and step three sits next to the button that starts
   the enrolment. */
.steps__num {
  display: grid;
  place-items: center;
  width: 88px;
  height: 88px;
  border-radius: 50%;
  background: var(--accent);
  color: var(--ink);
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 2.5rem;
  line-height: 1;
  letter-spacing: -0.04em;
  margin-bottom: var(--space-xs);
  box-shadow: inset 0 -5px 0 rgba(18, 48, 73, 0.16), 0 10px 22px rgba(18, 48, 73, 0.16);
  transition: transform 0.35s var(--ease-back), box-shadow 0.35s var(--ease-out);
}
.steps__item:hover .steps__num {
  transform: translateY(-6px) rotate(-5deg);
  box-shadow: inset 0 -5px 0 rgba(18, 48, 73, 0.16), 0 18px 34px rgba(18, 48, 73, 0.22);
}
.steps__item h3 { margin-bottom: 0.5rem; }
.steps__item p { max-width: 34ch; }

.steps__cta { position: relative; z-index: 1; margin-top: var(--space-lg); }

@media (min-width: 900px) {
  .steps { grid-template-columns: repeat(3, 1fr); gap: var(--space-lg); }
  /* The descent. Each card sits lower than the last, so reading left to right
     walks you down into the water the gradient is filling from the same side.
     Deliberately small steps — this has to read as a gentle slope, not as three
     cards that failed to align. */
  .steps__item:nth-child(2) { margin-top: 2.6rem; }
  .steps__item:nth-child(3) { margin-top: 5.2rem; }
  /* The last card is the deepest, so it gets the most water behind it and needs
     its own breathing room underneath. */
  .steps { padding-bottom: 2rem; }
}

/* Phones: the stagger is removed rather than rotated — three cards each
   indented further right would just look broken on a 375px screen. The wave
   flips to a horizontal surface across the top of the water instead, so the
   idea survives on the axis the layout actually has. */
@media (max-width: 899px) {
  .steps-water { height: 34%; }
  .steps-water svg { transform: none; }
  /* Was font-size only, which made sense when the numeral was bare text. Now it
     is a glyph inside a fixed disc, so the disc has to come down with it or the
     type outgrows its own circle. */
  .steps__num { width: 72px; height: 72px; font-size: 2.1rem; }
}

/* ---------------------------------------------------------- 17.12 Quotes */
/* position:relative here (the base rule above has never needed it) is what
   makes the z-index below take effect. */
.quote {
  position: relative;
  transition: transform 0.35s var(--ease-out), box-shadow 0.35s var(--ease-out);
}
/* Same "closer, not just higher" hover as .card--link — see the note there. */
@media (hover: hover) {
  .quote:hover {
    transform: translateY(-3px) scale(1.04);
    box-shadow: var(--shadow-xl);
    transition: transform 0.5s var(--ease-back), box-shadow 0.35s var(--ease-out);
    z-index: 2;
  }
}
/* Each avatar wears the colour of the level its caption names, set as --lv-ink
   on the figure. This is not the decorative rainbow the feature icons were
   rightly denied: the caption underneath says "Parent of a Stingrays swimmer",
   so the disc is naming a level exactly as the house rule requires — and it
   puts three saturated dots in the flattest stretch of the page.

   The -ink variants, not the vivid ones: white on plain --lv-starfish is
   2.4:1. Against the inks the same text runs 5.9–9.0:1, so this holds even
   when a future review names a level whose vivid value is a pale yellow. */
.quote--plain .quote__avatar { background: var(--lv-ink, var(--pool-200)); color: var(--white); }

/* ------------------------------------------------------- 17.12b FAQ split */
/* Common Questions was the last centred-narrow block on the page, and it was
   the weakest: an 820px column holding a head, four one-line rows and a button,
   with ~340px of dead pool-200 either side of it at desktop. That empty margin
   is exactly why the stingray got parked out there in the first place — there
   was nowhere else for it to go, and standing something in leftover space is
   not the same as composing with it.

   Split in two, the section stops wasting the width: the question the reader
   arrives with ("what else do I need to know?") is answered on the left as a
   statement, and the accordion becomes the answer column on the right.

   It also fixes the rhythm. The page ran centred-head five times in a row with
   one break at .head-split; a second break here, at the last section before the
   deep end, gives the run a bookend instead of just petering out. */
.faq-split { display: grid; gap: var(--space-lg); }
@media (min-width: 900px) {
  /* Aside deliberately narrower than the accordion — it is the label, not the
     content. Row one is the section proper: copy left, card right. Row two is a
     footer band that closes both columns at once — the follow-through link sits
     under the copy it follows from, the stingray under the card.

     align-items:start so nothing re-centres itself against a column that
     changes height as rows open. */
  .faq-split {
    grid-template-columns: 0.86fr 1.14fr;
    grid-template-rows: auto auto;
    column-gap: var(--space-xl);
    /* Tight, because this gap is what makes the stingray read as nested into
       the card's corner rather than stranded below it. The link doesn't end up
       cramped by it — it is centred in a row the mascot's height sets, so it
       still clears the paragraph above by ~85px. */
    row-gap: var(--space-xs);
    align-items: start;
  }
  .faq-split__aside { grid-column: 1; grid-row: 1; }
  .faq-split .accordion { grid-column: 2; grid-row: 1; }
  /* Centred against the mascot beside it, so the two read as one band rather
     than as two things that happen to be low on the page.

     Being in row two means it travels down as rows open — the alternative was
     to let the accordion span both rows so the link could stay pinned under the
     copy, but that only works if the mascot leaves row two as well, and then
     its distance from the card's underside swings between 12px (open) and 79px
     (closed, the state most readers see). A link that drifts is a smaller
     price than a mascot that strands, and drifting keeps it level with the
     mascot in every state, which is the point of the band. */
  .faq-split__cta { grid-column: 1; grid-row: 2; align-self: center; }
  .faq-split__ask { grid-column: 2; grid-row: 2; }
}
.faq-split__aside .lead { max-width: 42ch; }

/* ------------------------------------------------- 17.12c Level names in copy
   The house rule was written as "a level colour appears only when it is naming a
   level, and only ever alongside that level's mascot". The second clause was
   never the principle — it was the implementation, back when the only place a
   level got named was on its own card. It is also what licensed the two loose
   mascots: if colour requires a mascot, then a section that wants colour has to
   import one, and you end up with a Stingray standing next to copy about
   Seahorses.

   Amended: colour appears when it names a level. Full stop. That is stricter,
   not looser — it rules out both mascots and rules *in* this, where the colour
   lands on the actual words doing the naming. */
.lv-name {
  color: var(--lv-ink);
  font-weight: 800;
  /* A tinted underline rather than a background chip: four coloured chips in two
     short answers reads as highlighter pen. The rule sits under the word at the
     same weight as the level cards' own .lvl__rule, so the device is already
     established by the time you reach it. */
  box-shadow: inset 0 -0.32em 0 color-mix(in srgb, var(--lv-ink) 14%, transparent);
}

/* The ask block that closes row two. Deliberately not a card: the accordion
   above it is already a card, and two stacked cards in one column would read as
   a list of two things rather than a question and its follow-through.

   It needs REAL air above it, and the grid's row-gap cannot supply it. That gap
   is var(--space-xs), tuned when the thing below the accordion was a mascot that
   had to nest into the card's corner. What sits there now is a change of
   subject — the answers stop and a person is offered — and eight pixels between
   the last question and "Still not sure which level fits?" reads as a sixth
   accordion row that has lost its panel. Widening the row-gap instead would also
   push the ghost button in the left column away from the copy it follows from,
   where the tight gap is still correct. So the air goes on this block alone.

   Doubled again on the phone, where the two are in one column and the ask lands
   directly under the accordion's own rounded edge with nothing beside it to
   separate them. */
.faq-split__ask { max-width: 46ch; margin-top: var(--space-md); }
@media (min-width: 900px) { .faq-split__ask { margin-top: var(--space-lg); } }
.faq-split__ask-lead {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.15rem;
  color: var(--ink);
  margin-bottom: 0.35rem;
}
.faq-split__phone {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  /* Meets the 44px target without being a third button — this section already
     has a ghost button in the column beside it, and two buttons on one band
     would split the action rather than offer one. */
  min-height: 44px;
  margin-top: 0.4rem;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.35rem;
  color: var(--teal-ink);
  text-decoration: none;
}
.faq-split__phone svg { width: 22px; height: 22px; flex: none; }
.faq-split__phone:hover { color: var(--ink); text-decoration: underline; }

/* On a phone the ask block follows the accordion in normal flow — no override
   needed. The stingray that used to sit here needed one (display:none, then a
   re-show at 900, then a width change at 1100) because it was an object with no
   place in the stacked layout. Content doesn't have that problem. */

/* ------------------------------------------------------- 17.13 The deep end */
/* The bottom of the page is the bottom of the pool. This is the only place
   the canvas goes dark, and it lands right where the page asks for the
   booking — the descent is the whole point of the art direction. */
.deep {
  position: relative;
  overflow: hidden;
  /* .footer-wave below already overlaps both of its own edges by 1px for
     exactly this reason (see its comment): .deep is a composited layer over
     GSAP-animated children, so its block boundaries land on fractional
     pixels at any device ratio or zoom that isn't 1, and the page
     background shows through for one device pixel. That fix covers the
     deep→footer seam but not the seam above it, where .deep meets the plain
     <section> before it on every page — nothing bridges that edge, so it's
     the one that was still showing as a hairline on phones. Same medicine:
     pull the top edge up 1px so it overlaps the section above instead of
     butting against it. Same colour on both sides (the svg fill and ::after
     belt in .deep__surface already match --deep-above to whatever's above),
     so the overlap is invisible; only the gap it removes was visible. */
  margin-top: -1px;
  /* Extra top padding carries the water surface, which is now part of this
     section rather than a divider stacked above it. */
  padding-block: calc(var(--space-2xl) + var(--wave-h)) var(--space-2xl);
  /* Top stop is the worst case for the white/pale text over it. --teal-ink left
     the lead at 4.52:1 — technically passing, with nothing in hand. #0e6570
     takes it to 5.26:1, and reads deeper, which is the point of the section. */
  /* The descent, capped. The brief set #288698 as the floor; #267e8f is 2.1
     lightness points below it and is the shallowest value that carries white
     body copy (4.70:1 — #288698 itself manages 4.24 and fails).

     White body copy needs relLum at or below 0.183 and the floor is 0.170, so
     the entire legible range of this section is 0.170–0.183 — there is no
     room for a gradient underneath the copy. So the descent is delivered as an
     EVENT at the boundary rather than a slope across the band: the surface wave
     at the top drops from --pool-200 at relLum 0.781 to 0.170 across one curve,
     a 4.6x darkening in about 90px. That reads as going under far more sharply
     than the same range spread over 900px would.

     The stop is --wave-h, not a percentage, and that is the whole fix. At 38%
     the slope ran 330px into the section while the top padding only clears
     96px + --wave-h — so the accreditation plate, the first copy in the block,
     landed ON the slope rather than on the floor. Measured there: creds__lead
     4.24:1, creds__abbr 4.41, creds__name 4.47. All three fail, and they fail
     in the one block whose entire job is to look institutionally credible.

     A percentage cannot be checked against the band because it resolves against
     a section height that changes with the copy — the same trap the mascots in
     .deep__school were already pulled out of. Anchored to --wave-h the descent
     completes inside the surface curve.

     The third stop is new, and it is the half of that argument that was missing.
     "There is no room for a gradient" was only ever true UPWARD: white needs
     relLum at or below 0.183 and --trench is 0.170, so there are thirteen
     thousandths of headroom above and a clear run below. Continuing down to
     --trench-deep gives the section a real floor — the plate and the headline
     sit high on it at 4.70:1, the buttons sit low on it at 6.55:1, and every
     glyph gets MORE contrast as it descends, not less. The band now reads as
     water with a bottom to it instead of a flat teal rectangle. */
  /* The first 2px are --deep-above, and that is the phone hairline fixed at its
     actual source. Everything else here — the 1px overlap, the svg overshoot,
     the ::after belt — protects against the WAVE's edges. None of them protects
     against this section's OWN top edge, and that is the edge the line was on.

     A background is painted into the element's border box. When that box's top
     lands mid-device-pixel — which on a phone is every frame, because momentum
     scrolling puts the whole document on a fractional offset — Chrome blends
     the background's first row toward what sits behind it. .deep's clip does
     the same to every child at the same row, the belt included. So the top row
     composited as roughly three parts pale to one part --water-mid: a thin,
     slightly teal rule straight across the join, on every page with a deep end,
     and invisible at dpr 1 where nothing lands on a fraction. It read as hard
     because a one-pixel step always does.

     Starting the ramp at --deep-above means a partly-covered top row blends
     pale into pale and there is nothing left to see. Free, too: the first 2px
     sit under the surface fill's own opaque paint at every width — the crest of
     the curve is 7.6px down at --wave-h's 48px floor — so the stop is invisible
     in normal rendering and only does work in the row that was misbehaving. */
  background: linear-gradient(180deg, var(--deep-above) 0, var(--water-mid) 2px, var(--trench) var(--wave-h), var(--trench-deep) 100%);
}

/* Light coming down through the water. This used to be the visible seam the
   join suffered from: at inset:0 it began abruptly at the section box, so the
   eye read a hard rule where the plain wave colour met the lit section. It now
   starts at the top of the wave and the pale surface fill is painted over its
   brightest part, so what's left below the curve is light filtering through
   the surface — which is the effect it was always reaching for. */
.deep::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(90% 55% at 50% 0%, rgba(255, 255, 255, 0.26), transparent 68%);
  pointer-events: none;
}

/* The surface itself. Above the bubbles (z-index 2) so they vanish as they
   break it rather than sailing on over the pale water, and below the copy. */
.deep__surface {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: var(--wave-h);
  z-index: 2;
  pointer-events: none;
}
/* Same trick as .wave, mirrored: this one fills upward, so the overhang goes
   above the section and .deep's overflow:hidden trims it. Without it the pale
   fill ends exactly on the section's top edge and antialiasing lets a hairline
   of the dark gradient through against the pool above. */
.deep__surface svg { display: block; width: 100%; height: calc(100% + 2px); margin-top: -2px; }
/* Same belt-and-braces as .wave::after, at the other edge. The overhang moves
   the fill's edge above the section, but .deep's overflow:hidden then becomes
   the edge, and a clip landing mid-device-pixel antialiases — letting the
   gradient's --water-mid first stop through as a faint blue hairline right
   where the pale band above meets this one. Two solid rows of the colour the
   section above is actually painted in, which --deep-above already holds. */
.deep__surface::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  background: var(--deep-above);
}

/* The pale fill above the curve is not decoration. It is the same water the
   reader has been standing in all the way down the page, drawn one last time
   before they go under — so it has to be the SAME colour as the band directly
   above this section. Any other value and the two meet in a dead straight
   full-width rule, and the hand-drawn curve four pixels below it is left doing
   nothing at all. A boundary should be an event you can see the shape of, not
   a line somebody forgot to hide.

   The band above the closing block is --pool-200 on five of the seven site
   pages, so that is the default; the pages that differ declare themselves here
   rather than in their markup. That is deliberate and it is the actual fix:
   the guide pages are written by a tool that knows nothing about colour, and
   this way it never has to. Whatever section the templates end on, the surface
   picks the matching value up from the stylesheet.

   Setting fill in CSS also beats the presentational fill= attribute sitting in
   the markup, so every page already built inherits the correction untouched. */
.deep { --deep-above: var(--pool-200); }
.section--tint-2 + .deep,
.section--tint-4 + .deep,
.guide-more + .deep,
.guide-index + .deep { --deep-above: var(--pool-100); }
.section--pool + .deep { --deep-above: var(--sky); }
.deep__surface svg path { fill: var(--deep-above); }

.deep__inner { position: relative; z-index: 3; }
.deep h2 { color: var(--white); }
.deep .lead { color: #cfe7f4; }
.deep .btn--white { --btn-bg: var(--white); --btn-fg: var(--ink); }

/* Mascot silhouettes drifting in the deep water behind the copy. */
.deep__school {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  overflow: hidden;
}
.deep__school img {
  position: absolute;
  width: clamp(70px, 9vw, 128px);
  height: auto;
  opacity: 0.16;
  filter: grayscale(1) brightness(2.4);
}
/* Left-hand pair, turned to face the copy. GSAP animates y on these for the
   parallax drift and reads the existing transform first, so the flip survives
   — but it has to be a transform it can parse, which is why this is scaleX
   and not a scale shorthand. */
.deep__school .is-flipped { transform: scaleX(-1); }

/* Wave running out of the deep end into the footer. This is the site's LAST
   boundary and now its largest, and it is the one the page had been getting
   away with drawing in a single colour.

   What it draws is the footer's own ceiling: the path is filled --abyss and
   rises INTO the closing section, so the footer is not a slab bolted under the
   CTA — it is the seabed the water has been descending toward since the surface
   wave two thousand pixels above. Curve belongs to the block below it, which is
   the same rule every other wave on this site follows.

   The step across it is 1.9x in relative luminance (0.110 to 0.058) against
   0.0x before, which is the whole of the reported problem: a boundary drawn in
   one colour is a boundary you can only find by reading the content on either
   side of it. Both blocks are still the same hue at the same saturation, so
   this separates without becoming a second palette — see --abyss.

   The 2px overlap below is unrelated and still load-bearing. .deep is a
   composited layer (overflow:hidden over GSAP-animated children) and block
   boundaries land on fractional pixels at any device ratio or zoom that isn't
   1, which lets the page background through for one device pixel. Nothing
   paints that seam, so nothing can be recoloured to hide it — the element is
   2px taller than the wave needs and gives that 2px straight back as negative
   margin, lapping one pixel over the block above and one under the footer. Now
   that the two sides differ, that overlap is doing visible work rather than
   insuring against a hairline nobody would have seen. */
.footer-wave {
  display: block;
  /* Carries the ::after belt below. */
  position: relative;
  width: 100%;
  height: calc(clamp(38px, 5vw, 72px) + 2px);
  margin-block: -1px;
  /* The fill is the footer; the ground is whatever the wave is rising through.
     It is a variable because two pages (thanks, find-your-class) have no
     closing section and meet the footer straight off a pale band — they set
     this to their own last colour inline. Everywhere else it is the closing
     section's floor, so the join above the crest is invisible and the only
     edge you see is the curve itself. */
  color: var(--abyss);
  background: var(--wave-above, var(--trench-deep));
}
.footer-wave svg { display: block; width: 100%; height: 100%; }
/* The same belt .wave::after and .deep__surface::after already wear, and the
   last wave on the site that was still going without one.

   The 1px overlap above stops the two BOXES parting. It cannot stop the svg's
   own bottom row from being resampled: an svg is a replaced element rasterised
   to its layout box, and when that box's bottom lands mid-device-pixel — which
   at dpr 1.5 or 2, or at any zoom, is the normal case rather than the odd one —
   the outermost row is blended with what sits behind it. Behind it is this
   element's background, which is the colour ABOVE the wave and several steps
   lighter than the footer. Inline content paints after every block background,
   so that pale row lands on top of the footer as a hairline straight across the
   join. It is the same failure written up at the other three waves, arriving by
   a slightly different door.

   Two solid rows of the footer's own colour, positioned so they paint after the
   svg does. currentColor is already --abyss on the element, so this needs no
   second value and no per-page override. The path fills the full width at
   y=80/80, so there is nothing here for it to cover up but its own edge.

   Chrome DevTools proposed shape-rendering:crispEdges and a 1px translate
   instead. crispEdges turns antialiasing off for the whole path, which buys a
   clean 1px seam at the price of a staircased crest — the curve is the only
   thing this element exists to draw. The translate moves the artifact rather
   than removing it, and depends on an overflow this element does not set. */
.footer-wave::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: currentColor;
}

/* ------------------------------------------------------- 17.14 Home mobile */
/* Phone is not the desktop layout compressed: the hero drops to a single
   readable screen (motto, headline, one CTA, photo), the decorative panel and
   scroll cue are dropped rather than shrunk, and the mascot moves to a corner
   where it can't collide with the frame's subject. */
@media (max-width: 959px) {
  .hero--home { padding-top: calc(var(--nav-h) + var(--space-sm)); }
  /* Capped at 2.9rem, this was the single biggest contributor to the flat
     mobile hierarchy — the page's largest type was being held below three
     rem while h2 sat at 1.9rem underneath it. The new curve reaches 3.6rem,
     which is exactly where the desktop rule (6vw) lands at 960px, so the two
     meet at the breakpoint instead of stepping. */
  .hero--home h1 { font-size: clamp(2.35rem, 1rem + 6.5vw, 3.6rem); }
  /* Was 1.05rem — 16.8px, i.e. fractionally *smaller* than the 17px body copy
     it introduced. */
  .hero--home .lead { font-size: 1.1rem; }
  .hero__media::before { display: none; }
  /* Both corners are taken now, so neither dolphin can be parked out of the
     way — they shrink instead, and sit low enough to clear the subject. */
  .hero--home .hero__mascot { width: clamp(62px, 16vw, 96px); }
  .hero--home .hero__mascot--pink { left: -3%; }
  .hero--home .hero__mascot--blue { right: -3%; }
  /* Tablet still takes three columns — around 230px each, which holds a
     two-line label. Phones don't; see the 620 block. */
  .hero-stats { gap: 0.75rem; margin-top: var(--space-md); }
  .hero-stats > div { padding-left: 0.75rem; }
  .hero-stats__fig { font-size: 1.75rem; }
  .hero-stats__label { font-size: 0.76rem; line-height: 1.35; }
  .hero__reassure { font-size: 0.9rem; }
}
@media (max-width: 620px) {
  /* One primary action on a phone. "View timetable" is a research task, not
     a booking task, and it was competing with the thing that earns money. */
  .hero__actions .btn--ghost { display: none; }
  .hero__actions .btn { width: 100%; }
  .lvl__art { height: 168px; }
  .lvl__art img { height: 148px; }
  /* Tighter than desktop, but the wave allowance has to survive the override —
     without it the surface lands on top of the heading. */
  .deep { padding-block: calc(var(--space-xl) + var(--wave-h)) var(--space-xl); }

  /* Three columns collapse to ~90px on a phone, which turns a two-line label
     into four. Stacked, each stat gets the full width and reads as a sentence:
     "4 — children per class. Never a fifth." */
  .hero-stats { grid-template-columns: 1fr; gap: 0; }
  .hero-stats > div {
    display: flex;
    align-items: baseline;
    gap: 0.7rem;
    padding: 0.6rem 0 0;
    border-left: 0;
    border-top: 1px solid var(--pool-200);
  }
  .hero-stats > div:first-child { padding-top: 0; border-top: 0; }
  .hero-stats__fig { font-size: 1.6rem; min-width: 2.6ch; }
  .hero-stats__label { margin: 0; font-size: 0.92rem; line-height: 1.4; }
}

/* -------------------------------------------------- 17.15 Phone card rails */
/* "Mobile designed, not shrunk" is the item this section exists for. Seven
   level cards and three review cards stacked vertically is the desktop grid
   with its columns removed — competent, and about 4,000px of scrolling. On a
   phone they become horizontal snap carousels: a browse gesture instead of a
   scroll-past.

   Deliberately native CSS scroll-snap, not a carousel library. It costs nothing
   to load, it keeps the platform's own momentum and rubber-banding (which is
   most of why a good carousel feels expensive and a JS one doesn't), the cards
   stay in the DOM for SEO, and it needs no prefers-reduced-motion branch
   because the only motion is the user's own finger.

   Placed at the end of the file on purpose. .rail has the same specificity
   (0,1,0) as .levels and .grid, both of which set `display`, so source order is
   what decides — see the ordering note in section 4. */
@media (max-width: 979px) {
  .rail {
    display: flex;
    gap: var(--space-sm);
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;
    /* Full-bleed: the rail breaks out to both screen edges so cards scroll off
       the edge of the phone rather than inside a 20px-inset box. The padding
       puts the first card back on the page's own left margin, and
       scroll-padding makes every *snapped* card land there too — without it,
       cards two onward snap flush to the screen edge and the rail looks like it
       has lost its gutter. */
    margin-inline: calc(-1 * var(--gutter));
    /* Card and padding are both in vw so they can be solved against each
       other. A percentage flex-basis resolves against the CONTENT box, which
       shrinks as padding grows — so "card 84% + padding 8%" can never actually
       centre, and it didn't: the first card sat 27px from the left with 96px of
       gap on the right. In vw: 15 + 70 + 15 = 100, and the active card is
       genuinely in the middle of the screen.

       Was 11 + 78 + 11: the neighbour's sliver came out to padding-inline minus
       the gap, roughly 11vw − 1.25rem — about 21px at a 375px width, which read
       as barely there rather than as an invitation to swipe. Giving the padding
       4 more vw (and taking it from the card, not from thin air) grows that
       sliver to roughly 36px, over 70% more, while the card only comes down
       about 8% — still comfortably the dominant thing on screen. */
    padding-inline: 15vw;
    scroll-padding-inline: 15vw;
    /* Vertical room for the cards' own drop shadows — and, on a rail using the
       current-card treatment below, for the current card's own scale-up too.
       A scroll container clips both axes — you cannot have overflow-x:auto
       with overflow-y:visible — so without this margin the shadow, or the
       grown card itself, is sliced off flat at the top and bottom. */
    padding-block: 18px 30px;
    /* The scrollbar is noise here; the peeking next card is the affordance. */
    scrollbar-width: none;
  }
  .rail::-webkit-scrollbar { display: none; }

  /* Centred, with a small sliver of the neighbours either side.

     This used to be a 76% card snapped to `start`, which parked the active card
     hard against the left gutter with a 92px slab of the next one on the right —
     lopsided, and it read as a row that had been cut off rather than as a card
     you are meant to be looking at. Now 70vw snapped to `center` inside 15vw of
     side padding, so the active card is centred and each neighbour shows a
     ~36px sliver: enough to read as an obvious "there is more this way"
     without competing with the active card.

     70 + 15 + 15 = 100. If either number changes the other has to follow, or
     the centred card stops being centred. */
  .rail > * {
    flex: 0 0 70vw;
    scroll-snap-align: center;
    /* The review cards are <figure>s, and the UA default is `margin: 1em 40px`.
       In the grid that margin was absorbed by the track; in a flex rail it adds
       80px between every pair of cards, which collapsed the peek from 92px to
       21px and quietly destroyed the one affordance the carousel depends on.
       A rail owns its own spacing — that is what `gap` is for. */
    margin: 0;
  }

  /* No trailing spacer. That was needed for start-alignment, where the last
     card could never scroll far enough to reach the gutter. With centre
     alignment the equal side padding does that job for both ends. */

  /* No tabindex on the rail: every card already contains a link (.lvl__link and
     the review cards' own text), so Tab moves through the cards and the browser
     scrolls each into view. Adding a focusable wrapper would satisfy the same
     WCAG requirement by adding a tab stop that lands nowhere. */

  /* Browsing and scanning are different jobs. A parent arriving at Programs
     knows their child is four and wants to *find* that level, not tour seven —
     and swiping to locate is slower than scanning. So in rail state the age
     band becomes the loudest thing on the card, enough that a fast flick
     resolves "3–5 yrs" without stopping. On the desktop grid, where all seven
     are visible at once, the level name can lead instead. */
  .rail .lvl__age {
    font-size: 0.86rem;
    padding: 0.35rem 0.85rem;
    letter-spacing: 0.04em;
  }
  .rail .lvl { height: auto; }
}

/* ------------------------------------- 17.21a The rail, once it has room
   A phone shows one card because one card is all that fits: 70vw of a 375px
   screen is 262px, which is the card at its natural size. The same 70vw on a
   tablet is 537px and on an unfolded foldable 685px — the identical card,
   stretched, with the same three lines of copy floating in it. Nothing about
   the card wanted to be wider; it was only ever sized as a fraction of the
   screen so that ONE of it would land in the middle of a phone.

   So above 620px the card stops being a fraction and becomes a size. It keeps
   the width it has on a phone and the extra room goes into showing more of the
   pathway — two cards on a tablet, three on a foldable or a small laptop —
   which is what the reader gains by having a wider screen.

   Snapping moves with it. Centring is right when there is one card and slivers
   of two others; with whole cards on screen the row should start at the page's
   own margin and snap card to card from there, so the first card lines up with
   the heading above it instead of floating inboard of it. */
@media (min-width: 620px) and (max-width: 979px) {
  .levels.rail {
    /* Back to the page's own gutter — 15vw was the phone's centring maths
       (70 + 15 + 15 = 100) and means nothing once the card is a fixed size. */
    padding-inline: var(--gutter);
    scroll-padding-inline: var(--gutter);
  }
  .levels.rail > * {
    flex: 0 0 280px;
    scroll-snap-align: start;
  }
}

/* The one-card-at-a-time treatment, for the one width that shows one card.
   Below 620px the rail centres a single card with a sliver of its neighbours
   either side, and growing whichever card is nearest the centre is what makes
   a flick read as moving one card into focus. From 620px up the rail shows
   two or three whole cards at once (17.21a), where nominating one of them as
   the current one would single out a card the reader has not chosen. */
@media (max-width: 619px) {
  /* Current-card treatment (data-rail-active, toggled in main.js). The card
     nearest the rail's centre — the one actually being looked at — grows and
     sharpens; its neighbours recede. A flick that used to move a strip of
     equal tiles now moves one card into focus at a time, matching the desktop
     hover pop rather than sitting apart from it as a separate idea.

     Scale only, no lift: on a horizontal rail there is no "up" for a card to
     rise into, so translateY would read as a stray twitch. Growing in place
     is the whole gesture.

     Idle and current use different curves for the same reason the desktop
     hover does — see .card--link. --ease-back overshoots on the way in, which
     is what makes a card snapping into focus read as a small spring rather
     than a linear grow; the plain --ease-out below runs when a card loses
     focus, so it settles down rather than undershooting past its resting
     size.

     `.lvl` cards carry [data-reveal="scale"], and once revealed pick up
     [data-reveal].is-revealed { transform: none } (section 15) at specificity
     (0,2,0). `.rail--active > *` is only (0,1,0) and silently loses — the
     idle scale-down never applied. `.rail--active > .lvl:not(.is-current)`
     is (0,3,0): ancestor class + child class + the :not() argument's own
     class each count, which clears (0,2,0) outright rather than tying it, so
     this doesn't depend on staying below section 15 in the file the way that
     rule asks hover styles to. */
  /* transform carries !important on both states below for a reason beyond the
     is-revealed war described above. These cards also belong to a
     [data-reveal-group], and while that group's own entrance tween is running,
     GSAP is writing to the same element's inline style attribute every frame —
     and inline style beats any plain stylesheet rule regardless of
     specificity, including the (0,3,0) rules that already beat is-revealed.
     The visible symptom was a real delay: the current card sat at the same
     size as its neighbours until its entrance tween finished and handed the
     element back (~0.8s, longer still for whichever card is a few slots into
     the stagger), then suddenly popped. !important is the one thing that
     outranks inline style too, so the pop-out size is correct from the very
     first frame — the entrance tween still fades the card's opacity in, it
     just no longer also carries the transform. */
  .rail--active > .lvl:not(.is-current) {
    transform: scale(0.93) !important;
    opacity: 0.82;
    transition: transform 0.3s var(--ease-out), opacity 0.3s var(--ease-out);
  }
  .rail--active > .lvl.is-current {
    transform: scale(1.045) !important;
    opacity: 1;
    box-shadow: var(--shadow-xl);
    transition: transform 0.5s var(--ease-back), opacity 0.35s var(--ease-out);
    z-index: 1;
  }
}

/* Parent reviews' current-card treatment (data-rail-active on the rail
   markup) stays phone-only, unlike the level cards above — the reviews rail
   is already a peek carousel at every width (17.21), and scaling a "current"
   card only reads as a gesture on the width where nothing else is visible
   either side of it. `.quote` cards carry [data-reveal="deal"], which hits
   the same [data-reveal].is-revealed { transform: none } rule at (0,2,0)
   that `.lvl` above has to clear — (0,3,0) here for the same reason, and the
   same GSAP-inline-style conflict the !important flags answer. */
@media (max-width: 619px) {
  .rail--active > .quote:not(.is-current) {
    transform: scale(0.93) !important;
    opacity: 0.82;
    transition: transform 0.3s var(--ease-out), opacity 0.3s var(--ease-out);
  }
  .rail--active > .quote.is-current {
    transform: scale(1.045) !important;
    opacity: 1;
    box-shadow: var(--shadow-xl);
    transition: transform 0.5s var(--ease-back), opacity 0.35s var(--ease-out);
    z-index: 1;
  }
}

/* The scroll-position indicator built by initRails(). Not dots: with seven
   level cards, seven dots is a row of specks that reads as decoration, and it
   would need its own tap targets to justify itself. A single bar that tracks
   real scroll position answers the only two questions a rail raises — how much
   is there, and where am I — and it moves with the finger, which is what makes
   it feel attached to the gesture rather than reporting on it. */
.rail-progress {
  display: none;
  height: 4px;
  margin-top: var(--space-sm);
  border-radius: var(--r-pill);
  /* A flat --pool-200 fill was a fixed light blue sitting on top of section
     grounds that are never that exact colour — pool-50, pool-100, and a
     100→200 gradient on this page alone — so the track read as a hard-edged
     rectangle rather than a groove. A translucent dark tint darkens whatever
     is already behind it by a fixed amount instead of covering it with a
     colour of its own, so it reads as a groove on any of them. */
  background: rgba(18, 48, 73, 0.1);
  overflow: hidden;
}
.rail-progress__bar {
  height: 100%;
  border-radius: var(--r-pill);
  background: var(--teal-ink);
  transform-origin: left center;
  /* Transform only — the bar composites on the GPU and tracks a finger at
     60fps. Animating width would relayout on every scroll event.

     This value is only the pre-script resting state; initRails() overwrites it
     inline on first paint and on every scroll frame. It is set here so that if
     the script never runs the track still shows a bar rather than an empty
     groove. */
  transform: scaleX(0.35);
}
@media (max-width: 619px) {
  .rail-progress { display: block; }
}
/* The level cards' own rail now runs through tablet and foldable widths too
   (17.15a) — its progress bar follows. Scoped to the levels rail specifically
   (the adjacent-sibling selector matches only the element initRails() inserts
   right after it) so the parent-reviews rail, which stays phone-only for this
   bar, is untouched. */
@media (min-width: 620px) and (max-width: 979px) {
  .levels.rail + .rail-progress { display: block; }
}

/* ------------------------------------------------ 17.16 Smart form review */
/* The final step of the contact Smart Form: the assembled enquiry, shown back
   to the parent before they send it.

   Styled as a quiet receipt rather than as another card. It sits inside the
   .finder panel, which is already a card, and a second bordered box inside it
   would read as a separate thing to fill in rather than as a summary of what
   has been filled in. A tinted ground and a rule between rows is enough. */
.sf-review {
  margin: 0;
  padding: var(--space-sm) var(--space-md);
  border-radius: var(--r-md);
  background: var(--pool-50);
  /* Two columns on anything roomy enough: the label is short and fixed-ish, the
     value varies, and a label above its value doubles the height of a block
     whose whole job is to be scannable at a glance. */
  display: grid;
  grid-template-columns: minmax(9rem, auto) 1fr;
  gap: 0.55rem var(--space-sm);
  font-size: 0.95rem;
}
.sf-review dt {
  font-family: var(--font-display);
  font-weight: 700;
  color: var(--ink-faint);
}
.sf-review dd {
  margin: 0;
  color: var(--ink);
  font-weight: 600;
  /* Long notes and email addresses must not push the grid wider than its
     column — the review panel is the narrowest thing on the page. */
  overflow-wrap: anywhere;
}

/* Phones: the two-column grid gets tight once a label like "Swimming ability"
   meets a value like "Comfortable in water but cannot swim unassisted". Stack
   instead, with the label as a small caption above its value. */
@media (max-width: 559px) {
  .sf-review {
    grid-template-columns: 1fr;
    gap: 0.15rem;
  }
  .sf-review dt { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
  .sf-review dd + dt { margin-top: 0.7rem; }
}

/* ------------------------------------------- 17.17 Programs: groups & tots */
/* The three pre-stream levels take identity colours drawn from their own
   mascots, the same rule the seven streams follow.

   They went without for as long as they had no character to sample: a hue is
   identity here, and minting one for a level with nothing to attach it to would
   have spent a slot in the system on a card that could not earn it. So all
   three shared the house teal, which was honest about what existed.

   The artwork exists now. A colour still names a level and still arrives with
   its mascot, so the rule is unchanged — there are simply ten levels wearing it
   instead of seven.

   --pool-300 stays what it is elsewhere; it just should never have been doing
   duty as a level colour, which is what it did before the teal did. */
:root {
  /* Sampled from the artwork, the same way the seven above were: her body pink,
     his body blue, its body teal, each the largest area in its own drawing. The
     card head is that colour at full strength, so a mascot now stands in water
     its own colour — which is how Starfish and Seal have always worked, and the
     white keyline is what separates character from ground.

     Honest about the cost, because it is real. Aquababies is clear of every
     existing level (dE 42 from Shark, 50 from Seahorse — the closest pair among
     the original seven is Dolphin/Shark at 26). The other two are not: Aquatots
     lands dE 16 from Shark and 19 from Swordfish, Pufferfish dE 13 from Shark
     and 14 from Dolphin. There is no fix available in the artwork — a turtle
     drawn in cyan with a grass-green shell has one colour that collides with
     Swordfish and another that is Seal's almost exactly (dE 2), and the
     pufferfish is simply drawn in the same teal family as Dolphin.

     What keeps it legible is that the collisions are between the two ends of
     the pathway — a six-month baby class and a 12+ squad class — and every card
     names itself in type. The three that share a phase are far apart from each
     other (dE 22 to 54), which is the comparison a parent actually makes. */
  --lv-babies: #f8c0d8;     --lv-babies-ink: #a84a72;
  --lv-tots: #40c8f0;       --lv-tots-ink: #0c7695;
  --lv-puffer: #78c8c8;     --lv-puffer-ink: #147a7a;
}

/* Sub-heads inside the programs section. The section has one <h2>; these are
   <h3>s that group the cards below them, so the levels read as two phases
   rather than as ten cards in a row. */
.lvl-group { max-width: 62ch; margin-bottom: var(--space-md); }
.lvl-group--spaced { margin-top: var(--space-xl); }
.lvl-group__title {
  font-size: clamp(1.3rem, 1.05rem + 1.1vw, 1.75rem);
  margin-bottom: 0.2rem;
}
.lvl-group__sub { color: var(--ink-soft); margin: 0; }

@media (min-width: 620px) { .levels--tots { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 980px) { .levels--tots { grid-template-columns: repeat(3, 1fr); } }

/* A line-art motif where mascot artwork would be: the card keeps its tinted
   water and waterline and puts a simple stroke icon in it. Same silhouette as a
   mascot card at a glance, obviously not pretending to be one up close.

   Now only No Limits uses it, that card being a promise rather than a level.
   Aquababies, Aquatots and Pufferfish carried it while their characters were
   missing from the supplied artwork, and have since been given them. */
.lvl__art--motif { display: grid; place-items: center; }
.lvl__art--motif > svg:first-child {
  width: 84px;
  height: 84px;
  color: var(--lv-ink);
  opacity: 0.75;
  /* Sits above the tinted ground but below the two waterline waves, so the
     motif is cropped by the water exactly as a mascot is. */
  position: relative;
  z-index: 1;
  margin-bottom: 14px;
}
/* No hover launch: there is no character to launch. The card still lifts. */
.lvl--plain:hover .lvl__art--motif > svg:first-child { transform: none; }

/* The stage names under a stream's title — "Stella · Shelly · Splash · Spark".
   This is what lets seven cards carry fifteen levels without seven of them
   lying about how many levels they are. */
.lvl__stages {
  display: block;
  margin: -0.15rem 0 0.5rem;
  font-size: 0.84rem;
  font-weight: 700;
  color: var(--ink-faint);
}

/* Royal Life Saving framework reference, pinned to the bottom of the card.
   margin-top:auto rather than a fixed gap because the cards are equal-height
   flex columns with checklists of different lengths — without it the line sits
   wherever the copy happens to end and the row loses its baseline. */
.lvl__framework {
  display: block;
  margin-top: auto;
  padding-top: var(--space-sm);
  font-size: 0.8rem;
  font-weight: 700;
  letter-spacing: 0.03em;
  color: var(--ink-faint);
}
/* The checklist owns margin-top:auto by default; with a framework line present
   the checklist must sit in normal flow and let the framework line take the
   slack instead. */
.lvl__body:has(.lvl__framework) .checklist { margin-top: var(--space-sm); }

/* ------------------------------------------------- 17.18 Accreditations */
/* Lives inside .deep now — see the note in index.html. On the dark ground the
   wordmarks read as a credentials plate rather than as a grey footer strip,
   which is exactly what the white version was suffering from.

   Still a still row, not a marquee. Five entries do not need scrolling, an
   auto-scrolling logo strip implies thirty you don't have, and it would be the
   only motion on the page outside rise / float / refract. */
.deep .creds {
  position: relative;
  z-index: 1;
  margin-bottom: var(--space-xl);
  padding-bottom: var(--space-lg);
  /* A hairline under the block, not a box around it: this is a plate the CTA
     sits below, and a border on all four sides would make it a card competing
     with the booking ask. */
  border-bottom: 1px solid rgba(255, 255, 255, 0.16);
}
.creds__lead {
  text-align: center;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.8rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.72);
  margin-bottom: var(--space-md);
}
.creds__list {
  display: grid;
  gap: var(--space-md) var(--space-lg);
  margin: 0;
  padding: 0;
  list-style: none;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  align-items: start;
  justify-items: center;
  text-align: center;
}
.creds__item {
  display: grid;
  gap: 0.3rem;
  align-content: start;
  /* Reserves roughly the height an official logo will occupy, so dropping the
     real assets in later doesn't reflow the block. */
  min-height: 62px;
  max-width: 26ch;
}
/* Royal Life Saving is a sentence about methodology, not a membership line, so
   it takes the full width beneath the four marks rather than being squeezed
   into an equal column. The wording difference is deliberate — see index.html. */
.creds__item--wide {
  grid-column: 1 / -1;
  max-width: 60ch;
  padding-top: var(--space-sm);
  border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.creds__abbr {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.15rem;
  line-height: 1.15;
  color: var(--white);
}
.creds__name {
  font-size: 0.78rem;
  line-height: 1.4;
  /* Was rgba(255,255,255,0.7), which composites to 3.16:1 over --trench. On this
     ground "white at 70%" is not a quieter white, it is a failing grey — pure
     white itself only reaches 4.70:1, so there is no alpha to spend. The
     abbreviation above it is 1rem/800 and this is 0.78rem/400, which is already
     a two-step drop in the hierarchy; the opacity was never carrying it. */
  color: var(--white);
}
.creds__name em { font-style: italic; }
/* Official logo files drop straight in here; the item keeps its own height. */
.creds__item img { max-height: 54px; width: auto; margin-inline: auto; }

@media (max-width: 619px) {
  .creds__list { grid-template-columns: repeat(2, 1fr); gap: var(--space-sm); }
  .creds__abbr { font-size: 1rem; }
  .creds__name { font-size: 0.72rem; }
}

/* ------------------------------------------------------- 17.19 Pull quote */
/* Used on the About page for Angela's line. A rule and a size change rather
   than a card: it is the founder's own voice inside her own section, not a
   testimonial from outside it, and boxing it would make it read as one. */
.pull {
  margin: 0;
  padding-left: var(--space-sm);
  border-left: 5px solid var(--pool-300);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1.1rem, 1rem + 0.5vw, 1.3rem);
  line-height: 1.45;
  color: var(--ink);
}

/* ------------------------------------------------------------ 17.19b Figures
   The inner pages had one shape of section and only one: eyebrow, h2, lead,
   three paragraphs, sometimes a checklist. That is a readable page and a flat
   one — the type ladder tops out at h2, so a reader skimming has nothing to land
   on between the heading and the next heading, and the concrete facts a parent
   is actually shopping on (since 2016, over a thousand children a week, 32°C all
   year) were buried mid-sentence in the third paragraph.

   This is the missing tier: two or three numbers, set in display at a size the
   headings do not reach, on their own rule-separated row. Structural rather than
   decorative — it takes the facts that were already in the copy and gives them
   the scale they were always worth, which is what "generous hierarchy" means
   when the alternative is a wall.

   The number is --ink and the label --ink-soft, never a level hue: none of these
   figures name a level, and the house rule holds on the inner pages too. The
   accent lives in the 3px rule above each figure, which takes --accent-ink from
   whatever section it sits in. */
.figures {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: var(--space-md) var(--space-lg);
  margin-top: var(--space-lg);
  padding: 0;
  list-style: none;
}
.figures__item { display: flex; flex-direction: column; gap: 0.35rem; }
.figures__item::before {
  content: "";
  display: block;
  width: 34px;
  height: 3px;
  margin-bottom: 0.55rem;
  border-radius: 2px;
  background: var(--accent-ink, var(--teal-ink));
}
.figures__num {
  font-family: var(--font-display);
  font-weight: 800;
  /* Deliberately above the h2 curve (clamp 1.9→2.6rem on these pages). The
     figure is the biggest thing in its section, which is the whole point — a
     heading tells you the subject, the number is the evidence. */
  font-size: clamp(2.1rem, 1.3rem + 2.4vw, 3rem);
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--ink);
}
.figures__label {
  font-size: 0.92rem;
  line-height: 1.45;
  color: var(--ink-soft);
  max-width: 22ch;
}

/* ------------------------------------------------- 17.20 Level filter + more */
/* `hidden` is a UA-stylesheet rule (`[hidden] { display: none }`), and any author
   rule that sets `display` beats it. .lvl is `display: flex` and .levels is
   `display: grid`, so filtered cards stayed on the page at full height — the
   filter worked perfectly in the DOM and did nothing visible. These three rules
   are what make `hidden` mean hidden here. */
.lvl[hidden],
.levels[hidden],
.lvl-group[hidden] { display: none; }

/* The chips are authored in index.html now rather than injected, so they are on
   screen in the first paint at their final height — no arrival, no shift, and a
   reader scanning the section sees the control before they start on the cards.
   This is the whole of the no-JS contract that the `hidden` attribute used to
   carry: main.js clears `no-js` on its first line, so a visitor without
   scripting never sees a control that cannot work, and every card stays
   visible. */
.no-js .lvl-filter { display: none; }

.lvl-filter { margin-bottom: var(--space-lg); }
/* An instruction, not a heading. A first-time visitor reads a row of pills as
   decoration unless something names the mechanism — and on a tablet, where the
   section head's own type comes down a step, "Filter by age" at label scale was
   getting lost against it. So it is set as a label: small, tracked out,
   uppercase, in the ink the eyebrows use. It reads as a control's name at any
   width precisely because it is not competing with a heading. */
.lvl-filter__label {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.78rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--teal-ink);
  margin-bottom: 0.7rem;
}
.lvl-filter__chips { display: flex; flex-wrap: wrap; gap: 0.6rem; }
.lvl-filter__chip {
  min-height: 44px;
  padding: 0.55rem 1.1rem;
  border: 2px solid var(--border);
  border-radius: var(--r-pill);
  background: var(--white);
  color: var(--ink-soft);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.95rem;
  cursor: pointer;
  transition: border-color 0.25s var(--ease-out), background 0.25s var(--ease-out),
              color 0.25s var(--ease-out), transform 0.25s var(--ease-out);
}
.lvl-filter__chip:hover { border-color: var(--pool-300); transform: translateY(-2px); }
/* Selected state keyed off aria-checked rather than a class, so the thing the
   screen reader announces and the thing the eye sees can never disagree. */
.lvl-filter__chip[aria-checked="true"] {
  background: var(--teal-ink);
  border-color: var(--teal-ink);
  color: var(--white);
}
.lvl-filter__count {
  margin: 0.7rem 0 0;
  font-size: 0.9rem;
  color: var(--ink-faint);
}

/* The card's second half, collapsed by default. This is what makes ten cards
   scannable instead of a wall — the resting card is age, name, stages, one
   sentence and the framework line, and everything else is one click away.

   <details> rather than a JS toggle: it is keyboard operable, it is announced
   correctly, the content stays in the DOM for search, and Ctrl+F in the browser
   expands it automatically in current Chrome. The height animation is the one
   thing <details> cannot do on its own — it snaps — so that part is handed to
   GSAP in main.js, which is also the only honest place a motion library earns
   its bytes on this page: a bespoke curve on a control the reader operates,
   rather than a scroll effect they watch.

   The control says "Read more" rather than "What they'll learn", and the change
   is not cosmetic. The panel no longer holds only a skills list — it holds the
   skills, a sentence about how the level actually runs, the lesson length and
   class format, and the way through to the full page. "What they'll learn"
   named one of those four and undersold the other three. */
.lvl__more { margin-top: var(--space-sm); }
.lvl__more > summary {
  cursor: pointer;
  min-height: 34px;
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.88rem;
  color: var(--lv-ink, var(--teal-ink));
  list-style: none;
}
.lvl__more > summary::-webkit-details-marker { display: none; }
.lvl__more > summary::after {
  content: "";
  width: 8px; height: 8px;
  border-right: 2.5px solid currentColor;
  border-bottom: 2.5px solid currentColor;
  transform: rotate(45deg) translate(-1px, -1px);
  transition: transform 0.25s var(--ease-out);
}
.lvl__more[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.lvl__more > summary:hover { text-decoration: underline; }

/* Two labels, one shown at a time, rather than one label rewritten by script.
   Swapping textContent on a control while it has focus makes some screen
   readers re-announce the whole element mid-gesture; swapping which of two
   static spans is displayed does not, and it also keeps the wording in the
   markup where it can be read and translated. */
.lvl__more > summary [data-more-close],
.lvl__more[open] > summary [data-more-open] { display: none; }
.lvl__more[open] > summary [data-more-close] { display: inline; }

/* The animated box. GSAP writes height on .lvl__more-inner and the child div
   does the clipping, so the tween never touches the element that owns the
   padding — which is what stops the first and last frames jumping by a
   line-height. Same two-element arrangement as .accordion__panel. */
.lvl__more-inner > div { padding-top: 0.65rem; }
.lvl__more .checklist { margin-top: 0; padding-top: 0; }

/* The panel is the skills list and the way through to the full page, and
   nothing else. It briefly also carried a paragraph of prose and a row of fact
   pills; both went, and the reason is the one that put the list behind a
   disclosure to begin with. A card in this row has a single job — let a parent
   recognise their child — and three bullets do that in three glances. A
   paragraph asks to be READ, which is a slower and different act, and it was
   restating what programs.html already says at proper length one click away.
   The pills were worse: two of the three facts on them were already in the
   card's own age line, and information presented as new when it is a duplicate
   is exactly what makes a page feel padded rather than generous.

   In the deck the list comes down a step. The card there is 244–284px against
   the grid's ~360, and an item set at grid size wraps to three lines. */
.levels--deck .lvl__more-inner .checklist li { font-size: 0.88rem; }

@media (max-width: 979px) {
  .lvl__more-inner > div { padding-top: 0.5rem; }
  .lvl__more .checklist li { line-height: 1.45; }
}

/* With the list collapsed the framework line no longer needs to be pushed by
   auto margin — the cards are close enough in height that it reads level. */
.lvl__body:has(.lvl__more) .checklist { margin-top: 0; }

/* Ten level cards is inherently a lot of page. The age filter above is the main
   answer, but the cards themselves were also taller than they needed to be: a
   four-line blurb and a 179px art panel each. Blurbs are now two lines and the
   art is tightened, which takes roughly 90px off every card — about 15% off the
   section before a parent touches the filter. */
#programs .lvl__art { height: 150px; }
#programs .lvl__art img { height: 132px; }
#programs .lvl__body { padding-bottom: var(--space-sm); }

/* On auto-advancing steps the Next button is hidden, which would let the nav row
   collapse and shift Back upward between steps. Reserving the row's height keeps
   it still — a control that moves as you use it feels broken even when it isn't. */
.finder__nav--solo { min-height: 52px; }

/* The "Level details" link now lives INSIDE the expandable rather than sitting
   under it. Two controls pointing at overlapping information — one that expands
   bullet points, one that opens the page containing those same bullet points —
   was a row of clutter on every card and a decision the reader had to make
   twice. Now the summary is the only control on a collapsed card, and opening
   it reveals both the skills and the way through to the full level page.

   That takes another ~34px off every collapsed card, on top of the ~90px the
   earlier trim removed. */
.lvl__more .lvl__link {
  margin-top: var(--space-sm);
  /* .lvl__link::after is a full-card overlay that makes the whole card
     clickable. Inside a <details> that is wrong twice over: it would swallow
     clicks on the summary that opens it, and it would make a collapsed card
     navigate away instead of expanding. */
  position: static;
}
.lvl__more .lvl__link::after { content: none; }

/* ------------------------------------------------- 17.21 Rail arrows + peek */
/* The reviews rail runs at every width, not just on phones. .rail-wrap is the
   positioning context for the arrows and the thing that clips the fades.

   flow-root, not plain block: the rail inside carries a negative margin-block
   (see the shadow gutter below) and it is this wrapper's first in-flow child,
   so without a block formatting context that negative top margin would
   collapse straight through the wrapper's own edge and drag the whole rail up
   out of position. flow-root establishes the BFC that stops that without
   clipping anything — which `overflow: hidden` would have done, re-cutting the
   very shadows the gutter exists to protect. */
.rail-wrap { position: relative; display: flow-root; }

/* ------------------------------------------------ The rails' shadow gutter */
/* `overflow-x: auto` does not stay on the axis you name it for. Per spec a
   computed `visible` on the other axis becomes `auto`, so every rail is also a
   VERTICAL scroll container and clips its contents at the padding box.

   The cards' drop shadows are far taller than the headroom they had:
   --shadow-md reaches 52px below a card and --shadow-xl 126px, against 22px of
   padding. So each shadow was sliced off mid-strength rather than fading out —
   a straight edge of roughly 6% navy under the desktop cards and 13% under a
   mobile current card (which carries --shadow-xl), running the full width of
   the rail. That is the hard line where the ground behind the cards appeared
   not to blend into the section, and it is why it showed on desktop reviews
   and both mobile rails but never on the desktop programs grid: that one is
   not a scroll container, so nothing there was ever clipped.

   Two parts. The padding gives the shadow somewhere to finish; the negative
   margin hands that space straight back to the layout, so section rhythm and
   the arrows' centring are untouched — the same padding/negative-margin pair
   these rails already use horizontally. Each margin is the difference between
   the new padding and the old, which is why the two rails differ here.

   The vertical mask then dissolves whatever still reaches the boundary — a
   hovered or current card on --shadow-xl — so the edge fades rather than ends.
   18px of fade sits entirely inside the padding, so it never touches a card,
   and the shadow tail it does cross is already below ~2% there. */
.rail {
  --rail-fade-y: 18px;
}

.rail-arrow {
  position: absolute;
  top: calc(50% - 22px);
  z-index: 3;
  display: none;
  width: 46px;
  height: 46px;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 50%;
  background: var(--white);
  color: var(--ink);
  box-shadow: var(--shadow-md);
  cursor: pointer;
  transition: transform 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out),
              opacity 0.25s var(--ease-out);
}
.rail-arrow svg { width: 22px; height: 22px; }
.rail-arrow--prev { left: -14px; }
.rail-arrow--next { right: -14px; }
.rail-arrow:hover { transform: scale(1.08); box-shadow: var(--shadow-lg); }
/* Dimmed rather than removed at the ends: a control that disappears makes the
   row jump, and the reader loses the affordance they were just using. */
.rail-arrow:disabled { opacity: 0.3; cursor: default; transform: none; box-shadow: var(--shadow-sm); }
.rail-wrap--static .rail-arrow { display: none !important; }

/* Arrows are pointer affordances. On touch the swipe is the obvious gesture and
   two floating buttons over the cards are just something to mis-tap, so they
   only appear where there's a cursor and room for them. */
@media (min-width: 620px) and (pointer: fine) {
  .rail-arrow { display: flex; }
}

/* Peek + fade. The slivers either side are what tell the reader there is more,
   and fading them to nothing is what stops a half-card looking like a bug.

   mask-image rather than an overlaid gradient: an overlay would have to match
   the section's background exactly, and this section sits on a gradient that
   changes down the page. A mask is background-independent. */
@media (min-width: 620px) {
  .rail--peek {
    display: flex;
    gap: var(--space-sm);
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;
    scroll-padding-inline: 6%;
    padding-inline: 6%;
    margin-inline: -6%;
    /* Shadow gutter — see the note above .rail. Was 10px 22px; the margins are
       the difference, so the cards land exactly where they did. */
    padding-block: 34px 58px;
    margin-block: -24px -36px;
    scrollbar-width: none;
    /* Same scroll-driven fade as the phone rails — see the note there. The
       second layer is the vertical dissolve at the clip edge; intersect means a
       pixel has to survive both gradients. */
    -webkit-mask-image:
      linear-gradient(90deg,
        transparent 0, #000 var(--fade-l, 6%),
        #000 calc(100% - var(--fade-r, 6%)), transparent 100%),
      linear-gradient(180deg,
        transparent 0, #000 var(--rail-fade-y),
        #000 calc(100% - var(--rail-fade-y)), transparent 100%);
    -webkit-mask-composite: source-in;
            mask-image:
      linear-gradient(90deg,
        transparent 0, #000 var(--fade-l, 6%),
        #000 calc(100% - var(--fade-r, 6%)), transparent 100%),
      linear-gradient(180deg,
        transparent 0, #000 var(--rail-fade-y),
        #000 calc(100% - var(--rail-fade-y)), transparent 100%);
            mask-composite: intersect;
  }
  .rail--peek::-webkit-scrollbar { display: none; }
  .rail--peek > * {
    flex: 0 0 30%;
    scroll-snap-align: start;
    margin: 0;
  }
  /* Same derivation as the phone rails: the trailing spacer is what lets the
     last card come to rest on the same inset as the first. */
  .rail--peek::after { content: ""; flex: 0 0 calc(70% - var(--space-sm) - 6%); }
}

/* ------------------------------------------- 17.22 Mobile hero + rail polish */
/* The phone rails get the same edge fade the desktop reviews rail has. The
   sliver of the next card is what tells you the row scrolls; fading it to
   nothing is what stops a half-card reading as a rendering fault.

   mask-image rather than an overlaid gradient — these rails sit on three
   different section grounds (pool-50, pool-100, a gradient), and an overlay
   would have to match each one. A mask is background-independent. */
@media (max-width: 979px) {
  .rail {
    /* The fade is driven by scroll position, not fixed. A rail parked at its
       start has nothing to its left, and fading that edge just makes the first
       card look washed out — the fade has to mean "there is more this way", or
       it means nothing. initRails() sets --fade-l / --fade-r to 0% at each end. */
    -webkit-mask-image:
      linear-gradient(90deg,
        transparent 0, #000 var(--fade-l, 7%),
        #000 calc(100% - var(--fade-r, 10%)), transparent 100%),
      linear-gradient(180deg,
        transparent 0, #000 var(--rail-fade-y),
        #000 calc(100% - var(--rail-fade-y)), transparent 100%);
    -webkit-mask-composite: source-in;
            mask-image:
      linear-gradient(90deg,
        transparent 0, #000 var(--fade-l, 7%),
        #000 calc(100% - var(--fade-r, 10%)), transparent 100%),
      linear-gradient(180deg,
        transparent 0, #000 var(--rail-fade-y),
        #000 calc(100% - var(--rail-fade-y)), transparent 100%);
            mask-composite: intersect;
    /* Headroom so a card lift, a mascot, or a current card's --shadow-xl never
       meets the clip edge of the scroll container. Shadow gutter — see the note
       above .rail. Was 16px 22px; the margins are the difference, so the cards
       land exactly where they did. */
    padding-block: 34px 58px;
    margin-block: -18px -36px;
  }
}

/* The hero stat row. It was three figures with hairline rules between them and
   no shared alignment: "1,000+", "32°" and "4–6" are different widths, so every
   label started at a different x and the block read as a stray table rather than
   as three facts.

   Now one soft panel with a fixed figure column, so the numbers right-align to a
   common edge and the labels all start together. The panel also separates the
   stats from the paragraph above them, which the rules never did. */
@media (max-width: 620px) {
  .hero-stats {
    grid-template-columns: 1fr;
    gap: 0;
    margin-top: var(--space-md);
    padding: 0.35rem var(--space-sm);
    border-radius: var(--r-lg);
    background: rgba(255, 255, 255, 0.66);
    box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset, var(--shadow-sm);
    -webkit-backdrop-filter: blur(6px);
            backdrop-filter: blur(6px);
  }
  .hero-stats > div {
    display: grid;
    /* Wide enough for "1,000+" at this size; the other two right-align into it. */
    grid-template-columns: 5.2rem 1fr;
    align-items: baseline;
    gap: 0.85rem;
    padding: 0.7rem 0;
    border-top: 1px solid var(--pool-200);
    border-left: 0;
  }
  .hero-stats > div:first-child { border-top: 0; padding-top: 0.7rem; }
  .hero-stats__fig {
    font-size: 1.6rem;
    text-align: right;
    min-width: 0;
    color: var(--ink);
  }
  .hero-stats__label { margin: 0; font-size: 0.9rem; line-height: 1.35; }

  /* The two logo dolphins are dropped on phones. On desktop they straddle the
     photo's edge and give its rectangle something to break against — at 62px
     against a 335px photo there is no margin left to straddle, so they sit ON
     the picture and cover the instructor and both children, which is the entire
     subject of the shot. A decoration that hides the content it decorates has
     stopped being decoration. */
  .hero--home .hero__mascot { display: none; }
}

/* ------------------------------------------------------- 17.23 The pathway */
/* programs.html is one program at a time now, and this is the menu that
   changes it. The argument for the whole restructure is above the markup; this
   is only how it is built.

   WHAT THE OLD COMPONENT GOT WRONG, kept here because the mistake is instructive
   and easy to repeat: the depth gauge drew ten levels as a cross-section of the
   pool, each column's water deeper than the last. The idea was sound and the
   object was not a menu. It had ten equal items with no selected state, and its
   dominant visual mass was a saturated colour block — so it read as a swatch
   chart. Worse, clicking it only scrolled you to something already on screen, so
   it had no consequence. A control with no consequence never reads as a control,
   however carefully it is drawn.

   THE PATHWAY KEEPS THE IDEA AND THROWS AWAY THE EXECUTION. The sequence still
   is the content — but it is now carried by a hairline and an order rather than
   by ten blocks of colour. Ten stations threaded on one vertical line, shallow
   at the top, deep at the bottom, the current one lit. It costs a 268px column
   instead of a 225px full-width band, and it reads as a menu on sight, because a
   lit row in a list of ten is the oldest selected-state signal there is.

   GROUND: --pool-200 (relLum 0.781). One real step below the hero's --pool-50
   (0.939) and one above the --sky (0.582) band that follows, so the page's tonal
   ladder still descends without two neighbours sharing a value. The menu and the
   panel are white plates floating on it, which is this build's central device. */
.pathway {
  padding-block: var(--space-lg) var(--space-xl);
  background: var(--pool-200);
}

.pathway__layout { display: grid; gap: var(--space-md); align-items: start; }
@media (min-width: 900px) {
  .pathway__layout {
    grid-template-columns: 268px minmax(0, 1fr);
    gap: var(--space-md);
  }
}

/* ------------------------------------------------------------- the menu */
/* A white plate, like every other object on this site that carries type over
   water. Sticky on desktop: the panel beside it runs 700–900px and a menu that
   scrolls away is a menu you have to hunt for on the way back. */
.pathway__menu {
  --disc: 44px;
  --row-pad: 0.55rem;
  background: var(--white);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-md);
  padding: var(--space-sm) 0.85rem 0.9rem;
}
/* STICKY ONLY WHERE IT FITS, and the height condition is not belt-and-braces —
   a sticky element taller than its viewport is a trap. The menu measures 639px;
   add the 92px fixed nav and 36px of clearance and it needs 767px of window to
   sit still in. Below that, pinning it means the top rows lock to the nav while
   Shark and Dolphin sit permanently below the fold with no way to scroll to
   them — the menu would hide exactly the levels a parent scrolling this far is
   most likely looking for. On a short window it simply travels with the page,
   which is the behaviour that was never broken. */
@media (min-width: 900px) and (min-height: 780px) {
  .pathway__menu {
    position: sticky;
    top: calc(var(--nav-h) + 18px);
  }
}

.pathway__eyebrow {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.72rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin: 0 0 0.7rem 0.55rem;
}

.pathway__list {
  position: relative;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* There was a connecting spine here — a hairline through the disc centres that
   darkened as it descended, drawing the ten levels as one journey. It was the
   right idea and the wrong object: at two pixels between two discs it never read
   as a line, it read as a stray grey dash sitting in the gap, and a menu with
   ten pieces of visual noise in it is a menu you have to look past. The sequence
   is carried by the order, which is what an ordered list is for. */

/* THE MARKER. One object that travels between rows rather than ten backgrounds
   that switch on and off, and that is the whole gesture of this menu: choosing a
   level is a thing that MOVES, and what moves also changes colour on the way.
   Two properties, one element, no library.

   --lv-mark is a registered custom property (see @property below) purely so the
   colour can interpolate. An unregistered custom property is a token change, not
   an animation: the tint would jump while the position glided, which reads as a
   near-miss rather than a transition. Registering it makes background-color and
   the inset cap both follow the same eased colour ramp. */
@property --lv-mark {
  syntax: "<color>";
  inherits: false;
  initial-value: #29b8c6;
}
.pathway__marker {
  position: absolute;
  /* Inset from the left so the disc OVERHANGS the capsule instead of sitting
     inside it. The station breaking its own container is the detail that stops
     the selected row reading as a highlighted table cell — the mascot is the
     subject, and the plate is behind it rather than around it. */
  left: 22px;
  right: 0;
  top: 0;
  z-index: 0;
  /* A capsule, not a rounded rectangle. At 57px tall a 12px radius reads as a
     box that happens to have soft corners; a full pill reads as an object, and
     it echoes the .lvl__age chip and the stage pills the panel beside it uses,
     so the selected state is drawn in a shape the page has already established. */
  border-radius: var(--r-pill);
  /* 18%, not the 15% the discs and chips use. A chip only has to be a chip; a
     selected menu row has to be the loudest thing in a column of ten and still
     hold --lv-ink above 4.5:1 — seal is the tightest pair on the site and still
     measures 4.7 here. The 4px cap does the rest: a saturated edge reads as
     "selected" at any tint lightness, which is what keeps Shark's row as
     definite as Stingray's. */
  /* A ring all the way round rather than a cap on one edge. The cap needed the
     eye to notice a 4px sliver; a 2px ring closes the shape, so the row is
     unambiguously selected at a glance and stays that way at any tint lightness
     — which is what keeps Shark's near-white row as definite as Stingray's. */
  background-color: color-mix(in srgb, var(--lv-mark) 18%, #fff);
  box-shadow: inset 0 0 0 2px var(--lv-mark);
  transition:
    transform 0.52s var(--ease-back),
    --lv-mark 0.42s var(--ease-out);
  will-change: transform;
}

.pathway__item { position: relative; z-index: 1; }

.plevel {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.7rem;
  padding: 0.42rem var(--row-pad);
  border-radius: var(--r-sm);
  text-decoration: none;
  transition: background-color 0.3s var(--ease-out);
}

/* THE STATION. Resting, a 15% tint of the level's colour — pale enough that the
   mascot, which is saturated artwork, separates from it without needing the
   white silhouette halo the big plates use. Selected, it FILLS with that colour
   at full strength and the halo switches on to keep the character readable.

   The station filling with water when you choose it is the site's own metaphor
   doing the work a highlight would otherwise do, and it is the reason this menu
   can carry ten hues without becoming a rainbow: nine of them are sitting at 15%
   and one is at 100%, so there is only ever one loud colour on screen.

   The 4px white ring is what punches the disc through the spine, so the line
   passes BEHIND the stations instead of under them. */
.plevel__disc {
  position: relative;
  flex: 0 0 var(--disc);
  display: grid;
  place-items: center;
  width: var(--disc);
  height: var(--disc);
  border-radius: 50%;
  overflow: hidden;
  background-color: color-mix(in srgb, var(--lv) 15%, #fff);
  box-shadow:
    inset 0 0 0 1.5px color-mix(in srgb, var(--lv) 38%, #fff),
    0 0 0 4px var(--white);
  transition:
    background-color 0.42s var(--ease-out),
    box-shadow 0.42s var(--ease-out),
    transform 0.42s var(--ease-back);
}
.plevel__disc img {
  width: 78%;
  height: 78%;
  object-fit: contain;
  transition: filter 0.42s var(--ease-out), transform 0.42s var(--ease-back);
}
.plevel__motif {
  width: 56%;
  height: 56%;
  color: var(--lv-ink);
  transition: color 0.42s var(--ease-out);
}

.plevel__text { display: flex; flex-direction: column; min-width: 0; }
.plevel__name {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.95rem;
  line-height: 1.2;
  color: var(--ink);
  transition: color 0.42s var(--ease-out);
}
.plevel__meta {
  margin-top: 0.05rem;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 0.72rem;
  line-height: 1.25;
  color: var(--ink-faint);
}

/* SELECTED. --lv-ink is the darkened pair of each level colour and every one of
   them clears 4.5:1 on that colour's own 15–17% tint — which is exactly the
   ground the marker paints. Nothing here needs a contrast exception. */
.plevel.is-current .plevel__disc {
  background-color: var(--lv);
  box-shadow:
    inset 0 0 0 1.5px color-mix(in srgb, var(--lv) 66%, var(--ink)),
    0 0 0 4px var(--white);
  transform: scale(1.07);
}
.plevel.is-current .plevel__disc img {
  filter:
    drop-shadow(0 0 1px #fff) drop-shadow(0 0 1px #fff)
    drop-shadow(0 1px 2px rgba(5, 29, 38, 0.28));
}
.plevel.is-current .plevel__motif { color: var(--white); }
.plevel.is-current .plevel__name { color: var(--lv-ink); }

@media (hover: hover) {
  .plevel:hover { background-color: color-mix(in srgb, var(--ink) 4%, transparent); }
  .plevel:hover .plevel__disc { transform: scale(1.07); }
  .plevel:hover .plevel__disc img { transform: translateY(-1px); }
}
.plevel:focus-visible {
  outline: 3px solid var(--ink);
  outline-offset: 2px;
}

/* ------------------------------------------- the mobile level switcher */
/* Below 900 there is no room for a permanent column, and the obvious
   alternative — a ten-item horizontal swipe rail — is the wrong instrument.
   A rail is a BROWSE device; you swipe through it to see what is there. This is
   a PICKER: you already know you want Seal and swiping to find it among ten is
   strictly worse than reading a list.

   So the menu collapses into one row that is simultaneously the current-state
   readout and the control that changes it. It states where you are, it is
   obviously pressable, and it needs no explanation. It is display:none above 900
   and hidden outright until script un-hides it, because without script it would
   be a dead button. */
.pathway__current {
  display: none;
  width: 100%;
  align-items: center;
  gap: 0.7rem;
  padding: 0.35rem var(--row-pad);
  border: 0;
  border-radius: var(--r-sm);
  background: transparent;
  font: inherit;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.pathway__current-slot { display: flex; align-items: center; gap: 0.7rem; flex: 1; min-width: 0; }
.pathway__prompt {
  display: none;
  align-items: center;
  flex: 1;
  /* Matched to the disc so the button does not change height when the label
     swaps in — otherwise opening the menu nudges the whole panel down a
     centimetre before the list has even finished expanding. */
  min-height: var(--disc);
  padding-left: 0.15rem;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.78rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-faint);
}
.pathway__menu.is-open .pathway__current-slot { display: none; }
.pathway__menu.is-open .pathway__prompt { display: flex; }
.pathway__caret {
  flex: 0 0 22px;
  width: 22px;
  height: 22px;
  color: var(--ink-faint);
  transition: transform 0.4s var(--ease-back);
}
.pathway__menu.is-open .pathway__caret { transform: rotate(180deg); }
.pathway__current:focus-visible { outline: 3px solid var(--ink); outline-offset: 2px; }

@media (max-width: 899px) {
  .pathway { padding-block: var(--space-md) var(--space-lg); }
  .pathway__menu { padding: 0.6rem 0.7rem; }
  /* The eyebrow is the column's title. Collapsed to a single row the row IS the
     title, and a label above a label is the kind of duplication that makes an
     interface feel padded. */
  .pathway__eyebrow { display: none; }
  .pathway__current[hidden] { display: none; }
  .pathway__current { display: flex; }

  /* Only ever collapsed once script has taken over — see the contract above. */
  .pathway__menu.is-enhanced .pathway__list {
    overflow: hidden;
    height: 0;
    opacity: 0;
    margin-top: 0;
    /* Height is set in pixels by script from the list's own scrollHeight, so the
       curve describes the real distance instead of easing toward an arbitrary
       max-height ceiling and arriving early. */
    transition:
      height 0.42s var(--ease-out),
      margin-top 0.42s var(--ease-out),
      opacity 0.3s var(--ease-out);
  }
  .pathway__menu.is-enhanced.is-open .pathway__list {
    opacity: 1;
    margin-top: 0.6rem;
  }
}

/* The collapsed row is a live clone of the selected menu row, wrapped in the
   same .plevel.is-current so the filled disc and the level ink come for free
   rather than from a duplicate set of rules. It only has to shed the row
   padding, since the button supplies its own. */
.pathway__current .plevel { padding: 0; background: none; pointer-events: none; }
.pathway__current .plevel__name { font-size: 1.02rem; }

/* --------------------------------------------------------- the panel */
.lvlpanel {
  background: var(--white);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-lg);
  padding: var(--space-md);
}
.lvlpanel__grid { display: grid; gap: var(--space-md); align-items: center; }
/* 1180, NOT 900 — the panel splits into two columns much later than the menu
   appears beside it, and the arithmetic is the whole argument. The menu takes a
   fixed 268 off the top of the container before the panel gets anything, so a
   920px window leaves the panel 558px, and 558 minus its own padding split two
   ways is a 240px copy column and a 159px picture. That is not a narrow layout,
   it is a broken one: the checklist wraps every three words and the mascot plate
   becomes a sliver.

   Between 900 and 1180 the panel therefore stacks — copy over artwork — beside
   a menu that is already in its column. Copy gets the full 460px, the plate runs
   the panel's width, and the level reads exactly as it did on the old page. The
   split only switches on once there is genuinely enough width to divide. */
@media (min-width: 1180px) {
  .lvlpanel { padding: var(--space-lg); }
  /* Not 1fr 1fr, and measured rather than guessed. Inside a 1076px container the
     menu takes 268 and this panel's own padding takes 96, which leaves 703 to
     divide — an even split gives the copy 336px, where the lead wraps every five
     words and "Paddle arms and correct body position" breaks across two lines.
     A checklist that wraps has stopped being scannable, which is the only reason
     it is a checklist.

     0.66 puts the copy at ~430px — close to 55 characters, the measure the rest
     of the site sets — and the art column loses nothing that matters, because
     the mascot inside is constrained by the plate's HEIGHT, not its width. */
  .lvlpanel__grid { grid-template-columns: minmax(0, 1fr) minmax(0, 0.66fr); gap: var(--space-lg); }
  /* And the plate comes down with it. The shared --lv-art--page height was tuned
     for a full-width two-column section; left at 400px in a 270px column it
     becomes a slab, and the character starts to look posted in a lift shaft. */
  .lvlpanel__art .lvl__art--page { height: clamp(280px, 25vw, 358px); }
}

/* The mascot's own CSS transition exists for the hover launch on the home page's
   level CARDS. There is no hover launch here — these are panels, not cards — so
   all it does is fight the swap: the transition tries to ease every frame GSAP
   writes, and a 0.4s --ease-bounce curve chasing a 0.78s back.out one produces a
   character that lags its own animation and lands twice. Two easings on one
   property is never a blend, it is a race. */
.lvlpanel .lvl__art--page img,
.lvlpanel .lvl__art--page > svg { transition: none; }
.lvlpanel__copy > :last-child { margin-bottom: 0; }
.lvlpanel__name {
  margin: 0.15rem 0 0.55rem;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(1.9rem, 1.35rem + 2vw, 2.55rem);
  line-height: 1.05;
  letter-spacing: -0.01em;
  color: var(--ink);
}
.lvlpanel__sub {
  margin: var(--space-sm) 0 0.6rem;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.05rem;
  color: var(--ink);
}

/* THE STAGES, PROMOTED. These names were already in the copy, set as a grey
   caption under the level title. Giving them form is typography rather than
   invention, and it earns its space: it is the only thing on the page that shows
   a single level is itself a progression, which is what "fifteen levels across
   ten streams" actually means. Four pills for Starfish, one for Shay — the
   component is honest about levels that have only one stage rather than
   inflating them to match. */
.stages {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.34rem;
  margin: 0 0 0.9rem;
  padding: 0;
  list-style: none;
}
.stages li { display: flex; align-items: center; gap: 0.34rem; }
.stages li + li::before {
  content: "";
  width: 12px;
  height: 2px;
  border-radius: 1px;
  background: color-mix(in srgb, var(--lv) 40%, #fff);
}
.stages span {
  padding: 0.22rem 0.62rem;
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--lv) 15%, #fff);
  color: var(--lv-ink);
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.74rem;
  letter-spacing: 0.03em;
}

/* THE HAIRLINE IS NOT DECORATION, it is what makes the pale levels have a shape.
   A 15–17% tint works as a fill on a tinted band, which is where these chips
   used to live — the old level sections alternated onto --pool-200, so even a
   near-white chip stood out against it. On a white panel that stops being true
   at exactly the point the level colour is pale: 17% of Shark's #8fd4e8 is
   #f4fbfd, one step off the card it sits on, and the chip stops reading as an
   object at all while Starfish's orange still reads perfectly. The text contrast
   was never the problem; the boundary was.

   A rim at 42% is lightness-independent in the way a fill is not — it defines
   Shark without darkening Stingray — which is the same argument the level plates
   settled with their own 1px inset ring. */
.lvlpanel .lvl__age,
.stages span {
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lv) 42%, #fff);
}

/* ----------------------------------------------------------- the pager */
/* The one thing a single-panel view loses is the FEELING that these ten are a
   sequence — the long scroll gave that away for free, and a tab set does not.
   This gives it back, and it costs no invented copy: prev and next are derived
   entirely from an order that already existed. */
.lvlpager {
  display: flex;
  align-items: stretch;
  gap: var(--space-sm);
  margin-top: var(--space-md);
  padding-top: var(--space-sm);
  border-top: 1px solid color-mix(in srgb, var(--ink) 9%, transparent);
}
.lvlpager__spacer { flex: 1; }
.lvlpager__link {
  display: flex;
  flex-direction: column;
  gap: 0.08rem;
  padding: 0.45rem 0.7rem;
  border-radius: var(--r-sm);
  text-decoration: none;
  transition: background-color 0.3s var(--ease-out);
}
.lvlpager__link--next { margin-left: auto; align-items: flex-end; text-align: right; }
.lvlpager__dir {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 0.66rem;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--ink-faint);
}
.lvlpager__name {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.02rem;
  color: var(--ink);
}
.lvlpager__name svg {
  width: 17px;
  height: 17px;
  color: var(--lv-ink);
  transition: transform 0.3s var(--ease-out);
}
.lvlpager__end {
  margin-left: auto;
  align-self: center;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.72rem;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--ink-faint);
}
@media (hover: hover) {
  .lvlpager__link:hover { background-color: color-mix(in srgb, var(--ink) 5%, transparent); }
  .lvlpager__link--next:hover svg { transform: translateX(4px); }
  .lvlpager__link--prev:hover svg { transform: translateX(-4px); }
}
.lvlpager__link:focus-visible { outline: 3px solid var(--ink); outline-offset: 2px; }

@media (max-width: 560px) {
  .lvlpanel { padding: var(--space-sm); }
  /* STAYS A ROW. It used to stack into a column here, which put "Next level"
     directly under "Previous" and flush to the same left edge — and a pager
     whose forward control sits on the left has stopped being a pager. Direction
     is the entire content of this component: back is left, on is right, and
     that is what tells a reader the ten levels are a sequence rather than a
     menu. Losing it costs more than the width it saves.

     The width is affordable. Two links at ~0.94rem inside a --space-sm panel
     leave each about 150px on a 375px phone, which fits every level name in the
     set; the pair below lets a long one wrap inside its own half rather than
     pushing its neighbour off the row. */
  .lvlpager { gap: var(--space-xs); }
  .lvlpager__link { flex: 0 1 auto; min-width: 0; padding: 0.45rem 0.5rem; }
  .lvlpager__name { font-size: 0.94rem; }
  .lvlpager__link--next { margin-left: auto; align-items: flex-end; text-align: right; }
  .lvlpager__end { padding: 0.45rem 0.5rem; }
}

/* Everything above is a progressive enhancement over a page that already works.
   With no script the ten panels simply stack, the menu is a plain jump list, and
   nothing is hidden from a reader or a crawler — which is why the swap is driven
   by a class the script adds rather than by a default state in here. */
.pathway__stage.is-tabbed > .lvlpanel[hidden] { display: none; }
.pathway__stage.is-tabbed { position: relative; }

/* Applied only while the stage's height is being tweened between two panels.
   It has to clip — a fixed height with taller content inside would spill over
   the band below — but a plain overflow:hidden would also shear off the panel's
   --shadow-lg, and a card whose shadow vanishes for half a second reads as a
   rendering fault. overflow:clip with a clip margin is the exact tool: the box
   still clips, 70px further out than its edge, so the shadow survives intact.
   Older Safari takes the overflow:hidden fallback on the line above and loses
   the shadow for 0.46s, which is the correct thing to degrade. */
.pathway__stage.is-animating {
  overflow: hidden;
  overflow: clip;
  overflow-clip-margin: 70px;
}

@media (prefers-reduced-motion: reduce) {
  .pathway__marker,
  .plevel__disc,
  .plevel__disc img,
  .pathway__caret { transition: none; }
}

/* ------------------------------------------------ 17.23b The level's action
   One per level section on programs.html, and the argument for its FORM is the
   whole point of it.

   The obvious version is a "Book now" button in every level. Ten of those would
   be the end of the page's hierarchy: the orange fill is this site's primary
   action and it appears twice on programs.html — once in the hero, once in the
   closing band — which is exactly what makes it read as the moment. Repeat it
   ten times down the same page and it stops meaning "act here" and starts
   meaning "here is some orange". A restrained palette is not only about how many
   colours there are; it is about how often the loudest one is spent.

   The opposite version — no action at all until the bottom of the page — is the
   real friction the request identified, and it is a genuine cost. A parent
   reading Starfish and recognising their child has to scroll past five more
   levels before the page will let them do anything about it.

   So: an action in every level, but a SECOND-TIER one, wearing that level's own
   colour rather than the site's CTA orange. Outlined, not filled. It reads
   unmistakably as a control, it never competes with the two real buttons, and
   under the house rule its colour is legitimate because the words next to it
   name the level it belongs to.

   It also carries the level through to the enquiry (?level=Starfish), so the
   form arrives already knowing what the parent was reading — see the prefill in
   main.js. That is what makes it worth being ten separate links rather than ten
   copies of one: each is a different sentence, and each sends something
   different. */
.lvl-cta {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  /* THE WIDEST GAP IN THE BLOCK, and adding this control is what forced the
     question. Every level section used to run description → skills → badges on
     two identical --space-md gaps, and a third one under the badges made four
     groups sit in one undifferentiated column: nothing in the rhythm said which
     of them belonged together.

     They are not peers. The badges QUALIFY the skills list above them — a
     footnote to it, not a section after it — so they moved to --space-sm and
     bind to it. This is a change of mode, from reading to acting, and it takes
     --space-lg. md / sm / lg down the block, and the action reads as the thing
     the section arrives at rather than as one more row of it. */
  margin-top: var(--space-lg);
  /* Comfortably past 44px so it is a real target on a phone, where this is the
     only action inside a section a reader may have scrolled a long way into. */
  min-height: 48px;
  padding: 0.55rem 1.5rem;
  border: 0;
  border-radius: var(--r-pill);
  /* SOLID, IN THE LEVEL'S INK. The outlined version was the right call when ten
     of these ran down one page — ten filled buttons would have flattened the
     hierarchy and stolen the meaning from the two real orange CTAs. One program
     shows at a time now, so there is exactly one of these on screen, and the
     argument it was built on no longer holds. An outline that only washed 8%
     on hover was reading as a disabled control.

     The fill is --lv-ink rather than --lv because white has to sit on it: every
     -ink token clears 4.5:1 against white by construction, so white on the ink
     clears the same ratio on all ten. --lv would put white on Shark's #8fd4e8
     at 1.99:1. */
  background: var(--lv-ink, var(--teal-ink));
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.95rem;
  color: var(--white);
  text-decoration: none;
  transition: background-color 0.25s var(--ease-out),
              transform 0.3s var(--ease-out), box-shadow 0.3s var(--ease-out);
}
.lvl-cta svg {
  width: 18px;
  height: 18px;
  flex: none;
  transition: transform 0.25s var(--ease-out);
}
/* The same 5px travel every arrow link on this site makes. Hover DEEPENS rather
   than tints — a solid control confirms a commitment by getting heavier, and
   mixing toward --ink keeps the button inside its own level's identity instead
   of jumping to the site's CTA orange. Contrast only improves on the way. */
.lvl-cta:hover,
.lvl-cta:focus-visible {
  background-color: color-mix(in srgb, var(--lv-ink, var(--teal-ink)) 84%, var(--ink));
  transform: translateY(-2px);
  box-shadow: var(--shadow-md);
}
.lvl-cta:hover svg,
.lvl-cta:focus-visible svg { transform: translateX(5px); }

/* ------------------------------------------------- 17.24 FAQ search + heads */
/* 30rem held the field to a tidy measure and clipped the prompt by about a
   character and a half at desktop — the closing quote of the third example, so
   the line ended mid-punctuation and read as a rendering fault rather than as
   truncation. 33rem is the width at which all three worked examples land whole
   with air to spare, and it is still comfortably inside .container--narrow, so
   the field reads as a control rather than as a bar across the hero.
   Below roughly 560px the string genuinely cannot fit at any sane type size,
   and that is where the drift takes over — see .faq-search__ghost. */
.faq-search { max-width: 33rem; margin: var(--space-md) auto 0; }
.faq-search__box {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0 0.9rem;
  border-radius: var(--r-pill);
  background: var(--white);
  border: 2px solid var(--border);
  box-shadow: var(--shadow-sm);
  transition: border-color 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out);
}
.faq-search__box:focus-within { border-color: var(--pool-300); box-shadow: var(--shadow-md); }
.faq-search__box > svg { width: 20px; height: 20px; flex: none; color: var(--ink-faint); }
.faq-search__input {
  flex: 1;
  min-height: 52px;
  border: 0;
  outline: 0;
  background: none;
  font-size: 1rem;
  color: var(--ink);
}
.faq-search__input::placeholder { color: var(--ink-faint); }
/* The UA clear button is inconsistent across browsers and Esc already clears. */
.faq-search__input::-webkit-search-cancel-button { -webkit-appearance: none; }

/* THE DRIFTING PROMPT. The real placeholder carries three worked examples —
   "what to bring", "school holidays", "grommets" — and every one of them is
   there because it is checked to return hits. That is the whole value of it: a
   parent's first instinct in a search box is to type a heading, the index is
   full-text, and the examples are what teach that in one glance. The string is
   also 58 characters, and the field is 30rem. A native placeholder simply
   clips, so two of the three examples were never seen by anybody.

   ::placeholder cannot be transformed — it is a pseudo-element with a fixed
   subset of animatable properties — so the prompt is a real element sitting
   over an empty input, and the input's own placeholder is emptied. It drifts
   left until its tail is flush with the right edge, then comes back.

   WHAT KEEPS THIS OFF THE MARQUEE SHELF, because a scrolling line of text is
   one bad decision away from a 1998 news ticker:

   - It HOLDS at both ends. The keyframes give 16% of the cycle stationary at
     the start, 14% at the far end. Constant-velocity travel with an instant
     turnaround is what reads as a ticker; a long look, a slow glide, another
     long look reads as the line settling.
   - It is EASED, not linear, and slow — the duration is solved from the
     distance at roughly 26px per second, so a longer string takes longer
     rather than moving faster.
   - It is a MASK, not a scrollbar. The text fades out into the field's own
     white at both edges, so nothing is ever seen being sliced by a hard line.
   - It YIELDS instantly. Focus or a keystroke and the whole thing is gone —
     the reader's own text never shares the field with moving decoration.
   - It never runs under prefers-reduced-motion: the string simply parks at
     the left, which is exactly the behaviour we are replacing, and correct,
     because a parent who has asked the OS to stop moving things has asked. */
.faq-search__ghost {
  position: absolute;
  /* Lines up with the input's own text: the box's 0.9rem padding, the 20px
     magnifier and the 0.6rem gap. An absolutely positioned child is offset
     from the padding box, so the border is already excluded. */
  left: 2.75rem;
  right: 0.9rem;
  top: 50%;
  transform: translateY(-50%);
  overflow: hidden;
  pointer-events: none;
  font-size: 1rem;
  line-height: 1.3;
  color: var(--ink-faint);
  white-space: nowrap;
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 10px, #000 calc(100% - 14px), transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0, #000 10px, #000 calc(100% - 14px), transparent 100%);
  transition: opacity 0.18s var(--ease-out);
}
.faq-search__ghost > span {
  display: inline-block;
  will-change: transform;
}
/* Only animates once the script has measured a genuine overflow and written
   --drift. A string that already fits gets no animation at all. */
/* ease-in-out, NOT the house --ease-out. The timing function applies between
   each pair of keyframes, and the pairs here start from a dead stop at either
   end of the travel — an ease-out curve leaves that stop at full speed, which
   reads as the line being flicked rather than drifting. Symmetric easing is
   what makes the departure and the arrival both look like water. */
.faq-search__ghost.is-drifting > span {
  animation: faq-drift var(--drift-dur, 9s) ease-in-out infinite;
}
/* Set the moment the field is focused or has a value. Opacity rather than
   display so the exit is a fade rather than a blink, and the animation is
   paused underneath so it does not keep burning frames behind an invisible
   element. */
.faq-search__box.is-live .faq-search__ghost { opacity: 0; }
.faq-search__box.is-live .faq-search__ghost > span { animation-play-state: paused; }
@keyframes faq-drift {
  0%, 16%   { transform: translate3d(0, 0, 0); }
  50%, 64%  { transform: translate3d(var(--drift, 0px), 0, 0); }
  100%      { transform: translate3d(0, 0, 0); }
}
@media (prefers-reduced-motion: reduce) {
  .faq-search__ghost.is-drifting > span { animation: none; }
}
.faq-search__count {
  margin: 0.7rem 0 0;
  font-size: 0.9rem;
  font-weight: 700;
  color: var(--ink-soft);
  min-height: 1.3em;
}
/* A section emptied by the search must actually disappear — .section has no
   `display` of its own, but hiding it via [hidden] is only reliable if nothing
   else sets display. Declared for the same reason as .lvl[hidden]. */
.accordion__item[hidden],
section[hidden] { display: none; }

/* The four question groups were four identical white cards under four identical
   headings — correct, and completely flat next to the rest of the site. Each now
   carries the mascot and colour of the level it most relates to, which is the
   same colour-names-a-level device the programs cards use, so the page joins the
   system instead of sitting outside it. */
.faq-group__head {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  margin-bottom: var(--space-sm);
}
.faq-group__head h2 { margin: 0; }
.faq-group__mascot {
  width: 64px;
  height: auto;
  flex: none;
  filter: drop-shadow(0 8px 14px rgba(18, 48, 73, 0.16));
}
/* A tinted rule under the heading picks the mascot's colour back up and gives
   the group a visible start, which four bare <h2>s never had. */
.faq-group__head::after {
  content: "";
  flex: 1;
  height: 5px;
  border-radius: var(--r-pill);
  background: linear-gradient(90deg, var(--lv), transparent);
}
@media (max-width: 559px) {
  .faq-group__mascot { width: 48px; }
  .faq-group__head::after { display: none; }
}

/* ------------------------------------------------- 17.25 Review expanders */
/* Full review text, visually clamped. -webkit-line-clamp is the only reliable
   way to truncate at a line boundary with an ellipsis-free fade; it is
   prefixed-but-universal and degrades to "no clamp" rather than to broken. */
.quote__body {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 7;
  overflow: hidden;
  /* Floor at the clamp height so a short review occupies the same space as a
     clamped one. Without it the cards are equal only by accident — align-items
     is `start` (so an expanded card can't drag its neighbours), which means a
     128-character review sits in a visibly shorter card than a 920-character
     one and the row reads ragged at rest.

     In em, not the `lh` unit: lh is cleaner but only lands in Safari 16.4+, and
     an unsupported min-height here silently returns the ragged row. 1.65 is the
     inherited body line-height. */
  min-height: calc(7 * 1.65em);
  /* A fade rather than a hard cut. A hard cut on a mid-word line reads as a
     layout fault; a fade reads as "there is more". */
  -webkit-mask-image: linear-gradient(180deg, #000 72%, transparent 100%);
          mask-image: linear-gradient(180deg, #000 72%, transparent 100%);
}
.quote.is-expanded .quote__body {
  -webkit-line-clamp: unset;
  overflow: visible;
  -webkit-mask-image: none;
          mask-image: none;
}
/* No fade when the text isn't actually clipped — otherwise every short review
   gets a meaningless soft bottom edge. */
.quote:not(.is-clipped) .quote__body {
  -webkit-mask-image: none;
          mask-image: none;
}

.quote__more {
  align-self: flex-start;
  margin: 0.15rem 0 var(--space-sm);
  padding: 0;
  border: 0;
  background: none;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 0.9rem;
  color: var(--teal-ink);
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 3px;
}
.quote__more:hover { color: var(--ink); }

/* THE important rule for "expanding must not resize the stack".
   A flex row stretches its items to the tallest by default, so opening one card
   would drag every sibling to the same new height and the whole row would jump.
   align-items:start lets the opened card grow alone. The cards still LOOK equal
   at rest because the only variable-height part is the clamped quote. */
.rail,
.rail--peek { align-items: flex-start; }

/* The caption is pushed to the bottom by margin-top:auto, which needs a height
   to push against; with align-items:start there isn't one. Fixed spacing keeps
   the avatar row a consistent distance from the text instead. */
.quote figcaption { margin-top: 0; }

/* --------------------------------------------- 17.26 Mobile hero sequence */
/* The question was whether the button belongs after the image. Neither, quite —
   the image was in the wrong place, not the button.

   Stacked, the phone hero ran: motto, headline, lead, button, reassurance,
   three stats, and only THEN the photo. So a parent read a full screen of text
   and met the actual pool last, after they'd already been asked to act. The
   photo is the proof; it should arrive before the ask, not after it.

   Order is now headline → promise → see the pool → act. The button stays
   directly under the image rather than moving below the stats, because pushing
   the primary action further down the page to make room for a picture trades
   the wrong thing.

   display:contents dissolves .hero__copy so its children become grid items of
   .hero__grid and can be ordered against .hero__media. Safe here because
   .hero__copy carries no box styling of its own at this width — it is purely a
   column wrapper for the two-column desktop layout. */
@media (max-width: 959px) {
  /* .hero--home .hero__grid { align-items: center } is written for the desktop
     two-column grid, where it vertically centres the copy against the photo.
     Unscoped, it also reaches this flex-column layout, where "centre" is the
     cross axis — width — not height: every stacked item that doesn't happen
     to fill the full measure (the motto, the heading) got shrunk to its own
     content width and centred, while the long paragraph looked fine only
     because its own width already filled the column. stretch is what the
     desktop grid already gets by default (align-items' initial value); this
     just stops the mobile override from picking up the other rule's centring. */
  .hero--home .hero__grid { display: flex; flex-direction: column; align-items: stretch; gap: 5px; }
  .hero--home .hero__copy { display: contents; }

  .hero--home .motto          { order: 1; }
  .hero--home .hero__copy h1  { order: 2; }
  .hero--home .hero__copy > .lead { order: 3; }
  .hero--home .hero__media    { order: 4; margin-block: var(--space-md) 0; }
  .hero--home .hero__actions  { order: 5; margin-top: var(--space-md); }
  .hero--home .hero__reassure { order: 6; }
  .hero--home .hero-stats     { order: 7; }
}

/* Reserves the Read-more button's height on cards that don't need one, so the
   row stays level. Not `display:none` — that would collapse the space, which is
   the whole point of keeping it. */
.quote__more--spacer { visibility: hidden; pointer-events: none; }

/* ------------------------------------------------- 17.27 Equal-height stacks */
/* .lvl carried `height: 100%`, which made every card fill its grid row — so
   opening one card's "What they'll learn" grew the row and dragged its two
   row-mates from 407px to 609px with it. The cards are sized by the measured
   min-height that equalise() sets instead, which is a floor rather than a
   stretch: the opened card exceeds it alone. */
.lvl { height: auto; }

/* Grid rows stretch their items by default, same coupling as the flex rails.
   Both stacks now let each card keep its own height. */
.levels { align-items: start; }

/* ------------------------------------------------- 17.28 Review card ground */
/* Previously a very soft logo-palette wash cycling across the cards — seven
   near-white tints, one per reviewer. Correct by the house rule (a colour
   that names nothing may still come from the logo set) but the effect at
   full width was seven visibly different card colours in a row, which read
   as decoration choosing itself rather than as one considered card repeated.

   The deep end is the site's own answer to "what is one dark, considered
   colour" — it is the drawer and the footer, so a navy review card sits in
   the same world as the rest of the page instead of introducing an eighth
   ground. Every card now shares it; nothing here is nth-child'd any more.

   The identity job — one colour per person, so seventeen cards read as
   seventeen people rather than one card repeated — moves entirely onto the
   avatar disc, which still cycles through the logo palette below. Text is
   never tinted. */
.quote--plain { --rv: var(--teal); --rv-ink: var(--teal-ink); }
.quote--plain:nth-child(6n + 2) { --rv: var(--pink);    --rv-ink: var(--magenta-ink); }
.quote--plain:nth-child(6n + 3) { --rv: var(--green);   --rv-ink: var(--green-ink); }
.quote--plain:nth-child(6n + 4) { --rv: var(--dolphin); --rv-ink: var(--dolphin-ink); }
.quote--plain:nth-child(6n + 5) { --rv: var(--orange);  --rv-ink: var(--orange-ink); }
.quote--plain:nth-child(6n + 6) { --rv: var(--purple);  --rv-ink: var(--purple-ink); }

/* The card carries the depth now, because the band no longer does.

   The old fill was #175a68 → #103a46 — near-black, and correct for what it sat
   on: a full-saturation cyan wall needed something almost lightless to come
   forward off it. That wall is gone (see .section--tint-3), and the same card on
   pool-200 stopped reading as an object held close and started reading as a
   hole cut in the page. Too much value between ground and card is as wrong as
   too little.

   So the card opens at --trench #267e8f — the exact tone the deep end closes
   the page on, which is the point: the reviews are made of the same water you
   are about to be asked to book into. It descends to #17505d at the foot, and
   that descent is load-bearing rather than decorative. The top of the card is
   the lightest ground any copy here sits on and solid white clears 4.77:1 on
   it — the whole 4.5 budget, with nothing left for a second tone. Pushing the
   caption end down to #17505d (7.78:1 white) is what buys back the headroom for
   a quieter meta tier at all. The gradient is the contrast strategy, not a
   finish.

   Stops sit at 0 / 42% / 100% rather than a straight two-stop ramp so the dark
   end is reached before the figcaption, whatever a given review's length does
   to the card height. */
.quote--plain {
  background: linear-gradient(168deg, #267e8f 0%, #1d6b7a 42%, #17505d 100%);
  box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.10);
}
/* Solid white, not the old 0.88. On #103a46 that alpha still measured 12:1 and
   the softening was free; on #267e8f it lands at 3.42:1 and fails outright. The
   lightest stop sets the rule for the whole card. */
.quote--plain .quote__body { color: var(--white); }
.quote--plain .quote__name { color: var(--white); }
/* 0.82 over the card's lower half: 6.47:1 at the foot, 4.7:1 at the worst
   position a short card can put it in. */
.quote--plain .quote__meta { color: rgba(255, 255, 255, 0.82); }
/* Was --pool-300, which measured 2.85:1 against the new opening stop. A control
   the reader is meant to click cannot be the palest thing on the card — it is
   white and underlined now, and reads as a link rather than as decoration. */
.quote--plain .quote__more {
  color: var(--white);
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1.5px;
  text-decoration-color: rgba(255, 255, 255, 0.45);
}
.quote--plain .quote__more:hover { text-decoration-color: var(--white); }
/* Vivid now, not the -ink variant. The -ink discs existed because white text
   inside them had to clear 4.5:1, and they worked against near-black. Against
   #17505d they very nearly disappear — #0e6570 on #17505d is barely a value
   change — which killed the one job the disc has: making seventeen cards read
   as seventeen people. The vivid swatch separates from the card by 3.5–5x, and
   the initial goes back to --ink at 7.4:1, which is also how every other avatar
   on the site is built. Same colour rule, better ground for it. */
.quote--plain .quote__avatar { background: var(--rv); color: var(--ink); }

/* ============================================ 17.29 The drawer, rebuilt */
/* The menu was the one full-screen moment on the site that belonged to no part
   of it: a white panel, a hairline-ruled list, navy text. Correct and completely
   anonymous — and on a phone it is a *whole screen*, so it carried as much
   weight as any section on the page while doing none of the work.

   It is now the deep end. That is the site's most distinctive full-screen
   treatment and it is already established at the bottom of every page, so the
   menu reads as somewhere in the same world rather than as a system component
   that got bolted on. Orange CTA on navy is also the strongest that button
   looks anywhere on the site. */
.drawer {
  background: linear-gradient(180deg, #0e6570 0%, #0d4a70 55%, #0b3a5a 100%);
  padding-top: var(--space-lg);
}

/* Light filtering down through water, same device as .deep::before. Sits under
   the content and above the gradient. */
.drawer::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    radial-gradient(120% 60% at 50% 0%, rgba(255, 255, 255, 0.16), transparent 70%),
    radial-gradient(80% 40% at 12% 8%, rgba(255, 255, 255, 0.1), transparent 70%);
}
.drawer > * { position: relative; z-index: 1; }

.drawer li + li { border-top: 1px solid rgba(255, 255, 255, 0.14); }
.drawer a {
  color: var(--white);
  font-size: 1.45rem;
  letter-spacing: -0.01em;
}
/* Pool-300 rather than magenta: on navy the magenta ink went muddy, and this is
   the colour the site already uses for "water" highlights. */
.drawer a[aria-current="page"] { color: var(--pool-300); }
.drawer a[aria-current="page"]::after {
  content: "";
  width: 8px; height: 8px;
  border-radius: 50%;
  background: currentColor;
}
.drawer p { color: rgba(255, 255, 255, 0.72); }
.drawer p a { display: inline; font-size: 1rem; color: var(--white); }

/* The bubbles host is injected by main.js so the markup on ten pages doesn't
   have to change. */
.drawer .bubbles { z-index: 0; }

/* The full lockup, not a mascot — bottom-corner-clipped and coloured, a single
   dolphin read as a decoration that happened to be there rather than as the
   brand. Centred and full-width instead, so it sits in the space it was
   actually made for: the gap the address panel leaves below it.

   Grayscale flattens the lockup's four brand colours to one neutral tone
   first, then the low opacity fades that tone into the gradient — a watermark
   the same way a letterhead has one, not a second logo competing with the nav.
   Without the grayscale step, magenta and orange stay faintly identifiable
   even at 6% opacity and read as a smudge rather than a mark. */
.drawer::after {
  content: "";
  position: absolute;
  inset: auto 6% 4% 6%;
  height: 300px;
  opacity: 0.07;
  filter: grayscale(1) brightness(2.6);
  pointer-events: none;
  background: url("../img/logo-stacked-360.png") center bottom / contain no-repeat;
}

/* Links arrive in sequence as the panel slides in. Six items at 45ms is a
   quarter-second of stagger — enough to read as deliberate, not enough to make
   anyone wait for the menu. Transform and opacity only. */
@media (prefers-reduced-motion: no-preference) {
  .drawer.is-open li,
  .drawer.is-open > .btn,
  .drawer.is-open > p { animation: drawer-in 0.4s var(--ease-out) both; }
  .drawer.is-open li:nth-child(1) { animation-delay: 0.06s; }
  .drawer.is-open li:nth-child(2) { animation-delay: 0.105s; }
  .drawer.is-open li:nth-child(3) { animation-delay: 0.15s; }
  .drawer.is-open li:nth-child(4) { animation-delay: 0.195s; }
  .drawer.is-open li:nth-child(5) { animation-delay: 0.24s; }
  .drawer.is-open li:nth-child(6) { animation-delay: 0.285s; }
  .drawer.is-open > .btn { animation-delay: 0.33s; }
  .drawer.is-open > p { animation-delay: 0.375s; }
}
@keyframes drawer-in {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

/* The toggle sits on white nav chrome, so it keeps its own colours — only the
   panel below it changes. */

/* -------------------------------------------------- 17.30 The motto, mobile */
/* No overrides. The base rule's geometry is entirely em- and clamp()-based, so
   the row scales itself down and every ratio taken off the logo — the tilts, the
   0.75-tall baseline stagger, the overlaps, the keyline — survives at phone
   size. What used to be here set padding in rem and re-introduced a gap, which
   broke the em scaling and pushed the overlapping tags back apart. */

/* ------------------------------------------ 17.31 Why parents choose us */
/* Was four pale rounded icon squares in a 2x2 grid — the single most templated
   pattern in marketing sites after the three-step row, and the least designed
   thing on a page where the level cards have mascots and waterlines and the
   reviews have tinted grounds.

   Simple was the right instinct; generic was the problem. So this is still four
   plain blocks, but they are now editorial rather than card-ish: a keyline
   above each item, the icon in its own column beside the heading rather than
   floating above it, and the heading given real size.

   COLOUR ADDED. The editorial structure stays exactly as it was; what changes
   is that both of its structural elements — the keyline and the icon — now
   carry the item's --accent instead of the same pool tint four times over.

   The original objection to tiles was that "four large squares read as four
   boxes", and that was correct about a PALE square, which is a container and
   nothing else. A saturated chip in a two-column editorial grid is not a
   container; it is the colour chip the row is indexed by, the same way the
   level cards are indexed by their art panel. So the tile comes back at 54px —
   big enough to be one of the page's colour moments, small enough that the
   heading beside it is still the thing you read first.

   The keyline goes 2px → 6px and takes the accent too. At 2px in --pool-200 it
   was doing nothing a margin wouldn't have done; at 6px in the item's own
   colour it is the widest saturated element in the section and it lines the
   four columns up as a set. */
.feature {
  padding-top: var(--space-sm);
  border-top: 6px solid var(--accent, var(--pool-200));
}
/* Vivid fill, --ink glyph — same construction as .lvl__art and .steps__num. The
   glyph clears 4.14:1 on the weakest of the four (--magenta) and 11.04 on the
   strongest, so the fill can stay at full strength on all of them. */
.feature .card__icon {
  width: 54px;
  height: 54px;
  padding: 0;
  border-radius: 16px;
  background: var(--accent, var(--pool-100));
  color: var(--ink);
  box-shadow: inset 0 -4px 0 rgba(18, 48, 73, 0.16), 0 6px 14px rgba(18, 48, 73, 0.12);
  flex: none;
  transition: transform 0.35s var(--ease-back), box-shadow 0.35s var(--ease-out);
}
.feature .card__icon svg { width: 28px; height: 28px; }
/* The icon is a SIBLING of the h3 in the markup, not a child, so it can't be
   placed with flex on the heading. A two-column grid on the item puts it beside
   the heading and indents the paragraph to the same text column — no markup
   change needed on any page.

   The tile now leads its own row rather than sharing one with the heading: at
   26px it sat beside the words, but a 54px chip next to a two-line heading
   pushes that heading into a narrow column and rags it badly. So the chip takes
   row 1 across both columns and the text runs full width beneath it. */
.feature {
  display: grid;
  grid-template-columns: 1fr;
  align-items: start;
}
.feature .card__icon { grid-column: 1; grid-row: 1; margin-bottom: var(--space-xs); }
.feature h3 {
  grid-column: 1;
  grid-row: 2;
  font-size: clamp(1.15rem, 1.02rem + 0.5vw, 1.4rem);
  margin-bottom: 0.45rem;
}
.feature p { grid-column: 1; grid-row: 3; }
.feature:hover .card__icon {
  transform: translateY(-5px) rotate(-6deg);
  box-shadow: inset 0 -4px 0 rgba(18, 48, 73, 0.16), 0 14px 26px rgba(18, 48, 73, 0.18);
}
/* The keyline used to flip to --teal on hover, which was a leftover from when
   the bar was --pool-200 and any change at all was an improvement. Now that the
   bar carries the item's own accent, recolouring it says the opposite of what
   the section is built on: the four hues ARE the index, so overwriting one with
   an unrelated blue reads as a bug, not as feedback. Hover should confirm the
   colour, never replace it.

   What replaces it is the same colour, drawn. A second bar sits over the
   keyline in a deepened mix of the item's own accent and scales in from the
   left — an editorial underline draw, on an element that already exists rather
   than an ornament added for the occasion. It carries direction: the eye
   follows the line across the column and arrives at the heading, which is the
   order the content wants to be read in anyway.

   The origin flips between states so the bar travels the SAME way in and out —
   left-to-right on enter, then collapsing to the right on leave, so it passes
   through rather than rewinding. Rewinding is what makes a hover feel like a
   toggle; passing through makes it feel like a gesture. 0.55s is deliberately
   slower than the 0.35s chip, because a line covering 260px at chip speed
   reads as a flick.

   Guarded by (hover: hover): on a touch screen :hover latches after a tap and
   the drawn state would simply stay put, which is worse than no state at all. */
.feature { position: relative; }
.feature::before {
  content: "";
  position: absolute;
  top: -6px;
  left: 0;
  right: 0;
  height: 6px;
  background: color-mix(in srgb, var(--accent, var(--pool-300)) 74%, var(--ink));
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.55s var(--ease-out);
  pointer-events: none;
}
@media (hover: hover) {
  .feature:hover::before { transform: scaleX(1); transform-origin: left; }
  /* The chip follows the line instead of moving with it. 70ms is under the
     threshold where two events read as sequential rather than simultaneous, so
     it isn't perceived as a delay — it's perceived as the line arriving first
     and the tile answering. Four items all firing at once is a template; a
     two-beat response is a hand. */
  .feature:hover .card__icon { transition-delay: 0.07s; }
}
.feature p { max-width: 38ch; }

/* ------------------------------------------------- the four, on a phone */
/* EVERY IDEA IN THE DESKTOP VERSION IS ABOUT FOUR COLUMNS, AND THERE IS ONE.
   The 6px keyline exists to "line the four columns up as a set" — in a single
   column there is no set to line up, so what it actually delivers is four
   full-width coloured bars stacked four hundred pixels apart, each announcing a
   slab of body copy. The hover draw, which is where this section's craft lives,
   is switched off by (hover: hover) on a touch screen. So the phone was getting
   the skeleton of the idea with none of the idea, and the result read as four
   undifferentiated text blocks — worse, blocks with the SAME silhouette as the
   numbered steps in the section below, so two consecutive sections scrolled
   past looking like the same component twice.

   The keyline is therefore rotated onto the axis that exists. A thread down the
   left, hanging from the chip, running the height of the entry: the same
   coloured element doing the same organising job, on the only axis a phone has.
   Four of them turn a scroll through body copy into four indexed entries you
   can see the shape of, and it is a composition the steps below cannot be
   confused with.

   The thread is the accent DEEPENED, not the accent. Four pixels of --yellow on
   a pale ground is a smudge; the same 80/20 mix with --ink that the desktop
   hover bar already uses holds at that width across all four hues. The chip
   stays at full strength, so each entry reads as a colour at the top with its
   own thread running out of it.

   639px, not the phone breakpoint everything else here uses, because that is
   exactly where .grid--4 stops being a grid: one column below it, two above.
   The keyline's whole argument is that it lines columns up as a set, so it
   keeps the section from 640 up where there is a set to line up, and hands over
   to the thread only where there is not. (.98 rather than 639 — see the note on
   the pool card for the half-pixel band that swallows otherwise.) */
@media (max-width: 639.98px) {
  .grid:has(> .feature) { gap: 2.5rem; }

  /* The indent goes on the TEXT, not on the entry. An absolutely positioned
     child resolves left/top against its containing block's PADDING box, so
     padding-left on .feature would have carried the chip and the thread inward
     with it and dropped both straight on top of the heading. Indenting the two
     text children instead leaves the entry's own left edge at zero, which is
     where the marker and the line have to be measured from. */
  .feature {
    position: relative;
    border-top: 0;
    padding-top: 0;
  }
  .feature h3,
  .feature p { padding-left: 3.5rem; }
  /* The desktop hover bar is pinned to a keyline that no longer exists here,
     and a small desktop window still matches (hover: hover). */
  .feature::before { display: none; }

  /* It TAPERS OUT rather than stopping. Four 4px bars ending flat in mid-air,
     one under each entry, look like four bars that were cut off; fading the
     last quarter reads as the thread running out, which is what a thread does.
     It also keeps the eye moving down to the next marker instead of parking it
     on a hard terminal. */
  .feature::after {
    content: "";
    position: absolute;
    left: 22px;
    top: 58px;
    bottom: 0;
    width: 4px;
    margin-left: -2px;
    border-radius: 2px;
    --thread: color-mix(in srgb, var(--accent, var(--pool-300)) 80%, var(--ink));
    background: linear-gradient(to bottom,
      var(--thread) 0, var(--thread) 74%, transparent 100%);
  }
  /* Out of the grid and pinned, so the heading gets the full column rather than
     sharing a row with it — the reason the chip was moved off the heading's line
     on desktop was that a 260px column could not carry both. Indenting the whole
     entry instead gives the heading MORE measure than it had, not less. */
  .feature .card__icon {
    position: absolute;
    left: 0;
    top: 0;
    width: 44px;
    height: 44px;
    border-radius: 14px;
    margin-bottom: 0;
  }
  .feature .card__icon svg { width: 23px; height: 23px; }
  .feature h3 {
    margin-top: 0.15rem;
    margin-bottom: 0.55rem;
    font-size: 1.24rem;
  }
  .feature p { max-width: none; }
}

/* The drawer was pinned to `top: var(--nav-h)` (92px) but the nav is only 74px
   tall on a phone, leaving an 18px band of the page showing between the two.
   Invisible while the panel was white; obvious now it is navy.

   Anchored to the top of the viewport instead and padded past the nav, so there
   is no seam at any nav height. The nav sits at z-index 100 against the drawer's
   99, so it still renders over the top. */
.drawer {
  top: 0;
  padding-top: calc(var(--nav-h) + var(--space-sm));
}

/* ============================== 17.32 Drawer: alignment fixes + interaction */
/* Three faults, one cause. `.drawer a { justify-content: space-between }` was
   written for nav links but matches EVERY anchor in the panel — including the
   CTA, whose single text node then got pushed hard left, and the current-page
   dot, which got flung to the far right where it read as a stray mark rather
   than an indicator. The address was a third case: an inline
   `color: var(--ink-faint)` in the markup, which beats any stylesheet rule, so
   grey-blue body text stayed grey-blue on navy. That inline style is now a
   .drawer__meta class on every page. */

/* Scope the link styling to the nav list so the CTA keeps its own. */
.drawer .btn {
  justify-content: center;
  font-size: 1.0625rem;
  color: var(--btn-fg);
}

/* The current-page marker moves from a far-right dot to a left accent rule.
   An indicator belongs at the start of the thing it marks — beside the word you
   read first, not 300px away at the end of an empty row. */
.drawer nav a { justify-content: flex-start; gap: 0.85rem; position: relative; }
.drawer a[aria-current="page"]::after { content: none; }
.drawer nav li { position: relative; }
.drawer nav a[aria-current="page"]::before {
  content: "";
  position: absolute;
  left: -0.9rem;
  top: 50%;
  width: 4px;
  height: 1.5rem;
  margin-top: -0.75rem;
  border-radius: var(--r-pill);
  background: var(--pool-300);
}

.drawer__meta {
  text-align: center;
  font-size: 0.95rem;
  line-height: 1.7;
  /* 0.82 white on the deep gradient clears AA comfortably; the previous
     --ink-faint was 2.1:1 against it and effectively unreadable. */
  color: rgba(255, 255, 255, 0.82);
}
.drawer__meta a {
  display: inline;
  font-family: var(--font-body);
  font-size: 1rem;
  font-weight: 800;
  color: var(--white);
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* ---- The part that makes it feel like somewhere rather than a list ---- */

/* Caustics. Light refracting off a water surface, drifting slowly across the
   whole panel. This is the site's own signature effect — the hero frame uses
   the same technique — and it is what turns a dark menu into an underwater one.
   Two layers on unrelated periods so the pattern never visibly repeats. */
.drawer::before {
  background:
    radial-gradient(closest-side, rgba(255, 255, 255, 0.13), transparent 70%) 18% 22% / 58% 42% no-repeat,
    radial-gradient(closest-side, rgba(255, 255, 255, 0.09), transparent 70%) 78% 58% / 46% 38% no-repeat,
    radial-gradient(120% 60% at 50% 0%, rgba(255, 255, 255, 0.14), transparent 70%);
  will-change: transform;
}
@media (prefers-reduced-motion: no-preference) {
  .drawer.is-open::before { animation: drawer-caustics 19s ease-in-out infinite alternate; }
}
@keyframes drawer-caustics {
  from { transform: translate3d(-3%, -2%, 0) scale(1.02); }
  to   { transform: translate3d(4%, 3%, 0) scale(1.12); }
}

/* Each link reveals as a wipe rather than a fade — the text arrives left to
   right like water running across it. Costs nothing extra: it replaces the
   opacity in the existing stagger, so the timing is unchanged. */
@media (prefers-reduced-motion: no-preference) {
  .drawer.is-open li { animation-name: drawer-wipe; }
}
@keyframes drawer-wipe {
  from { opacity: 0; transform: translateY(10px); clip-path: inset(0 100% 0 0); }
  to   { opacity: 1; transform: none; clip-path: inset(0 -8% 0 0); }
}

/* A chevron that lives off the left edge and slides in on press. Touch has no
   hover, so the affordance has to land on :active — and a row that visibly
   responds to a finger is most of what separates an app-feeling menu from a
   list of links. */
.drawer nav a::after {
  content: "";
  width: 9px;
  height: 9px;
  border-right: 2.5px solid var(--pool-300);
  border-bottom: 2.5px solid var(--pool-300);
  transform: rotate(-45deg) translateX(-6px);
  opacity: 0;
  transition: opacity 0.25s var(--ease-out), transform 0.25s var(--ease-out);
}
.drawer nav a:hover::after,
.drawer nav a:focus-visible::after,
.drawer nav a:active::after { opacity: 1; transform: rotate(-45deg) translateX(0); }

/* The row itself lights up — a shallow wash pulled in from the left, so the
   highlight arrives from the same direction as the wipe on open. */
.drawer nav li {
  transition: background-color 0.25s var(--ease-out);
  border-radius: var(--r-sm);
}
.drawer nav li:hover,
.drawer nav li:active { background: rgba(255, 255, 255, 0.07); }
.drawer nav a { transition: transform 0.25s var(--ease-out), color 0.2s var(--ease-out); }
.drawer nav a:hover,
.drawer nav a:active { transform: translateX(5px); color: var(--pool-300); }

/* The current-page marker moves inside the row. At left: -0.9rem it rendered in
   the gutter, ~6px clear of the text it was marking, which reads as a stray mark
   rather than an indicator. The row now carries its own padding and the marker
   sits at its left edge, so the bar and the word it marks are one object. */
.drawer nav a { padding-left: 1rem; }
.drawer nav a[aria-current="page"]::before { left: 0; }
.drawer nav li:has(a[aria-current="page"]) { background: rgba(255, 255, 255, 0.08); }

/* ===================================== 17.33 Drawer: refraction on the rows */
/* The site names three water behaviours — rise, float, refract. The drawer had
   two: the stagger is rise, the caustics are float. Refraction was missing, and
   that absence is most of why the panel read as competent rather than crafted.

   This is it: a narrow band of brighter water travelling across each row, the
   same thing that happens when light bends through a moving surface. It plays
   twice — once per row as the menu opens, so it is always seen, and again on
   hover for pointer devices. Touch gets it on open; :active would cut the
   animation the instant a finger lifts. */
.drawer nav li { overflow: hidden; }
.drawer nav li::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(100deg,
    transparent 35%,
    rgba(255, 255, 255, 0.13) 48%,
    rgba(167, 226, 245, 0.16) 52%,
    transparent 65%);
  transform: translateX(-100%);
  opacity: 0;
}
@media (prefers-reduced-motion: no-preference) {
  /* Rides the existing open stagger. Each row's sweep is offset a further 45ms,
     matching the delays on the wipe, so one band appears to travel down the
     whole list rather than six firing at once. */
  .drawer.is-open nav li::before { animation: drawer-refract 0.9s var(--ease-out) both; }
  .drawer.is-open nav li:nth-child(1)::before { animation-delay: 0.10s; }
  .drawer.is-open nav li:nth-child(2)::before { animation-delay: 0.145s; }
  .drawer.is-open nav li:nth-child(3)::before { animation-delay: 0.19s; }
  .drawer.is-open nav li:nth-child(4)::before { animation-delay: 0.235s; }
  .drawer.is-open nav li:nth-child(5)::before { animation-delay: 0.28s; }
  .drawer.is-open nav li:nth-child(6)::before { animation-delay: 0.325s; }

  @media (hover: hover) {
    .drawer nav li:hover::before { animation: drawer-refract 0.7s var(--ease-out); }
  }
}
@keyframes drawer-refract {
  from { transform: translateX(-100%); opacity: 0; }
  35%  { opacity: 1; }
  to   { transform: translateX(100%); opacity: 0; }
}

/* Hover state. Deliberately only on devices that can hover — on touch this
   selector sticks after a tap and leaves a row highlighted behind you. */
@media (hover: hover) {
  .drawer nav li:hover { background: rgba(255, 255, 255, 0.12); }
}

/* The address was set by `.drawer p` (0-1-1) rather than `.drawer__meta` (0-1-0),
   so it stayed at 0.72 alpha and the .drawer__meta rule below it never applied —
   a specificity loss, not a missing rule. At 15px, centred, over drifting
   caustics, 0.72 was legible in theory and hard work in practice.

   Raised to 0.94 and 1rem, and the caustics are held back from the block so the
   text sits on flat water. Address lines are information a parent may need to
   read once, quickly, standing outside the building. */
.drawer p.drawer__meta {
  color: rgba(255, 255, 255, 0.94);
  font-size: 1rem;
  line-height: 1.75;
  margin-top: var(--space-md);
  padding: var(--space-sm) var(--space-xs);
  border-radius: var(--r-sm);
  background: rgba(4, 34, 54, 0.42);
}
.drawer p.drawer__meta a { color: var(--white); }

/* The nav is `position: fixed`, so the first thing in <main> has to clear it.
   Every page but one opens with .hero, which already reserves that space in its
   own padding — child-protection.html opens with a plain .section, whose 72px of
   top padding is less than the 92px nav, so its eyebrow and the top of the
   heading sat underneath the menu bar.

   Written generically rather than as a one-page fix: any future page that
   doesn't open with a hero needs exactly the same clearance, and this is the
   kind of thing that is invisible until someone reports it. */
main > section:first-child:not(.hero) {
  padding-top: calc(var(--nav-h) + var(--space-lg));
}


/* ==========================================================================
   18. SUNLIT SHALLOWS — water-band inks and atmosphere
   Home page only. Everything in this section is additive to version one.
   ========================================================================== */

:root {
  /* ------------------------------------------------------------------ Grain
     Black noise with a luminance-to-alpha matrix, so what lands on the page is
     transparent-to-black speckle rather than grey haze. Deep Water used white
     noise for the same job; on a bright ground white grain is invisible and the
     sign has to flip.

     It is here for a specific reason and not for texture's sake: a large area
     of a single saturated mid-tone is exactly what 6-bit and dithered panels
     band on, and #2bb0d4 across a full-width section is the worst case on this
     page. A 180px noise tile at 3.4% breaks the gradient into something a cheap
     display cannot posterise. Static — no animation, one tile, cached once. */
  /* REMOVED. The argument for it was banding on cheap panels; the argument
     against it is that it looked like dirt on the screen, which is the only one
     that matters. A saturated mid-tone at this lightness turned out not to band
     enough to be worth a texture that reads as grime over a swimming pool.
     Kept as an empty value so the three band rules that reference it still
     parse without needing to be rewritten. */
  --grain: none;;

  /* ---------------------------------------------------------- Surface light
     ~210px of light down from each water band's top edge. Two flat saturated
     colours meeting on a straight line reads as two sheets of paper butted
     together, which is a print gesture. This does not soften the join — the
     step stays crisp, that is the point of banding — it explains it, so the eye
     reads "light enters here and is absorbed going down" rather than "the colour
     changes here". The difference between strata and slabs. */
  --surface-glow: linear-gradient(180deg, rgba(255, 255, 255, 0.055) 0%, rgba(255, 255, 255, 0) 210px);

  /* The colour a --sky band ACTUALLY paints in its first row, which is not
     --sky — the glow above is sitting on it. #98d2e1 lit by 5.5% white
     resolves to rgb(158, 212, 227), six levels up on red.

     Every wave on this site works to one contract: the fill is the colour of
     the section below. On the home page that section is .section--pool, and
     the contract was being honoured against the TOKEN rather than against the
     paint — so the divider filled with #98d2e1 while the band under it started
     six levels lighter and ramped back down over 210px. Six levels is nothing
     in a photograph and quite a lot on a dead-straight edge running the full
     width of the screen, which is why it read as a drawn line rather than as a
     tonal change. It is worse on a phone because the wave is 48px there
     against 96px on a desktop, so the two tones sit half as far apart.

     Filling the wave with this instead makes the join continuous and leaves
     the surface light exactly where it was: the light now begins at the wave's
     crest and ramps down through the section in one unbroken move, which is
     what it was always meant to look like.

     Kept beside --surface-glow deliberately. The 5.5% appears in both, and if
     one moves without the other the line comes back. */
  --sky-lit: color-mix(in srgb, #fff 5.5%, var(--sky));
}

/* ------------------------------------------------- Ink on the water bands
   The three-tier ink ladder collapses to ONE tier on saturated water, and this
   is forced rather than chosen. Body copy needs 4.5:1, --water sits at relative
   luminance 0.363, so any ink on it must be at 0.0418 or below. Measured:

     --ink       #0e2c38  relLum 0.0218   5.75:1   ok
     --ink-soft  #34596b  relLum 0.1100   2.96:1   fails
     --ink-faint #446676  relLum 0.1690   2.13:1   fails
     --teal-ink  #0e6570  relLum 0.1057   2.65:1   fails

   Nothing between --ink and the failing tier is usable — I tested #14414f
   (4.35) and #16404f (4.39) and both miss. There is no room for a secondary
   tone. So on water bands, hierarchy comes from weight, size and spacing only.
   That is a real constraint and arguably the better discipline: version one
   leans on three greys where two of them were doing the work of a font-weight.

   Redefining the tokens rather than restyling the roles means every descendant
   follows automatically — one block instead of thirty rules.

   .section--pool has dropped off this list. The collapse is a property of the
   WATER, and Programs is sand now — the full ladder measures 15.59 / 6.77 /
   5.54 / 6.06 on --sand, so the section head, the filter label and the level
   colours all get their proper tier back. Leaving it here would have flattened
   the one section that most needs hierarchy, on a ground with contrast to
   spare.

   .section--tint-3 has now dropped off it too, for the same reason: Parent
   Reviews is pool-200 and the full three-tier ladder measures 13.74 / 5.96 /
   4.88 there. Collapsing it would have thrown away the eyebrow / head / body
   separation on a ground with contrast to spare — and with the ladder back, the
   companion rule that restored it inside cards is redundant and has gone with
   it. Two rules deleted by fixing the ground rather than patching the symptom. */
.hero--home {
  --ink-soft: var(--ink);
  --ink-faint: var(--ink);
  --teal-ink: var(--ink);
}

/* ------------------------------------------------------------- Light shafts
   REMOVED — rule kept disabled rather than deleted so the reasoning survives.

   These were the diagonal lines. A repeating-linear-gradient has a hard
   discontinuity at every stop boundary, and softening the ramp (which I did,
   from three stops to six) makes the bands wider and blurrier without making
   them stop being bands. The eye locks onto the periodicity, not the edges —
   any evenly spaced repeat at this scale reads as a pattern laid over the
   artwork rather than as light in it, and no amount of alpha tuning fixes a
   structural problem.

   The hero already has real light: the two radials in the brief's gradient and
   the four-blob caustic drift above. Those are irregular, which is why they
   read as light and this did not. */
.hero--home .hero__water::after { content: none; }
.hero--home .hero__water--disabled::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* Six stops rather than three. The hard-edged version read as stripes: a
     repeating-linear-gradient with two stops has a discontinuity at every
     boundary, and the eye finds those edges before it finds the light. Ramping
     in and out across ~14px either side removes every hard edge in the pattern,
     which is the difference between a shaft of light and a set of bars. Peak
     alpha is down to 0.038 as well — once the edges are soft the same
     brightness reads considerably stronger. */
  background: repeating-linear-gradient(98deg,
    rgba(255, 255, 255, 0) 0 30px,
    rgba(255, 255, 255, 0.014) 44px,
    rgba(255, 255, 255, 0.038) 52px,
    rgba(255, 255, 255, 0.014) 60px,
    rgba(255, 255, 255, 0) 74px 104px);
  animation: shafts 29s ease-in-out infinite;
  will-change: transform;
}
/* Four waypoints and an asymmetric return, same reasoning as caustics-drift:
   two states on `alternate` reads as one thing sliding back and forth on a
   timer. Translate only. The x travel is 104px, exactly one gradient period, so
   the pattern never appears to jump when the cycle closes. */
@keyframes shafts {
  0%   { transform: translate3d(0, 0, 0); }
  31%  { transform: translate3d(52px, 0, 0); }
  58%  { transform: translate3d(66px, 0, 0); }
  81%  { transform: translate3d(24px, 0, 0); }
  100% { transform: translate3d(104px, 0, 0); }
}

/* -------------------------------------------------------------------- Motes
   Suspended particulate. Three fields on mutually coprime tiles (127/191,
   209/289, 313/437) — with two fields on round numbers the eye finds the
   lattice immediately, and anything on a visible grid reads as a screen defect
   rather than matter in the water. Coprime tiles push the combined repeat far
   outside any viewport.

   Radii are deliberately sub-pixel. At 1.3px these were specks; under a pixel
   they antialias into points of light, which is what lit particulate looks
   like. Water bands only — white motes on #eaf7f9 would be invisible. */
.motes {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}
.motes::before {
  content: "";
  position: absolute;
  /* Overshoots by 320px because the animation travels 289px, and not a pixel
     more. The outer .motes clips it back to the section box. */
  inset: -320px 0;
  background-image:
    radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.62) 0 0.6px, transparent 1.3px),
    radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.5) 0 0.8px, transparent 1.6px),
    radial-gradient(circle at 50% 50%, rgba(238, 252, 255, 0.4) 0 0.5px, transparent 1.1px);
  background-size: 127px 191px, 209px 289px, 313px 437px;
  background-position: 0 0, 61px 93px, 137px 41px;
  opacity: 0.4;
  animation: motes-rise 46s linear infinite;
  will-change: transform;
}
/* Travels exactly one tile height of the middle field (289px) so that field's
   loop point is invisible; the other two are still mid-cycle when it wraps,
   which is what stops the composite from visibly resetting. */
@keyframes motes-rise {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-14px, -289px, 0); }
}
.hero--home .motes::before { opacity: 0.34; animation-duration: 52s; }

/* -------------------------------------------------------------------- Drift
   Mascot silhouettes moving behind the content. The test is whether you can
   tell which animal it is — you should not be able to. At any opacity where
   the goggles and the face resolve, this stops being atmosphere and becomes
   decoration placed on top of the layout, which is the failure the loose
   mascots were removed for in the first place.

   On this build they are LIGHTER than their ground rather than darker, which
   only works because they live exclusively on water bands. On #2bb0d4 a pale
   shape at 9% reads as a form catching light from above; the same shape on
   #eaf7f9 is nothing at all, which is why the pale bands have no drift.

   No filter. A filter on an element this size is a full render pass per frame,
   and these move — opacity does everything a blur would here. */
.drift {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}
.drift img {
  position: absolute;
  width: auto;
  /* 0.09 was the first pass and it failed the test stated above: on #2bb0d4 the
     goggles and the face still resolved, which makes it a picture of a mascot
     placed behind unrelated copy rather than a shape in the water. 0.06 is where
     it stops being identifiable and starts being form. */
  opacity: 0.06;
  will-change: transform;
  animation: drift-swim 90s ease-in-out infinite alternate;
}
/* Facing. Every mascot is drawn head-left, so a silhouette parked on the LEFT
   edge swims out of the page and one on the right swims into it — and the eye
   follows a face, so half of these were quietly pulling attention off the copy
   they sit behind. Flipped, they bracket the column and face the reader.

   This has to be a variable rather than a transform, and that is why it was
   silently doing nothing before: .is-flipped was on the markup in three places,
   but @keyframes drift-swim writes the whole `transform` property every frame,
   so a scaleX(-1) declared on the element is overwritten the moment the
   animation starts. .deep__school gets away with a plain transform because its
   mascots are moved by GSAP, which composes with the existing matrix instead of
   replacing it. Here the flip has to travel INSIDE the keyframe. */
.drift img { --flip: 1; }
.drift .is-flipped { --flip: -1; }
/* Nudged down, not up. The instinct on a lighter ground is to add alpha, and at
   0.10 the dolphin stopped being a shape under the surface and became a large
   pale blob with a readable face — the exact failure the 0.06 note above
   describes, arrived at from the other direction. --sky is light enough that
   less contrast, not more, is what keeps this reading as depth. */
.section--pool .drift img { opacity: 0.07; }
/* 1.3x on Y because the mascot artwork is taller than it is wide; scaling
   uniformly left them reading as small and sharp rather than large and near. */
@keyframes drift-swim {
  from { transform: translate3d(0, 0, 0) scale(var(--flip, 1), 1.3); }
  to   { transform: translate3d(6%, -4%, 0) scale(var(--flip, 1), 1.3); }
}

/* ------------------------------------------------------------ Reduced motion
   The existing global prefers-reduced-motion block kills animation-duration
   site-wide, which already stops all four layers above. They stay VISIBLE and
   static — grain, shafts, motes and drift are atmosphere, not feedback, and
   removing them entirely would take the depth out of the page for a user who
   asked for less movement, not less design. */
@media (prefers-reduced-motion: reduce) {
  .motes::before,
  .drift img,
  .hero--home .hero__water::after { animation: none; }
}

/* Phones: the motes thin out and the drift goes. A 375px-wide band has no room
   for a large silhouette to read as anything but a smudge behind the text, and
   the mascots are already present at full strength in the level cards. */
@media (max-width: 767px) {
  .motes::before { opacity: 0.26; }
  .drift { display: none; }
}

/* ------------------------------------------------------- Ink in the deep zone
   The same collapse as the water bands, at the opposite end, and again forced by
   measurement rather than chosen. On --trench #267e8f, pure white is the only
   ink that clears 4.5:1 — and it clears it at 4.70 with nothing in hand:

     #ffffff  4.70  ok        #dff1f5  4.04  fails
     #f2fafb  4.45  fails     #cdeaf0  3.72  fails

   Version one's softer deep-zone tones (#cfe7f4 on the closing lead) measure
   3.67 here and had to go. The closing section therefore still has exactly one
   text colour, the same way the water bands do.

   The FOOTER no longer shares that constraint. It sits on --abyss at 0.058, not
   --trench at 0.170, and at 9.68:1 against white there is room for the soft and
   faint tiers again — so the collapse below applies to .deep only, and section
   14 sets the footer's own three. This is the clearest thing the extra depth
   bought: a text ladder is not decoration, it is how a reader knows the phone
   number matters more than the copyright line. */
/* `color` is set once on body as var(--ink-soft) and then INHERITS AS A RESOLVED
   VALUE. Redefining --ink-soft further down the tree does nothing to it — the
   descendants already hold #34596b as a computed colour, not a reference to the
   token. That mistake is worth spelling out because it is silent and it is
   exactly the kind of thing a token-swap refactor gets wrong: the tokens looked
   correct, the audit passed (every element with its own colour rule was fine),
   and the inherited text underneath measured 1.38:1 against --trench — invisible,
   not merely low-contrast.

   So `color` has to be declared, not tokenised, at every point where the ground
   changes out from under inherited text. The token overrides stay for anything
   that resolves var(--ink-soft) at its own level. */
.deep {
  color: var(--white);
  --ink-soft: var(--white);
  --ink-faint: var(--white);
}
.deep .lead,
.creds__lead,
.creds__item,
.creds__note { color: var(--white); }

/* The footer keeps the same mechanism and different values: `color` is
   DECLARED, not left to inherit a resolved #34596b from body, and the tokens
   are re-pointed for anything below that resolves them at its own level. Both
   tones are measured on --abyss in section 14. */
.footer {
  color: var(--footer-ink);
  --ink-soft: var(--footer-ink);
  --ink-faint: var(--footer-faint);
}
.footer p,
.footer li,
.footer address { color: var(--footer-ink); }

/* Silhouettes at the floor. Version one greyscaled these to sit them back into
   a navy ground; on teal that reads as ash, so the grayscale is gone and a
   saturate() replaces it — the mascots keep their own colour, just very faint.
   Opacity is up from 0.16 because #267e8f is lighter than the navy this was
   tuned for, and 0.16 against it was almost nothing. */
.deep__school img {
  opacity: 0.26;
  filter: saturate(1.2);
}

/* ==========================================================================
   19. THE REEL — video as the Our Pool section's media

   This is the second version. The first was a full-bleed, square-cornered
   plate with white display type over the footage, sitting in its own section
   between Why and Our Pool, and it failed for a reason worth keeping: every
   one of those choices was the only instance of itself on the page. .frame
   gives all other media a 28px radius and --shadow-lg; all other sections wrap
   their content in .container; white type over an image occurs exactly once on
   this site and that once is the hero. A well-executed object from a different
   design system still reads as an intrusion, and it did.

   So the video is not a new component. It is the media slot of a section that
   already existed, and everything below is only what a <video> needs that an
   <img> does not — controls, and a rule reporting how much is left. The frame,
   the radius, the shadow, the parallax and the yellow card overhanging it are
   all inherited unchanged from the photograph this replaces.
   ========================================================================== */
.reel { position: relative; }

/* Reserved before a byte of video is fetched, so the frame never contributes to
   CLS while preload="none" holds the file back. Matches the encoded crop. */
.reel,
.reel__video { aspect-ratio: 2 / 1; }
.reel__video {
  display: block;
  width: 100%;
  height: 100%;
  /* cover is a no-op at desktop widths, where the box and the file are both
     2:1. It earns its place at the mobile ratio below. */
  object-fit: cover;
  object-position: center;
}

/* ------------------------------------------------------------- controls */
.reel__controls {
  position: absolute;
  z-index: 2;
  right: clamp(0.75rem, 2vw, 1.25rem);
  bottom: clamp(0.75rem, 2vw, 1.25rem);
  display: flex;
  gap: 0.5rem;
}

/* Glass, not a solid fill. A button in any brand colour reads as UI chrome
   pasted on top of a photograph; a translucent disc reads as part of the frame.
   The blur is doing real work — without it the icon stroke disappears against
   pale water for about a second and a half around the 17s mark. */
.reel__btn {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1px solid rgba(255, 255, 255, 0.34);
  border-radius: var(--r-pill);
  background: rgba(5, 29, 38, 0.42);
  -webkit-backdrop-filter: blur(10px) saturate(1.3);
  backdrop-filter: blur(10px) saturate(1.3);
  color: var(--white);
  cursor: pointer;
  transition: background 260ms var(--ease-out), transform 260ms var(--ease-out),
              border-color 260ms var(--ease-out);
}
.reel__btn svg { width: 19px; height: 19px; }
.reel__btn:hover {
  background: rgba(5, 29, 38, 0.62);
  border-color: rgba(255, 255, 255, 0.6);
  transform: translateY(-2px);
}
.reel__btn:active { transform: translateY(0); }

/* Two icons, one button, swapped on state rather than rebuilt — the disc never
   reflows and the label is the only thing that changes for a screen reader. */
.reel__ico--on { display: none; }
.reel__btn--sound[aria-pressed="true"] .reel__ico--off { display: none; }
.reel__btn--sound[aria-pressed="true"] .reel__ico--on { display: block; }
.reel__btn--replay[hidden] { display: none; }

/* ---------------------------------------------------------------- the rule */
/* Reporting real state, not decorating. It exists because the clip plays once
   and stops: a reader arriving at fourteen seconds otherwise cannot tell
   whether they are near the end or watching a loop. Magenta because on this
   site magenta marks the things that are actually happening. Its ends are
   clipped by the frame's own radius, which is why it needs no radius of its
   own. */
.reel__rule {
  position: absolute;
  z-index: 2;
  inset: auto 0 0 0;
  height: 3px;
  background: rgba(255, 255, 255, 0.22);
  transition: opacity 600ms var(--ease-out);
}
/* Once the clip has ended the rule has said everything it can, and a full
   magenta bar pinned across the frame is then pure decoration — the one thing
   this element was justified on NOT being. It retires itself, and comes back
   when the replay control restarts the clip. */
.reel.is-ended .reel__rule { opacity: 0; }
.reel__rule span {
  display: block;
  height: 100%;
  width: 100%;
  background: var(--magenta);
  transform: scaleX(0);
  transform-origin: left center;
  /* No transition: this is driven from timeupdate every frame, and easing an
     already-continuous value would only make it lag the footage it reports. */
}

/* ---------------------------------------------- the card, at full width */
/* The measure is capped alongside the overlap it belongs to, up at the
   .pool-card rule — 27.5rem, from 640px up. Left uncapped, a plate spanning the
   whole container is the "caption bar bolted to the bottom" the original note
   set out to avoid, and it also strands the rules: the widest fact in the table
   is 235px, so at 698px of plate every line stopped four hundred pixels short of
   the line under it. Ruled rows want to be filled. */

/* ------------------------------------------------------------------ mobile */
/* Re-framed, not scaled. 2:1 at 390px is a 195px strip — too short to read as
   anything but a banner, and small enough that the instructor and child are
   illegible. 3:2 gives it 260px and real presence in the scroll, costing 25%
   off the sides; the subjects sit centre in all three shots and the AQUAKids
   lockup is centred in the wide one, so the crop takes mural and bare water
   and nothing else. */
@media (max-width: 700px) {
  .reel, .reel__video { aspect-ratio: 3 / 2; }
  .reel__btn { width: 40px; height: 40px; }
  .reel__btn svg { width: 17px; height: 17px; }
}

/* ON A PHONE IT GOES PORTRAIT, AND IT IS A DIFFERENT FILE.
   3:2 was the right answer while this was a landscape clip in a landscape
   layout, and it stopped being one the moment the frame and the plate became a
   single stacked object: 223px of video above 328px of data is a header strip
   on a card, and the thing that is supposed to hold the section is the smaller
   half of it. 4:5 gives it 419px — nearly double — and the video finally has
   more presence than the table underneath it.

   4:5 AND NOT TALLER. Every step down that road is paid for in frame width,
   because the source is 2:1 and this crop is centred:

       3:2   75% of the frame kept   the old strip
       1:1   50%                     comfortable, but only +112px of height
       4:5   40%                     the sweet spot
       9:16  28%                     the AQUAKids lockup loses its S
       100vh 26%                     lockup clipped both sides, and every
                                     two-person shot loses one of the two

   Checked frame by frame across all three shots rather than argued: at 4:5 the
   child's face gets BIGGER and reads better than it ever did at 3:2, the mural's
   two dolphins still cross the top, and the wide shot still holds the whole
   lockup. One notch further and the clip stops being about a child and an
   instructor, which is the only thing it is for.

   Full-screen was the other thing worth testing and it fails on more than the
   crop. It would separate the video from the plate, and the whole point of the
   phone layout is that they are one object — at 4:5 the pair comes to 747px, so
   a reader gets the video and the top of the table in a single view and can see
   that the one belongs to the other. That is the full screen; it just is not the
   video alone. A square-cornered 100vh plate is also the exact thing this
   section built and threw away, for reasons recorded in the markup.

   The file changes with the shape — see initReel(). Displaying the 960 landscape
   encode in this box would discard 60% of every frame it downloaded. */
@media (max-width: 639.98px) {
  .reel, .reel__video { aspect-ratio: 4 / 5; }
}

/* --------------------------------------------------------- reduced motion */
/* The rule goes when autoplay does — with the clip held at its poster there is
   nothing in progress to report. initReel() handles the rest, swapping the
   replay control's label to "Play" so it does not offer to repeat something
   the reader has not seen. */
@media (prefers-reduced-motion: reduce) {
  .reel__rule { display: none; }
}
.no-motion .reel__rule { display: none; }

/* ==========================================================================
   20. THE LEVELS DECK — desktop rendering of the pathway

   Ten cards over four rows was the densest thing on the page, and it also had
   the parent's job backwards: they arrive knowing their child is four, not
   wanting a tour. The deck collapses the pathway to one row of overlapping
   cards where only the pointed-at one is open.

   The shape of the interaction is argued in initLevelDeck() in main.js — in
   short, a deck says "one continuous thing you can look into" where a rail
   says "N interchangeable things, scroll for more", and the levels are the
   former. What lives here is only what the deck needs that the grid does not.

   Everything below is scoped to .levels--deck, which script adds and only
   above 980px. Phones keep the existing flick rail untouched, reduced-motion
   keeps the grid, and no-JS keeps the grid. All three are the same eleven
   cards with more of their content showing, not a degraded version of this.
   ========================================================================== */
/* The height is the tallest card's natural height, not a number chosen to make
   the band look right, and the difference is not academic. At 348px every card
   was 84px short of what its own content needed — .lvl__art is a flex child with
   the default flex-shrink, so the deficit came out of the artwork: the mascot's
   pool was crushed from 190px to as little as 104px and the artwork inside it
   spilled 38px out of the top of the card. That was invisible while closed cards
   were painted over as solid colour, and the moment they showed their real faces
   it read as eleven cards with something broken at the top edge. */
.levels--deck {
  display: block;
  position: relative;
  height: var(--deck-h, 448px);
  /* The cards lift out of the row and the mascots leap out of the cards, and
     both need somewhere to go. */
  overflow: visible;
  margin-top: var(--space-lg);
}

/* The slot is exactly the card's box, and it took three passes to get back to
   something that simple — the history is worth keeping because every attempt to
   make it cleverer produced a visible artefact.

   The slot used to be much LARGER than its card, carrying padding on all four
   sides. That padding was the budget for a clip-path: the script cut each card
   to its own advance so the last card, with nothing to its right to cover it,
   could not sit permanently open. The padding existed because the clip needed
   room for the shadow and the mascot, and because a negative inset() offset is
   invalid — Chrome drops such a clip so completely that the element still lays
   out and still hit-tests but never paints. Eleven cards, addressable, invisible.

   All of it went. The clip only ever mattered where a card was NOT covered by
   its neighbour, and the fan made exactly those places visible: cards now sit a
   few pixels apart vertically, so the strip each one shows below the card
   covering it was the clip's own edge — a square guillotine cut, once per card,
   in a line along the bottom of the deck. Painting every card whole and letting
   the next card cover it means an exposed card shows its own rounded corner,
   because that is what is actually there. Nothing is cut, so no shadow can end in
   a ruled line and the padding has nothing left to protect.

   What remains is one absolutely positioned box per card, moved by a transform.
   The stylesheet's only jobs here are the origin and the compositing hint. */
.lvl-slot {
  position: absolute;
  top: 0;
  left: 0;
  width: 284px;                 /* placeholder; the layout solver writes the real one */
  height: 100%;
  /* Scale and tilt are pinned to the card's own left edge — the one edge of a
     covered card that never stops being visible, and the edge the whole row's
     overlap is measured from. Scaling about the centre would walk it sideways as
     the card grew and the spacing would breathe with it. */
  transform-origin: 0 50%;
  /* Script writes the transform every frame from a lerp; a CSS transition on top
     of that would be a second easing fighting the first. The smoothness is in
     the interpolation, not here. */
  will-change: transform;
}
/* Every card is now drawn full width and mostly covered, so ten of the eleven
   boxes under the pointer belong to cards you cannot see. The topmost one wins a
   hit test and the topmost one is the card actually drawn there, which is the
   right answer — but only the CARD should take events, never its wrapper, or the
   rules would depend on which of two coincident boxes was asked. Movement is
   read from the deck, which receives the bubbled event either way. */
.levels--deck .lvl-slot { pointer-events: none; }
.levels--deck .lvl { pointer-events: auto; }
.lvl-slot[hidden] { display: none; }

/* And the artwork is nailed open, so it can never pay for a shortfall again. A
   card in the grid sizes to its content; a card in the deck is given a fixed
   height, and the difference has to be absorbed by the body — which has flex:1
   and a link on margin-top:auto, so it absorbs it correctly by design. */
.levels--deck .lvl__art { flex: 0 0 auto; }

.levels--deck .lvl {
  height: 100%;
  /* Shadow is the only thing here CSS still eases. Everything geometric is
     lerped per-frame by script, and a transition on top of that would be a
     second easing fighting the first. */
  transition: box-shadow 0.45s var(--ease-out);
}

/* The grid card's own hover lift is switched off inside the deck, and this is a
   rule about authority rather than taste. The slot already owns translate,
   scale and tilt and writes all three every frame; .lvl:hover adds a SECOND
   transform to the same card with its own 0.5s ease-back, so the card arrives
   at a size the deck never asked for and overflows the window the deck sized
   for it. One element, one authority.

   The mascot's leap is kept, but it changes hands — see below. */
@media (hover: hover) {
  .levels--deck .lvl:hover,
  .levels--deck .lvl:focus-within { transform: none; }
}

/* The mascot leaps because its card is OPEN, not because the cursor happens to
   be inside its border box, and in a deck those are two different things.

   The slot's shadow padding is pointer-events:none, so a cursor resting in the
   open card's right margin is geometrically over the NEXT card — which meant the
   open card's mascot dropped back into the water while a sliver's mascot half a
   card to the right jumped. The leap was reporting the pointer's position rather
   than the deck's state, and the two disagree constantly during a riffle. It also
   meant the one card a reader is actually looking at spent most of its time in
   its resting pose.

   So the deck takes the leap off :hover entirely and hangs it on .is-open, which
   is the same flag driving the lift, the scale and the veil. Four cues, one
   state, one moment. The card lifts 20px and the mascot clears it by 30 more —
   the vertical read of "this one has been picked up" is what the whole deck is
   for, and it should not blink out because a cursor drifted 12px right. */
@media (hover: hover) {
  .levels--deck .lvl:hover .lvl__art img,
  .levels--deck .lvl:focus-within .lvl__art img {
    transform: translate3d(-50%, 0, 0);
    filter:
      drop-shadow(1.5px 0 0 rgba(255, 255, 255, 0.92))
      drop-shadow(-1.5px 0 0 rgba(255, 255, 255, 0.92))
      drop-shadow(0 1.5px 0 rgba(255, 255, 255, 0.92))
      drop-shadow(0 -1.5px 0 rgba(255, 255, 255, 0.92));
  }
}
.levels--deck .lvl-slot.is-open .lvl__art img,
.levels--deck .lvl-slot:has(.lvl__link:focus-visible) .lvl__art img {
  transform: translate3d(-50%, -30px, 0) scale(1.06) rotate(-4deg);
  filter:
    drop-shadow(1.5px 0 0 rgba(255, 255, 255, 0.92))
    drop-shadow(-1.5px 0 0 rgba(255, 255, 255, 0.92))
    drop-shadow(0 1.5px 0 rgba(255, 255, 255, 0.92))
    drop-shadow(0 -1.5px 0 rgba(255, 255, 255, 0.92))
    drop-shadow(0 16px 20px rgba(14, 44, 56, 0.3));
}

/* ---------------------------------------------- THE VEIL IS GONE, ON PURPOSE
   There used to be an atmospheric wash here: closed cards took up to 34% of the
   section's own ground, so the open card read as being in front of them rather
   than merely larger than them. The theory was sound — that IS how distance is
   read — and the practice was not, for a reason no amount of tuning fixes.

   The effect could only be seen by the one thing that also triggered it. At rest
   the deck was clean, and the instant a pointer touched any card, all eleven
   drained toward the ground colour. So what a reader actually experienced was
   not depth arriving; it was the pathway's ten identity colours LEAVING, all at
   once, in response to their own mouse. The single loudest thing this section
   has — a row of eleven saturated heads, which is the programme's whole visual
   argument — was being spent to explain a spatial relationship that four other
   cues were already making.

   Those four are still here and they are all geometry: the open card lifts 20px
   out of the row, the other ten stand back to 0.94, the open card's right edge
   tucks under its neighbour, and its shadow changes from a stack shadow leaning
   left to a lifted one underneath. That is enough. Depth built out of movement
   costs the colour nothing.

   The shadow below picks up the small amount of separation the wash was doing,
   which is the right place for it — a shadow is cast BY the lifted card, so it
   cannot be mistaken for the deck dimming itself. */

/* A deck lies on a table, so every card throws its shadow LEFT onto the one it
   covers. That negative x-offset is the entire reason an overlap reads as an
   overlap instead of as a seam between two flat colours — the stock --shadow-*
   ramp is downward-biased, which is correct for a card in a grid and gives a
   stack nothing at all.

   The open card swaps it for something large, centred and much softer: it is no
   longer lying on the pile, it is above it, and a shadow that still leaned left
   would keep insisting otherwise. */
.levels--deck .lvl {
  box-shadow: -14px 12px 28px -12px rgba(5, 29, 38, 0.4);
}
/* THE SPREAD IS THE WHOLE RULE HERE, and getting it wrong is what turned this
   shadow into a grey plate laid across the deck.

   Unlike almost every other shadow on the page, this one is not cast into the
   section: the open card sits above everything to its left, so what its shadow
   lands on is the white face of the card behind it, and a white card takes an ink
   wash far more visibly than the sky ground does. The previous values were 34px
   down, 68px of blur and only -20px of spread: on a 279 × 448 card that is a
   239 × 408 shadow rectangle, nearly the size of the card itself, so it still had
   a flat near-opaque CORE after blurring. What showed below the card was not a
   falloff, it was the middle of a slab — about 45% ink at the card's own bottom
   edge, running 82px down across the faces of four other cards. Reading it as
   "the background beneath the card" is exactly right: that is what it looked like.

   So the shadow is made much SMALLER than the thing casting it. At -30px of
   spread the source rectangle sits 30px inside the card on every side, and 40px
   of blur pushes it back out only 20 — so it never reaches the left or right
   edges at all and cannot wash the neighbours' faces. Vertically the rectangle
   stops 4px above the card's own bottom edge, which means every pixel that shows
   below the card is blur tail: 18% at the edge, gone by 36px. A pool of light
   under a lifted card rather than a block under a floating one.

   It can afford to be this quiet because the shadow is the fifth cue and not the
   first. The card is already 20px higher than the deck, at full size against a
   deck standing back at 0.94, with its haze lifted, its mascot out of the water
   and its right edge tucked under the next card. */
.levels--deck .lvl-slot.is-open .lvl,
.levels--deck .lvl-slot:has(.lvl__link:focus-visible) .lvl {
  box-shadow:
    /* Contact. Tight and close, and the only layer with any weight to it — a
       lifted object is darkest where it is nearest what it was lifted off. */
    0 6px 12px -8px rgba(5, 29, 38, 0.32),
    /* Ambient. Wide blur, deep negative spread, so there is no core to see. */
    0 26px 40px -30px rgba(5, 29, 38, 0.42);
}

/* Reduced to what a parent actually scans on, and no further. The description
   and the "What they'll learn" disclosure are not deleted — they are still in
   the markup for phones, for no-JS and for crawlers, and the full pathway
   detail was always programs.html's job.

   Nothing else is hidden, and an earlier pass that blanked every closed card to
   a solid level colour was wrong. The reasoning was that a sliver of a real
   card shows a third of an age pill and two letters of a name, and that this
   reads as broken. It does not — it reads as a card underneath another card,
   which is what it is. Solid colour reads as a bar chart. A deck spread across
   a table shows slivers of faces, and each sliver is doing the same job the
   whole card would: the level's ground colour, the edge of its mascot, the
   start of its name. */
.levels--deck .lvl__body > p { display: none; }

/* ------------------------------------------------ Read more, inside the deck
   The disclosure is NOT hidden here any more. It used to be, on the reasoning
   that a spine has no room for a control — which is true of a spine and false of
   the card the reader is actually looking at, and that card is the only one this
   rule ever needed to think about.

   So it fades on the same number the lift and the scale already ride, and the
   frame loop writes that number straight onto this element — it used to arrive
   as an inherited custom property on the slot, which invalidated style for all
   thirty nodes under every card, every frame, to move one of them. Thirty times
   the cost of the transforms it sat beside. See the note in main.js.

   There is no separate transition and no display switch to pop: the control
   arrives at exactly the rate its card opens, and it is one gesture with
   everything else the card does. Closed cards carry it at zero and cannot be
   clicked through, because a sliver that answers a click is a sliver that stole
   one from the card on top of it. */
.levels--deck .lvl__more {
  opacity: 0;
  pointer-events: none;
  /* Above the card-wide navigation overlay (z 4) and above the depth haze
     (z 5) — the summary has to take its own click rather than hand it to the
     anchor that makes the rest of the card go to programs.html. */
  position: relative;
  z-index: 6;
  margin-top: auto;
  /* .levels--deck .lvl__body is align-items:flex-start, which is right for the
     pill, the name and the rule — they should be their own width — and very
     wrong for this. A <details> allowed to shrink-to-fit sizes to its SUMMARY,
     about 90px, and the panel inside then wraps its checklist and its paragraph
     into that column: measured at 932px tall for content that is 250 at the
     card's real width. The card was asked to grow by three times its own
     height. One line, and the panel gets the width it is actually drawn in. */
  align-self: stretch;
}
.levels--deck .lvl-slot.is-open .lvl__more { pointer-events: auto; }
/* A keyboard user reaches the summary before the card it belongs to has
   finished opening — focusin runs the same focus() the pointer does, but the
   card is still easing up from 0, so for a few frames the control that has
   focus would be invisible. Forced to full straight away: the fade is a pointer
   affordance and a tab stop is not a hover.

   The forcing is done in script, not here, and it has to be: opacity is now an
   inline style and no stylesheet rule can outrank one. This rule keeps the half
   a stylesheet can still win — the control has to answer a click once it has
   focus, however faded the card it sits on. */
.levels--deck .lvl__more:focus-within { pointer-events: auto; }

/* The two controls sit together at the foot of the card, under the Swim and
   Survive line rather than either side of it. In the markup the disclosure comes
   before that line — which is right for the grid, where the framework band is a
   footnote under an already-open card — and in the deck it left "Read more"
   stranded in the middle of the body with a credibility line beneath it, so the
   card read identity, action, fact, action.

   `order` rather than a second copy of the markup. Neither the framework line
   nor the disclosure's position changes what is focusable, and the two things
   that ARE focusable — the summary and the link — keep their relative order, so
   tab order still follows what the eye does. */
.levels--deck .lvl__more { order: 1; }
.levels--deck .lvl__link { order: 2; }

/* THE CARD GROWS DOWNWARD, AND THE ROW DOES NOT MOVE.

   This is the only direction available and it is worth saying why, because the
   obvious answer — make the card wider — cannot work here. The deck solves
   every card's width so the painted row comes to exactly the container at every
   pointer position (see relayout() in main.js); widening one card either pushes
   the last card past the container's right edge or forces every sliver to
   re-solve mid-gesture, and both are a lurch at the moment a reader has just
   committed to reading something.

   Growing down costs the row nothing. The slot keeps its width, its x and its
   place in the fan; only its height changes, and the deck hands itself the same
   number as bottom margin so the section below is pushed rather than covered.
   Two properties, one number, no reflow of anything horizontal.

   --exp is written by script (the measured panel height) and both transitions
   are declared here rather than tweened, because this is a two-value A-to-B move
   that CSS eases perfectly well — and because the panel's own height IS being
   tweened by GSAP, and the card must never be the shorter of the two mid-flight
   or the content spills out of the white. Matching duration and curve on both is
   what keeps them locked together. */
.levels--deck { margin-bottom: var(--exp-room, 0px); }
.levels--deck,
.levels--deck .lvl-slot {
  transition: height 0.5s var(--ease-out), margin-bottom 0.5s var(--ease-out);
}
.levels--deck .lvl-slot.is-expanded { height: calc(100% + var(--exp, 0px)); }

/* An expanded card is being read, not riffled, so it stops behaving like a card
   in a stack and takes a shadow that says "this one is out". */
.levels--deck .lvl-slot.is-expanded .lvl {
  box-shadow:
    0 8px 16px -10px rgba(5, 29, 38, 0.34),
    0 34px 52px -34px rgba(5, 29, 38, 0.46);
}

/* Except on the cards that would otherwise be empty. Most levels carry two
   lines the deck keeps — the stage names and the Swim and Survive band — and
   the three baby levels carry neither, so hiding their sentence as well left
   them with a pill, a name and 175px of nothing above the link. Whitespace is
   generous when it is holding something apart; there it was just the shape of
   missing content.

   The test is what the card actually has, not what class it wears. No Limits is
   also .lvl--plain but carries BOTH lines, and matching on the class alone gave
   it a fourth block it had no room for — its link overflowed 36px below the
   card and sat on the section background.

   Clamped rather than trusted either way: the deck is a fixed height and a
   longer sentence would push the link off the bottom, so the sentence gets the
   room that exists and no more.

   Four lines is the CEILING, not a reservation — min-height:0 is what makes it
   one. A flex item defaults to min-height:auto, which means it refuses to shrink
   below its own content, and four clamped lines plus a pill plus a name is
   exactly the height of the card with nothing to spare. So the moment anything
   above the sentence takes a line it did not take before, the overflow comes out
   of the bottom of the card instead: the link slid 17px down its card and sat
   out of line with the other nine.

   The trigger is the age pill. "12–30 months · with a parent" is the longest of
   the three strings and the deck's cards narrow as the row grows, so Aquatots
   wraps to two lines while its neighbours are still on one — a misalignment that
   appears at some widths and not others, on one card, which is why it read as a
   fault in that card rather than in this rule.

   min-height:0 lets the flex algorithm take the line back from the sentence,
   which is already overflow:hidden and is the one thing on the card that can
   lose a line without losing meaning. The link stays where the other nine are. */
.levels--deck .lvl--plain:not(:has(.lvl__stages)):not(:has(.lvl__framework)) .lvl__body > p {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 4;
  overflow: hidden;
  min-height: 0;
  margin-bottom: 0;
}

/* min-height:0 for the same reason the sentence above needs it, one level up:
   the body is `flex: 1 1 0%` inside a card of fixed height, so it is ALLOTTED
   the space under the art panel — but min-height:auto let it ignore that and
   grow to its content instead, which is how a wrapped age pill reached the
   bottom of the card at all. Held to its allotment, the body passes the
   shortfall down to the only child that can absorb it. */
.levels--deck .lvl__body {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  min-height: 0;
}
/* Promoted out of the <details> by script. The pseudo-element is what makes the
   whole card clickable while leaving exactly one link in the accessibility
   tree — a second overlapping anchor would announce the same destination
   twice. */
/* Was margin-top:auto. It is no longer the last thing in the body — the Read
   more disclosure now sits above it and owns the auto margin, and TWO auto
   margins in one flex column split the free space between them, which opened a
   gap above the control and another below it. One element pushes; the rest
   follow it. */
.levels--deck .lvl__link {
  margin-top: 0.55rem;
  position: static;
}
.levels--deck .lvl__link::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 4;
  border-radius: var(--r-lg);
}

/* Below the breakpoint the deck does not exist, and the wrapper has to become
   invisible to layout — the phone rail is the original design and gets it back
   untouched. The !important is deliberate and narrow: the transform and clip are
   INLINE styles written by a disabled enhancement, and a stylesheet cannot
   otherwise outrank them. Without it a queued animation frame landing after
   disable() leaves the rail shifted 12px left with most of every card clipped
   away — which is a phone-only bug that never appears while you are testing on
   a desktop. */
@media (max-width: 979px) {
  .lvl-slot {
    position: static;
    height: auto;
    /* Released with everything else. The hint is for the deck's per-frame
       transform, and below 980px there is no deck — leaving it set kept eleven
       compositor layers alive on phones for a wrapper that never moves. */
    will-change: auto;
    /* Both are written INLINE by the enhancement — the width by the layout
       solver, the transform by the animation frame — so a stylesheet cannot
       otherwise outrank them. Without this a queued frame landing after
       disable() leaves the rail shifted left and every card the wrong width,
       which is a phone-only bug that never appears while you are testing on a
       desktop. */
    width: auto !important;
    transform: none !important;
  }
}

/* ==========================================================================
   18. GUIDES
   Long-form editorial: the article page, the index, and the featured strip
   that sits on the floor of the closing section on the home page.

   Everything here is generated by scripts/guide-tool/build_guides.py from
   templates in guides/_templates/. If a rule below looks like it has no
   markup, the markup is in a template rather than in a .html file.
   ========================================================================== */

/* ------------------------------------------------------ 18.1 The row unit */
/* One component, three grounds: the dark strip on the home page, the pale
   index, and the "keep reading" block at the foot of an article. Rather than
   three near-copies it carries its own tokens and each ground redefines two
   of them. That is the whole colour switch â€” nothing else moves.

   It is deliberately NOT a card. The home page arrangement is one lead plus
   two supporting items, and the hierarchy only survives if the supporting two
   have no panel, no shadow and no radius to compete with. Type on a hairline
   reads as subordinate; a card reads as an equal. */
.grow {
  --grow-ink: var(--ink);
  --grow-soft: var(--ink-faint);
  --grow-rule: rgba(5, 29, 38, 0.14);

  display: grid;
  grid-template-columns: 72px 1fr 22px;
  align-items: center;
  gap: var(--space-sm);
  padding-block: var(--space-sm);
  border-top: 1px solid var(--grow-rule);
  color: var(--grow-ink);
  text-decoration: none;
}
.grow:last-child { border-bottom: 1px solid var(--grow-rule); }

.grow__thumb {
  width: 72px;
  height: 72px;
  border-radius: 14px;
  overflow: hidden;
  background: var(--pool-200);
}
.grow__thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  /* The thumbnail is the only object in the row, so it carries the entire
     hover gesture. Scaling the image inside a fixed frame moves nothing in
     layout â€” the row cannot reflow and the type cannot shift. */
  transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.grow:hover .grow__thumb img,
.grow:focus-visible .grow__thumb img { transform: scale(1.07); }

.grow__text { display: flex; flex-direction: column; gap: 0.18rem; min-width: 0; }
.grow__cat {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.72rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--grow-soft);
}
.grow__title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.06rem;
  line-height: 1.28;
  color: var(--grow-ink);
  text-wrap: balance;
}
.grow__meta { font-size: 0.85rem; color: var(--grow-soft); }

.grow__arrow {
  width: 22px;
  height: 22px;
  color: var(--grow-soft);
  transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1), color 0.32s ease;
}
.grow:hover .grow__arrow,
.grow:focus-visible .grow__arrow { transform: translateX(4px); color: var(--grow-ink); }

.grow:focus-visible {
  outline: 3px solid var(--teal);
  outline-offset: 4px;
  border-radius: 8px;
}

/* ----------------------------------------------------- 18.2 The lead unit */
/* The only item in the strip that gets a photograph at size and the only one
   that shows its excerpt. Those two privileges are what make it read as the
   lead rather than as the first of three equals. Its frame is 3:2 while the
   row thumbnails are square â€” two different shapes read as two tiers, whereas
   one shape at two sizes reads as an accident. */
.glead {
  display: grid;
  gap: var(--space-sm);
  color: inherit;
  text-decoration: none;
}
.glead__frame {
  display: block;
  overflow: hidden;
  border-radius: var(--r-md);
  aspect-ratio: 3 / 2;
  background: var(--water-mid);
}
.glead__frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.glead:hover .glead__frame img,
.glead:focus-visible .glead__frame img { transform: scale(1.04); }

.glead__text { display: flex; flex-direction: column; gap: 0.4rem; }
.glead__cat {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.75rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}
.glead__title {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(1.45rem, 2.6vw, 1.95rem);
  line-height: 1.14;
  letter-spacing: -0.01em;
  text-wrap: balance;
}
.glead__excerpt { font-size: 1.02rem; line-height: 1.6; }
.glead__meta { font-size: 0.85rem; }
.glead:focus-visible { outline: 3px solid var(--teal); outline-offset: 6px; border-radius: 8px; }

/* --------------------------------------------- 18.3 The home page strip */
/* Lives INSIDE .deep rather than as its own section, and the reason is the
   ladder at the base of the page: --trench 35.5% L, --trench-deep 28.5%,
   --abyss 21%, three even steps down one hue column. A new band between the
   closing CTA and the footer forces a fourth step and halves both bottom
   intervals, flattening the descent exactly where the page is meant to land.
   Riding the tail of the existing gradient costs no token and breaks no rule.

   It stays typographic. White panels down here would compete directly with
   the CTA white buttons a few hundred pixels above, and the orange is
   reserved for the one action that earns money. The photographs are the only
   bright objects, so the guides read as the quieter thing they are. */
.pooldeck {
  position: relative;
  z-index: 3;
  margin-top: var(--space-2xl);
  padding-top: var(--space-xl);
  /* The one hairline on the page that has to separate two things sharing a
     ground. At 0.16 over --trench-deep it composites to a line you can see
     without it reading as a border. */
  border-top: 1px solid rgba(255, 255, 255, 0.16);
  color: var(--white);
}
.pooldeck__head { margin-bottom: var(--space-lg); }
.pooldeck__head h2 { color: var(--white); margin-bottom: 0; }

/* --pool-200 rather than --pool-300: the eyebrow is small text, and 300 lands
   at 3.9:1 on this ground where 200 clears 5.2:1. */
.eyebrow--onwater { color: var(--pool-200); }

.pooldeck__grid { display: grid; gap: var(--space-lg); }
@media (min-width: 900px) {
  /* 1.15fr rather than 1fr: the lead carries a 3:2 photograph and the rows
     carry type, so equal columns leave the right-hand stack looking starved.
     The asymmetry is the point of the arrangement â€” it should be visible. */
  .pooldeck__grid { grid-template-columns: 1.15fr 1fr; gap: var(--space-xl); align-items: start; }
}

.pooldeck .glead { color: var(--white); }
.pooldeck .glead__cat { color: var(--pool-200); }
.pooldeck .glead__excerpt,
.pooldeck .glead__meta { color: rgba(255, 255, 255, 0.82); }

/* The two-token switch promised at .grow. Both tones clear 4.5:1 over
   --trench-deep once composited. */
.pooldeck .grow {
  --grow-ink: var(--white);
  --grow-soft: rgba(255, 255, 255, 0.8);
  --grow-rule: rgba(255, 255, 255, 0.18);
}
.pooldeck .grow__thumb { background: rgba(255, 255, 255, 0.12); }
.pooldeck .grow:focus-visible,
.pooldeck .glead:focus-visible { outline-color: var(--pool-200); }

.pooldeck__cta { margin-top: var(--space-md); }

/* The positioning context the closing section's mascots and bubbles resolve
   against. .deep__school is inset:0 and two of its four fish are anchored from
   the BOTTOM, so once the guides strip doubled the height of .deep those two
   percentages started resolving against a box twice the size and both fish
   surfaced in the middle of the guides block. Scoping them to the part of the
   section they decorate is the fix; see the note in index.html. */
.deep__top { position: relative; }

/* -------------------------------------------------- 18.4 The article page */
/* An editorial masthead rather than a hero: no photograph behind the type, no
   gradient, no mascots. A guide headline has to be readable at a glance and
   every one of those would be decoration competing with it. The photograph
   gets its own frame directly below at full measure, where it can actually be
   looked at rather than squinted through. */
.guide-head {
  background: var(--pool-200);
  padding-block: calc(var(--nav-h) + var(--space-lg)) var(--space-lg);
}
/* Capped below the site's h1 clamp. The global scale tops out at 65px, which
   is hero scale — right for a page whose headline IS the page, wrong for an
   article title that has a standfirst, a byline and a photograph queued
   behind it. An editorial masthead should introduce, not announce. */
.guide-head h1 {
  font-size: clamp(1.95rem, 4.2vw, 3.05rem);
  text-wrap: balance;
  margin-bottom: var(--space-sm);
}
/* The standfirst is set larger than body copy, so the container's full width
   runs it well past a comfortable line. Capped at roughly the same character
   count as the prose below rather than the same pixel width. */
.guide-head .lead { max-width: 42rem; margin-bottom: var(--space-md); }
.guide-head--hub { padding-bottom: var(--space-xl); }

/* align-items:baseline, and it is load-bearing. The links carry
   padding-block:0.3rem so they clear a 40px touch target on a phone; the "/"
   separator is a bare span with none. Under the flex default (stretch) all
   three boxes matched height, but the slash's glyph sat at the top of its box
   while the link text sat 4.8px down inside its padding — so the separator
   floated visibly above the words it separates. Baseline aligns the type and
   leaves the tap targets alone. */
.crumbs {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5rem;
  font-size: 0.86rem;
  margin-bottom: var(--space-sm);
  color: var(--ink-soft);
}
.crumbs a { color: var(--ink-soft); text-decoration: none; padding-block: 0.3rem; }
.crumbs a:hover { color: var(--teal-ink); text-decoration: underline; }

.guide-byline {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.35rem 0.8rem;
  margin: 0;
  padding-top: var(--space-sm);
  border-top: 1px solid rgba(5, 29, 38, 0.14);
  font-size: 0.9rem;
  color: var(--ink-soft);
}
.guide-byline__who { font-family: var(--font-display); font-weight: 700; color: var(--ink); }
.guide-byline__role::before,
.guide-byline__when::before { content: "\00b7  "; }

/* Pulled up into the masthead band so the photograph straddles the colour
   change. A full-width image that begins exactly where a band ends reads as
   two stacked rectangles; overlapping it makes one object out of the pair. */
.guide-hero {
  margin-top: calc(var(--space-lg) * -1);
  margin-bottom: var(--space-lg);
  position: relative;
  z-index: 1;
}
.guide-hero img {
  width: 100%;
  height: auto;
  display: block;
  border-radius: var(--r-md);
  box-shadow: 0 24px 60px -28px rgba(5, 29, 38, 0.45);
}

/* Measure, capped. Long-form is the one place on this site where line length
   is the whole readability story, and .container--narrow at 820px runs past
   what prose can carry. Figures are exempt, they want the full container.

   In rem, not ch — and left-aligned, not centred. Both started out the other
   way and both were wrong:

   1. ch is the width of a ZERO in the ELEMENT'S OWN font size. At 60ch a
      paragraph capped at 628px while an h2 two-thirds larger capped at about
      1000px, which is past the container and therefore no cap at all. The
      headings sat at the container edge and the paragraphs sat inset from it,
      so the column had two left edges. A measure is one distance, not one
      number applied to several sizes.
   2. margin-inline:auto then centred each element inside the container, so the
      prose no longer shared a left edge with the photograph above it. Left
      aligned, everything on the page starts at the same x and only the right
      edge varies, which is what an article column should do.

   39rem is 624px, which measures at about 72 characters on this body size. */
.guide-body { padding-top: 0; }
.guide-body p,
.guide-body h2,
.guide-body h3,
.guide-body h4,
.guide-body .guide__list,
.guide-body .guide__quote,
.guide-body .guide__rule,
.guide-body .guide-updated { max-width: 39rem; }

.guide-body p { font-size: 1.09rem; line-height: 1.72; }
.guide-body h2 { margin-top: var(--space-lg); font-size: clamp(1.4rem, 2.4vw, 1.8rem); }
.guide-body h3 { margin-top: var(--space-md); font-size: 1.2rem; }
.guide-body > .container > *:first-child { margin-top: 0; }

.guide__list { padding-left: 1.2rem; margin-bottom: var(--space-sm); }
.guide__list li { margin-bottom: 0.5rem; line-height: 1.65; }

/* A rule on the left rather than a panel. A tinted box here would read as a
   callout component; a hairline and a size change reads as a voice change,
   which is what a pull quote actually is. */
.guide__quote {
  margin: var(--space-md) auto;
  padding: 0.2rem 0 0.2rem var(--space-sm);
  border-left: 3px solid var(--teal);
}
.guide__quote p {
  font-family: var(--font-display);
  font-size: 1.2rem;
  line-height: 1.5;
  color: var(--ink);
  margin: 0;
}

.guide__figure { margin: var(--space-lg) 0; }
.guide__figure img { width: 100%; height: auto; display: block; border-radius: var(--r-md); }

/* margin-inline:0 is doing real work here. The UA stylesheet ships
   `hr { margin-inline: auto }`, and `margin-block` alone leaves it standing —
   so inside a 39rem cap in an 820px column the rule centred itself and sat
   about 57px right of every other left edge on the page. Third component
   caught by the same thing; see the note at 18.10. */
.guide__rule {
  border: 0;
  border-top: 1px solid rgba(5, 29, 38, 0.14);
  margin-block: var(--space-lg);
  margin-inline: 0;
}

/* The refresh date, stated. Evergreen guides get updated rather than
   duplicated, and this is the line that makes that visible to a reader â€” and
   dateModified in the schema makes it visible to Google. */
.guide-updated {
  margin-top: var(--space-md);
  padding-top: var(--space-sm);
  border-top: 1px solid rgba(5, 29, 38, 0.14);
  font-size: 0.88rem;
  color: var(--ink-faint);
}

/* ----------------------------------------------------- 18.5 Author block */
.guide-author-band { padding-block: var(--space-lg); }
.guide-author { display: grid; gap: var(--space-sm); align-items: center; }
@media (min-width: 700px) {
  .guide-author { grid-template-columns: 120px 1fr; gap: var(--space-md); }
}
.guide-author h2 { font-size: 1.35rem; margin-bottom: 0.35rem; }
.guide-author p { margin-bottom: var(--space-sm); }
.guide-author__mark img { width: 120px; height: auto; display: block; }

/* ------------------------------------------------------ 18.6 Keep reading */
.guide-more__list { margin-block: var(--space-sm) var(--space-md); }
.guide-more h2 { font-size: 1.5rem; }

/* When there is only one guide on the site, the rows between the markers are
   written out empty and the section is left promising more reading with none
   underneath it — a heading, a gap, and a button back to a page listing the
   article you are already on. The tool cannot hide the section itself: it is
   only allowed to write between GUIDE:RELATED:START and :END, and everything
   outside those two comments belongs to whoever edited the page. So the
   stylesheet decides, off the one thing that is actually true — are there any
   links in the list — and the section comes back on its own the moment a
   second article is published. Nothing to remember, nothing to undo.

   The second rule is the part that is easy to miss. .deep draws the water above
   its curve in whatever colour the band above it is, and hiding a section does
   not stop it matching a sibling selector — so without this the closing wave
   would keep taking its fill from a section nobody can see, and meet the author
   band in the dead straight rule the whole arrangement exists to avoid. Hidden
   here, the band above the curve is .guide-author-band, which is pool-200. */
.guide-more:not(:has(.guide-more__list a)) { display: none; }
.guide-more:not(:has(.guide-more__list a)) + .deep { --deep-above: var(--pool-200); }

/* ----------------------------------------------------------- 18.7 The hub */
.guide-index__count {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.78rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin-bottom: 0;
}

/* --------------------------------------------------- 18.8 Compact closing */
/* The guide pages carry the same closing block as the home page but without
   its mascot school or its accreditation plate â€” both belong to the home page
   argument, and repeating them at the foot of every article would make them
   furniture. The gradient and the surface stay, because they are what joins
   the block to the footer. */
.deep--compact { padding-block: calc(var(--space-xl) + var(--wave-h)) var(--space-xl); }

/* --------------------------------------------------- 18.9 Guide FAQ block */
/* Questions at the foot of an article, on the shallow band so the block reads
   as a change of register rather than more article.

   Closed by default, and that is a content decision rather than a space one.
   An open list of eight answers is a wall the reader scans past; eight
   questions is a list they read, and the one they open is the one they came
   for. It also means the questions themselves â€” the part written to match how
   a parent actually phrases it â€” are what the eye lands on.

   Built on <details>, so it works with JavaScript off, is keyboard operable
   for free, and is searchable in-page by the browser's own find. A hand-rolled
   accordion here would be three of those things at best. */
.guide-faq { padding-block: 0 var(--space-xl); }
.guide-faq h2 { font-size: 1.5rem; margin-bottom: var(--space-md); }

/* Deliberately quieter than the questions above it and without a rule of its
   own. The last .faq__item already closes the list with a border, so this
   reads as a footnote to the set rather than a fourth question. */
.guide-faq__more {
  margin-top: var(--space-sm);
  font-size: 0.95rem;
  color: var(--ink-soft);
}

/* The rule and the space above it are what separate this from the article,
   in place of a change of background. Cheaper than a band and it keeps the
   questions where they belong — attached to the piece they answer. */
.faq { border-top: 1px solid rgba(5, 29, 38, 0.14); }

.faq__item {
  border-bottom: 1px solid rgba(5, 29, 38, 0.14);
}

/* The whole row is the target, not just the words â€” a question is a short
   string and a 40px hit area on a phone is the difference between a component
   that works and one that gets poked twice. */
.faq__item summary {
  display: flex;
  align-items: flex-start;
  gap: var(--space-sm);
  padding-block: 0.95rem;
  cursor: pointer;
  list-style: none;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.04rem;
  line-height: 1.4;
  color: var(--ink);
  transition: color 0.18s ease;
}
.faq__item summary::-webkit-details-marker { display: none; }
.faq__item summary:hover { color: var(--teal-ink); }
.faq__item summary:focus-visible {
  outline: 3px solid var(--teal);
  outline-offset: 3px;
  border-radius: var(--r-sm);
}

/* A plus drawn in CSS rather than an icon file: two gradients, one bar each.
   Opening collapses the upright to nothing, so the plus becomes a minus.

   Rotating the whole glyph would have been fewer lines and was wrong — a plus
   turned 45 or 135 degrees is an X, and an X in the corner of a panel means
   dismiss, not collapse. The affordance has to say what the click actually
   does. Only the upright is animated, so the crossbar never moves and the
   change reads as one bar retracting rather than the icon spinning. */
.faq__item summary::after {
  content: "";
  flex: none;
  margin-left: auto;
  margin-top: 0.34rem;
  width: 13px;
  height: 13px;
  background:
    linear-gradient(currentColor, currentColor) center/100% 2px no-repeat,
    linear-gradient(currentColor, currentColor) center/2px 100% no-repeat;
  color: var(--teal-ink);
  transition: background-size 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}
.faq__item[open] summary::after { background-size: 100% 2px, 2px 0; }

.faq__answer { padding-bottom: 1.05rem; padding-right: 2rem; }
.faq__answer p { margin-bottom: 0.7rem; }
.faq__answer p:last-child { margin-bottom: 0; }
.faq__answer a { color: var(--teal-ink); }

/* Opening is the only place this component moves. The panel arrives rather
   than appearing, which is the difference between a page that responds and a
   page that redraws â€” and it is short enough that a second click never has to
   wait for it. */
@media (prefers-reduced-motion: no-preference) {
  .faq__item[open] .faq__answer {
    animation: faq-open 0.26s cubic-bezier(0.22, 1, 0.36, 1);
  }
}
@keyframes faq-open {
  from { opacity: 0; transform: translateY(-5px); }
  to { opacity: 1; transform: translateY(0); }
}

/* ============================================== 18.10 Article furniture ===
   Everything above gives a guide a masthead, a measure and a tail. What it
   did not give it was a MIDDLE. A 1,100-word article built from nothing but
   <h2> and <p> has exactly two type sizes below the standfirst, so a reader
   skimming has nothing to land on between one heading and the next — the
   page is readable and completely flat, which is the failure mode the
   .figures block on the inner pages was written to fix.

   These four are the missing tiers, in order of loudness:
     .guide__statement  one line, the page's largest type after the h1
     .ladder            a ruled table of ages that reads as a table
     .ask               a numbered list of questions to put to a swim school
     .guide__figure     a photograph wider than the prose it interrupts
   ========================================================================= */

/* -------------------------------------------------------- The statement
   The answer to the headline's question, given the size that answer is worth.
   .pull-statement already does this on the inner pages, and is centred — right
   there, wrong here. Section 18.4 spends a paragraph explaining why the article
   column is left-aligned rather than centred (one left edge, shared with the
   photograph above it) and a centred line here would put a second one back.
   Same idea, same weight, left-aligned.

   Capped in ch rather than rem because the whole effect is the sentence
   breaking into two balanced lines, and a px cap re-breaks it badly the moment
   the copy changes. Specificity beats `.guide-body p` without !important. */
.guide-body .guide__statement {
  max-width: 22ch;
  margin: var(--space-md) 0 var(--space-lg);
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(1.9rem, 1.2rem + 2.6vw, 2.7rem);
  line-height: 1.1;
  letter-spacing: -0.02em;
  text-wrap: balance;
  color: var(--ink);
}

/* ------------------------------------------------------------ The ladder
   Five age bands, previously five paragraphs each opening with a bolded
   phrase. That is a table wearing prose: the reader is scanning for one row —
   theirs — and a paragraph makes them read four they don't want to find it.

   Hairlines rather than a panel or zebra fill. The rows are a sequence, and a
   sequence wants separators; a tinted box would make it a callout, which is a
   different thing that means "aside". The rule colour is the same 14% ink the
   byline, the article rule and the FAQ use, so it joins the page's existing
   set of hairlines rather than introducing a sixth one.

   The tag line under each age is the third dimension that makes the block
   worth building: "parent in the water" is the single fact a parent is
   actually shopping on, and in the paragraph version it was buried mid-
   sentence in row three. --teal-ink, never a level hue — none of these rows
   names a level, and the house rule holds. */
.guide-body .ladder {
  max-width: 39rem;
  margin: var(--space-md) 0 var(--space-lg);
  padding: 0;
  list-style: none;
  border-top: 1px solid rgba(5, 29, 38, 0.14);
}
.ladder__item {
  display: grid;
  gap: 0.45rem;
  padding-block: var(--space-sm);
  border-bottom: 1px solid rgba(5, 29, 38, 0.14);
}
/* Two columns only once there is room for the age to sit on one or two lines
   without hyphenating. Below that the label stacks above its own prose, which
   is the correct mobile shape for a definition list and not a squeezed table. */
@media (min-width: 620px) {
  .ladder__item {
    grid-template-columns: 11rem 1fr;
    gap: var(--space-sm);
    align-items: baseline;
  }
}
.ladder__age {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1.06rem;
  line-height: 1.25;
  letter-spacing: -0.01em;
  color: var(--ink);
}
.ladder__tag {
  display: block;
  margin-top: 0.35rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.72rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--teal-ink);
}
/* Set below body size on purpose. The ladder is reference material read in
   fragments, and a denser setting is what tells the eye it may be scanned
   rather than read straight through. */
.guide-body .ladder__note {
  max-width: none;
  margin: 0;
  font-size: 1rem;
  line-height: 1.6;
  color: var(--ink-soft);
}
.guide-body .ladder__note a { color: var(--teal-ink); }

/* --------------------------------------------------------- The questions
   The "what to look for" section had the same problem as the ages and needed a
   different answer, or the article would carry two identical-looking blocks
   400 words apart and the second would read as a mistake.

   So: an <ol>, because these are things to do in a sensible order, numbered in
   decimal-leading-zero. 01 / 02 / 03 is the one numeral style that reads as
   editorial rather than as a form field, and it holds its column width all the
   way to ten without the text reflowing. */
.guide-body .ask {
  counter-reset: ask;
  max-width: 39rem;
  margin: var(--space-md) 0 var(--space-lg);
  padding: 0;
  list-style: none;
}
.ask__item {
  position: relative;
  padding-left: 2.9rem;
  margin-bottom: var(--space-sm);
}
.ask__item:last-child { margin-bottom: 0; }
.ask__item::before {
  counter-increment: ask;
  content: counter(ask, decimal-leading-zero);
  position: absolute;
  left: 0;
  top: 0.12em;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: 1rem;
  letter-spacing: 0.02em;
  color: var(--teal-ink);
}
.ask__q {
  display: block;
  margin-bottom: 0.3rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.08rem;
  line-height: 1.4;
  color: var(--ink);
}
.guide-body .ask__item p { max-width: none; margin-bottom: 0; }

/* ------------------------------------------------------------ The figures
   Deliberately outside the 39rem measure — an inline photograph is allowed the
   full 820px column, so it overhangs the prose on the right and the article
   visibly breathes at three points instead of running 1,100 words unbroken.

   16:9 against the masthead photograph's 3:2, and a shadow at roughly half its
   depth. Same reasoning as the square thumbnails in "keep reading": two
   different shapes read as two tiers, the same shape at two sizes reads as an
   accident. The masthead is still the lead image; these are interruptions. */
.guide__figure figcaption {
  max-width: 39rem;
  margin-top: 0.75rem;
  padding-left: 0.85rem;
  border-left: 3px solid var(--pool-300);
  font-size: 0.9rem;
  line-height: 1.55;
  color: var(--ink-soft);
}
.guide__figure img { box-shadow: 0 18px 44px -30px rgba(5, 29, 38, 0.55); }

/* ------------------------------------------------- The note and the quote
   Both of these centre themselves — .note--flat via margin-inline:auto, the
   pull quote via `margin: space-md auto` — and inside a 39rem cap in an 820px
   column that puts each one about 17px to the right of everything else on the
   page. Section 18.4 argues at length that the article has ONE left edge; two
   components were quietly opting out of it. Pulled back onto the column, with
   the quote's rule now sitting exactly on that edge rather than near it. */
.guide-body .note {
  max-width: 39rem;
  margin-inline: 0;
  margin-block: var(--space-md);
}
.guide-body .guide__quote { margin-inline: 0; }

/* ==========================================================================
   19. THE GATE — the contact form's pre-step

   The Smart Form answers "tell us about your child". It does not answer "I
   already know what I want, let me book", and until now the site had no
   honest reply to that: every "Book now" button on every page landed on a
   form whose own note says, correctly, that sending it books nothing. The
   iClassPro portal — the actual booking system — was linked from nowhere.

   So the card opens on a fork rather than on question one. Two halves, top
   and bottom, filling the whole panel.

   WHY TOP AND BOTTOM, NOT LEFT AND RIGHT. Three reasons, in order of weight.
   First, hierarchy: side-by-side halves are equal weight and read as an
   invitation to compare, which is the same trap the .reach plate was built to
   avoid. Stacked, the enquiry sits first and reads as the way in, with the
   booking route deliberately second. Second, line length: in the two-column
   split this card is ~520px wide, so half of that is a 240px measure for a
   paragraph that has to explain what iClassPro is. Third, it is one design at
   every width — the phone gets the same object as the desktop rather than a
   collapsed version of it.

   THE GROUND IS ONE STEP DEEPER THAN THE MASTHEAD, and that is a deliberate
   departure worth writing down. --trench sits at relative luminance 0.170,
   thirteen thousandths below the 0.183 ceiling where white text stops
   clearing 4.5:1. There is no headroom on it at all: a caustic at 6% white
   drops white type to 4.13:1, and a second ink tier is impossible before you
   start. This panel carries four tiers of voice — kicker, title, paragraph,
   cue — and wants light moving over it. --trench-deep (0.110) and --abyss
   (0.058) are the same hue and the same saturation two and three steps down
   the ladder the footer already uses, and they buy back both. The gate's top
   band resolves to --trench at the end of the opening move, so the strip that
   survives IS the masthead.

   Light is capped, not eyeballed. The cursor caustic peaks at 9% --water-lit
   on the upper half, which lands the ground at 0.156: white 5.10:1, and
   --pool-100 body copy 4.65:1. Both clear with room. The lower half takes 13%
   because --abyss can afford it.
   ========================================================================== */

/* The stage is everything the form already was — masthead and body — wrapped
   so the gate can sit in front of it. Hidden while the gate is shut.

   The `.no-js` override is the same contract the level filter and the FAQ
   accordion use: a visitor with scripting off never sees a gate they could not
   open, and lands on the form exactly as before. Defaulting the OTHER way
   would hide the form from them on the site's only conversion path.

   `.has-js` is what makes that contract free. Everywhere else on the site the
   `no-js` class is cleared by main.js, which is deferred — it runs after the
   document has parsed and therefore after the first paint, so for one frame
   the gate's fallback was true and the form was painted before being pulled
   back under the gate. A fallback that is only wrong for 200ms is still wrong
   for 200ms, and it is the first thing a parent sees on this page.

   So contact.html sets `.has-js` from a blocking inline script in the head,
   before a pixel is drawn, and the fallback below asks for both: no-js and no
   inline script. Only the gate uses it. Clearing `no-js` outright that early
   would be the tidier-looking change and is the wrong one — every
   `[data-reveal]` on the site starts at opacity 0 and relies on `no-js`
   surviving if main.js never arrives. */
/* The gate is pinned over the card for the length of the opening move, so the
   card has to be the containing block. Scoped to [data-gate] rather than put
   on .finder, because the class finder shares that component and has no gate
   to contain. */
.finder[data-gate] { position: relative; }

.finder[data-gate="shut"] .finder__stage { display: none; }
.no-js:not(.has-js) .finder[data-gate="shut"] .finder__stage { display: block; }
.no-js:not(.has-js) .gate { display: none; }
.finder[data-gate="open"] .gate { display: none; }

.gate {
  display: flex;
  flex-direction: column;
  background: var(--trench-deep);
  /* The halves paint blend and clip layers of their own; isolating here keeps
     them off the card's shadow and off the section behind it. */
  isolation: isolate;
}

.gate__half {
  position: relative;
  display: flex;
  align-items: center;
  width: 100%;
  min-height: 11.5rem;
  margin: 0;
  padding: var(--space-md);
  border: 0;
  background: var(--trench-deep);
  color: var(--white);
  font: inherit;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  overflow: hidden;
  /* Peak alpha of the cursor caustic on this half. See the contrast maths in
     the section header — these two numbers are the reason the copy stays
     legible with a light source moving under it. */
  --gate-glow: 0.09;
  --gate-veil: 0.42;
}
@media (min-width: 720px) {
  .gate__half { min-height: 13.5rem; padding: var(--space-lg); }
}
.gate__half--book {
  background: var(--abyss);
  --gate-glow: 0.13;
  --gate-veil: 0.55;
}

/* The waterline. A hairline between the two halves, and the one place on the
   panel where light is allowed to be bright — no type sits on it, so the
   streak can run at full --water-lit without costing a contrast ratio. It
   tracks the cursor along whichever half is being pointed at, which is the
   only cursor-reactive thing on the whole site and is why it earns being here
   rather than a hover shadow. */
.gate__line {
  position: relative;
  flex: none;
  height: 1px;
  background: rgba(255, 255, 255, 0.16);
}
.gate__line i {
  position: absolute;
  top: -1px;
  left: 0;
  height: 3px;
  border-radius: var(--r-pill);
  opacity: 0;
  will-change: transform, opacity;
}
.gate__line [data-sf-streak] {
  width: 190px;
  margin-left: -95px;
  background: linear-gradient(90deg, transparent, var(--water-lit) 45%, var(--white) 50%, var(--water-lit) 55%, transparent);
}
/* The hold before iClassPro opens, drawn as the waterline filling. Same
   gradient as .finder__bar i, which is the point: the enquiry branch measures
   its progress with a line of light across the masthead, and the booking
   branch measures its one-second wait with the same line in the same colour.
   One component, one way of saying "this is moving".

   scaleX from the left rather than an animated width — a meter is the one
   place a transform is unambiguously right, and it composites. */
.gate__line [data-sf-fill] {
  width: 100%;
  transform: scaleX(0);
  transform-origin: 0 50%;
  background: linear-gradient(90deg, var(--water-lit), var(--white));
}

/* Deep water rather than lit water. The obvious move on a dark panel is an
   ambient caustic drifting across it, and on this ground that move is simply
   unavailable — anything that raises luminance under body copy fails AA (see
   the header). These ripples go the other way and DARKEN, which can only
   improve the type, and they read as depth under the surface instead of light
   on it. Same four-waypoint drift as the hero, at half the amplitude and twice
   the period, so it never resolves into one blob sliding on a timer. */
.gate__deep {
  position: absolute;
  inset: -22%;
  pointer-events: none;
  background:
    radial-gradient(closest-side, rgba(3, 20, 26, 0.6), transparent 74%) 24% 28% / 54% 62% no-repeat,
    radial-gradient(closest-side, rgba(3, 20, 26, 0.42), transparent 74%) 78% 72% / 46% 54% no-repeat;
}
@media (prefers-reduced-motion: no-preference) {
  .gate__deep {
    animation: gate-drift 38s ease-in-out infinite;
    will-change: transform;
  }
}
@keyframes gate-drift {
  0%   { transform: translate3d(-1.6%, -1%, 0); }
  29%  { transform: translate3d(1.9%, 0.8%, 0); }
  55%  { transform: translate3d(0.8%, 1.8%, 0); }
  81%  { transform: translate3d(-2%, 0.4%, 0); }
  100% { transform: translate3d(-1.6%, -1%, 0); }
}

/* The cursor caustic. A fixed circle moved by transform only — a radial
   gradient whose POSITION animates repaints its whole box every frame, and
   this one is nearly half the card. */
.gate__glow {
  position: absolute;
  top: 0;
  left: 0;
  width: 460px;
  height: 460px;
  margin: -230px 0 0 -230px;
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(closest-side,
    rgba(159, 216, 223, var(--gate-glow)),
    rgba(159, 216, 223, calc(var(--gate-glow) * 0.35)) 52%,
    transparent 74%);
  opacity: 0;
  will-change: transform, opacity;
}

/* The half you are NOT pointing at sinks. Darkening is the only luminance
   move that is free here — white type on it gets more legible, never less —
   and "the pool lights going down everywhere except your lane" is a more
   expensive read than lifting the hovered half would have been anyway.

   The two alphas are not the same number because the two grounds are not the
   same value: 42% over --trench-deep and 55% over --abyss land the pair at a
   comparable distance below where each started, so the half that sinks looks
   like it sank by the same amount either way. */
.gate__veil {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: rgba(2, 16, 21, var(--gate-veil));
  opacity: 0;
}

/* The rim. The half's own edges catch light, which is the one place on a panel
   this dark where light is affordable at a visible strength — the copy starts
   a full --space-lg inside, so a 1px edge and a tight inner bloom never reach
   it. This is most of what the hover actually reads as: the caustic under the
   cursor is capped at 9% by the contrast maths and can only ever whisper. */
.gate__half::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  box-shadow:
    inset 0 0 0 1px rgba(159, 216, 223, 0.5),
    inset 0 0 26px -12px rgba(159, 216, 223, 0.55);
  opacity: 0;
  transition: opacity 0.45s var(--ease-out);
}
.gate__half.is-lit::after { opacity: 1; }

.gate__inner {
  position: relative;
  z-index: 1;
  max-width: 34rem;
}

/* Four tiers, and only one of them is a colour change. On a ground this deep
   the ink ladder is mostly scale, weight and tracking — --pool-100 is the one
   step down that survives the caustic at its brightest. */
.gate__kicker {
  display: block;
  margin-bottom: 0.45rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.72rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--white);
}
.gate__title {
  display: block;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(1.5rem, 1.15rem + 1.3vw, 2rem);
  line-height: 1.1;
  letter-spacing: -0.01em;
  color: var(--white);
}
.gate__copy {
  display: block;
  margin-top: 0.6rem;
  max-width: 42ch;
  font-size: 0.95rem;
  line-height: 1.6;
  color: var(--pool-100);
}

.gate__cue {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: var(--space-sm);
  padding-bottom: 0.3rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.95rem;
  color: var(--white);
}
/* Drawn from the left on hover, so the cue behaves like a link being decided
   on rather than a button already pressed. */
.gate__cue::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  border-radius: var(--r-pill);
  background: var(--water-lit);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.45s var(--ease-out);
}
.gate__half.is-lit .gate__cue::after { transform: scaleX(1); }
.gate__cue svg {
  width: 19px;
  height: 19px;
  flex: none;
  will-change: transform;
}

/* --focus is #0b6ea8 and is tuned for pale grounds; on --abyss it all but
   disappears. --water-lit clears 3:1 against both halves, which is the bar for
   a non-text indicator. */
.gate__half:focus-visible {
  outline: 3px solid var(--water-lit);
  outline-offset: -5px;
}

/* Touch. There is no hover to reward, so the press itself has to answer —
   and the cursor caustic and the sinking veil are both switched off, because
   a light that appears where a finger already is tells nobody anything. */
@media (hover: none), (pointer: coarse) {
  .gate__half { min-height: 10.5rem; }
  .gate__half:active { background: var(--trench); }
  .gate__half--book:active { background: var(--trench-deep); }
  .gate__cue::after { transform: scaleX(1); }
}

@media (prefers-reduced-motion: reduce) {
  .gate__cue::after { transition: none; }
}

/* ==========================================================================
   21. PHONES: MEDIA TO THE EDGES

   Every photograph on the site was inset. On a desktop that is correct — a
   picture floating in a wide field of ground with air on both sides is what
   makes it read as placed rather than pasted. Carried down to a 375px phone
   it stops being composition and becomes arithmetic: a 20px gutter each side
   takes 11% of the screen, and the subject of the picture — the child, the
   pool, the face — is left occupying about a third of the display. The page
   read as a desktop grid that had been made narrow, which is exactly what
   checklist item 7 says a mobile layout must not be.

   So on phones the feature media runs to both screen edges. The move is
   deliberately not "make the images bigger": it is a change of category. An
   inset photo is an object ON the page; a bleeding photo is a SURFACE the
   page passes through, and the difference is most of what separates a native
   app's feel from a responsive website's.

   Three things travel with it, and all three are consequences rather than
   decisions. The radius goes, because a rounded corner at the screen edge
   reads as a rendering fault. The shadow goes, because there is no ground
   left for it to fall on. And the shorter edge is what the eye now measures
   the image by, so anything that was cropped for a landscape slot has to be
   re-cropped for a portrait one — see the hero below, and the note about the
   pictures that refuse it.

   WHAT IS DELIBERATELY LEFT ALONE. Not every image is a photograph. The level
   cards, the guide-index thumbnails, the mascots, the avatars and the logo are
   COMPONENTS: they are sized by the thing they sit inside, and bleeding them
   would break the object they belong to rather than free them from it. The
   test is whether the image is the subject of its own block. If it is, it
   bleeds.

   -1 * --gutter and not `calc(50% - 50vw)`. The two are identical on a phone,
   where the scrollbar is an overlay — but 100vw includes the scrollbar on a
   narrow desktop window, and the difference is 15px of horizontal scroll on
   the one viewport nobody tests. The gutter cancels the container's own
   padding exactly, at every width, forever.
   ========================================================================== */
@media (max-width: 639.98px) {

  /* <picture> is an inline box with no height of its own, so `height:100%` on
     the <img> inside it resolves against nothing and silently falls back to
     auto. Everywhere else on the site that never showed, because each frame's
     ratio already matched its file's — the image filled the box by accident.
     The moment a box is given a ratio the photo does NOT have, the difference
     appears as a band of --pool-200 under the picture. It cost about ten
     minutes to find, twice. */
  .hero__media picture,
  .hero__frame picture { display: block; height: 100%; }

  /* ------------------------------------------------------------ home hero
     The one image on the site with a box chosen before the photograph: it is
     a fixed slot in a composition, so it gets a fixed ratio rather than the
     file's own. 4:5, matched to the crop shipped in index.html, which means
     object-fit has nothing left to do — the picture arrives already framed.

     At 375px that is 469px tall, at 430px it is 537px. max-height is the
     backstop for the wide-and-short end of this range (a 620px window, a
     phone held sideways), where 4:5 would otherwise ask for 775px of height
     and push everything else off the screen. */
  .hero--home .hero__frame {
    /* width:100% is load-bearing, not a tidy-up. With `width:auto` the box
       takes its width FROM the ratio and the capped height, so the moment
       max-height bites — a 620px window, a phone on its side — the photo
       stopped bleeding and sat 490px wide in a 620px slot with a gap beside
       it. Stated width, capped height, and the ratio is the one that yields;
       object-fit re-crops for the few viewports where that happens. */
    width: 100%;
    aspect-ratio: 4 / 5;
    max-height: 68vh;
    border-radius: 0;
    box-shadow: none;
  }
  .hero--home .hero__media { margin-inline: calc(-1 * var(--gutter)); }

  /* -------------------------------------------------------- about hero
     Square, and the reason is in the photograph. This is a wide interior
     shot, and at the desktop 1248/765 it came to 230px of height on a phone —
     a letterbox strip of a room, which is the shape that made the page feel
     unfinished in the first place. A 4:5 portrait would be worse: it would
     cut the pool in half, and the pool is the entire point of the picture.

     1:1 is where the two arguments meet. The centre square holds the water,
     the mural, the AQUAKids wall and the children on the far side, and drops
     only the grey walkway on the left — which the composition was carrying
     rather than using. It needs no new file: object-fit takes the middle 864
     of the 1217, which is exactly the crop this would have been exported as. */
  .hero:not(.hero--home) .hero__media {
    margin-inline: calc(-1 * var(--gutter));
    aspect-ratio: 1;
    border-radius: 0;
    box-shadow: none;
  }
  .hero:not(.hero--home) .hero__media img { border-radius: 0; }

  /* ------------------------------------------------------------- the reel
     The video and the yellow facts card are already welded into one plate at
     this width (see the 639.98 block in section 8). The plate is the object,
     so the plate is what bleeds — the card's own padding keeps its type off
     the screen edge, and its full-width rules now genuinely run full width. */
  .pool-media {
    margin-inline: calc(-1 * var(--gutter));
    border-radius: 0;
  }

  /* --------------------------------------------------- the featured guide
     A card whose picture bleeds while its type stays on the column. This is
     the ordinary editorial arrangement and it is worth being explicit about
     why: the image is the subject, the text is the caption, and captions
     belong on the measure the rest of the page is set to. */
  .glead__frame {
    margin-inline: calc(-1 * var(--gutter));
    border-radius: 0;
  }

  /* ------------------------------------------------------ Angela's portrait
     Same arrangement, same reason — and this one CANNOT be cropped taller:
     both outstretched hands and the hoop sit hard against the frame's left
     and right edges, so a portrait crop would amputate the gesture that makes
     the picture worth having. It bleeds at its own ratio and the caption stays
     on the column. */
  .framed .frame {
    margin-inline: calc(-1 * var(--gutter));
    border-radius: 0;
    box-shadow: none;
  }

  /* ---------------------------------------------------------- the two maps
     A map is the one image nobody wants smaller. Contact's carries a 10px
     white mount, which is a picture-framing device and has no meaning once
     the picture reaches the edge of the screen, so it goes and the map itself
     runs the full width. */
  .rounded-media,
  .visit-map__frame {
    margin-inline: calc(-1 * var(--gutter));
    border-radius: 0;
    box-shadow: none;
  }
  .visit-map__frame { padding: 0; }
  .visit-map__frame iframe { border-radius: 0; }
  /* The veil is inset by exactly the mount it was drawn against. With the
     mount gone it has to go to the edges too, or the outer 10px of map keeps
     its Google beige and the tint reads as a border. */
  .visit-map__veil { inset: 0; border-radius: 0; }

  /* ------------------------------------------------------- guide articles
     Long-form is the one place the inset was doing real work: a picture that
     interrupts a column of prose has to be clearly a picture. Bleeding it
     turns out to make that MORE obvious rather than less — the reading column
     stays exactly where it was, the image steps outside it, and the caption
     underneath comes back to the measure to hand the reader back to the text.
     It is how every newspaper on a phone does it, for the same reason. */
  .guide-hero picture,
  .guide-hero img,
  .guide__figure picture,
  .guide__figure img {
    border-radius: 0;
    box-shadow: none;
  }
  /* The lead image is its own .container, so it drops its padding rather than
     pulling against it — a negative margin here would widen the container and
     leave the picture inset inside a wider box, which is the same photograph
     in a bigger frame. The figures further down are inside the prose column
     and do pull. */
  .guide-hero { padding-inline: 0; }
  .guide__figure { margin-inline: calc(-1 * var(--gutter)); }
  .guide__figure figcaption {
    margin-inline: var(--gutter);
  }
}

/* ==========================================================================
   22. THE RATING PLAQUE — Parent Reviews

   4.9 from 158 Google reviews, at the head of the section it belongs to.

   WHY IT IS NOT A BANNER. The obvious build is a full-width strip: gold stars
   centred on a coloured slab, the number large, the count beside it. Every
   swim school in Sydney has one. Two specific reasons it is wrong here.

   First, this band's own history says so. Parent Reviews has been three
   different grounds (see .section--tint-3) and the finding both failures
   pointed at was the same one: drama in the GROUND reads as a component pasted
   in from another site, drama in an OBJECT reads as something you can hold. A
   strip is a ground. It would also sit between the head and the rail, so the
   reader meets three horizontal bands in a row — statement, slab, cards — and
   the slab is the one with nothing to say that the cards don't say better.

   Second, a rating and a section head are the same job. The head claims
   "families like us"; the rating is the receipt. Splitting them over two rows
   states the claim, then proves it a beat later, when the whole point is that
   they are one sentence. So the head goes asymmetric — claim left, receipt
   right, bottoms aligned — and the page gains no new row at all.

   The plaque is the review cards' exact gradient. Not a near miss, the same
   three stops: the number is made of the same water as the quotes underneath
   it, which is what stops it reading as a badge bolted onto the section.

   HOW IT POPS WITHOUT SHOUTING. The band is pool-200 (relLum 0.781) and the
   plaque opens at --trench (0.170) — a 3.78:1 value break, the hardest edge in
   the page body. Nothing else on this ground is dark. The gold is the same
   --yellow already in every quote card's stars, so the palette here is three
   colours: deep water, gold, white. That is the whole treatment.
   -------------------------------------------------------------------------- */

.head-split--rating .section-head {
  /* .section-head brings its own margin-bottom, which on top of the grid gap
     would open a hole between the two halves on phones. The grid owns the
     spacing now. Cap released too: half of 1180px is already inside 66ch. */
  margin-bottom: 0;
  max-width: none;
}

@media (min-width: 900px) {
  /* fit-content, not the full 1fr. Stretched across half the container the
     plaque becomes a 540px box holding 200px of content, which is a banner
     again by another route. Sized to its contents and pushed to the margin, it
     reads as placed. Bottoms align because .head-split is align-items: end. */
  .head-split--rating .rating { justify-self: end; }
}

.rating {
  --pad-x: clamp(1.15rem, 4vw, 1.7rem);
  --pad-y: clamp(0.95rem, 3.2vw, 1.3rem);
  --star: clamp(27px, 6.8vw, 32px);
  /* How much of a star sits above the plaque's top edge. 44% is the number
     that matters: below about a third the overhang reads as a misalignment,
     and past half the row looks detached from the thing it belongs to. */
  --star-rise: 0.44;

  position: relative;
  display: flex;
  align-items: center;
  gap: clamp(0.9rem, 3vw, 1.35rem);
  /* The lifted row needs air above it or it collides with the lead paragraph
     on phones, where the two stack. */
  margin: calc(var(--star) * var(--star-rise) + 0.35rem) 0 0;
  /* Top padding takes whichever is larger: the plaque's own rhythm, or the
     clearance the lifted stars need. The part of a star still INSIDE the
     plaque is (1 - rise) of its height, and the interior has to start below
     that or the meta line runs into the fifth star on a narrow phone, where
     the padding shrinks faster than the star does. Deliberately asymmetric —
     the weight is up there now, so the air should be too. */
  padding: max(var(--pad-y), calc(var(--star) * (1 - var(--star-rise)) + 0.4rem)) var(--pad-x) var(--pad-y);
  border-radius: var(--r-lg);
  /* Identical to .quote--plain. If one changes, change both. */
  background: linear-gradient(168deg, #267e8f 0%, #1d6b7a 42%, #17505d 100%);
  /* --shadow-lg where the cards take --shadow-md: the plaque sits half a step
     closer to the reader than the evidence it summarises. The inset hairline
     is gold rather than the cards' white — one pixel, and it is the only place
     on the page the gold touches an edge rather than a shape. */
  box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255, 198, 41, 0.34);
}

/* --------------------------------------------------------- The lifted row
   The stars straddle the plaque's top edge rather than sitting inside it, and
   that is the whole reason this reads as an object with depth rather than a
   filled rectangle. Something crossing its own container's edge is the
   cheapest dimensionality there is, because the eye reads the overlap as
   proof that the two things are on different planes.

   There is a house test for this and it is worth applying: the mascots that
   survived on this page all STRADDLE something (the hero pair the photo's
   edge, the seal the frame's corner). Anything merely placed on top got cut.
   This passes for a reason particular to the section — the plaque is water,
   and the score surfaces out of it.

   Absolute, anchored to the plaque's top-right, so the interior lays out as
   though the row weren't there. In flow it would either leave a hole where it
   used to be or drag the meta line up with it. */
.rating__stars {
  position: absolute;
  top: 0;
  right: var(--pad-x);
  display: inline-flex;
  transform: translateY(calc(-1 * var(--star) * var(--star-rise)));
}

/* THE STROKE IS NOT A FINISH, IT IS WHAT MAKES THE OVERHANG POSSIBLE.
   Gold on the pale band measures 1.21:1. The instant a star crosses the top
   edge its upper half is on --pool-200, and without an edge the tips wash out
   and the row reads as a rendering fault rather than as a lift. #16505c on
   pool-200 is 7.1:1, so the silhouette holds wherever the star happens to be.

   paint-order puts the stroke UNDER the fill, so 1.5 units show as ~0.75
   outside the shape: an edge, not an outline. Over the plaque it disappears
   into the ground and reads as a struck edge; over the pale band it is doing
   all the work. Same rule the site's own logo follows. */
.rating__row svg {
  width: var(--star);
  height: var(--star);
  stroke: #16505c;
  stroke-width: 1.5;
  stroke-linejoin: round;
  paint-order: stroke fill;
}

.rating__score {
  font-family: var(--font-display);
  font-weight: 800;
  /* Two steps below the h1 and one above the h2 it sits beside. The number has
     to be the largest thing in this section or the plaque is decoration. */
  font-size: clamp(3rem, 2rem + 4.4vw, 4.3rem);
  line-height: 0.84;
  letter-spacing: -0.025em;
  color: var(--white);        /* 4.77:1 on the opening stop — the whole budget */
  white-space: nowrap;
}

.rating__row { display: flex; gap: 4px; }

/* The unfilled row underneath. It shows through in exactly one place — the
   sliver at the far right that the gold never reaches — and its stroke is
   what completes the fifth star's silhouette there. Without it that corner
   would simply be missing rather than empty. */
.rating__row--track { color: rgba(255, 255, 255, 0.28); }

/* The clip lives on a WRAPPER, not on the gold row itself. clip-path forces
   an element's transform-style to flat, and the row's five stars each carry a
   3D rotation on entry — putting the two on one element silently killed the
   wave and left it a fade. One property on the parent, and the children get
   their third dimension back. */
.rating__clip {
  position: absolute;
  inset: 0;
  /* 4.9/5 is 98%, so the fifth star lands two percent short and stays there.
     The resting state is the truth; the animation only stages the arrival.
     That sliver is the point — a row of five solid stars is what a designer
     draws, and a row stopping just before the end is what the data says.
     Nobody trusts a perfect score. */
  clip-path: inset(0 2% 0 0);
}
.rating__row--fill { color: var(--yellow); }

.rating__meta {
  font-size: clamp(0.86rem, 0.8rem + 0.2vw, 0.94rem);
  line-height: 1.35;
  /* ~6.7:1 against the lower half of the plaque, where it sits. */
  color: rgba(255, 255, 255, 0.86);
  /* The hairline is the whole reason this reads as one object: it makes the
     number and the source two fields of a single record rather than two
     things that happen to be adjacent.

     It stretches to the full interior height rather than hugging its own line
     of text. A 19px rule beside a 58px numeral reads as a tick mark; a rule
     that runs the height of the plaque reads as a column division, which is
     what it is. The text then centres inside the stretched box. */
  align-self: stretch;
  display: flex;
  align-items: center;
  padding-left: clamp(0.9rem, 3vw, 1.35rem);
  border-left: 1px solid rgba(255, 255, 255, 0.16);
}
.rating__meta strong { color: var(--white); font-weight: 800; }

@media (max-width: 899.98px) {
  /* Phones keep the horizontal lockup rather than stacking it. A stacked
     version is taller, pushes the first review card below the fold, and turns
     a single glanceable object into a list of three facts. Full-bleed to the
     container edges instead, which is the one place the "banner" instinct is
     right: on a 375px screen a fit-content plaque and a full-width one are
     within 30px of each other, so it may as well square up to the gutters. */
  .rating { width: 100%; }
}

/* ==========================================================================
   20. FEES — timetable.html #fees
   ==========================================================================

   The section had no focal point. It carried no prices (the invented ones were
   pulled in August), and what replaced them was two tinted cards of prose, so
   the page most likely to be found by a parent searching "how much are swimming
   lessons in Smithfield" answered with paragraphs. The client confirmed the
   schedule on 2026-08-18 and this is built around it.

   RHYTHM is the thing to preserve if you edit this. An earlier pass gave every
   block its own rounded plate, and by the fourth one the shape had stopped
   meaning anything — a card says "this is a distinct object", and when
   everything is one, nothing is. Two things are plates: the price itself and
   the private-lesson block. The level row and the two lists sit bare on the
   section ground, and the voucher runs full-bleed in --water. Plate, open,
   plate, open, band. Adding a fifth card is the easiest way to undo this.

   COLOUR. The level palette appears exactly once, under "The same $28 at every
   level", and it is on-rule there for a reason worth stating: that line names
   every level, so the mascots are the evidence for the sentence rather than
   decoration around it. Nothing else in this section names a level, so nothing
   else here may carry --lv-*. The 10% ring uses --water, which is brand texture
   and names nothing. Orange stays what it always is — the CTA, once. */

/* ------------------------------------------------------------ 20.1 The photo
   refs/Pool.jpg, cropped to a band. It is the actual Smithfield pool, and the
   mural along its back wall carries the real mascots — so the level row further
   down echoes something the reader has already seen in a photograph. */
.fees-photo {
  position: relative;
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--shadow-lg);
  margin: 0 0 calc(var(--space-md) * -1 + -1.5rem);
}
.fees-photo img {
  display: block;
  width: 100%;
  /* 300px floor rather than 240: the plate overlaps the photo's lower edge and
     the caption has to clear it, which at 240 ate a third of the picture. */
  height: clamp(300px, 34vw, 440px);
  object-fit: cover;
  object-position: 62% 58%;
}
.fees-photo::after {
  content: "";
  position: absolute; inset: auto 0 0 0; height: 45%;
  background: linear-gradient(to bottom, rgba(5, 29, 38, 0) 0%, rgba(5, 29, 38, 0.55) 100%);
  pointer-events: none;
}
.fees-photo__cap {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 1.25rem clamp(1.25rem, 3vw, 2rem) 4.75rem;
  color: var(--white);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1rem, 2vw, 1.2rem);
  text-shadow: 0 2px 12px rgba(5, 29, 38, 0.5);
}

/* ------------------------------------------------------------ 20.2 The plate
   overflow stays visible so a lifted half is not clipped by its own parent. */
.fees-plate {
  position: relative; z-index: 2;
  background: var(--white);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-xl);
  margin-inline: clamp(0px, 2vw, 2.5rem);
  padding: clamp(0.5rem, 1.4vw, 0.9rem);
}
/* Equal halves. They are two ways to buy the same thing, so neither gets the
   wider column — an uneven split quietly says one of them matters more. */
.fees-plate__grid { display: grid; grid-template-columns: 1fr; }
@media (min-width: 820px) { .fees-plate__grid { grid-template-columns: 1fr 1fr; } }

.fees-half {
  position: relative;
  padding: clamp(1.5rem, 3.4vw, 2.4rem);
  border-radius: var(--r-md);
  transition: transform 0.38s cubic-bezier(0.22, 1, 0.36, 1),
              box-shadow 0.38s cubic-bezier(0.22, 1, 0.36, 1),
              background-color 0.38s ease;
}
/* The seam. Hidden the moment either half lifts — a rule running under a raised
   panel is the detail that gives the trick away. */
.fees-half + .fees-half::before {
  content: "";
  position: absolute; left: 0; top: 12%; bottom: 12%;
  width: 1px;
  background: rgba(5, 29, 38, 0.12);
  transition: opacity 0.3s ease;
}
@media (max-width: 819.98px) {
  .fees-half + .fees-half::before {
    left: 8%; right: 8%; top: 0; bottom: auto; width: auto; height: 1px;
  }
}
.fees-plate:has(.fees-half:hover) .fees-half + .fees-half::before { opacity: 0; }

/* Buoyancy, the same gesture the level cards use — it belongs to the page's
   float vocabulary rather than being a generic card hover. :focus-within keeps
   it on the keyboard path, since the half carries no focusable content of its
   own today but may later. */
@media (hover: hover) {
  .fees-half:hover,
  .fees-half:focus-within {
    transform: translateY(-10px);
    box-shadow: var(--shadow-xl);
    background: var(--pool-50);
    z-index: 3;
  }
}
@media (prefers-reduced-motion: reduce) {
  .fees-half { transition: none; }
  .fees-half:hover, .fees-half:focus-within { transform: none; }
}

.fees-label {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.86rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin: 0 0 0.4rem;
}

/* The dollar sign set smaller and raised, so it reads as a price rather than a
   heading that happens to contain digits. Tracking pulled in because Baloo's
   figure spacing goes loose at display size. */
.fees-price {
  font-family: var(--font-display);
  font-weight: 800;
  color: var(--ink);
  line-height: 0.88;
  letter-spacing: -0.025em;
  margin: 0;
  display: flex;
  align-items: flex-start;
  gap: 0.08em;
}
.fees-price__cur { font-size: 0.42em; line-height: 1; padding-top: 0.2em; font-weight: 700; }
.fees-price--hero { font-size: clamp(3.75rem, 9vw, 5.75rem); }

.fees-unit {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.05rem;
  color: var(--ink-soft);
  margin: 0.65rem 0 0;
}
.fees-note-p { color: var(--ink-soft); font-size: 1rem; margin: 1rem 0 0; max-width: 34ch; }

/* One CTA under both halves, on its own rule. Inside a column it looked like it
   belonged to that column's price. */
.fees-plate__foot {
  margin: 0.5rem clamp(1.5rem, 3.4vw, 2.4rem) 0;
  padding: clamp(1.25rem, 2.6vw, 1.6rem) 0 clamp(0.75rem, 1.6vw, 1rem);
  border-top: 1px solid rgba(5, 29, 38, 0.1);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}
.fees-plate__foot p { margin: 0; color: var(--ink-soft); font-size: 1rem; max-width: 44ch; }

/* -------------------------------------------------------------- 20.3 The 10%
   A badge is what every pricing page reaches for, and a badge inside a card is
   a pill inside a pill. This is the number in the display face at a size that
   means it, with a ring drawn round it using the same [data-draw] stroke as the
   home page heading underline.

   It has to be a <path>. main.js measures path, circle and line only, so an
   <ellipse> renders solid and never animates. It wants to be a path anyway —
   the ring overshoots its own start the way a pen does. */
.fees-mark { display: flex; align-items: center; gap: 0.9rem; margin: 0 0 1.1rem; }
.fees-mark__ring { position: relative; flex: none; }
.fees-mark__ring svg {
  position: absolute; inset: -22% -16%;
  width: 132%; height: 144%;
  overflow: visible;
  pointer-events: none;
}
.fees-mark__ring svg path {
  fill: none; stroke: var(--water); stroke-width: 3.6; stroke-linecap: round;
}
.fees-mark__num {
  font-family: var(--font-display);
  font-weight: 800;
  font-size: clamp(1.9rem, 4.5vw, 2.4rem);
  color: var(--ink);
  line-height: 1;
  display: block;
  padding: 0.15em 0.1em;
}
.fees-mark__txt {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.02rem;
  color: var(--ink-soft);
  line-height: 1.25;
}

/* --------------------------------------------------------- 20.4 Every level
   No card — this is the section's own claim, so it sits on the section's own
   ground. See the colour note at the top of section 20 before adding --lv-*
   anywhere else in here. */
.fees-levels { margin-top: calc(var(--space-lg) + 0.5rem); text-align: center; }
.fees-levels h3 { font-size: clamp(1.6rem, 4vw, 2.5rem); margin: 0 0 0.5rem; }
.fees-levels > p { color: var(--ink-soft); max-width: 52ch; margin: 0 auto; }

.fees-row {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  /* Column and row gaps are set separately because the mascots overhang their
     discs on both axes. One shared gap sized for the columns let the second
     row's heads land on the first row's labels at 375px. */
  column-gap: clamp(1rem, 2.4vw, 1.4rem);
  row-gap: 2.4rem;
  margin-top: 2.5rem;
  padding-top: 0.5rem;
}
/* 58px floor, not 64: at 64 the row broke 3+3+1 on a 375px screen and the shark
   sat alone on a line, which reads as a row that ran out rather than a
   composition. 58 fits four and the set breaks 4+3. */
.fees-lv { width: clamp(58px, 9.5vw, 104px); }

/* Head above the water. The disc is the level's colour at a light wash; the
   mascot is anchored to the disc's floor and sized past its top edge so it
   breaks the circle instead of sitting politely inside it.

   --m-scale is hand-set per mascot rather than derived. The supplied artwork
   runs from a 1.23:1 stingray to a 0.67:1 shark, and any single formula
   flatters one and ruins the other: a uniform height turns the stingray into a
   plank, a uniform width buries it. The stingray also gets --m-bottom, because
   a flat ray cannot clear a circle on width alone. Seven numbers, eyeballed,
   and they live in the markup rather than here because they belong to the
   artwork, not to the layout. */
.fees-lv__disc {
  position: relative;
  width: 100%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: color-mix(in srgb, var(--lv) 30%, #fff);
  box-shadow: inset 0 -3px 0 color-mix(in srgb, var(--lv) 44%, #fff);
}
/* .bob writes transform, so the centring translate lives on a wrapper or the
   float overwrites it on the first frame. */
.fees-lv__float {
  position: absolute;
  left: 50%; bottom: var(--m-bottom, -4%);
  width: calc(var(--m-scale, 1) * 100%);
  transform: translateX(-50%);
}
.fees-lv__float img {
  display: block; width: 100%; height: auto;
  filter: drop-shadow(0 7px 11px rgba(5, 29, 38, 0.2));
}
/* .fees-levels p already claims --ink-soft at 0,1,1, which outranks a bare
   class. The level's own ink is the whole point of the label, so it wins. */
.fees-levels .fees-lv__name {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(0.72rem, 1.5vw, 0.86rem);
  color: var(--lv-ink);
  margin: 0.75rem 0 0;
  line-height: 1.2;
  max-width: none;
}

/* ------------------------------------------------------- 20.5 Private lessons
   The prices used to sit in a tinted rounded panel, which was one more pill in
   a section that had too many. It is two lengths against two prices, which is
   what a table is for. Rules instead of a fill. */
.fees-private {
  margin-top: var(--space-lg);
  background: var(--white);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-md);
  padding: clamp(1.75rem, 4vw, 2.5rem);
  display: grid;
  gap: clamp(1.25rem, 3vw, 2.75rem);
  align-items: center;
}
@media (min-width: 820px) { .fees-private { grid-template-columns: 1.05fr 0.95fr; } }
.fees-private h3 { font-size: clamp(1.6rem, 3.4vw, 2.1rem); margin: 0 0 0.5rem; }
.fees-private > div > p { color: var(--ink-soft); margin: 0; }

.fees-table { width: 100%; border-collapse: collapse; font-family: var(--font-display); }
.fees-table caption {
  text-align: left;
  font-weight: 700;
  font-size: 0.86rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-faint);
  padding-bottom: 0.6rem;
}
.fees-table th {
  text-align: left;
  font-weight: 700;
  font-size: 0.9rem;
  color: var(--ink-faint);
  padding: 0 0 0.5rem;
  border-bottom: 2px solid rgba(5, 29, 38, 0.14);
}
.fees-table th:last-child,
.fees-table td:last-child { text-align: right; }
.fees-table td {
  padding: 0.95rem 0;
  border-bottom: 1px solid rgba(5, 29, 38, 0.08);
  font-weight: 600;
  font-size: 1.08rem;
  color: var(--ink-soft);
}
.fees-table tr:last-child td { border-bottom: 0; }
.fees-table__amt {
  font-weight: 800;
  font-size: clamp(1.5rem, 3.4vw, 1.9rem);
  color: var(--ink);
  letter-spacing: -0.02em;
}
.fees-table__amt span { font-size: 0.55em; vertical-align: 0.42em; padding-right: 0.06em; }

/* --------------------------------------------------- 20.6 The two lists, bare
   These were two tinted cards of unequal height sitting under two more cards.
   Bare on the ground with a rule between them they stop competing with the
   price plate, and the section gets somewhere to breathe. */
.fees-lists { margin-top: var(--space-lg); display: grid; gap: var(--space-md); }
@media (min-width: 820px) {
  .fees-lists { grid-template-columns: 1fr 1fr; gap: clamp(2rem, 5vw, 4rem); }
  .fees-lists > div + div {
    padding-left: clamp(2rem, 5vw, 4rem);
    border-left: 1px solid rgba(5, 29, 38, 0.14);
  }
}
.fees-lists h3 { font-size: clamp(1.25rem, 2.6vw, 1.55rem); margin: 0 0 1.1rem; }

/* ----------------------------------------------------------- 20.7 The voucher
   Full-bleed, and the only saturated ground in the section. It breaks a run of
   pale rectangles at the point the page starts to feel like a list, and it is
   the one item here worth $100 to the reader.

   Navy on --water, never white: --water measures 2.54:1 against white and
   5.75:1 against --ink, so the legible choice is also the one that keeps the
   section inside its four colours.

   It sits mid-section rather than last on purpose. The closing .deep__surface
   paints the band above it, so a saturated band at the very end would force
   that fill to change too. */
.fees-voucher {
  margin-top: var(--space-xl);
  background: #ffffff;
  padding-block: clamp(3.5rem, 5vw, 5.25rem);
}
.fees-voucher__in { display: grid; gap: clamp(1rem, 3vw, 2.5rem); align-items: center; }
@media (min-width: 820px) { .fees-voucher__in { grid-template-columns: 0.9fr 1.1fr; } }
.fees-voucher h3 { font-size: clamp(1.5rem, 3.4vw, 2.15rem); margin: 0; color: var(--ink); }
.fees-voucher p { color: var(--ink); margin: 0; }
.fees-voucher .fees-label { color: rgba(5, 29, 38, 0.62); }

/* ------------------------------------------------------------ 20.8 The answer
   This was a yellow .note. A caution panel around the one sentence the page is
   most likely to be found for reads as a warning about the price. It gets type
   and space instead of another container.

   Heading centred, answer ragged-right: seven centred lines of body copy give
   the eye no fixed left edge to return to, which is a lot to ask of the
   paragraph a stranger reads first. */
.fees-answer { margin-top: var(--space-xl); max-width: 68ch; margin-inline: auto; }
.fees-answer h3 { font-size: clamp(1.4rem, 3vw, 1.9rem); margin: 0 0 0.9rem; text-align: center; }
.fees-answer p { color: var(--ink-soft); margin: 0; }
