/* ==========================================================================
   Barefoot Conservation — inner-page styles
   Tokens live in tokens.css and are imported here (CLAUDE.md rule 16).
   Component grammar is ported from Ayo Divers and restyled; class names are
   deliberately unchanged so the templates transfer.
   ========================================================================== */

@import url("/tokens.css");

/* ---------- Reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
body, h1, h2, h3, h4, p, figure, blockquote, dl, dd, ul, ol { margin: 0; padding: 0; }
/* Strip list markers everywhere, then put them back only inside prose, where a
   list is content rather than layout. The old `ul[class], ol[class]` form missed
   any list whose class sits on a wrapper instead of the list itself — the
   breadcrumb and the in-page nav were both rendering decimal markers over their
   own text. */
ul, ol { list-style: none; }
.prose ul, .prose ol,
.prose--wide ul, .prose--wide ol,
.doc-section ul, .doc-section ol,
.legal-body ul, .legal-body ol { list-style: revert; padding-left: 1.2em; }
img, picture, video, svg { max-width: 100%; display: block; }
input, button, textarea, select { font: inherit; color: inherit; }
button { background: none; border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
table { border-collapse: collapse; width: 100%; }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ---------- Page shell ---------- */
/* THE TYPE IS ALREADY AYO'S, AND THAT IS WORTH WRITING DOWN so nobody goes
   looking for a difference that is not there. Both projects load ONE font link,
   and the two are byte-identical: Fraunces (ital + the 9..144 opsz axis, weights
   400/500/600/700/900), Hanken Grotesque (300–800) and DM Mono (400/500). The
   family variables, the `.display` and `.lead` clamps and the italic-`em`
   treatment all match line for line as well.
   The one real difference in the base setup was `text-rendering`, which Ayo
   sets and this did not. Where the two DID diverge is per-component weight —
   Ayo runs its numbers and card titles at 800/900 where this ran 700/800, which
   is what reads as "a different font" without being one. Those are matched at
   the component, not here. */
body.page {
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: 1.62;
  font-weight: 400;
  background: var(--shell);
  color: var(--deep-900);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

.shell {
  width: 100%;
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: var(--pad);
}

/* Skip link — first focusable thing on every page */
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 200;
  background: var(--ochre);
  color: var(--on-ochre);
  padding: 12px 20px;
  border-radius: 0 0 var(--r-sm) 0;
  font-weight: 700;
}
.skip-link:focus { left: 0; }

:focus-visible {
  outline: 2.5px solid var(--seagrass-deep);
  outline-offset: 2px;
  border-radius: 3px;
}
.section--deep :focus-visible,
/* Ayo's global `:focus-visible` is `--turq`. Applied in the header and the
   drawer, which is where its turquoise now lives; the FOOTER keeps
   `--seagrass-bright`, because it is on every page in Barefoot's own colours
   and a lone turquoise ring down there would be the only one on the site. */
.site-header :focus-visible,
.mobile-menu :focus-visible { outline-color: #18e0c6; }
/* The footer is Ayo's colours now, so its focus ring goes with them — the
   reason it was left on `--seagrass-bright` when the header changed (that it
   was the last Barefoot-coloured chrome) no longer holds. */
.site-footer :focus-visible { outline-color: #18e0c6; }

/* ---------- Texture layers ---------- */
/* .grain — reused verbatim from Ayo (plan/03 §3). */
.grain {
  position: fixed; inset: 0; z-index: 70; pointer-events: none;
  opacity: .42; mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
  background-size: 160px 160px;
}
/* .paper — Barefoot only. Coarser noise, multiplied. This is what makes the
   light surfaces read as paper rather than as white.
   FIXED, like .grain (plan/03 §3 says both are). It does not need scoping to
   the light sections: multiply against --deep-900 (#061512) shifts each channel
   by under two units at .16 opacity, i.e. nothing, so one layer self-selects —
   texture on paper, invisible on deep.
   This matters for more than tidiness: the previous version put a blended layer
   inside every light section, and mix-blend-mode on a scrolling container forces
   a repaint of that container on every frame. Fixed + pointer-events:none keeps
   it on its own compositor layer instead. */
.paper {
  position: fixed; inset: 0; z-index: 69; pointer-events: none;
  opacity: .16; mix-blend-mode: multiply;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.42' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23p)' opacity='.62'/%3E%3C/svg%3E");
  background-size: 220px 220px;
}

/* ---------- Surfaces ---------- */
.section {
  position: relative;
  padding-block: clamp(72px, 10vw, 132px);
}
.section > .shell { position: relative; z-index: 1; }
.section--deep { background: var(--deep-900); color: var(--shell); }
.section--paper {
  background:
    radial-gradient(900px 520px at 88% 0%, rgba(55, 181, 140, .09), transparent 62%),
    var(--shell);
  color: var(--deep-900);
}
.section--sand { background: var(--shell-3); color: var(--deep-900); }
/* Referenced by the homepage's #expeditions and #proof since Phase 6 but never
   actually defined, so both sections computed to background rgba(0,0,0,0) and
   only LOOKED cream because body.page is. A transparent section shows whatever
   is behind it — which is how the homepage map ended up behind the Expeditions
   heading. Declared explicitly: a section named for a surface must paint it. */
.section--shell { background: var(--shell); color: var(--deep-900); }
.section--tight { padding-block: clamp(48px, 6vw, 76px); }

/* WHEN TWO SECTIONS PAINT THE SAME SURFACE, DRAW THE BOUNDARY — plan/15 §C1.
   Measured on 9 pages: all eight `/science/<programme>` (outcomes → other
   programmes) and `/expedition` (deposit → before you apply). Two sections of
   identical colour read as one long section, and the second one's heading looks
   like a sub-heading of the first rather than a new part of the page.

   Why a general rule and not a surface change on those two templates:
   `section--paper section--tight` is the site's established "where next" rail —
   `/about`, `/science`, `/expedition` and every science programme page end that
   way — so the convention is right and only its NEIGHBOUR varies. Reassigning
   surfaces page by page is exactly how these collisions keep appearing: making
   the rail sand would have fixed those two and created a fresh collision on
   `/science`, whose preceding section is already sand. This rule cannot do that.
   It is self-maintaining — a new page, or a section reordered in a template,
   gets its boundary automatically.

   The spacing was never the problem: there is already 120–200px between them.
   What was missing is a visual event saying "new section", which is what a
   hairline is for. Full-bleed rather than inset to the shell, because it
   divides sections and not content — same call as `.cta-band`'s top rule.

   `--line-*` and not `--edge-*`: decorative divider, not an interactive
   boundary (rule 25). The dark pairing needs the light token — the same
   on-light/on-dark split this file gets wrong every time it is forgotten. */
.section--paper + .section--paper,
.section--shell + .section--shell,
.section--sand  + .section--sand  { border-top: 1px solid var(--line-ink); }
.section--deep  + .section--deep  { border-top: 1px solid var(--line-light); }

/* A HERO IS NOT A CONTENT SECTION, so it never draws that boundary against
   whatever follows it. The rule above exists so two adjacent sections of the
   same surface do not read as one long section — but a hero already reads as
   the top of the page, and the section beneath it already reads as the first
   real one, so the line says nothing and looks like an underline on the hero.

   Why this never came up before 13 Aug 2026: `.page-hero` carries
   `section--deep`, and on every other page a hero is followed by a LIGHT
   section, so the pair never matched. The homepage's science section is dark
   and, once the twelve-week timeline moved to /expedition, it sat directly
   under the hero — the rule then drew a line across the top of it that was not
   there when the timeline occupied that space, which is a visible change to a
   section the owner asked to leave alone.

   Verified by sweeping all 23 content routes: `.page-hero + .section--deep`
   matches on `/` and on nothing else, so this line changes exactly one
   boundary. */
.page-hero + .section--deep { border-top: 0; }

/* ---------- Typography ---------- */
.display-xl, .display, h1, h2, h3, h4 {
  font-family: var(--font-display);
  text-wrap: balance;
}
.display-xl {
  font-size: clamp(2.6rem, 7.2vw, 6.2rem);
  font-weight: 900;
  letter-spacing: -.025em;
  line-height: .96;
}
.display {
  font-size: clamp(2rem, 4.6vw, 4rem);
  font-weight: 900;
  letter-spacing: -.018em;
  line-height: 1.02;
}
.display em, .display-xl em {
  font-style: italic;
  font-weight: 500;
}
h3, .h3 {
  font-size: clamp(1.3rem, 2vw, 1.75rem);
  font-weight: 600;
  letter-spacing: -.01em;
  line-height: 1.16;
}
h4 { font-size: 1.05rem; font-weight: 600; line-height: 1.3; }

.lead {
  font-size: clamp(1.05rem, 1.6vw, 1.32rem);
  line-height: 1.6;
  color: var(--txt-ink);
  max-width: 62ch;
}
.section--deep .lead { color: var(--txt-light); }

.prose { max-width: 68ch; }
.prose p + p { margin-top: 1.1em; }
.prose p { color: var(--txt-ink); }
.section--deep .prose p { color: var(--txt-light); }
.prose a { text-decoration: underline; text-underline-offset: .18em; color: var(--seagrass-deep); }
.section--deep .prose a { color: var(--seagrass); }

/* .eyebrow — the rule colour is set EXPLICITLY per surface. If it inherits
   currentColor it turns green on paper (plan/03 §2). */
.eyebrow {
  font-family: var(--font-mono);
  text-transform: uppercase;
  letter-spacing: .22em;
  font-size: .74rem;
  font-weight: 500;
  color: var(--seagrass-deep);
  display: flex; flex-wrap: wrap; align-items: center; gap: .6em;
  max-width: 100%;
  margin-bottom: 1.1rem;
}
/* Ayo draws the rule in `currentColor` at .6, so it follows the eyebrow's own
   colour rather than being a fixed sand line that has to be kept in step with
   it — which is exactly how the on-dark override below came to exist. */
.eyebrow::before {
  content: ""; width: 26px; height: 1px; flex: none;
  background: currentColor; opacity: .6;
}
/* Ayo's `--turq-bright` on dark: measured 14.00:1 against `--deep-900` where
   `--seagrass` gave 7.25. Brighter AND clearer. */
.section--deep .eyebrow { color: #67f5e2; }

/* `.eyebrow--light` carried NO colour of its own — it only looked right where
   it happened to sit inside a .section--deep, and the ancestor rule above did
   the work. On the homepage hero (over the map) and on .cta-full it therefore
   painted --seagrass-deep, the ON-LIGHT variant, at 2.67:1. A modifier named
   "--light" has to be self-sufficient: it is used precisely where the surface
   is dark but the container is not .section--deep. */
/* Ayo's `--turq-bright` on dark. Measured 14.00:1 against `--deep-900` where
   `--seagrass` gave 7.25 — this one is brighter AND clearer. */
.eyebrow--light { color: #67f5e2; }

/* Every number, price, measurement, week and coordinate. Never Hanken.
   (CLAUDE.md rule 17.) */
.mono, .num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

/* THE MEASURE CAP IS NOT DECORATION.
   Uncapped, `.note` ran to 147 characters a line on 20 of the 26 public
   routes, in the SMALLEST type on the site. Longest line at the smallest size
   is the exact inversion of what type wants, and it survived four build phases
   because it is invisible at 375px, where every column is ~40ch whatever the
   CSS says.

   60ch, and the unit matters: `ch` is the width of "0", so for a MONOSPACED
   face it is exactly one character — 60ch here really is 60 characters. That
   is not true of the proportional caps elsewhere in this file (`.prose` 68ch
   measures ~76 real characters, because Hanken's lowercase is narrower than
   its "0"). So do not read 60 against 68 as "tighter than prose": mono runs
   wider per character, and 60 mono characters is the optical equivalent.
   `.footer__reg` already sets 60ch on mono — same reasoning, same number. */
.note {
  font-family: var(--font-mono);
  font-size: .8rem;
  letter-spacing: .02em;
  line-height: 1.5;
  color: var(--txt-ink-dim);
  max-width: 60ch;
  /* Capping the measure creates a second-order problem: a sentence just over
     60 characters now wraps, and the leftover can be a single word on its own
     line. The homepage's one note did exactly that ("…the expeditions / page.")
     — and that page is signed off, so its only visible change should not be an
     orphan. `pretty` rebalances the last lines. Progressive enhancement: a
     browser that does not know it ignores the declaration and gets the plain
     wrap, which is what shipped before this cap existed. */
  text-wrap: pretty;
}
/* Ink-coloured helpers must fail safe on EVERY dark surface, not just
   .section--deep. `.site-footer` and `.cta-full` paint --deep-900 without
   carrying that class, so a `.note` inside the footer rendered
   --txt-ink-dim (near-black) on --deep-900: measured contrast 1.00, i.e.
   invisible, on every page of the site. Same failure mode the Phase 3 notes
   record for .btn--quiet / .btn--ink — dark surfaces are declared in more
   than one place, so anything specified for light surfaces needs the whole
   list. Keep these three selectors together; adding a dark container means
   adding it here too. */
.section--deep .note,
.site-footer .note,
.cta-full .note { color: var(--txt-light-dim); }

.rule--dashed {
  border: 0;
  border-top: 1.5px dashed var(--sand);
  margin-block: var(--s-6);
}
.section--deep .rule--dashed { border-top-color: var(--deep-600); }

/* ---------- Icons & sketches ---------- */
.icon { width: 1.15em; height: 1.15em; flex: none; vertical-align: -.16em; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.icon--lg { width: 1.6em; height: 1.6em; }
.sketch {
  width: 100%; height: auto; display: block;
  fill: none; stroke: currentColor; stroke-width: 1.75;
  stroke-linecap: round; stroke-linejoin: round;
  color: var(--deep-700);
}
.section--deep .sketch { color: var(--sand); }

/* Draw-on: --len is set from getTotalLength() by site.js */
.sketch--draw path, .sketch--draw circle, .sketch--draw line, .sketch--draw polyline {
  stroke-dasharray: var(--len, 1200);
  stroke-dashoffset: var(--len, 1200);
  transition: stroke-dashoffset 1.5s var(--ease);
}
.in-view .sketch--draw path, .in-view .sketch--draw circle,
.in-view .sketch--draw line, .in-view .sketch--draw polyline { stroke-dashoffset: 0; }

/* ---------- Buttons ---------- */
.btn {
  --btn-pad: .95em 1.5em;
  display: inline-flex; align-items: center; justify-content: center; gap: .55em;
  font-weight: 700; font-size: .96rem; letter-spacing: .01em; line-height: 1.2;
  padding: var(--btn-pad);
  /* Every variant carries the same border box: the bordered variants (ghost,
     glass, quiet) were 3px taller than the filled ones (ochre, ink), so a
     side-by-side pair of buttons was visibly asymmetric. */
  border: 1.5px solid transparent;
  border-radius: 999px;
  min-height: 44px;  /* WCAG 2.5.5 target size */
  transition: transform var(--t-micro) var(--ease), background var(--t-micro),
              box-shadow var(--t-micro), color var(--t-micro), border-color var(--t-micro);
  white-space: nowrap; will-change: transform;
  text-align: center;
}
.btn .icon { transition: transform var(--t-micro); }
.btn:hover .icon { transform: translateX(3px); }
/* Still 44px: .btn--sm is smaller in type and horizontal padding, but the
   header CTAs use it and a target floor is a floor (CLAUDE.md rule 21). */
.btn--sm { --btn-pad: .66em 1.05em; font-size: .85rem; min-height: 44px; }
.btn--lg { --btn-pad: 1.08em 1.9em; font-size: 1.02rem; }
.btn--block { display: flex; width: 100%; }

/* AYO'S `.btn--coral`, value for value — the owners asked for Apply, Book now
   and every button of that kind to match theirs. Renamed from `.btn--ochre`
   rather than left lying about its own colour; the 28 usages in views were
   renamed with it. The `--ochre` TOKEN is untouched because fifteen things that
   are not buttons still use it. */
.btn--coral { background: var(--coral); color: var(--on-coral); }
.btn--coral:hover { background: var(--coral-bright); transform: translateY(-2px); box-shadow: 0 16px 34px -14px rgba(255, 90, 56, .7); }
.btn--ghost { border: 1.5px solid var(--edge-light); color: var(--shell); }
/* `--coral-bright`, not `--hover-lume`, and it is the one hover on the site that
   needs the brighter step. This button lives in the HEADER, whose bar is
   translucent — composited over a pure-white pixel it lightens to rgb(38,55,61),
   where --coral is 4.08:1 and fails AA for a label. --coral-bright measures
   5.05 there and 7.62 on the drawer's dark gradient, so one value serves both
   places the button appears. It is also the step the nav's current-page marker
   already uses, so the header runs the same two-step orange Ayo's runs in
   green. */
.btn--ghost:hover { border-color: var(--coral-bright); color: var(--coral-bright); transform: translateY(-2px); }
.btn--glass { background: rgba(244, 237, 225, .10); color: var(--shell); border: 1.5px solid var(--edge-light); backdrop-filter: blur(8px); }
.btn--glass:hover { background: rgba(244, 237, 225, .18); transform: translateY(-2px); }
.btn--ink { background: var(--deep-900); color: var(--shell); }
.btn--ink:hover { background: var(--deep-700); transform: translateY(-2px); box-shadow: var(--shadow-soft); }
.btn--quiet { border: 1.5px solid var(--edge-ink); color: var(--deep-900); }
.btn--quiet:hover { border-color: var(--hover-ink); color: var(--hover-ink); transform: translateY(-2px); }

/* .btn--quiet and .btn--ink are specified for LIGHT surfaces (plan/03 §4). A
   card dropped into a --deep section would otherwise paint --deep-900 text on
   --deep-800 and vanish. Fail safe instead of failing silently. */
.section--deep .btn--quiet { border-color: var(--edge-light); color: var(--shell); }
.section--deep .btn--quiet:hover { border-color: var(--hover-lume); color: var(--hover-lume); }
.section--deep .btn--ink { background: var(--shell); color: var(--deep-900); }
.section--deep .btn--ink:hover { background: var(--shell-2); }

.btn[aria-disabled="true"], .btn:disabled { opacity: .5; pointer-events: none; }

/* Tactile feedback: hover lifts, press settles back down and compresses very
   slightly. Without this the buttons lift on hover and then do nothing on the
   actual click, which reads as unresponsive on touch where there is no hover
   at all. Transform-only, so it composites. */
.btn:active { transform: translateY(0) scale(.985); transition-duration: .06s; }
.acc__btn:active, .m-acc__btn:active, .nav__burger:active,
.mobile-menu__close:active, .footer__social a:active { transform: scale(.97); }

@media (prefers-reduced-motion: reduce) {
  .btn:active, .acc__btn:active, .m-acc__btn:active, .nav__burger:active,
  .mobile-menu__close:active, .footer__social a:active { transform: none; }
}

/* ---------- .stat — the evidence tile ---------- */
.stat-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: var(--s-6);
}
.stat { border-top: 1px solid var(--line-ink); padding-top: 18px; }
.section--deep .stat { border-top-color: var(--line-light); }
.stat__num {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  /* plan/03 §2 specifies clamp(2.2rem, 4vw, 3.4rem). Lowered after running the
     real values: "15,000 m²" is nine mono characters and overflowed a quarter
     of a 1240px shell at 3.4rem, wrapping the unit onto its own line. */
  font-size: clamp(2rem, 3.2vw, 2.9rem);
  font-weight: 500;
  letter-spacing: -.02em;
  line-height: 1;
  color: var(--seagrass-deep);
  display: block;
}
.section--deep .stat__num { color: var(--seagrass); }
.stat__label { font-weight: 500; font-size: .92rem; margin-top: .5em; color: var(--deep-900); }
.section--deep .stat__label { color: var(--txt-light); }
.stat__sub { font-size: .82rem; color: var(--txt-ink-dim); margin-top: .25em; }
.section--deep .stat__sub { color: var(--txt-light-dim); }

/* ---------- .prog — the programme card on a dark photo section ----------
   MOVED HERE from styles.css (14 Aug 2026), unchanged, because the science
   programme pages' "Other programmes" section now uses it and they do not load
   styles.css. The homepage's science section is the other user.

   `.prog__s` WAS `--txt-light-dim` AND THAT FAILED AA (fixed 15 Aug 2026, at
   the owner's instruction; the finding had been sitting in plan/16 §11).
   The summary sits straight on the scrimmed photograph with no card background
   under it, and `reef-sunrays` genuinely reaches PURE WHITE in the sunbeams —
   measured off the file, not assumed. At the scrim's .75 middle stop that put
   the old .54 alpha at **3.37:1** against the 4.5 body text needs.

   IT IS `--txt-light` AND NOT A BESPOKE ALPHA, because that is the token every
   other body copy on a `section-bg` scrim already uses and is already verified
   at 5.03:1 there (15 Aug). One vocabulary, one solved value.

   THE HOVER STATE IS WHAT RULES OUT THE OBVIOUS SMALLER BUMP. The whole card is
   a link and lightens by `rgba(244,237,225,.04)` on hover, which raises the
   background under the text. plan/16 §11 recommended .70 on the strength of a
   4.51:1 at-rest figure — measured hovered, .70 is **4.17:1 and still fails**.
   `--txt-light` measures 5.00 at rest and **4.59 hovered**, so it passes in
   both states. Any future change here must be measured in the hover state too. */
.prog-grid {
  display: grid; gap: var(--s-4);
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  margin-top: var(--s-7);
}
.prog {
  display: block; padding: var(--s-5);
  border: 1px solid var(--line-light);
  border-radius: var(--r);
  text-decoration: none;
  transition: border-color var(--t-micro) var(--ease), background var(--t-micro) var(--ease);
}
.prog:hover { border-color: var(--edge-light); background: rgba(244, 237, 225, .04); }
.prog .sketch { width: 62px; height: auto; color: var(--seagrass); margin-bottom: var(--s-3); }
.prog__t { font-size: 1rem; font-weight: 600; color: var(--shell); }
.prog__s { margin-top: var(--s-2); font-size: .875rem; line-height: 1.5; color: var(--txt-light); }

/* ---------- .mslide — a card grid that becomes a slider on a phone ----------
   MOVED HERE from styles.css (14 Aug 2026), unchanged, because /science's
   "Eight programmes" now uses it and that page does not load styles.css. This
   is the second block to make that trip today; `.prog` above is the first, and
   the reason is identical. The old note claimed it had to live in styles.css
   because that sheet is linked later — belt and braces, not a requirement:
   `.mslide > .prog-grid` is TWO classes against `.prog-grid`'s one and wins on
   specificity from anywhere.

   The owner's brief (14 Aug 2026): "instead of making all the 8 programmes
   below each other we need a sliding section with the arrows both side and
   slidable too, same as the village pictures", and the same for the four
   expedition lengths.

   A MEDIA QUERY OVER THE EXISTING GRID, NOT A SECOND SET OF MARKUP. The grid
   turns into the village's scroll-snap track below 700px and is an ordinary
   grid above it, so there is exactly one copy of each card in the document,
   the reading order never changes, and a phone with no JavaScript still gets a
   swipeable list rather than a broken one. main.js adds the arrows and the dots
   on top and removes them again above the breakpoint.

   700px IS THE VILLAGE SLIDESHOW'S OWN BREAKPOINT, which is the reason to
   reuse it rather than pick a third number: below it every one of these grids
   is a single column anyway, so the slider replaces a stack and never a row.

   THE EIGHT PROGRAMME PAGES' OWN "Other programmes" GRID IS STILL UNWRAPPED
   and so still a plain stack on a phone — it has no `.mslide`, which is what
   these selectors require. That is the existing decision, not an oversight. */
.mslide { position: relative; }

@media (max-width: 700px) {
  .mslide > .prog-grid,
  .mslide > .band-grid {
    display: flex;
    /* `grid-template-columns` is inert under display:flex, but leaving the
       declaration live means a future auto-fit edit reads as if it applies
       here. It does not. */
    grid-template-columns: none;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scroll-behavior: smooth;
    scrollbar-width: none;
    /* Room for a card's focus ring, which the overflow would otherwise clip —
       the same 3px the village track uses. */
    padding: 3px;
  }
  .mslide > .prog-grid::-webkit-scrollbar,
  .mslide > .band-grid::-webkit-scrollbar { display: none; }

  /* One card per screen, snapping centre — the village's own slide sizing. */
  .mslide > .prog-grid > *,
  .mslide > .band-grid > * { flex: 0 0 100%; scroll-snap-align: center; }
}

@media (prefers-reduced-motion: reduce) {
  .mslide > .prog-grid,
  .mslide > .band-grid { scroll-behavior: auto; }
}

/* ---------- .card--field — the field-notebook card ---------- */
.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 28px;
}
/* Science hub: 8 cards → 4 / 2 / 1 (plan/03 §8) */
.card-grid--science { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 1100px) { .card-grid--science { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 720px)  { .card-grid--science { grid-template-columns: 1fr; } }

.card--field {
  position: relative;
  background: var(--shell-2);
  border: 1px solid var(--line-ink);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-soft);
  padding: var(--s-6) var(--s-5) var(--s-5);
  display: flex; flex-direction: column; gap: var(--s-3);
  transition: transform var(--t-micro) var(--ease), box-shadow var(--t-micro) var(--ease);
}
.card--field:hover { transform: translateY(-4px); box-shadow: var(--shadow); }
.card--field__index {
  position: absolute; top: 14px; left: 20px;
  font-family: var(--font-mono);
  font-size: .78rem; letter-spacing: .12em;
  /* was --sand: a decorative token on the card's light --shell-2, i.e. the
     same invisible-index bug as .support-option__index. The hover state below
     already reached for --seagrass-deep, which is the on-light accent. */
  color: var(--txt-ink-dim);
  transition: color var(--t-micro);
}
.card--field:hover .card--field__index { color: var(--hover-ink); }
.section--deep .card--field__index { color: var(--txt-light-dim); }
.section--deep .card--field:hover .card--field__index { color: var(--hover-lume); }
.card--field h3, .card--field .h3 { margin-top: var(--s-4); } /* .h3: heading-order fix, 16 Aug */
.card--field p { color: var(--txt-ink); font-size: .95rem; }
.card--field__foot { margin-top: auto; padding-top: var(--s-3); }
.card--field a.card__link::after { content: ""; position: absolute; inset: 0; border-radius: var(--r-lg); }

/* THE INSIDES OF CARDS IN A ROW HAVE TO LINE UP, AND THEY DID NOT. Each card
   stacked its own contents independently, so a title that wrapped to two lines
   pushed that card's summary down while its neighbours' stayed put. Measured at
   1440: on /science the summaries were out by **23px in the first row of four
   and 71px in the second** ("Crown-of-Thorns Starfish" wraps to four lines in a
   261px card against "Marine Debris" on one); on /science/reports the Download
   buttons were out by **35px**, because two of the three reports carry an extra
   "Also in Bahasa Indonesia" line inside a bottom-aligned foot and the third
   does not.

   Same mechanism as the paired form rows: the row owns the tracks and each card
   borrows them, so a long title reserves the same height in every card beside
   it. `span 5` covers the deepest card in the family (sketch, heading, summary,
   foot — plus one spare); a card with fewer children simply leaves the last
   track empty at zero height, which is why one number works for every grid.
   `row-gap` is restated because a subgrid inherits its parent's gutters, and
   the 28px BETWEEN cards is far too much between a heading and its summary.

   Guarded twice: single-column layouts have nothing to align, and without
   `subgrid` every grid keeps exactly the flex stacking it had before. */
@supports (grid-template-rows: subgrid) {
  @media (min-width: 721px) {
    .card-grid > .card--field {
      display: grid;
      grid-template-rows: subgrid;
      grid-row: span 5;
      row-gap: var(--s-3);
    }
    /* `margin-top: auto` bottom-aligned the foot when the card was a flex
       column, and it has to go now that the row owns the tracks: it pushed a
       short foot to the BOTTOM of a track sized by a taller one, which is
       precisely the 35px that kept the three Download buttons out of line on
       /science/reports. The track already holds the foot at a shared height. */
    .card-grid > .card--field > .card--field__foot { margin-top: 0; }
  }
}

.section--deep .card--field { background: var(--deep-800); border-color: var(--deep-600); }
.section--deep .card--field p { color: var(--txt-light); }
.section--deep .card--field:hover .card--field__index { color: var(--hover-lume); }

/* ---------- Photo card: a photograph behind the words ----------------------
   Owner, 15 Aug 2026, first for the blog — "put some pictures on the card as
   background. Put the Read button on the same bottom area in all of them" —
   then for the liveaboard itineraries.

   NAMED `card--photo`, NOT `card--blog`, BECAUSE IT HAS TWO USERS. It was
   `card--blog` for a few hours; the second ask arrived and the choice was one
   implementation or a second copy of it, which is rule 16 one level up.

   EVERY PICTURE IS ONE THE SUBJECT CAN HONESTLY CLAIM (rule 22). The blog
   cards carry each post's own cover from the old site; the itinerary cards
   carry a dive site that route actually visits.

   THIS CARD OPTS OUT OF THE SUBGRID ALIGNMENT ABOVE, and that is the whole
   answer to "the same bottom area". Subgrid lines up children TRACK BY TRACK,
   which needs every card to have the same children — and these do not: the
   Misool report has no excerpt, so its foot fell into the excerpt's track and
   its Read button sat a line higher than the other eight. A flex column with
   `margin-top: auto` on the foot pins the button to the bottom of the CARD
   instead, and the grid already stretches every card in a row to one height,
   so all nine land on one line however much text is above them.
   Three classes, to beat the two-class subgrid rule inside its @supports. */
.card--photo {
  overflow: hidden;
  /* 30rem leaves a 180–200px band of clear photograph above the copy at
     every width — measured, not guessed: the copy block settles at 246–320px
     depending on how the title and excerpt wrap, so a shorter card showed as
     little as 20% picture and the pictures were the point of the ask. */
  min-height: 30rem;
  border-color: var(--deep-600);
  /* The floor under the scrim, so a photograph that fails to load — or one
     with a bright corner — still has something solid behind the text. */
  background: var(--deep-900);
}
/* THE COPY IS BOTTOM-ANCHORED, and that is what lets the photograph show.
   An even wash dark enough for text everywhere leaves only ~25% of the picture
   visible — which defeats the point of putting one there. Anchoring the words
   to the foot puts them in the bottom band where the scrim is strong, and
   leaves the top of the card nearly clear. */
.card-grid > .card--field.card--photo {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  grid-row: auto;
  row-gap: 0;
  padding: 0;
}
/* WITHOUT THIS, `hidden` DOES NOTHING AND THE FILTER FILTERS NOTHING.
   The UA sheet's `[hidden] { display: none }` loses to any author `display`
   rule, and the one directly above is three classes deep. The card kept its
   `display: flex` and stayed on screen however the attribute was toggled —
   which is exactly what the owner saw. Third recording of this trap in the
   project: booking.css:225 says the same thing about `.btn`, and apply.css
   and booking.css both carry their own `[hidden]` rules for the same reason.

   ANY NEW `display` ON A TOGGLEABLE ELEMENT NEEDS ITS OWN `[hidden]` PARTNER. */
.card-grid > .card--field.card--photo[hidden] { display: none; }
/* THE READ BUTTON ON ONE LINE ACROSS THE ROW, which is the owner's second ask.
   The grid stretches every card in a row to one height, the body is pushed to
   the bottom by its own `margin-top: auto`, and the foot is the body's last
   child — so all three feet land together however much text is above them.
   `margin-top: auto` INSIDE the body would fight that by stretching one card's
   body taller than its neighbours', so the foot just sits in the flow. */
.card-grid > .card--field.card--photo .card--field__foot { margin-top: 0; padding-top: 0; }

.card--photo__bg { position: absolute; inset: 0; z-index: 0; }
.card--photo__bg img,
.card--photo__bg picture { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }

/* THE SCRIM HUGS THE COPY, WHICH IS THE WHOLE TRICK.
   A card-wide gradient has to guess where the text begins, and it guesses
   wrong: measured across five widths, the category label lands anywhere from
   22% to 34% down the card depending on how the title wraps, and a
   bottom-weighted wash is still nearly clear up there — the label came out at
   1.01:1. Sizing the scrim to the copy block instead makes the alpha under
   every line a CONSTANT, so contrast stops depending on layout at all, and the
   photograph above the words keeps its own light.

   Solved against a PURE WHITE pixel, not against these nine photographs, so a
   picture the owners swap in later cannot break the text. */
.card--photo__body {
  position: relative;
  z-index: 2;
  margin-top: auto;
  padding: var(--s-6) var(--s-5) var(--s-5);
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  background: color-mix(in srgb, var(--deep-900) 88%, transparent);
}
/* A soft lead-in above the block so the wash does not start on a hard line.
   Purely cosmetic — no text ever sits in it, which is why it can be gentle. */
.card--photo__body::before {
  content: "";
  position: absolute; left: 0; right: 0; bottom: 100%;
  height: 4.5rem;
  background: linear-gradient(to bottom,
    transparent 0%,
    color-mix(in srgb, var(--deep-900) 88%, transparent) 100%);
}
/* The photograph sits under everything. */
.card--photo > .card--photo__bg { z-index: 0; }

/* --seagrass-bright, NOT --seagrass, and the reason is rule 18 one step
   further on. --seagrass is "on dark only", meaning on --deep-900 at full
   strength, where it measures 7.25:1. Here it sits on a TRANSLUCENT wash over
   an unknown photograph, and against a pure white pixel it measures 3.15:1 —
   a fail on 4.5. --seagrass-bright is the palette's existing on-dark emphasis
   step and clears it. Same two-token construction as --coral/--coral-bright in
   the header, for the same reason: one value cannot serve both a solid surface
   and a translucent one. */
.card--photo .card--field__cat { color: var(--seagrass-bright); }
.card--photo h3, .card--photo .h3 { color: var(--txt-light); margin-top: var(--s-3); }
.card--photo p { color: var(--txt-light); }
.card--photo .btn--quiet { color: var(--txt-light); border-color: var(--edge-light); }
.card--photo:hover .btn--quiet { color: var(--hover-lume); border-color: var(--hover-lume); }

/* A post with no cover keeps the same shape and just shows the deep surface —
   no empty frame, no shorter card breaking the row. */
.card--photo--none { background: var(--deep-800); }

/* The photograph lifts very slightly with the card. Purely decorative, so it
   goes when the reader has asked for less movement. */
@media (prefers-reduced-motion: no-preference) {
  .card--photo__bg img { transition: transform 1.2s var(--ease); }
  .card--photo:hover .card--photo__bg img { transform: scale(1.04); }
}

/* The live count under the chips, and the all-filtered-out message.
   `.empty` happens to set no `display` today, so `hidden` works on it — but
   this element is toggled by the same code that the card's missing `[hidden]`
   rule caught out, and a `display` added to `.empty` later would break it in
   exactly the same silent way. Stated, not left to luck. */
.filter-count { margin-top: var(--s-4); }
.empty--filter { margin-top: var(--s-5); }
.empty--filter[hidden] { display: none; }

/* ---------- Tables ---------- */
.table-wrap { margin-block: var(--s-5); }
.table--data { font-size: .95rem; }
.table--data thead th {
  font-family: var(--font-mono);
  text-transform: uppercase;
  font-size: .72rem;
  letter-spacing: .16em;
  font-weight: 500;
  text-align: left;
  color: var(--txt-ink-dim);
  padding: 10px 14px;
  border-bottom: 1.5px solid var(--edge-ink);
}
.table--data td { padding: 14px; border-bottom: 1px solid var(--line-ink); vertical-align: top; }
.table--data tbody tr:nth-child(odd) { background: var(--shell-3); }
.table--data .is-num { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

/* A TWO-COLUMN TABLE MUST NOT SPAN THE WHOLE SHELL. `table { width: 100% }` in
   the reset is right for a dense table and wrong for a sparse one: the
   equipment rental list is a label and a price, so at 1440 "Buoyancy Control
   Device (BCD)" sat 1,100px from its own "£20" and the eye had to cross the
   page to pair them. Six rows of that, held together only by the zebra stripe.
   Capped at a width where the row reads in one fixation.
   Matched by shape rather than by a modifier class — `th:nth-child(2)` that is
   also `:last-child` is a header row with exactly two cells — so the rule is
   self-maintaining and cannot be forgotten on a future two-column table. It
   matches exactly one table on the site today (equipment rental); the 3-, 4-,
   5- and 8-column tables are dense and keep the full width.
   640 against a measured 520 wrap point for the longest item, so a longer item
   name from the CMS has room before it breaks. Inert below 720px, where the
   table is already stacked into definition rows. */
.table--data:has(thead th:nth-child(2):last-child) { max-width: 640px; }
.section--deep .table--data thead th { color: var(--txt-light-dim); border-bottom-color: var(--edge-light); }
.section--deep .table--data td { border-bottom-color: var(--line-light); }
.section--deep .table--data tbody tr:nth-child(odd) { background: var(--deep-800); }

/* A link in a data cell has to LOOK like a link. The "Itinerary" links on
   /liveaboard/schedules carry `.note`, which is deliberately quiet grey mono for
   captions, so the only thing marking fifteen of them as tappable was that they
   happened to be anchors. Site convention for a link in running text is
   --seagrass-deep + underline (`.prose a`), so they get that. Not scoped to
   mobile — it was just as invisible on desktop; the mobile half of the fix is
   the 44px target in the stacked block below. */
.table--data td a {
  color: var(--seagrass-deep);
  text-decoration: underline;
  text-underline-offset: .16em;
}
.section--deep .table--data td a { color: var(--seagrass); }

/* Stack into definition rows on small screens. A price table must NEVER
   horizontal-scroll on a phone (plan/03 §4, §9). */
@media (max-width: 720px) {
  .table--data thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
  .table--data tr {
    display: block;
    border: 1px solid var(--line-ink);
    border-radius: var(--r-sm);
    padding: var(--s-3);
    margin-bottom: var(--s-3);
    background: var(--shell-2);
  }
  .table--data tbody tr:nth-child(odd) { background: var(--shell-2); }
  .table--data td { display: flex; gap: var(--s-4); justify-content: space-between; align-items: baseline; border: 0; padding: 6px 0; text-align: left; }
  /* A link inside a stacked cell is the smallest target on the site: the
     "Itinerary" links on /liveaboard/schedules measured 71×19px, below even
     WCAG 2.2's 24px minimum, and there are fifteen of them. The cell is a flex
     row, so stretching the link to 44px costs nothing horizontally and the
     text stays optically on the same baseline as its label. */
  .table--data td a {
    display: inline-flex; align-items: center;
    min-height: 44px;
  }
  .table--data td:has(a) { align-items: center; }
  .table--data td::before {
    content: attr(data-label);
    font-family: var(--font-mono);
    text-transform: uppercase;
    font-size: .68rem;
    letter-spacing: .14em;
    color: var(--txt-ink-dim);
    flex: none;
  }
  .table--data .is-num { text-align: right; }
  .section--deep .table--data tr,
  .section--deep .table--data tbody tr:nth-child(odd) { background: var(--deep-800); border-color: var(--deep-600); }
}

/* ---------- Forms ---------- */
/* `--s-4` between fields rather than `--s-5`. The gap between one field and the
   next is the other half of "the forms are too large": on a 30-field form the
   spacing costs more height than the controls do. The label-to-control gap
   stays at 6px — tightening that pair is what makes a label look detached from
   the box it names. */
.field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 18px; }
/* Ayo Raja Ampat Divers' `.field > label`, value for value: mono, .7rem, .12em,
   uppercase, weight 500. The owners chose it after seeing the two form
   vocabularies side by side.

   `:not(.field--check)` IS LOAD-BEARING. A checkbox row carries `.field` too,
   and its label is a full sentence — a consent line, a rental item, a radio
   choice. Setting those in uppercase mono at 11px would be unreadable, and the
   existing `.field--check label { font-weight: 400 }` only wins on WEIGHT
   (equal specificity, later in the file), so the face and the transform would
   still have landed on them. */
.field:not(.field--check) > label {
  font-family: var(--font-mono);
  font-size: .7rem; font-weight: 500;
  letter-spacing: .12em; text-transform: uppercase;
  color: var(--txt-ink);
}
/* An on-light token needs all three dark surfaces, every time — the failure
   this file records for `.note`, `.btn--quiet`, `.eyebrow--light` and `.todo`.
   The label had no colour of its own before, so it inherited and this is the
   first time it can be wrong. */
.section--deep .field:not(.field--check) > label,
.cta-full .field:not(.field--check) > label,
.site-footer .field:not(.field--check) > label { color: var(--txt-light); }
/* Ayo's hint: the BODY face at .8rem, not mono. `text-transform`/`letter-spacing`
   are reset explicitly because the label above them is now uppercase and tracked.
   THE COLOUR IS DELIBERATELY NOT AYO'S: they set `--txt-ink` at `opacity: .72`,
   which composites to 3.81:1 on this cream and fails AA for text this size.
   `--txt-ink-dim` measures 7.15:1 and is what a hint is read at here.
   The 60ch cap stays (hints ran to 111ch on /apply). Note it is no longer 60
   literal characters — `ch` is the advance of "0", so in the proportional face
   60ch renders about 67 characters. Still inside the measure, and narrower than
   prose at 68ch. */
.field .hint {
  font-family: var(--font-body); font-size: .8rem;
  color: var(--txt-ink-dim);
  text-transform: none; letter-spacing: 0;
  max-width: 60ch;
}
.field input[type="text"], .field input[type="email"], .field input[type="tel"],
.field input[type="date"], .field input[type="number"], .field input[type="password"],
.field select, .field textarea {
  background: var(--shell-2);
  border: 1.5px solid var(--edge-ink);
  border-radius: var(--r-sm);
  /* 10px, not the 14px this shipped with. The owners' note was that the forms
     are too large, and a 52px control is what a long form is mostly made of —
     the booking form alone stacks 30 of them. This lands them at 46px, which
     still clears rule 21's 44px floor with room, so it is a size change and
     never a target-size regression. Do not take the padding below 10px: at 8px
     the box measures 42px and fails.
     THE FONT SIZE IS NOT PART OF THIS. 16px is the iOS zoom floor, not a taste
     decision — anything smaller makes Safari zoom the page on focus. */
  padding: 10px 14px;
  font-size: 16px;
  width: 100%;
  /* DECLARED, NOT ARITHMETIC. At this padding a <select> lands on exactly 44px
     — passing, with no margin at all. A font that loads at a different metric,
     or a browser that rounds the line box down, would drop it under the floor
     with nothing in the stylesheet to stop it. State the floor. */
  min-height: 44px;
  transition: border-color var(--t-micro), box-shadow var(--t-micro);
}
.field textarea { min-height: 104px; resize: vertical; }
.field input:focus-visible, .field select:focus-visible, .field textarea:focus-visible {
  outline: 2.5px solid var(--seagrass-deep);
  outline-offset: 2px;
  border-color: var(--seagrass-deep);
}
.field--error input, .field--error select, .field--error textarea { border-color: var(--clay); }
.field__error { color: var(--clay-deep); font-size: .86rem; font-weight: 600; }
.section--deep .field__error { color: var(--clay-bright); }

/* The input itself is 20px, but the <label> is associated via `for`, so the
   LABEL is the clickable target — not the row. `min-height: 44px` on
   `.field--check` therefore sized the wrong box: measured on /apply, fourteen
   checkboxes had a 44px row containing a 36px hit area. The floor belongs on
   the label. */
.field--check { flex-direction: row; align-items: flex-start; gap: 10px; min-height: 44px; padding-block: 4px; }
.field--check label { display: flex; align-items: center; min-height: 44px; }
.field--check input[type="checkbox"], .field--check input[type="radio"] { width: 20px; height: 20px; margin-top: 2px; flex: none; accent-color: var(--seagrass-deep); }
.field--check label { font-weight: 400; }

/* A checkbox row that also carries a hint or an error. The label keeps its 36px
   row so the touch target survives; the hint and error stack under it rather
   than pushing the box off its own line. */
.field__checkbody { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.field__checkbody label { align-items: flex-start; }
.field__checkbody .hint { margin: 0; }
/* THE CONSENT SENTENCES NEEDED A MEASURE AND WERE THE ONE BODY TEXT LEFT
   WITHOUT ONE. The 8 Aug pass capped `.note` and `.hint` at 60ch and did not
   reach these, because they are `<label>` elements rather than prose. Measured
   on /apply at 1440: the medical declaration ran **99 rendered characters a
   line**, sitting directly above its own hint at 73 — so the longest line on
   the page belonged to the sentence a reader is being asked to agree to.
   56ch on this face lands at ~70, just inside the hint. Short labels (the
   rental items, the two dive-stream radios) are far under the cap and do not
   move; the target stays far larger than the 44px floor. */
.field--check label { max-width: 56ch; }

/* `.field__opt` WAS HERE AND IS GONE. It marked the fields that could be
   SKIPPED, on the argument that "on a 40-field form nearly everything is
   required, and forty red asterisks read as forty warnings". The owner asked
   for the asterisk directly on 16 Aug 2026, and the count that worried that
   note was never the real one — /apply has 14 required answers against 12
   optional. partials/form-field.ejs now marks the required ones with
   `<span class="req">*</span>`, which is what the booking form has always done,
   so the two forms speak one convention. Nothing referenced `.field__opt` once
   the partial stopped emitting it.

   `.req` IS DECLARED HERE AND NOT IN form-shell.css, because the partial that
   emits it is site-wide and /contact uses it too — that page loads neither of
   the form stylesheets, and an unstyled asterisk is a typo on a label.

   `--hover-ink` AND NOT `--clay-deep`, WHICH IS THE TRAP THE BOOKING PAGE SETS.
   Ayo marks required fields with their `--coral`, and `.palette-ayo` faithfully
   remaps `--clay-deep` to that coral — which measures 2.78:1 on this cream and
   fails AA for a mark that carries information. `--hover-ink` is declared on
   :root as a var() of the UNMAPPED `--clay-deep`, so it resolves to the
   palette's on-light orange (#A33F1D, 5.72:1) and keeps its value inside that
   scope. Same colour family, same design, legible. Measured, not guessed. */
.req { color: var(--hover-ink); }
/* The third on-light token needs its dark surfaces, every time — the failure
   this file records for `.note`, `.btn--quiet` and the field label above. */
.section--deep .req,
.cta-full .req,
.site-footer .req { color: var(--coral-bright); }

/* Two fields side by side, stacking well before they get cramped. */
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--s-5); }

/* THE TWO BOXES IN A ROW HAVE TO LINE UP, AND THEY DID NOT. Each field stacked
   its own label, hint and control independently, so a field carrying helper
   text pushed its box down and its partner's did not follow. Measured on
   /apply at 1440: **three of the eight paired rows were out by 27 to 49px** —
   email against mobile number, date of birth against nationality, logged dives
   against last dive. Nothing overflowed and no check caught it; the form simply
   looked misaligned.

   `subgrid` is the fix rather than a nudge: the row owns four tracks — label,
   hint, control, error — and each field borrows them, so a hint on one side
   reserves the same height on the other and every box sits on one line. The
   cost is a visible gap under the hintless label, which is the honest price of
   an aligned row and much quieter than a 49px step between neighbouring boxes.
   Assigning rows explicitly (rather than letting auto-placement fill them) is
   what makes it hold when only one field carries an error.

   Guarded twice: below 700px the row is a single column and each field stacks
   normally, and without `subgrid` the page keeps exactly what it had before. */
@supports (grid-template-rows: subgrid) {
  @media (min-width: 701px) {
    .field-row { grid-template-rows: auto auto auto auto; row-gap: 7px; }
    .field-row > .field { display: grid; grid-template-rows: subgrid; grid-row: span 4; gap: 0; }
    .field-row > .field > label { grid-row: 1; }
    .field-row > .field > .hint { grid-row: 2; }
    .field-row > .field > input,
    .field-row > .field > select,
    .field-row > .field > textarea { grid-row: 3; }
    .field-row > .field > .field__error { grid-row: 4; }
  }
}
@media (max-width: 700px) { .field-row { grid-template-columns: 1fr; } }

/* A radio group is a fieldset so the question is announced with each option. */
.radio-set { border: 0; padding: 0; margin: 0 0 var(--s-5); }
.radio-set > legend { font-family: var(--font-body); font-size: .94rem; font-weight: 600; margin-bottom: var(--s-3); }
.radio-set .field--check { margin-bottom: var(--s-3); }

.file-input {
  width: 100%; font-size: .92rem; padding: 12px 14px;
  background: var(--shell-2); border: 1.5px dashed var(--edge-ink); border-radius: var(--r-sm);
}
.file-input::file-selector-button {
  font: inherit; font-weight: 600; cursor: pointer;
  margin-right: 12px; padding: 9px 14px; min-height: 40px;
  background: var(--shell); color: var(--txt-ink);
  border: 1.5px solid var(--edge-ink); border-radius: var(--r-sm);
}
.file-input:focus-visible { outline: 2.5px solid var(--seagrass-deep); outline-offset: 2px; }

.thanks-ref { font-size: .82rem; color: var(--txt-light-dim); margin-top: var(--s-5); }

/* Error summary above a re-rendered form. Every message is also shown inline
   next to its own field; this exists so the errors are announced at once on a
   long form and so each one is a link straight to the control that caused it. */
.form-errors { margin-bottom: var(--s-6); }
.form-errors__list { display: grid; gap: 4px; margin-top: var(--s-3); }
/* Underlined on purpose. These read as a list of complaints; without the
   underline nothing says each one is a jump to the field that caused it, and
   colour alone is not an affordance (1.4.1). */
.form-errors__list a {
  color: var(--clay-deep); font-weight: 600;
  text-decoration: underline; text-underline-offset: 3px;
}
.form-errors:focus-visible { outline: 2.5px solid var(--seagrass-deep); outline-offset: 3px; }

/* Honeypot — visually hidden but not display:none (bots skip display:none) */
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

fieldset { border: 0; padding: 0; margin: 0 0 var(--s-7); }
legend { font-family: var(--font-display); font-size: 1.3rem; font-weight: 600; margin-bottom: var(--s-4); }

.form-errors {
  border: 1.5px solid var(--clay);
  background: rgba(194, 81, 44, .07);
  border-radius: var(--r-sm);
  padding: var(--s-4);
  margin-bottom: var(--s-5);
  color: var(--clay-deep);
}

/* ---------- Header ---------- */
/* THE HEADER BAR IS AYO'S, at the owners' request: `rgba(3, 23, 29, .86)` with
   a blur behind it, and its hairline always drawn — Ayo's `.site-header--solid`
   value for value. Ours was a SOLID `--deep-900`, which is why the two never
   looked alike however the type was set: theirs lets the page move underneath.

   `position: sticky` is kept where Ayo uses `fixed`. Ours is in flow and `--hdr`
   is measured from it; going fixed would take the header out of the flow and
   shift every page by 69px. That is a layout decision, not the colour the
   owners asked about.

   THE BAR IS NO LONGER A KNOWN COLOUR, and that is the whole cost of the
   change. Composited over the brightest thing that can scroll under it — a pure
   white pixel — it lightens to rgb(38,55,61). Everything in the header was
   re-measured against THAT, not against the old solid: nav link 8.38, hover
   10.72, current-page 9.23, the underline 7.34, the ghost label 11.02. All
   clear. The one that did not is handled below. */
.site-header {
  position: sticky; top: 0; z-index: 90;
  background: rgba(3, 23, 29, .86);
  backdrop-filter: blur(14px) saturate(120%);
  -webkit-backdrop-filter: blur(14px) saturate(120%);
  border-bottom: 1px solid var(--line-light);
  transition: background var(--t-micro), border-color var(--t-micro);
}
/* Without backdrop-filter the bar is just 86% translucent with no blur, so busy
   content reads straight through the nav. Fall back to opaque. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .site-header { background: #03171d; }
}
/* Ayo's solid header does not change on scroll beyond its padding, and it has
   no shadow. Ours dropped its shadow to match; the hairline is always drawn —
   except on the homepage, below. */

/* ---------- The homepage header dissolves into its own hero ----------
   Ayo's content pages carry `--solid`; their homepage overrides it to a
   transparent bar that gains its background on scroll, so the hero reads as one
   piece. The owners asked for the same here.

   TWO HEROES USE THIS NOW: the twelve-week timeline on /expedition, and the
   homepage's full-viewport island hero (`.hero-island`, 13 Aug 2026 — the owner
   asked for the same behaviour there). Both pull their frame up under the
   header with a negative top margin; that is the precondition, because a
   transparent bar over a page that starts BELOW it is just cream nav on cream.

   SCOPED BY `:has(.weeks--live)`, NOT BY A PAGE CLASS. The transparent state is
   only honest while the timeline is PINNED — that is the one mode where
   `.weeks` pulls the frame up under the header and there is a hero behind
   it at all. It is also what let the timeline MOVE: it lived on the homepage
   under a `.home` body class until 13 Aug 2026, and when the owner moved it to
   /expedition this chrome followed it without a single selector changing
   meaning. Keep it keyed to the timeline, never to a page. In the stacked modes (a frame too short to hold a
   stage, JS off) the negative margin is reset to 0, so nothing is behind the
   bar and a transparent header would be cream nav on a cream page. `:has()`
   re-evaluates when `stagesFit()` changes its mind, and a browser without it
   simply keeps the solid bar — the safe direction.

   THE SCRIM IS NOT OPTIONAL AND ITS ALPHA IS MEASURED, NOT CHOSEN. Ayo's hero
   is a dark dive scene, so their header needs nothing behind it. Ours is the
   sunny aerial: compositing the served map through its own
   `brightness(1.34) saturate(1.14) contrast(.94)`, the `.map__tint` wash and
   `.weeks__grade`, then sampling the 69px the header covers, the brightest
   pixel under the bar is rgb(248,247,244) — effectively white — and it lands
   under the middle and right of the bar at 1440 and 1600, and under most of it
   at 375 and 320. Cream nav on that is unreadable.
   Solved against a PURE WHITE backdrop for headroom: at .70 the nav link
   measures 4.93, the current-page link 5.15 and the underline 4.10.

   What makes a header read as a separate bar is the hairline, the hard bottom
   edge and the blur rectangle — not the darkness. All three go; the scrim holds
   through the text and dissolves below the bar, which is what a photographic
   hero does. */
/* 14 Aug 2026: EVERY hero page now takes this state (owner: "make same like
   Home Page that the header's background appear after start scrolling").
   `:has(.page-hero)` covers the homepage too (`.hero-island` carries the
   class) and every content hero, which are all pulled up under the bar below.
   The bar's own ::before scrim is what keeps this safe on any hero: it was
   solved against a PURE WHITE backdrop, so no individual photograph has to be
   re-measured. Pages with no hero — the liveaboard title-bar pages, the
   admin, 404 — keep the solid bar. */
.page:has(.weeks--live) .site-header:not(.scrolled),
.page:has(.page-hero) .site-header:not(.scrolled) {
  background: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  border-bottom-color: transparent;
  /* `--edge-light` at .46 was solved against the .86 bar. On the lighter scrim
     it falls to 2.56 and misses WCAG 1.4.11's 3:1 for the two controls in here
     drawn with a line — the "Support us" outline and the burger. .55 is the
     first alpha that clears it; .58 is used for the same headroom the .46
     carries. */
  --edge-light: rgba(244, 237, 225, .58);
}
/* The scrim overruns the bar by 90% of its height so the fade finishes well
   clear of the type. `z-index: -1` keeps it behind the nav inside the header's
   own stacking context. */
.page:has(.weeks--live) .site-header::before,
.page:has(.page-hero) .site-header::before {
  content: "";
  position: absolute; inset: 0 0 auto 0; height: 190%;
  z-index: -1; pointer-events: none;
  opacity: 0;
  transition: opacity var(--t-micro);
  background: linear-gradient(180deg,
    rgba(3, 23, 29, .70) 0%,
    rgba(3, 23, 29, .70) 36%,
    rgba(3, 23, 29, .40) 62%,
    rgba(3, 23, 29, 0) 100%);
}
.page:has(.weeks--live) .site-header:not(.scrolled)::before,
.page:has(.page-hero) .site-header:not(.scrolled)::before { opacity: 1; }

/* `--edge-light` is measured at 3.01:1 against a SOLID `--deep-900` — it had no
   margin, and the translucent bar takes it to 2.75:1 over white content, under
   the 3:1 that WCAG 1.4.11 asks of a control boundary. It is the outline on the
   "Support us" button and on the burger, and those are the only two controls in
   here that are drawn with a line rather than a fill.
   Solved rather than nudged: .40 is the first alpha that clears 3.0 on the
   lightest possible bar (3.07). .46 is used for headroom — 3.58 on the lightest
   bar, 4.18 on the darkest. Scoped to the header, so the footer and the dark
   sections keep the measured global value. */
.site-header {
  --edge-light: rgba(244, 237, 225, .46);
  /* Ayo's hairline is rgba(243,239,228,.14) against our .15 of a slightly
     different cream. Four RGB units and a hundredth of alpha, matched here so
     the bar is theirs down to the rule under it; the global token is untouched
     for every other dark surface. */
  --line-light: rgba(243, 239, 228, .14);
}
.nav { display: flex; align-items: center; gap: 24px; padding-block: 12px; }

/* min-height keeps the logo link itself on the 44px target floor even though
   the artwork inside it is shorter.
   `flex: 0 1 auto` + `min-width: 0`, NOT `flex: none`: the logo is the only
   elastic thing in the header, so it has to be the thing that yields. With
   `flex: none` it held its full 173px at every width and pushed the Apply
   button and the burger off the right-hand edge instead — measured 10px past
   the viewport at 320px, on every page. Now the header can never overflow
   whatever the artwork or the button labels do; the logo just gets smaller,
   and `object-fit: contain` is what keeps it from squashing when it does
   (the global `img { max-width: 100% }` would otherwise cap the width while
   `height` stayed fixed). */
.brand { display: flex; align-items: center; gap: 12px; flex: 0 1 auto; min-width: 0; min-height: 44px; }
.brand__mark { height: 42px; width: auto; object-fit: contain; }
.site-footer .brand__mark { height: 34px; }

/* THE LOGO IS LARGER ON LANDING AND SNAPS BACK THE MOMENT YOU SCROLL, at the
   owner's request (13 Aug 2026). `.scrolled` is already written by site.js at
   y > 24, so this rides on the class the header state machine already has.

   A TRANSFORM, NOT A HEIGHT, AND THAT IS THE WHOLE SAFETY OF IT. A taller
   `.brand__mark` grows `.nav`, which grows the header, which changes `--hdr`
   in fact but not in the token — and `--hdr` is what the pinned timeline's
   negative margin, both hero pages' offsets, /apply's sticky rail and every
   `scroll-margin-top` are built on. Worse, the flow box would change AS YOU
   SCROLL, so the whole page below would jump 40-odd pixels the instant the
   class landed and ScrollTrigger's cached measurements would be wrong. A
   transform paints bigger and lays out identically: the header is 69px at
   every scroll position, on every page, exactly as it was.

   1.5x AND NOT THE 2x ASKED FOR, and this is measured rather than chosen. The
   logo shares its line with the menu, and at 1440 there are only 132px of slack
   between them — 2x needs 173px more, so it would run under "The Expedition".
   Max safe scale by width: 1.76 at 1250+, 1.53 at 1200, 1.25 at 1151 (the
   tightest, just before the burger takes over), 1.76-5.27 from 1150 down to
   480 where the links are gone, then 1.42 at 414 and 1.20 at 375. 1.5 clears
   everything except two narrow bands, which step down; both are measured, not
   guessed. At 1.5 the mark is 63px inside a 69px bar, so it fills the header
   without overflowing it — nothing is covered on any page and no content moves.
   Going bigger than the bar means growing the bar, which on the 28 pages with a
   solid header would paint over the top of the page. */
.brand__mark { transform-origin: left center; transition: transform .25s var(--ease); }
.site-header:not(.scrolled) .brand__mark { transform: scale(1.5); }
/* The desktop nav only just fits below 1280 — it needs 609px and the burger
   does not take over until 1150 (see the breakpoint note further down; three
   places carry that number). */
@media (min-width: 1151px) and (max-width: 1279px) {
  /* 1.2 measured 8px of clearance at 1151 — passing, and one font metric from
     touching. 1.15 buys 17px for a difference nobody can see. */
  .site-header:not(.scrolled) .brand__mark { transform: scale(1.15); }
}
/* Phones: the Apply pill and the burger are the constraint, not the links. */
@media (max-width: 479px) {
  .site-header:not(.scrolled) .brand__mark { transform: scale(1.1); }
}
@media (prefers-reduced-motion: reduce) { .brand__mark { transition: none; } }

.nav__links { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.nav__item { position: relative; }
/* THE NAV TYPE IS AYO RAJA AMPAT DIVERS', at the owners' request — theirs reads
   clearer, and measured, that is exactly what it is: their `.nav__links a` is
   `--foam` at `opacity: .86` and .96rem, rendering rgb(209,209,200) at 11.93:1.
   Ours was `--txt-light` (.76 alpha) at .93rem, rendering rgb(187,185,175) at
   9.51:1 — a fifth dimmer and half a pixel smaller.
   Written as one rgba rather than `color` + `opacity` because these links carry
   a chevron icon and `opacity` would fade that with the text; Ayo's links have
   no icon, so the two constructions are only equivalent here.
   Measured after: rgb(210,208,199) at 12.12:1. */
.nav__link {
  display: inline-flex; align-items: center; gap: 5px;
  /* GEOMETRY UNCHANGED ON PURPOSE. `--hdr` is 69px and the homepage's pinned
     timeline is built on it — the frame maths, the sticky offsets and every
     `scroll-margin-top` read it — so the box this text sits in must not move.
     Only the text and the hover treatment change. */
  padding: 10px 12px;
  min-height: 44px;
  font-size: .96rem; font-weight: 500;
  /* DECLARED, and it is load-bearing. Without it the link inherits the body's
     1.62, so the content box is font-size x 1.62 + 20px of padding — at .93rem
     that was 44.1px and `min-height: 44px` governed, but at .96rem it becomes
     44.9px and the HEADER GREW TO 70px against a `--hdr` of 69. Measured, and
     caught before it shipped. At 1.2 the box is 38.4px, min-height governs
     again, and the header is 69px at every width. */
  line-height: 1.2;
  color: rgba(243, 239, 228, .86);
  border-radius: var(--r-sm);
  position: relative;
  transition: color var(--t-micro);
}
/* Ayo underlines on hover with a 2px bar that wipes in from the left instead of
   lighting a background pill. That is the "attractive" half of the owners' note,
   and it is quieter too — a row of pills appearing under the cursor is busier
   than one rule.
   Inset to the text rather than the padding box, so it reads as an underline and
   not as a border on a button. THE ACCENT STAYS BAREFOOT'S GREEN: the header is
   site-wide, and Ayo's turquoise here would put their brand on all 29 pages
   rather than the three the owners scoped it to. */
.nav__link::after {
  content: ""; position: absolute;
  left: 12px; right: 12px; bottom: 12px;
  /* Ayo's `--turq`. Owners' call — the header is site-wide, so this is the one
     place their turquoise sits outside the three scoped liveaboard pages.
     7.34:1 over the lightest bar. */
  height: 2px; background: var(--coral);
  transform: scaleX(0); transform-origin: left;
  transition: transform var(--t-micro) var(--ease);
}
.nav__link:hover { color: #f3efe4; }
.nav__link:hover::after { transform: scaleX(1); }
/* Ayo marks the current page in `--turq-bright`, a step up from the underline
   so the two are not the same green. 9.23:1 over the lightest bar. */
.nav__link[aria-current="page"] { color: var(--coral-bright); }
.nav__link[aria-current="page"]::after { transform: scaleX(1); }
@media (prefers-reduced-motion: reduce) { .nav__link::after { transition: none; } }
.nav__link .icon { width: .8em; height: .8em; transition: transform var(--t-micro); }
.nav__item[data-open="true"] > .nav__link .icon { transform: rotate(180deg); }

.nav__menu {
  position: absolute; top: calc(100% + 6px); left: 0;
  min-width: 268px;
  /* see .nav__menu::before — the 6px above this box is a hover trap */
  background: var(--deep-800);
  border: 1px solid var(--deep-600);
  border-radius: var(--r);
  box-shadow: var(--shadow);
  padding: 8px;
  display: none;
  z-index: 95;
}
.nav__item[data-open="true"] > .nav__menu { display: block; }
/* THE HOVER BRIDGE. The menu is offset 6px below its trigger, and that strip
   belongs to `.nav`, not to `.nav__item` — measured with elementFromPoint, five
   consecutive pixel rows where a pointer moving straight down from the trigger
   is OUTSIDE the item's subtree. `mouseleave` fires there, so the menu closed
   before the pointer could ever reach it: hover to open, move down, it shuts.
   This transparent strip spans the gap and is a DESCENDANT of `.nav__item`, so
   the pointer never leaves the item and `mouseleave` never fires.
   `left/right: 0` covers the trigger's full width — the menu is `left: 0` and
   always the wider of the two (268px against 83–145px triggers).
   Keep the height equal to the offset in `top` above; they are one measurement
   expressed twice, and a bridge shorter than the gap reopens the hole. */
.nav__menu::before {
  content: "";
  position: absolute;
  left: 0; right: 0; top: -6px;
  height: 6px;
}
.nav__menu a {
  display: block; padding: 10px 12px; border-radius: var(--r-sm);
  font-size: .9rem; color: var(--txt-light);
  transition: background var(--t-micro), color var(--t-micro);
}
.nav__menu a:hover { background: var(--deep-700); color: var(--shell); }
/* THE PAGE YOU ARE ON, IN THE SAME ORANGE THE BAR ABOVE ALREADY USES (owner,
   14 Aug 2026 — "make these highlights on the header orange too"). It is
   `--coral-bright` rather than `--coral`, and the reason is the rule directly
   above this one: `:hover` and this selector have EQUAL specificity, so source
   order decides and this one wins — a current item that is also hovered keeps
   this colour while its background lightens to `--deep-700`. Measured there,
   `--coral` is 4.41:1 and fails body text; `--coral-bright` is 5.58:1 hovered
   and 6.86:1 at rest. `.nav__link[aria-current]` in the bar took the same token
   on 13 Aug for the same kind of reason, so the two now match. */
.nav__menu a[aria-current="page"] { color: var(--coral-bright); }

/* ---------- second-level nav (liveaboard routes) ----------
   PURE CSS, ON PURPOSE. `:hover` opens it for a pointer and `:focus-within`
   for a keyboard, so it needs no JavaScript and cannot fall out of step with
   the first-level disclosure that site.js drives. The parent row stays a real
   link to the itinerary, so this only ever ADDS a route.
   Only the two liveaboard itineraries use it — see views/partials/header.ejs. */
.nav__sub { position: relative; }
.nav__sub > a { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.nav__sub-chev { width: 14px; height: 14px; flex: none; transform: rotate(-90deg); opacity: .7; }

.nav__flyout {
  position: absolute; top: -8px; left: 100%;
  min-width: 210px;
  background: var(--deep-800);
  border: 1px solid var(--deep-600);
  border-radius: var(--r);
  box-shadow: var(--shadow);
  padding: 8px;
  display: none;
  z-index: 96;
}
.nav__sub:hover > .nav__flyout,
.nav__sub:focus-within > .nav__flyout { display: block; }
/* The same hover-bridge problem the first level has: the flyout sits outside
   its trigger's box, so a pointer travelling sideways would leave the subtree
   and close it. There is no gap here (left: 100%), but a 6px bridge on the
   left edge absorbs the diagonal. */
.nav__flyout::before { content: ""; position: absolute; top: 0; bottom: 0; left: -6px; width: 6px; }

/* Indented sub-items in the phone drawer. NOT a nested disclosure: there is
   nothing to hover on a phone, and a disclosure inside a disclosure is two taps
   before you can reach a link. */
.m-acc__sub {
  padding-left: 30px !important;
  font-size: .9rem !important;
  color: var(--txt-light-dim) !important;
}
.m-acc__sub:hover { color: var(--hover-lume) !important; }

.nav__actions { display: flex; align-items: center; gap: 12px; flex: none; }
.nav__burger {
  display: none;
  width: 44px; height: 44px;
  align-items: center; justify-content: center;
  color: var(--shell);
  border: 1.5px solid var(--edge-light);
  border-radius: var(--r-sm);
}

/* 1150, NOT 1080 — and this fixes a defect that predates the type change.
   The desktop nav needs 609px; between 1081 and 1149 it was being given as
   little as 574, so THREE links wrapped to a second line and the header stood
   at 82px against a `--hdr` of 69. That token is what the homepage's pinned
   timeline, the sticky offsets and every `scroll-margin-top` are built on, so a
   70px-wide band of viewports had a header a third taller than the rest of the
   layout believed. Measured before touching the type: 1081 wrapped at the old
   .93rem too.
   `hoverNav()` in site.js carries the same number and MUST move with it, or
   between the two values the CSS shows a burger while the script still thinks
   it is driving a hover menu. */
@media (max-width: 1150px) {
  .nav__links { display: none; }
  .nav__actions .btn--ghost { display: none; }
  .nav__burger { display: inline-flex; }
  /* Apply + burger sit flush TOP RIGHT. On desktop `.nav__links` carries the
     `margin-left: auto` that pushes everything after it to the right; here the
     links are hidden, so the actions have to carry it themselves. Without this
     they sat immediately after the logo with all the slack to their right —
     measured 31px short of the edge at 320px, 51px at 360px and 63px at 414px,
     which read as "floating in the middle" rather than anchored to the corner.
     (The old `margin-left: auto` on the burger could not do this: it lives
     INSIDE `.nav__actions`, which is content-sized, so there was no free space
     for it to absorb.) */
  .nav__actions { margin-left: auto; }
  .nav { gap: var(--s-3); }
}

/* Small phones: the header is logo | Apply | burger, and the three of them need
   310px of a content box that is only 280px wide at 320px. The flex guard above
   stops that overflowing, but it does it by letterboxing the logo, which looks
   like a bug. Choosing a smaller logo outright is the same saving and reads as
   a decision: 42px → 32px tall takes it from 173px to 132px, which fits with
   room to spare. Threshold is 360px because the full-size logo still clears at
   375px (25px spare) and there is no reason to shrink it there. */
@media (max-width: 360px) {
  .nav .brand__mark { height: 32px; }
}

/* ---------- Mobile drawer ---------- */
/* Ayo's drawer: it drops from the top over a gradient rather than sliding in
   from the right, with their easing and their timing.

   ONE THING OF THEIRS IS DELIBERATELY NOT TAKEN — `justify-content: center`.
   Ayo centres six flat links in a frame they always fit. This drawer holds five
   section rows, thirty sub-links and a footer, and it scrolls: centring an
   overflowing flex column puts the overflow ABOVE the scroll origin, where no
   scrollbar can reach it. Open two sections on a short phone and the top of the
   menu becomes unreachable. It stays top-aligned. */
.mobile-menu {
  position: fixed; inset: 0; z-index: 140;
  background: linear-gradient(180deg, var(--deep-900), #010f13);
  padding: var(--s-6) var(--pad) var(--s-7);
  display: flex; flex-direction: column; gap: 8px;
  overflow-y: auto;
  transform: translateY(-100%);
  transition: transform .45s cubic-bezier(.6, .05, .2, 1);
  visibility: hidden;
}
.mobile-menu.open { transform: none; visibility: visible; }
.mobile-menu__top { display: flex; align-items: center; justify-content: space-between; }
/* 46px and a 12px radius are Ayo's. The border is NOT: theirs is `--line-light`
   at .14 alpha, which measures 1.40:1 and fails WCAG 1.4.11 for a control
   boundary. Same call as the footer's social buttons. */
.mobile-menu__close {
  width: 46px; height: 46px;
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--shell);
  border: 1.5px solid var(--edge-light); border-radius: var(--r-sm);
}
.mobile-menu__links { display: flex; flex-direction: column; }
/* Ayo's drawer links: display face, 700, clamp(1.8rem, 8vw, 2.8rem). Their rows
   are plain links; ours are a section name plus a disclosure chevron, so the
   padding stays ours — the type is the part that was different. */
.m-acc__btn {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  width: 100%; padding: 16px 0;
  font-family: var(--font-display); font-weight: 700;
  font-size: clamp(1.8rem, 8vw, 2.8rem); line-height: 1.18;
  color: var(--shell);
  border-bottom: 1px solid var(--line-light);
  text-align: left;
}
.m-acc__btn:active { color: #18e0c6; }
/* The section name and its chevron are two separate targets on one row, so the
   row owns the rule that used to belong to the single button. */
.m-acc__row { display: flex; align-items: center; gap: 4px; border-bottom: 1px solid var(--line-light); }
.m-acc__row .m-acc__btn { flex: 1 1 auto; border-bottom: 0; }
.m-acc__toggle {
  flex: none;
  width: 44px; height: 44px;
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--shell);
  border-radius: var(--r-sm);
}
.m-acc__toggle .icon { transition: transform var(--t-micro); }
.m-acc__toggle[aria-expanded="true"] .icon { transform: rotate(180deg); }
/* Without JS the panels ship open, so the chevron has nothing to do. */
html:not(.has-js) .m-acc__toggle { display: none; }
.m-acc__panel { display: none; padding: 6px 0 14px; }
.m-acc__panel[data-open="true"] { display: block; }
.m-acc__panel a { display: block; padding: 11px 0 11px 14px; color: var(--txt-light); font-size: .98rem; border-left: 1px solid var(--deep-600); }
.m-acc__panel a:hover { color: var(--hover-lume); }
/* THE PAGE YOU ARE ON, in the drawer, in the same orange the dropdown uses
   (owner, 14 Aug 2026 — "yes add it on mobile too"). The drawer marked nothing
   at all before this: the template has always emitted `aria-current` here, and
   no rule had ever picked it up.

   DELIBERATELY AFTER `:hover` AND AT EQUAL SPECIFICITY, so source order makes
   the mark win — the same construction as `.nav__menu a[aria-current]`. A
   phone has no hover; a narrow desktop window shows this drawer and does, and
   there the "you are here" mark should not vanish under the pointer.

   SECTION NAMES THAT OPEN A PANEL ARE NOT MARKED, and that is why the second
   selector carries `:not(.m-acc__btn--link)`. On /science, "The Science" and
   "Science Overview" are the SAME destination, so marking both paints one
   answer twice; the panel item is the one that says where you are, and the
   desktop bar leaves its dropdown trigger unmarked for exactly this reason.
   A section with no panel has no such item to carry the mark, so it takes it
   itself — that is The Village, the only one, and header.ejs now passes it
   `aria-current` so this can reach it. */
.m-acc__panel a[aria-current="page"],
.m-acc__btn:not(.m-acc__btn--link)[aria-current="page"] { color: var(--coral-bright); }
.mobile-menu__foot { margin-top: auto; padding-top: var(--s-5); }
/* Ayo's display type is much larger than what this drawer carried, and on the
   shortest phone still in use (320×568) that pushed the two footer buttons 2px
   below the fold with every section still closed — measured 618px of content in
   a 568px frame. The drawer scrolls, so nothing was unreachable, but the two
   things somebody opens the menu to do should not need a scroll to be seen.
   Six pixels of row padding, five rows, buys 60px and clears it. The type is
   untouched; the row still measures 54px against rule 21's 44px floor. */
@media (max-height: 600px) {
  .m-acc__btn { padding: 10px 0; }
}

/* ---------- Sticky mobile CTA bar ---------- */
.cta-bar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 100;
  display: none;
  align-items: center; justify-content: space-between; gap: var(--s-4);
  padding: 10px var(--pad);
  padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
  background: var(--deep-800);
  border-top: 1px solid var(--deep-600);
  transform: translateY(110%);
  transition: transform .34s var(--ease);
}
.cta-bar.is-visible { transform: none; }
.cta-bar__price { font-family: var(--font-mono); font-size: .84rem; line-height: 1.35; color: var(--txt-light); }
.cta-bar__price b { color: var(--shell); font-weight: 500; }
@media (max-width: 860px) { .cta-bar { display: flex; } }

/* ---------- Flash ---------- */
.flash {
  background: var(--seagrass-deep); color: var(--shell);
  padding: 12px 0; font-weight: 600;
  transition: opacity var(--t-cross), transform var(--t-cross);
}
.flash--error { background: var(--clay-deep); }
.flash--hide { opacity: 0; transform: translateY(-8px); }

/* ---------- Accordion (FAQs) ---------- */
.acc__item { border-bottom: 1.5px dashed var(--sand); }
.acc__btn {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-4);
  width: 100%; padding: 20px 0; min-height: 44px;
  font-family: var(--font-display); font-size: 1.08rem; font-weight: 600;
  text-align: left; color: inherit;
}
.acc__btn .icon { flex: none; transition: transform var(--t-micro); }
.acc__btn[aria-expanded="true"] .icon { transform: rotate(45deg); }
.acc__panel { display: none; padding-bottom: 20px; }
.acc__panel[data-open="true"] { display: block; }
.acc__panel p { color: var(--txt-ink); max-width: 68ch; }
.section--deep .acc__panel p { color: var(--txt-light); }

/* ---------- Footer ---------- */
/* THE FOOTER IS AYO RAJA AMPAT DIVERS', at the owners' request. The two were
   already the same STRUCTURE — same grid, columns, social row and bottom bar —
   so what changes is colour and a handful of metrics, read off their
   public/styles.css.

   TWO THINGS OF THEIRS ARE DELIBERATELY NOT COPIED:
     · `.footer__pay`, the payment-provider badges. **CLAUDE.md rule 12: no
       payment processing anywhere on this site, and no payment badges in the
       footer.** Barefoot takes no money here; badges would imply otherwise.
     · `.footer__eco`, their eco line. That is a CLAIM, and inventing one for
       Barefoot is rule 1. An equivalent has to be the owners' own words.

   The grid stays FIVE columns against Ayo's four — that is our content, not our
   styling; Barefoot's footer carries one more section than theirs. */
.site-footer {
  background: #010f13;                            /* Ayo --ink-900 */
  color: var(--txt-light);
  padding-top: clamp(56px, 8vw, 84px);
  --txt-light: rgba(243, 239, 228, .74);          /* 9.40:1 here */
  --txt-light-dim: rgba(243, 239, 228, .52);      /* 5.08:1 here */
  --line-light: rgba(243, 239, 228, .14);
  /* Ayo outlines its social buttons in `--line-light`, which measures 1.40:1 on
     this darker ground and fails WCAG 1.4.11's 3:1 for a control boundary. .40
     is the first alpha that clears it (3.00). Same call as in the header. */
  --edge-light: rgba(243, 239, 228, .40);
}
.footer__grid {
  display: grid;
  grid-template-columns: 1.4fr repeat(4, 1fr);
  gap: 40px;
  padding-bottom: 48px;
}
@media (max-width: 1000px) { .footer__grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 560px) { .footer__grid { grid-template-columns: 1fr; } }
.footer__brand p { font-size: .92rem; margin: 18px 0; max-width: 38ch; }
.footer__col { display: flex; flex-direction: column; gap: 11px; }
/* The footer sitemap is the biggest block of links on the site — 25 of them on
   every page — and at the desktop setting each row measured 335×24px with a 9px
   gap. 24px is exactly WCAG 2.2 AA's floor and well under this project's own
   44px rule (rule 21), and a full-width 24px row is very easy to mis-tap when
   the next link is a thumb-width away. Raised to 44px at touch widths only: on
   a pointer device the compact list is fine and making it 44px everywhere would
   add ~140px to the desktop footer for no benefit. The gap goes to 0 because
   the taller rows already separate themselves. */
@media (max-width: 1000px) {
  .footer__col { gap: 0; }
  .footer__col a {
    display: flex; align-items: center;
    min-height: 44px;
  }
}
.footer__col h2 {
  /* h2, not h4: Lighthouse flags the jump from a page's h2/h3 straight to h4
     as a broken heading outline (16 Aug speed pass). The rule sets its own
     size, so the tag swap paints identically. */
  font-family: var(--font-mono); font-size: .7rem; font-weight: 500;
  text-transform: uppercase; letter-spacing: .16em;
  color: #18e0c6; margin-bottom: 6px;             /* Ayo --turq, 11.60:1 here */
}
.footer__col a, .footer__col span { font-size: .92rem; color: var(--txt-light); transition: color .25s; }
.footer__col a:hover { color: var(--hover-lume); }
.footer__social { display: flex; gap: 12px; margin-top: var(--s-4); }
.footer__social a {
  /* Ayo's are 42px rounded squares that FILL on hover and lift. Ours
     stay 44px — two pixels, invisible, and rule 21's floor. */
  width: 44px; height: 44px; border-radius: var(--r-sm);
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--edge-light); color: #f3efe4;
  transition: background .3s, border-color .3s, color .3s, transform .3s;
}
.footer__social a:hover {
  background: var(--hover-lume); border-color: var(--hover-lume); color: var(--on-coral);
  transform: translateY(-3px);
}
.footer__bar {
  border-top: 1px solid var(--line-light);
  padding-block: var(--s-5);
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s-4);
  font-size: .84rem;
}
.footer__bar .mono { color: var(--txt-light-dim); }
.footer__reg { font-family: var(--font-mono); font-size: .78rem; color: var(--txt-light-dim); max-width: 60ch; }

/* Email and WhatsApp sit above the registration line and are brighter than it:
   one is how you reach them, the other is a legal disclosure, and they should
   not read as the same weight. Wraps to two lines on a phone. */
.footer__contact {
  display: flex; flex-wrap: wrap; gap: 6px 12px;
  margin-bottom: var(--s-3); font-size: .92rem;
}
.footer__contact a { color: var(--txt-light); text-decoration: underline; text-underline-offset: 3px; }
.footer__contact a:hover { color: var(--hover-lume); }
/* The email and WhatsApp links are the two most likely things anyone taps in
   the footer, and they measured 24px tall on every page. Same treatment as the
   sitemap above: a comfortable target at touch widths, desktop left alone. */
@media (max-width: 1000px) {
  .footer__contact { gap: 0 12px; }
  .footer__contact a { display: inline-flex; align-items: center; min-height: 44px; }
}
.footer__contact span { color: var(--txt-light-dim); }

/* ---------- Reveal system (plan/03 §5) ---------- */
[data-reveal] {
  opacity: 0;
  transform: translateY(22px);
  transition: opacity var(--t-reveal) var(--ease), transform var(--t-reveal) var(--ease);
}
[data-reveal].in-view { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
  [data-reveal] { opacity: 1; transform: none; }
}

/* ---------- No-JS fallbacks ----------
   site.js adds .has-js to <html>. Everything that JS collapses must be OPEN and
   reachable without it — the site has to be fully readable with JS off
   (CLAUDE.md rule 20, plan/08 quality gates). */
html:not(.has-js) .nav__burger { display: none; }
html:not(.has-js) .acc__panel,
html:not(.has-js) .m-acc__panel { display: block; }
html:not(.has-js) .nav__item:hover > .nav__menu,
html:not(.has-js) .nav__item:focus-within > .nav__menu { display: block; }

@media (max-width: 1150px) {
  /* Without JS the drawer can never open, so lay the primary nav out inline
     instead of hiding it behind a burger that does nothing. */
  html:not(.has-js) .nav { flex-wrap: wrap; }
  html:not(.has-js) .nav__links {
    display: flex; flex-wrap: wrap; width: 100%;
    margin-left: 0; padding-bottom: 8px; gap: 2px;
  }
  html:not(.has-js) .nav__menu {
    position: static; display: block; min-width: 0;
    background: none; border: 0; box-shadow: none; padding: 0 0 0 12px;
  }
  html:not(.has-js) .nav__menu a { padding: 7px 0; font-size: .85rem; }
  html:not(.has-js) .nav__item[data-dropdown] { width: 100%; }
}

/* ---------- Photography treatment (plan/03 §7) ---------- */

/* ===== THE FRAME PRIMITIVE — every fixed-ratio picture box on the site =====
   A frame is a `position: relative` box whose height comes from an
   `aspect-ratio`, holding one picture / img / placeholder that must fill it.

   IT IS DECLARED ONCE, HERE, AND THAT IS THE POINT. The rule below used to be
   copy-pasted per component with `height: 100%` children — eight times across
   this file, styles.css and booking.css. Safari does not treat an
   aspect-ratio-derived height as definite, so a percentage height there
   computes to `auto`: the child collapses to its own content and a photograph
   letterboxes inside its own frame. When that was fixed on 14 Aug 2026 the fix
   had to be written per copy and reached only three of the eight — the village
   slideshow, the itinerary media and the schedules card — leaving .media
   itself, .team-card__photo, .empty__media and
   .cabin-card__media still broken on iOS.

   `position: absolute; inset: 0` removes the percentage entirely: it sizes
   against the positioned ancestor's padding box, which is well defined in
   every engine however that ancestor got its height. Add new frames to the
   selector list below rather than re-deriving this per component, and do not
   "simplify" any of it back to `height: 100%`.

   Direct children only, so an <img> inside a <picture> keeps filling its
   already-absolute parent instead of being positioned twice. The two frames
   outside this file carry the same rule locally, each pointing back here. */
.media,
.team-card__photo,
.vshow__figure { position: relative; overflow: hidden; }

.media > picture, .media > img, .media > .ph,
.team-card__photo > picture, .team-card__photo > img, .team-card__photo > .ph,
.vshow__figure > picture, .vshow__figure > img, .vshow__figure > .ph {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
}
.media picture,
.team-card__photo picture,
.vshow__figure picture { display: block; }
.media img,
.team-card__photo img,
.vshow__figure img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The photographic grade is .media's alone. */
.media img { filter: saturate(1.04) contrast(1.02); }
.media--hero { aspect-ratio: 21 / 9; }
.media--section { aspect-ratio: 16 / 9; }
.media--card { aspect-ratio: 4 / 3; border-radius: var(--r-lg); }
.media--portrait { aspect-ratio: 3 / 4; border-radius: var(--r-lg); }
.section--deep .media::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(to top, rgba(6, 21, 18, .85), transparent 62%);
}

/* ---------- Empty state ----------
   plan/06-CONTENT-MAP.md writes the empty-state copy but nothing rendered it.
   Phase 4 needs this for /expedition/dates, /about/reviews, /blog and the
   gallery filters — an unstyled "no results" line is where a careful site
   suddenly looks unfinished. */
/* Ayo's `.empty`: a filled panel with a hairline dashed edge, not an outlined
   box on the page surface. `max-width: 56ch` and the auto margin are theirs too
   — an empty state that runs the full column reads as a broken section. */
.empty {
  border: 1px dashed var(--line-ink);
  border-radius: var(--r-lg);
  background: var(--shell-2);
  padding: clamp(36px, 5vw, 56px);
  max-width: 56ch; margin-inline: auto;
  text-align: center;
  display: flex; flex-direction: column; align-items: center; gap: var(--s-4);
}
.empty .sketch { max-width: 132px; opacity: .55; }
.empty__title { font-family: var(--font-display); font-size: 1.15rem; font-weight: 600; }
.empty p { color: var(--txt-ink-dim); max-width: 46ch; }
/* AN OPAQUE CREAM PANEL INSIDE A DARK SECTION STILL NEEDS ON-LIGHT INK, and
   `.empty` is one: it paints `--shell-2` on every surface. So `.section--deep`
   must NOT be read as "everything in here is on dark" — only the things sitting
   directly on the section's own background are. Without the `color` below the
   panel inherits `.section--deep`'s cream and its heading is cream on cream at
   1.10:1, i.e. invisible.
   The `p` rule used to say `--txt-light-dim`, which is the same mistake in the
   other direction — it assumed a transparent panel. It fired on no route until
   the homepage's reviews section went dark, and the zero-reviews branch there
   would have been the first thing to hit it. */
.section--deep .empty { border-color: var(--deep-600); color: var(--deep-900); }
.section--deep .empty p { color: var(--txt-ink-dim); }

/* ---------- Utilities ---------- */
.stack > * + * { margin-top: var(--s-4); }
.stack-lg > * + * { margin-top: var(--s-6); }
.center { text-align: center; margin-inline: auto; }
.visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* The "awaiting content" marker (.todo / .todo__label) was removed on
   14 Aug 2026. Its only renderer, h.pending() in src/helpers.js, has
   returned the empty string since 12 Aug, so nothing has emitted this
   markup since. The [[TODO:]] source markers and the helper both stay —
   see helpers.js for why. */

/* ==========================================================================
   Page components — Phase 4
   Built on the tokens and the primitives above; nothing here introduces a new
   colour, radius or easing. Layout families are deliberately varied: the brief
   asks for alternating surfaces, and a page that repeats one card grid eight
   times reads as templated however good the tokens are.
   ========================================================================== */

/* ---------- Page hero ---------- */
/* Full-bleed photograph behind a dark section. Moved here from styles.css so
   every page can use it, not just the homepage — it was homepage-only and every
   other content page had no photograph at all.
   `isolation: isolate` is what makes it work: `.section--deep` paints an opaque
   background, so a `z-index: -1` child would sit behind it and never show. The
   stacking context keeps the image above the section's own background and below
   `.section > .shell`, which site.css already raises to z-index 1. */
.section--media { position: relative; isolation: isolate; overflow: hidden; }
.section--media > .shell { position: relative; z-index: 1; }
.section__bg { position: absolute; inset: 0; z-index: -1; }
/* The picture element needs the box too, or the image has nothing to resolve
   against and `object-fit` is dead — the same trap the closing CTA banner hit. */
.section__bg picture { display: block; width: 100%; height: 100%; }
.section__bg img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The scrim used to run .92 / .82 / .93, which is so heavy that NOTHING shows
   through it — the sections read as flat dark panels and the images behind them
   were decorative in name only. These stops are the measured floor instead. The
   binding constraint is NOT the headings — it is --txt-light-dim, which sits
   directly on this scrim with no card background under it. Against a PURE WHITE
   pixel (the brightest input possible):
       middle .72 → 4.46:1  FAILS AA body
       middle .75 → 4.97:1  passes
   So .75 is the floor. Do not raise the stops to hide a weak photograph, and do
   not lower them without re-measuring against --txt-light-dim, not --shell. */
.section__bg::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(6, 21, 18, .87) 0%, rgba(6, 21, 18, .75) 50%, rgba(6, 21, 18, .89) 100%);
}

.page-hero { padding-block: clamp(56px, 8vw, 104px); }
/* PULLED UP UNDER THE HEADER (14 Aug 2026) — the precondition for the
   transparent-until-scrolled bar, same as the homepage: a transparent header
   over a page that starts BELOW it is cream nav on cream. The hero starts at
   the document top, the bar floats over its first 69px, and the copy's top
   padding gains the header height back so it clears the bar. The homepage hero
   already does its own version of this and is excluded. */
.page-hero:not(.hero-island) {
  margin-top: calc(var(--hdr) * -1);
  padding-top: calc(var(--hdr) + clamp(56px, 8vw, 104px));
}
/* A hero carrying a photograph gets more room — the picture needs height to
   read as one. */
.page-hero.section--media { padding-block: clamp(76px, 11vw, 148px); }
/* THE EYEBROW'S COLOUR SETS THE SCRIM, so it is the thing to change — not the
   scrim. Solved numerically: for every hero, every text element and every pixel
   under it, the minimum scrim alpha that clears AA is
       `--seagrass`        (7.25:1 on deep)  → **0.84**
       `--seagrass-bright` (10.71:1 on deep) → **0.735**
   and at 0.84 every photograph read as a black panel, which defeats the point
   of having one. The eyebrow is four small words; the picture is the page. So
   the hero eyebrow takes the on-dark emphasis token and the scrim drops by a
   tenth. Nothing else in the hero binds — the headings and leads are cream and
   clear it comfortably.
   Stops never go below .76, against a measured requirement of .735.
   **Re-run the solver if a hero photograph is swapped for a brighter one.** */
/* Matched to the on-dark eyebrow above so the hero does not sit a different
   green from every other section. `--turq-bright` is BRIGHTER than
   `--seagrass-bright`, so the scrim solved for the old colour has more margin
   under this one, not less — measured over the worst case (a pure white
   photograph behind the .78 stop): 7.07 against the old 5.41. */
.page-hero.section--media .eyebrow { color: #67f5e2; }
/* LIGHTENED 14 Aug 2026 (owner: pages look flat, the photograph is the point).
   The old stops .78/.76/.80 were bound by the DIM tokens sitting on the scrim —
   the .76-alpha lead needs .72 of wash over a pure-white pixel. So the dim
   tokens are gone from media heroes instead: every text element below is
   solid cream or the bright turquoise, and the binding element becomes the
   eyebrow (#67f5e2, 4.5:1 at .66). These stops carry .02-.08 of headroom over
   that floor — measured at the middle stop against pure white: solid cream
   5.60, eyebrow 4.88, stat figures 4.88 against a 3:1 need. A third more
   photograph shows through than before (32% vs 24% transmission).
   DO NOT put a dim or translucent text token back inside a media hero without
   re-raising the stops — that is the trade these two blocks make together. */
.page-hero.section--media .section__bg::after {
  background: linear-gradient(180deg, rgba(6, 21, 18, .70) 0%, rgba(6, 21, 18, .68) 50%, rgba(6, 21, 18, .74) 100%);
}
/* The other half of the lightened scrim: no translucent ink on a media hero.
   Solid cream where the shared components use the .76-alpha --txt-light or the
   dimmer tokens, and the stat figures join the eyebrow's turquoise — the
   on-dark --seagrass measures 2.53 at the new middle stop and fails. */
.page-hero.section--media .page-hero__lead,
.page-hero.section--media .stat__label,
.page-hero.section--media .stat__sub,
.page-hero.section--media .crumbs a,
.page-hero.section--media .crumbs li + li::before,
.page-hero.section--media .crumbs [aria-current] { color: #f4ede1; }
/* NOT --coral-bright, which the scrim-lightening commit added here without
   putting it through the same solve as everything else in this block. Against
   the worst case that block solves for — a pure-white photograph under the .68
   middle stop, background rgb(86,96,94) — #FF8160 measures 2.66:1, and .crumbs
   is .78rem mono so it needs 4.5:1. The eyebrow turquoise is already measured
   at 4.88:1 on this exact backdrop and is still clearly a hover state against
   the solid cream the crumbs rest at. */
.page-hero.section--media .crumbs a:hover { color: #67f5e2; }
.page-hero.section--media .stat__num { color: #67f5e2; }

/* ---------- Every photograph hero fills the window (14 Aug 2026) ----------
   The owner's ask: "make all the heroes on every page flexible when I resize
   the browser window like home page. Also make sure they fit the window."

   BEFORE THIS THEY DID NEITHER. Their height was padding plus copy and nothing
   else, so at 1440x900 they measured 456-675px in an 831px opening — every page
   started with a band of photograph and a slab of the next section under it,
   and the band changed size only because the padding clamp moved. Meanwhile
   /science, whose hero carries four statistics, went the other way and measured
   944px inside an 812px phone window: the one hero that genuinely did not fit.

   `min-height`, NOT `height`. Fitting the window is the floor, not a cap — a
   hero whose copy needs more room grows and scrolls rather than clipping it.
   That is the same reasoning as the timeline's stages and the reason /science's
   statistics are re-laid-out below instead of being squeezed.

   `calc(100svh - var(--hdr))` because on these pages the header is opaque and in
   flow, so the hero starts under it and the pair fills exactly one screen. (The
   homepage is the exception and is excluded — see below.) `svh` and not `vh`:
   `vh` is the viewport with a phone's URL bar RETRACTED, so a 100vh hero is
   taller than what you can actually see on load. Not `dvh` either, which
   resizes while you scroll. A plain `vh` line precedes it as the fallback.

   `:not(.hero-island)` IS LOAD-BEARING AND IS NOT TIDINESS. The homepage hero
   carries `.page-hero.section--media` as well, and its own `.hero-island` rules
   are ONE class — so anything declared here at two classes silently beats them.
   That is not hypothetical: the `padding-block` above already overrides
   `.hero-island`'s own padding longhands, and the homepage's scrim was SOLVED
   against the copy where that override puts it. Excluding the homepage keeps it
   byte-for-byte as measured. Do not "simplify" this selector, and do not
   correct the padding it exposes without re-running the homepage scrim solve.

   THE COPY CENTRES. There is now up to 400px of free space in the frame, and an
   auto margin (the homepage's construction) would drop the copy onto the bottom
   edge with the next section immediately under it. The homepage bottom-anchors
   because its scrim is a bottom BAND that has to leave the island clear above;
   these heroes carry a full even wash, so the copy is equally legible anywhere
   in the frame and centred is the balanced reading. Contrast is therefore
   unchanged by this rule — re-measured across every hero all the same.

   PADDING COMES DOWN, from clamp(76px, 11vw, 148px) to the base hero's value.
   Once min-height sets the size, that padding no longer opens the frame up — it
   only adds to the one case that matters, a hero too tall for a short window. */
.page-hero.section--media:not(.hero-island) {
  /* Full viewport now, not viewport-minus-header: since 14 Aug the hero starts
     at the document top and the transparent bar floats over its first 69px, so
     hero alone = one screen. The pull-up and the padding-top that pays for it
     are on `.page-hero:not(.hero-island)` above. */
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  align-items: center;
  padding-top: calc(var(--hdr) + clamp(56px, 8vw, 104px));
  padding-bottom: clamp(56px, 8vw, 104px);
}
/* A short window has no room to give away. Keyed to HEIGHT, because height is
   what decides whether the copy fits: a landscape phone is 812 wide and 375
   tall, and it needs this every bit as much as a 320px portrait one does. */
@media (max-height: 700px) {
  .page-hero.section--media:not(.hero-island) {
    padding-top: calc(var(--hdr) + clamp(28px, 4vh, 48px));
    padding-bottom: clamp(28px, 4vh, 48px);
  }
}

/* ---------- Hero entrance (14 Aug 2026) ----------
   The owner's ask: pictures and motion on the other pages, in the spirit of the
   home page. On landing, the hero photograph settles from a slight zoom and the
   copy rises over it. Pure CSS, so it needs no script and ends in the fully
   visible state either way — there is no observer to fail and leave a page
   blank, which is the [data-reveal] failure this project has already had once.

   THE HOME PAGE IS IN AS OF 14 AUG 2026, at the owner's request ("I saw you
   created a small animation on background of the heroes — can you make same
   with the home page hero too"). It was excluded when this shipped because that
   session's brief was explicitly "home page and header untouched", not because
   the motion was unsafe there; dropping `:not(.hero-island)` is the whole
   change. Nothing on `.hero-island` declares `overflow`, `animation` or
   `transform`, so there is no rule for these to collide with — checked before
   widening, because the specificity trap on the layout rules above is real and
   sits fifty lines up.

   THE SCRIM DID HAVE TO BE RE-CHECKED, AND THIS IS WHY. The other nineteen
   heroes carry a full even wash solved against a PURE WHITE pixel, so what the
   photograph does underneath cannot break them. The homepage's scrim is a
   bottom BAND whose top 46% is completely clear, and it was solved against the
   pixels this particular picture puts under each line of copy — so an 8% zoom
   moves different pixels under the text and the solve does not automatically
   carry. Measured at the zoomed extreme rather than assumed; figures in the
   session log.

   The clip stays scoped to a hero's own bg rather than sitting on
   `.section__bg`: the homepage's OTHER backdrops use that class too, and they
   are `.section--media` without being `.page-hero`, so they still never match.

   The settle is a TRANSFORM, not a width/background change, so it composites;
   and the bg wrapper clips it, or the scaled photograph would paint outside the
   hero over the section below. */
@media (prefers-reduced-motion: no-preference) {
  .page-hero.section--media .section__bg { overflow: hidden; }
  .page-hero.section--media .section__bg img {
    animation: hero-settle 16s cubic-bezier(.16, .6, .3, 1) both;
  }
  .page-hero > .shell {
    animation: hero-rise .9s cubic-bezier(.2, .7, .3, 1) both;
  }
}
@keyframes hero-settle {
  from { transform: scale(1.08); }
  to { transform: scale(1); }
}
@keyframes hero-rise {
  from { opacity: 0; transform: translateY(18px); }
  to { opacity: 1; transform: none; }
}

/* THE HERO'S STATISTICS GO TWO-UP ON A PHONE, and this is what makes /science
   fit rather than merely stop overflowing by luck. `.stat-grid`'s auto-fit
   tracks collapse to one column below ~430px, which stacked its four tiles into
   523px of the 812px available — the hero could not fit whatever the padding
   did. Two columns cost 278px instead, and the tiles are a number and a short
   label, so they read perfectly well at half width.
   SCOPED TO `.page-hero__stats`, not to `.stat-grid`: the same grid carries the
   five headline figures on /science/reports and the evidence tiles in ordinary
   sections, where there is no window to fit and stacking is right. */
@media (max-width: 560px) {
  .page-hero__stats { grid-template-columns: repeat(2, 1fr); gap: var(--s-5); }
}

/* ---------- The homepage hero: Barefoot's drone shot of Arborek ----------
   ONE HERO ONLY — `hero-island` is on views/pages/home.ejs and nowhere else, so
   the other nineteen heroes keep the solved-for-any-photograph scrim above.

   FULL VIEWPORT, ALWAYS (the owner's "fit the landing page size all the time").
   `100svh` minus the header, so the hero and the sticky header together fill
   exactly one screen and the next section starts below the fold at every size.
   `svh`, not `vh` — vh resolves to the viewport with the browser's URL bar
   RETRACTED, so on a phone a 100vh hero is taller than what you can see the
   moment the page loads, and the copy anchored to its bottom falls off. Not
   `dvh` either: that resizes as the bar hides and shows, which would jump the
   copy while you scroll. Same reasoning as the timeline's pinned frame.

   THE COPY SITS AT THE BOTTOM so the island is unobstructed above it — which is
   what lets the scrim be a bottom band instead of a full wash, and that is the
   whole reason the picture can read clearly. */
/* PULLED UP UNDER THE HEADER, so the transparent bar has a picture behind it —
   the same negative top margin the timeline uses, and the precondition for the
   `:has()` header rule further down this file. The hero is therefore a FULL
   100svh, not 100svh minus the header: it starts at the document top and the
   bar floats over its first 69px. */
.hero-island {
  margin-top: calc(var(--hdr) * -1);
  min-height: 100vh;
  min-height: 100svh;
  /* the copy must clear the bar it now sits under */
  padding-top: calc(var(--hdr) + clamp(48px, 7vw, 96px));
  display: flex;
  /* NOT `align-items: flex-end`. These look identical while the copy fits and
     behave completely differently when it does not: flex-end distributes
     NEGATIVE free space too, so on a short screen the copy is pushed UP out of
     the hero and the eyebrow ends up behind the header. Measured at 320x568
     before this: the first glyph sat at 17% of the hero height instead of 51%.
     An auto margin absorbs only POSITIVE free space, so it bottom-aligns when
     there is room and resolves to 0 when there is not. Exactly the bug the
     timeline's stages hit on 5 Aug — do not "simplify" this back. */
  align-items: stretch;
  padding-bottom: clamp(48px, 7vw, 96px);
}
.hero-island__body { width: 100%; margin-top: auto; }
.hero-island .section__bg img { filter: saturate(1.18); }

/* THE SCRIM IS A BOTTOM BAND, NOT A WASH — "no opacity like we did in scroll
   motion". The timeline reads as a real place because its wash sits at ~.10 and
   the legibility comes from a directional grade behind the copy; this is the
   same construction. The top 42% of the frame is COMPLETELY CLEAR — no scrim at
   all over the island — and the gradient only closes under the text.

   SOLVED, NOT CHOSEN, and re-solved for this layout. Method: measure every LINE
   of hero text with Range.getClientRects — the glyph boxes, not the block boxes,
   which span the full shell and are wildly pessimistic — map each to the pixels
   the photograph actually shows under `object-fit: cover` at that viewport,
   composite this gradient over every one of those pixels, take the worst ratio.

   THE BINDING ELEMENT IS THE LEAD, not the heading: `.page-hero__lead` is
   --txt-light at .76 alpha and 17-21px so it needs 4.5:1, where the h1 is
   32-64px and needs 3:1. Solving against the heading would be far too
   permissive.

   Measured at 1600 / 1440 / 1024 / 901 / 900 / 768 / 375 / 320, against whichever
   of the two pictures that width actually gets:

       worst ratio 6.42 against a 4.5 requirement
       the top 46% of the frame carries NO SCRIM AT ALL (14% on a short window)

   RE-SOLVED after the hero was pulled up under the header for the transparent
   nav: that made the frame 69px taller and moved every line of copy down, so
   the previous 40%/50% stops left the eyebrow at 1.00:1 on a 320px screen.
   MOVING THE HERO IS A RE-SOLVE.

   SELECTOR NOTE: `.page-hero.hero-island`, not `.hero-island`. The shared hero
   scrim above is `.page-hero.section--media .section__bg::after` — three classes
   — so a two-class override silently loses and this hero renders the ordinary
   dark wash. It did, and the probe caught it reading .78/.76/.80 back.

   Numbers and the method are in CLAUDE.md's 13 Aug 2026 entry. If the picture
   is swapped, or the hero copy changes length, re-run the solve. Do not edit
   these stops by eye. */
.page-hero.hero-island .section__bg::after {
  background: linear-gradient(180deg,
    rgba(6, 21, 18, 0) 0%,
    rgba(6, 21, 18, 0) 46%,
    rgba(6, 21, 18, .78) 55%,
    rgba(6, 21, 18, .86) 100%);
}
/* A SHORT WINDOW HAS NO CLEAR BAND TO GIVE. Below ~600px of viewport the copy
   fills the hero — measured at 320x568 it starts at 26% of the frame, and on a
   landscape phone it starts at the very top — so the gradient has to close
   almost immediately. Keyed to viewport height because that, not width, is what
   decides whether the copy fits: 320x568 and 812x375 both land here. */
@media (max-height: 600px) {
  .page-hero.hero-island .section__bg::after {
    background: linear-gradient(180deg,
      rgba(6, 21, 18, 0) 0%,
      rgba(6, 21, 18, 0) 14%,
      rgba(6, 21, 18, .78) 26%,
      rgba(6, 21, 18, .86) 100%);
  }
}
.page-hero__lead { margin-top: var(--s-5); }
.page-hero__stats { margin-top: clamp(36px, 5vw, 64px); }

.crumbs { margin-bottom: var(--s-5); }
.crumbs ol { display: flex; flex-wrap: wrap; gap: 8px; font-family: var(--font-mono); font-size: .78rem; }
.crumbs li + li::before { content: "/"; margin-right: 8px; color: var(--txt-light-dim); }
.crumbs a { color: var(--txt-light-dim); }
.crumbs a:hover { color: var(--hover-lume); }
.crumbs [aria-current] { color: var(--txt-light); }

/* ---------- Compact page header ----------
   A title bar for the four liveaboard pages that dropped their photograph hero:
   the schedules list, the two itineraries and the booking form. See the note at
   the top of views/partials/page-head.ejs for why those four and not the rest.

   HIERARCHY BY WEIGHT AND COLOUR, NOT BY SCALE. `.display` tops out at 4rem,
   which is right when a heading is the whole frame and wrong when its job is to
   name the page above a filter bar. This tops out at 2.45rem and keeps the
   display face and its italic. */
.page-head { padding-block: clamp(30px, 4vw, 48px) 0; }
.page-head + .section { padding-top: clamp(26px, 3.4vw, 42px); }
/* .crumbs is authored for the dark hero: --txt-light-dim measures ~1.3:1 on
   this surface. Every on-dark token in here needs its on-light counterpart. */
.page-head .crumbs { margin-bottom: var(--s-3); }
.page-head .crumbs a { color: var(--txt-ink-dim); }
.page-head .crumbs a:hover { color: var(--hover-ink); }
.page-head .crumbs li + li::before { color: var(--txt-ink-dim); }
.page-head .crumbs [aria-current] { color: var(--txt-ink); }
.page-head__h {
  font-family: var(--font-display);
  font-size: clamp(1.75rem, 3vw, 2.45rem);
  font-weight: 800;
  letter-spacing: -.014em;
  line-height: 1.06;
  color: var(--txt-ink);
  text-wrap: balance;
}
.page-head__h em { font-style: italic; font-weight: 500; }
.page-head__meta {
  margin-top: var(--s-3);
  font-size: .78rem;
  letter-spacing: .04em;
  color: var(--txt-ink-dim);
}
.page-head__lead {
  margin-top: var(--s-3);
  font-size: 1.02rem;
  color: var(--txt-ink);
  max-width: 62ch;
}
/* THE SECTION HEADINGS HAVE TO COME DOWN WITH THE TITLE. `.display` runs to
   4rem, which is set against a hero — under a 2.45rem page title it makes every
   `h2` on the page LOUDER THAN THE `h1`. Measured on the itinerary pages: "Day
   by day" rendered at 4rem above a 2.45rem route name.
   Scoped by the sibling combinator, so it reaches only the pages that actually
   use the compact header and cannot touch a page with a hero. */
.page-head ~ * .display { font-size: clamp(1.45rem, 2.3vw, 1.95rem); letter-spacing: -.012em; }

/* ---------- Pre-footer CTA band ----------
   THE BAND OWNS ITS OWN SURFACE.
   It used to be `.section--sand` and nothing else, so its top boundary was
   whatever the page happened to put above it. On `/expedition/dates` and
   `/support` that was also `section--sand`: sand met sand, the edge vanished,
   and the closing ask read as the tail of the section above it rather than as
   a separate thing. Measured — 2 of the 21 pages that carry the band.

   A shared partial must not depend on what 21 pages each choose to end on, so
   the fix belongs here and not in those two templates.

   Both halves are load-bearing:
   - `--shell-2` is the lightest surface and is used by NO section background,
     so the band can never match its neighbour. It is the "elevated" token,
     which is also the right meaning — a lift just before the dark footer.
   - the top rule is the guarantee. Against a `--shell` section above, the
     surface delta is only 8/11/15 in RGB: real, but far too quiet to carry a
     section boundary alone. Against sand or deep it is already obvious there
     and the rule costs nothing.
   `--line-ink`, not `--edge-ink`: this is a decorative divider, not an
   interactive boundary (rule 25).

   The partial no longer carries `section--sand`. Leaving it there would have
   worked — same specificity, and this rule is later in the file — but that is
   the cascade winning by accident, which is exactly how three notes escaped the
   callout cap earlier today. `color` is restated because `.section` alone sets
   none and `--sand` was supplying it. */
.cta-band { background: var(--shell-2); color: var(--deep-900); border-top: 1px solid var(--line-ink); }
.cta-band__inner {
  display: grid; grid-template-columns: 1.6fr 1fr; gap: var(--s-7); align-items: center;
}
.cta-band__heading { font-size: clamp(1.7rem, 3.4vw, 2.9rem); max-width: 20ch; }
.cta-band__actions { display: flex; flex-wrap: wrap; gap: var(--s-3); justify-content: flex-end; }

/* A PAIR OF BUTTONS THAT MAY WRAP. Six wrappers on the site held two buttons in
   a bare <p> with nothing but the source newline between them: 4.2px apart on a
   desktop, and 0px stacked on a phone, where that whitespace becomes the line
   break and disappears. Owner reported it on the field-station section
   (15 Aug 2026).

   Same construction and the same `--s-3` as `.cta-band__actions` above,
   `.cta-full__actions` and `.stage__cta` — every button pair that already
   carries a class measures 12px, so this makes the unclassed ones agree rather
   than inventing a spacing. Declared here in site.css because these six live on
   pages that load only this sheet; `/` loads both. */
.btn-row { display: flex; flex-wrap: wrap; gap: var(--s-3); }
@media (max-width: 860px) {
  .cta-band__inner { grid-template-columns: 1fr; gap: var(--s-5); }
  .cta-band__actions { justify-content: flex-start; }
}

/* ---------- Expedition bands ----------
   Not a 3-up card row: a stacked list where the featured band is visibly the
   recommended one.

   The price used to be the loudest thing in each row — set at up to 2.6rem,
   above the band's own title, with a "£X → £Y per week" line under it. The
   owner's 7 Aug note: on a volunteer not-for-profit that pitches the stay on
   what it costs rather than on what it is. The description now leads and the
   figure is a quiet fact underneath it. `.band__per` and `.band__flag` were
   removed with the markup that used them. */
.band-list { display: flex; flex-direction: column; gap: var(--s-5); }
.band {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
  gap: var(--s-4) var(--s-7);
  grid-template-areas: "head body" "price body" "cta body";
  /* The body spans all three rows and is much the taller column, so the three
     left-hand rows are forced to share its height — `align-content: start`
     cannot pack them, because a spanning item sets the total. Left to `auto`
     the title, the figure and the button drifted apart down a tall column.
     Pinning the first two to their content and letting the last row absorb
     every spare pixel keeps them as one tight stack at the top. */
  grid-template-rows: auto auto 1fr;
  align-content: start;
  padding: var(--s-6);
  border: 1px solid var(--line-ink);
  border-radius: var(--r-lg);
  background: var(--shell-2);
}
.band--featured { border-color: var(--seagrass-deep); box-shadow: var(--shadow-soft); }
.band__head { grid-area: head; display: flex; align-items: baseline; gap: var(--s-3); flex-wrap: wrap; }
.band__title { font-family: var(--font-display); font-size: clamp(1.3rem, 2vw, 1.7rem); font-weight: 600; }
.band__price { grid-area: price; font-size: 1.1rem; line-height: 1.2; color: var(--txt-ink-dim); }
.band__body { grid-area: body; }
.band .btn { grid-area: cta; justify-self: start; align-self: start; margin-top: var(--s-2); }
@media (max-width: 860px) {
  /* Stacked, the description comes BEFORE the figure — same intent as the
     desktop reordering above, which the grid areas would otherwise undo.
     `grid-template-rows` has to be reset with it: the desktop value is three
     rows and this layout has four, so the inherited `1fr` would land on the
     price row and stretch it. */
  .band {
    grid-template-columns: 1fr;
    grid-template-areas: "head" "body" "price" "cta";
    grid-template-rows: auto auto auto auto;
  }
}

/* ---------- Price explorer ----------
   A SOLID EDGE ON THE INSET SURFACE, NOT A DASHED ONE. This was the only
   working, populated, interactive panel on the site drawn with a dashed border,
   and dashed now means one specific thing here: "nothing supplied yet" — it is
   the language of `.empty` and of the `.ph` picture slots on the liveaboard
   pages. A price calculator you can actually drag should not be wearing it.
   The dashed HAIRLINES elsewhere on this page (the divider between Included and
   Not included, the rule under it) are a different role and stay as they are:
   they separate things rather than enclosing them. */
.price-explorer {
  display: grid; grid-template-columns: 1.4fr 1fr; gap: var(--s-7); align-items: center;
  margin-block: var(--s-6);
  padding: var(--s-6);
  background: var(--shell-2);
  border: 1px solid var(--line-ink);
  border-radius: var(--r-lg);
}
.price-explorer__label { display: block; font-weight: 600; margin-bottom: var(--s-4); }
.price-explorer input[type="range"] { width: 100%; accent-color: var(--seagrass-deep); height: 44px; }
.price-explorer__ticks {
  display: flex; justify-content: space-between;
  font-size: .75rem; color: var(--txt-ink-dim); margin-top: 2px;
}
.price-explorer__readout { display: grid; gap: 2px; }
.price-explorer__weeks { font-size: clamp(2.4rem, 5vw, 3.6rem); line-height: 1; letter-spacing: -.03em; color: var(--seagrass-deep); }
.price-explorer__unit { font-size: .82rem; color: var(--txt-ink-dim); text-transform: uppercase; letter-spacing: .16em; }
.price-explorer__total { font-size: clamp(1.5rem, 2.6vw, 2rem); margin-top: var(--s-3); }
.price-explorer__per { font-size: .9rem; color: var(--txt-ink-dim); }
@media (max-width: 720px) { .price-explorer { grid-template-columns: 1fr; gap: var(--s-5); } }

/* ---------- Two-column include / exclude ---------- */
.split-cols { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-7); margin-top: var(--s-6); }
.split-cols > * + * { border-left: 1px dashed var(--deep-600); padding-left: var(--s-7); }
.section--paper .split-cols > * + * { border-left-color: var(--sand); }
.split-cols__title { font-family: var(--font-display); font-size: 1.1rem; font-weight: 600; margin-bottom: var(--s-4); }
@media (max-width: 800px) {
  .split-cols { grid-template-columns: 1fr; gap: var(--s-5); }
  .split-cols > * + * { border-left: 0; padding-left: 0; border-top: 1px dashed var(--deep-600); padding-top: var(--s-5); }
  .section--paper .split-cols > * + * { border-top-color: var(--sand); }
}

.tick-list { display: grid; gap: 10px; }
.tick-list li { display: flex; gap: 10px; align-items: flex-start; font-size: .95rem; }
.tick-list .icon { margin-top: .28em; color: var(--seagrass); flex: none; }
.section--paper .tick-list .icon, .section--sand .tick-list .icon { color: var(--seagrass-deep); }
.tick-list--muted .icon { color: var(--txt-light-dim); }
.section--paper .tick-list--muted .icon { color: var(--txt-ink-dim); }

/* ---------- Link rail ---------- */
.link-rail { display: grid; gap: 0; margin-top: var(--s-5); }
.link-rail li + li { border-top: 1px dashed var(--sand); }
.section--deep .link-rail li + li { border-top-color: var(--deep-600); }
.link-rail a {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-4);
  padding: 18px 0; min-height: 44px;
  font-family: var(--font-display); font-size: 1.1rem; font-weight: 600;
  /* transform, not padding: animating padding relayouts the whole rail on every
     frame of the hover. */
  transition: color var(--t-micro), transform var(--t-micro) var(--ease);
}
.link-rail a:hover { color: var(--hover-ink); transform: translateX(8px); }
.section--deep .link-rail a:hover { color: var(--hover-lume); }
.link-rail .icon { flex: none; }

/* ---------- Numbered pathway ---------- */
.pathway { display: grid; gap: var(--s-4); margin-top: var(--s-5); counter-reset: p; }
.pathway li { display: flex; gap: var(--s-4); align-items: flex-start; }
.pathway__step {
  flex: none; width: 32px; height: 32px; border-radius: 999px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--seagrass); color: var(--seagrass); font-size: .82rem;
}
.science-how { display: grid; grid-template-columns: 1.3fr 1fr; gap: var(--s-8); align-items: start; }
@media (max-width: 900px) { .science-how { grid-template-columns: 1fr; gap: var(--s-6); } }

/* ---------- Partner chips ---------- */
.partner-rail { display: flex; flex-wrap: wrap; gap: 10px; margin-top: var(--s-5); }
.partner-rail--stack { flex-direction: column; align-items: flex-start; }
.partner-chip {
  font-family: var(--font-mono); font-size: .8rem; line-height: 1.4;
  border: 1px solid var(--edge-ink); border-radius: 999px;
  padding: 7px 14px;
}
.section--deep .partner-chip { border-color: var(--edge-light); }

/* ---------- Science project layout ---------- */
.project-body { display: grid; grid-template-columns: 1.6fr 1fr; gap: var(--s-8); align-items: start; }
/* The programme photo pair is the project-body grid's third child, so it must
   span both columns or it sits in a second row of column one only. */
.project-body .project-photos { grid-column: 1 / -1; }
.project-aside { position: sticky; top: 96px; display: grid; gap: var(--s-5); }
.project-aside .sketch { max-width: 220px; }
.project-aside__title {
  font-family: var(--font-mono); font-size: .72rem; text-transform: uppercase;
  letter-spacing: .16em; color: var(--txt-ink-dim); font-weight: 500;
}
.prose--wide { max-width: 74ch; }
@media (max-width: 900px) {
  .project-body { grid-template-columns: 1fr; gap: var(--s-6); }
  .project-aside { position: static; }
}

/* ---------- Course pathway stepper ----------
   GRID, NOT WRAPPING FLEX, AND THE REASON IS THE CONNECTOR LINE. Each step's
   line is its own `border-top`, so the line is exactly as long as the box that
   draws it. Under `flex: 1 1 168px` a step left alone on the last row absorbed
   all the free space: at 1440 the seven steps broke 6 + 1 and "Divemaster"
   became 1128px wide, trailing a full-width rule across empty paper with no
   step on it — which reads as a pathway that continues somewhere. A grid track
   is sized by the ROW, not by what happens to be sitting in it, so the lone
   step keeps one column's width and its line stops with it.
   `auto-fit` rather than a fixed count so an eighth course does not need a CSS
   change: 150px yields all seven across at 1240, then 4+3, 2s, and 1. */
.stepper {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 0;
  margin-block: var(--s-6);
}
.stepper__item {
  display: flex; flex-direction: column; gap: 4px;
  padding: var(--s-4) var(--s-5) var(--s-4) 0;
  position: relative;
  border-top: 2px solid var(--sand);
}
.stepper__item::before {
  content: ""; position: absolute; top: -6px; left: 0;
  width: 10px; height: 10px; border-radius: 999px; background: var(--seagrass-deep);
}
.stepper__item--end::before { background: var(--ochre); }
/* Each segment runs from its own dot to the next one, so the last segment has
   nowhere to arrive and stopped dead in mid-air past Divemaster. Faded out
   instead of squared off — the same 2px line, drawn as a background band so the
   border box (and with it the dot's -6px offset) is unchanged. */
.stepper__item--end {
  border-top-color: transparent;
  /* `transparent` rather than a zero-alpha copy of --sand: gradients
     interpolate in premultiplied alpha, so there is no grey fringe, and rule 16
     keeps the token in one place. */
  background-image: linear-gradient(to right, var(--sand) 45%, transparent);
  background-size: 100% 2px;
  background-position: 0 0;
  background-repeat: no-repeat;
}
.stepper__label { font-weight: 600; font-size: .95rem; }
/* Bottom-aligned so the timings read as one row. Grid stretches every step to
   the tallest in its row, and "Emergency First Response" is the only two-line
   label — its "flexible" sat a line below all the others. */
.stepper__when { font-size: .78rem; color: var(--txt-ink-dim); margin-top: auto; }

/* ---------- Q&A blocks ---------- */
.qa-list { display: grid; gap: var(--s-6); margin-top: var(--s-6); }
.qa { display: grid; grid-template-columns: 1fr 2fr; gap: var(--s-5); align-items: start; }
.qa__q { font-family: var(--font-display); font-size: 1.08rem; font-weight: 600; }
.qa__a { color: var(--txt-light); max-width: 68ch; }
.section--paper .qa__a, .section--sand .qa__a { color: var(--txt-ink); }
.qa + .qa { border-top: 1px dashed var(--deep-600); padding-top: var(--s-6); }
.section--paper .qa + .qa, .section--sand .qa + .qa { border-top-color: var(--sand); }
@media (max-width: 800px) { .qa { grid-template-columns: 1fr; gap: var(--s-3); } }

/* ---------- Camp: route + week strip ---------- */
/* Every other block on this page carries its own top margin — `.camp-map` s-6,
   `.photo-grid` s-6, `.photo-row` s-6, `.week-strip` s-5, `.gallery-strip` s-5.
   These two were the exceptions and it showed: "Where we are" and "Camp in
   pictures" both measured a **0px** gap to the text under them, so the heading's
   descenders and the body's ascenders were almost touching. The display face
   sits at a tight line-height, so there is no half-leading to absorb it.
   Deliberately NOT fixed with a blanket `.display { margin-bottom }`: measured
   across 22 routes that shifts 20 of them by 5–48px, including the closing CTA
   band on almost every page and pages already signed off. Logged in plan/15 as a
   question about heading rhythm instead of answered from one page's review. */
.camp-where { display: grid; grid-template-columns: 1.4fr 1fr; gap: var(--s-8); align-items: start; margin-top: var(--s-5); }
/* .gallery-empty is gone with the note it spaced — the camp page's CMS photo
   strip now renders nothing when it is empty (12 Aug). */
@media (max-width: 900px) { .camp-where { grid-template-columns: 1fr; gap: var(--s-6); } }

.route { display: grid; gap: 0; }
.route__stop { position: relative; padding: 0 0 var(--s-5) var(--s-6); }
.route__stop::before {
  content: ""; position: absolute; left: 0; top: 6px;
  width: 11px; height: 11px; border-radius: 999px;
  border: 2px solid var(--seagrass-deep); background: var(--shell);
}
.route__stop::after {
  content: ""; position: absolute; left: 5px; top: 17px; bottom: 0;
  width: 1.5px; background: repeating-linear-gradient(to bottom, var(--sand) 0 5px, transparent 5px 10px);
}
.route__stop--end::after { display: none; }
.route__stop--end::before { background: var(--ochre); border-color: var(--ochre); }
.route__place { display: block; font-family: var(--font-display); font-weight: 600; font-size: 1.05rem; }
.route__mode { display: block; font-size: .78rem; color: var(--txt-ink-dim); }

.week-strip { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-top: var(--s-5); }
.week-strip__day {
  display: flex; flex-direction: column; gap: 8px;
  padding: var(--s-4) var(--s-3);
  background: var(--shell-2); border-top: 3px solid var(--sand);
  font-size: .82rem; line-height: 1.4;
}
.week-strip__day--mark { border-top-color: var(--seagrass-deep); }
.week-strip__day--rest { border-top-color: var(--ochre); }
.week-strip__label { font-size: .72rem; text-transform: uppercase; letter-spacing: .14em; color: var(--txt-ink-dim); }
@media (max-width: 860px) { .week-strip { grid-template-columns: 1fr; gap: 6px; } }

.gallery-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--s-4); margin-top: var(--s-5); }

/* Captioned photo grid. `.media--card` gives every frame the same 4:3 box, so
   the captions line up across a row whatever shape the original was — these are
   a mix of phone portrait and camera landscape.

   EXPLICIT COLUMN COUNTS, for the same reason as `.photo-row` below. `auto-fit`
   fitted FOUR 260px tracks into the 1128px shell, so the six sleeping
   photographs ran 4 + 2 — while the markup asked for three (`sizes` 33vw). The
   two disagreeing is not cosmetic: `sizes` is what the browser picks a variant
   from, so it was choosing for a 372px column and painting a 264px one.
   Six divides evenly by 3, 2 and 1, so this ladder cannot orphan. The
   `.photo-grid--wide` variant holds three, which divides by 3 and 1 but not 2 —
   so it skips the 2-up step rather than orphaning its third frame.
   Every breakpoint here is duplicated in camp.ejs's `sizes` and must stay in
   step with it. */
.photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-5); margin-top: var(--s-6); }
@media (max-width: 900px) { .photo-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .photo-grid { grid-template-columns: minmax(0, 1fr); } }
.photo-grid__item { display: flex; flex-direction: column; gap: var(--s-3); }
.photo-grid__item .note { margin: 0; }
.photo-grid--wide { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (max-width: 900px) { .photo-grid--wide { grid-template-columns: minmax(0, 1fr); } }
/* Two frames, so two columns. The base `.photo-grid` is three, which would
   leave a third of the row empty beside a pair. Explicit counts here for the
   same reason as everywhere else in this block: `sizes` in the markup has to
   agree with the layout, and `auto-fit` decides that for itself. */
.photo-grid--pair { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (max-width: 720px) { .photo-grid--pair { grid-template-columns: minmax(0, 1fr); } }

/* Uncaptioned strip, for the food row. */
/* FOUR ITEMS, SO 4-UP OR 2×2 AND NEVER auto-fit. This row holds exactly four
   meal photographs (camp.ejs is its only user), and `auto-fit` sized them by how
   many happened to fit, which is the wrong question for a fixed set:
     - at 375 only one 200px track fits a 335px shell, so the phone got ONE per
       row — four stacked portrait photographs, ~1,050px of food on a page
       already 10,868px tall — even though the markup says 2-up;
     - at 768 exactly three fit, orphaning the fourth on its own row.
   Both were live. Explicit counts cannot produce either, and 4 divides evenly
   both ways so there is no orphan at any width.
   The 900px switch is duplicated in the template's `sizes` attribute — the two
   MUST agree, or the browser picks a variant for a column width that is not the
   one being rendered. */
.photo-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--s-4); margin-top: var(--s-6); }
@media (max-width: 900px) { .photo-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }

.camp-figure { margin-top: var(--s-7); border-radius: var(--r-lg); }

/* The site plan. It is dense with labels, so it gets width and a real link to
   the full-size file — shrinking it into a card would throw away the only
   reason to show it. `background` rather than a transparent frame because the
   artwork's own paper tone is close to --shell and it would otherwise float. */
.camp-map { margin-top: var(--s-6); }
.camp-map__link { display: block; border-radius: var(--r-lg); overflow: hidden; background: var(--shell-2); border: 1px solid var(--line-ink); }
.camp-map__link img { display: block; width: 100%; height: auto; }
.camp-map__link:hover { border-color: var(--hover-ink); }
.camp-map figcaption { margin-top: var(--s-4); max-width: 62ch; }
.camp-map__hint { color: var(--seagrass-deep); text-decoration: underline; text-underline-offset: 2px; }

/* ---------- Filters + status chips ---------- */
.filters { display: grid; gap: var(--s-3); margin-bottom: var(--s-6); }
.filters__row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.filters__legend { font-size: .72rem; text-transform: uppercase; letter-spacing: .16em; color: var(--txt-ink-dim); min-width: 56px; }
.chip {
  display: inline-flex; align-items: center; min-height: 44px;
  padding: 8px 16px; border-radius: 999px;
  border: 1.5px solid var(--edge-ink); font-size: .88rem; font-weight: 500;
  transition: background var(--t-micro), color var(--t-micro), border-color var(--t-micro);
}
.chip:hover { border-color: var(--hover-ink); color: var(--hover-ink); }
.chip--on { background: var(--deep-900); color: var(--shell); border-color: var(--deep-900); }
.chip--on:hover { color: var(--shell); border-color: var(--deep-900); }


/* ---------- Document layout (practicalities, terms, privacy) ---------- */
.doc-layout__inner { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: var(--s-8); align-items: start; }
/* A captioned full-width photograph in a content section. The caption sits
   OUTSIDE `.media`, which has `overflow: hidden` and a fixed aspect-ratio — put
   a figcaption inside it and the box clips it. Same reason `.camp-map` wraps its
   media rather than being it. */
.dive-figure { margin-top: var(--s-6); }
.dive-figure .media { border-radius: var(--r-lg); }
.dive-figure figcaption { margin-top: var(--s-4); }

/* Downloadable documents: a button and the sentence that says what it is.
   Deliberately not `.card-grid`/`.card--field` like /science/reports — a report
   is a thing you browse and choose between, a form is a thing you are told to
   go and get. A short list reads as an instruction; a card grid reads as a
   catalogue, and there are only ever a handful of these.
   `align-items: start` so the pill hugs its label instead of stretching to the
   row, and the note is already capped at 60ch by the `.note` measure rule. */
.doc-list { display: grid; gap: var(--s-5); margin-top: var(--s-5); }
.doc-list__item { display: grid; justify-items: start; gap: var(--s-3); }
.doc-list__item .note { margin: 0; }

.doc-nav { position: sticky; top: 96px; }
.doc-nav__title { font-size: .72rem; text-transform: uppercase; letter-spacing: .16em; color: var(--txt-ink-dim); margin-bottom: var(--s-3); }
.doc-nav ol { display: grid; gap: 2px; }
/* min-height, not more padding: the in-page jump lists on /practicalities,
   /faqs, /privacy and /terms measured 40px — close enough to look right and
   still under the 44px floor. Centring keeps the single-line labels optically
   where they were, so nothing moves on desktop. */
.doc-nav a {
  display: flex; justify-content: space-between; align-items: center; gap: 8px;
  min-height: 44px;
  padding: 8px 0; font-size: .9rem; color: var(--txt-ink);
  border-bottom: 1px solid var(--line-ink);
}
.doc-nav a:hover { color: var(--hover-ink); }
.doc-nav__count { color: var(--txt-ink-dim); font-size: .76rem; }
.doc-body { display: grid; gap: var(--s-7); }
/* Landing points for the old-site 301s — src/middleware/redirects.js sends real
   inbound traffic to these fragments. The header is `position: sticky` at
   var(--hdr), so an anchor without an offset scrolls the section's own heading
   underneath it. Same offset apply.css already uses for its rail.
   `.workstream` is here because /village#education and #teaching-english are
   two of those landing points and predate this — they had the same problem. */
.anchor-target,
.workstream { scroll-margin-top: calc(var(--hdr) + var(--s-5)); }

.doc-section { scroll-margin-top: 96px; max-width: 72ch; }
.doc-section h2 { font-size: clamp(1.4rem, 2.4vw, 2rem); font-weight: 600; margin-bottom: var(--s-4); }
.doc-section p + p, .doc-section ul { margin-top: var(--s-4); }
.doc-section ul { display: grid; gap: 8px; padding-left: 1.1em; list-style: disc; }
.doc-section__q { margin-bottom: var(--s-3) !important; }

/* /expedition/what-to-bring is the first doc-section whose body arrives as a
   CMS html row carrying its own sub-headings, so `.doc-section` — which spaces
   paragraphs and lists but has never needed an h3 — left every group heading
   sitting on the list above it. */
.kit-body h3 { font-size: 1.05rem; font-weight: 700; margin-top: var(--s-6); }
.kit-body h3:first-child { margin-top: var(--s-4); }
.kit-body h3 + p { margin-top: var(--s-2); }
.kit-body ul + p, .kit-body p + .note { margin-top: var(--s-4); }
@media (max-width: 900px) {
  .doc-layout__inner { grid-template-columns: 1fr; gap: var(--s-6); }
  .doc-nav { position: static; }
}

/* A full hairline plus a tint, not a thick coloured left edge. The chunky
   accent border on a callout is one of the most recognisable generated-UI
   tells, and the enclosed shape reads as a callout on its own. */
.callout {
  border: 1px solid var(--seagrass-deep);
  padding: var(--s-4) var(--s-5);
  background: rgba(23, 101, 76, .05);
  border-radius: var(--r-sm);
  margin-top: var(--s-5);
}
.callout--warn { border-color: var(--clay); background: rgba(194, 81, 44, .06); }
.section--deep .callout { background: var(--deep-800); }
/* Until Phase 7 every callout held a single paragraph, so stacked children had
   nothing separating them — a heading sat directly on the sentence under it. */
.callout > * + * { margin-top: var(--s-3); }
/* The TEXT is capped, not the box. Callout text ran to 110
   characters a line on /about/team and /privacy. Shrinking the box instead
   would leave a bordered panel stopping short of the column it sits in, which
   reads as a broken edge rather than an aside; capping the children keeps the
   box aligned with everything around it and fixes the reading line.
   68ch to match `.prose` — this is proportional text, so it lands at the same
   real measure as the body copy beside it.

   `.note` and `.hint` are EXCLUDED, and the exclusion is load-bearing rather
   than tidiness. Both are mono and carry their own 60ch cap, but that cap is
   declared earlier in this file at the same specificity, so a bare
   `.callout > *` silently won the cascade and widened them back out to 68 —
   which is how three notes on /expedition/practicalities, /apply and /support
   survived the first pass of this fix. */
.callout > *:not(.note):not(.hint) { max-width: 68ch; }

.legal-body h2 { font-family: var(--font-display); }
.legal-body h3 { font-size: 1.05rem; margin-top: var(--s-5); }
.legal-body p { color: var(--txt-ink); }
.legal-body .clause { padding-left: var(--s-4); border-left: 1px solid var(--line-ink); }

/* ---------- FAQs ---------- */
.faq-layout { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: var(--s-8); align-items: start; }
.faq-body { display: grid; gap: var(--s-8); }
.faq-group { scroll-margin-top: 96px; }
.faq-group__title { font-family: var(--font-display); font-size: clamp(1.3rem, 2.2vw, 1.8rem); font-weight: 600; margin-bottom: var(--s-3); }
.faq-group__more { margin-top: var(--s-4); }
@media (max-width: 900px) { .faq-layout { grid-template-columns: 1fr; gap: var(--s-6); } }

/* ---------- Partnership cards ---------- */
.partnership { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--s-5); margin-block: var(--s-6); }
.partnership__card { padding: var(--s-5); border: 1px solid var(--line-ink); border-radius: var(--r); background: var(--shell-2); }
.partnership__card--ours { border-color: var(--seagrass-deep); }
.partnership__card h3 { font-size: 1.08rem; }
.partnership__meta { font-size: .76rem; color: var(--txt-ink-dim); margin: 6px 0 var(--s-3); }
.partnership__card p:last-child { font-size: .93rem; color: var(--txt-ink); }
.section--deep .partnership__card { background: var(--deep-800); border-color: var(--deep-600); }
.section--deep .partnership__card p:last-child { color: var(--txt-light); }

/* ---------- Village workstreams ---------- */
.workstreams { display: grid; gap: 0; margin-top: var(--s-7); }
.workstream {
  display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: var(--s-6);
  padding-block: var(--s-6); align-items: start;
  border-top: 1.5px dashed var(--deep-600);
}
.workstream__icon .sketch { max-width: 108px; }
.workstream__body h3 { font-size: 1.2rem; margin-bottom: 6px; }
.workstream__summary { color: var(--txt-light); max-width: 62ch; }
.workstream__body p + p { margin-top: var(--s-3); }
@media (max-width: 720px) { .workstream { grid-template-columns: 1fr; gap: var(--s-3); } }

.reef-warriors { display: grid; grid-template-columns: 1.4fr 1fr; gap: var(--s-8); align-items: center; }
.reef-warriors .sketch { max-width: 300px; margin-inline: auto; }
@media (max-width: 860px) { .reef-warriors { grid-template-columns: 1fr; gap: var(--s-5); } }

/* ---------- Pull quote ---------- */
.pull-quote { margin-top: var(--s-6); max-width: 44ch; }
.pull-quote blockquote {
  font-family: var(--font-display); font-style: italic; font-weight: 500;
  font-size: clamp(1.4rem, 3vw, 2.1rem); line-height: 1.28;
}
.pull-quote figcaption { margin-top: var(--s-4); font-size: .82rem; color: var(--txt-light-dim); }

/* ---------- Contact ---------- */
.contact-layout { display: grid; grid-template-columns: 1.3fr 1fr; gap: var(--s-8); align-items: start; }
/* The heading and the first label were touching: `.field` carries a bottom
   margin, nothing carried a top one, so "Send a message" sat directly on
   "Your name". */
.contact-form > form,
.contact-form > .form-errors { margin-top: var(--s-5); }
.detail-list { display: grid; gap: var(--s-4); margin-top: var(--s-5); }
.detail-list dt { font-family: var(--font-mono); font-size: .72rem; text-transform: uppercase; letter-spacing: .14em; color: var(--txt-ink-dim); }
.detail-list dd { margin-top: 4px; font-size: .95rem; }
/* The email address and the WhatsApp number are the two things anyone actually
   taps on /contact — it is the whole point of the page — and they measured
   18px and 20px tall.

   AT EVERY WIDTH, not just on a phone, which is where this differs from the
   footer's copies of the same two links. Those were 24px, exactly on WCAG 2.2's
   24px floor, so raising them was an enhancement and the desktop layout was
   left alone. These are BELOW that floor, and the inline-link exemption does
   not cover them: they are standalone content in a definition list, not links
   inside a sentence. Two rows grow by ~24px each on a 2,510px page.

   `dd > a` and not `.contact-details a`: the direct-child selector picks up the
   two actionable links and leaves alone the one inside the closing `.note`
   paragraph, which IS inline in a sentence and correctly exempt. */
.detail-list dd > a { display: inline-flex; align-items: center; min-height: 44px; }
/* A note that follows one of those links gets its own line. The WhatsApp number
   and "Usually the fastest route." sat 4px apart on one line, at two different
   sizes, so they read as a single run of text — flagged as cosmetic when the tap
   targets were fixed and left alone then. `a + .note` is what scopes it: the
   only note in this list that follows a link is that one. The "(England)" after
   the company number has no link before it and correctly stays inline. */
.detail-list dd > a + .note { display: block; margin-top: 2px; }
@media (max-width: 900px) { .contact-layout { grid-template-columns: 1fr; gap: var(--s-7); } }

/* ---------- Error pages ---------- */
.error-page { min-height: 62vh; display: flex; align-items: center; }
.error-page__inner { display: grid; grid-template-columns: 1.5fr 1fr; gap: var(--s-8); align-items: center; }
.error-page__prop { max-width: 280px; margin-inline: auto; opacity: .7; }
@media (max-width: 860px) {
  .error-page__inner { grid-template-columns: 1fr; }
  .error-page__prop { display: none; }
}

/* Science hub runs four cards across at 1100px+, which leaves each title about
   220px. Titles like "Manta Ray Photo-Identification" broke to three ragged
   lines at the shared h3 size, so this grid gets its own slightly smaller step. */
.card-grid--science .card--field h3 { font-size: clamp(1.15rem, 1.4vw, 1.32rem); }
.card-grid--science .card--field p { font-size: .9rem; }

/* Sub-headings inside a science project block (Coral Ecology's four research
   areas). Distinct from the section h2 without competing with it. */
.project-sub {
  font-family: var(--font-display);
  font-size: 1.12rem; font-weight: 600;
  margin-top: var(--s-6); margin-bottom: var(--s-2);
}
.project-sub:first-child { margin-top: 0; }
.prose .tick-list, .prose--wide .tick-list { margin-block: var(--s-4); }

/* Heading rhythm inside prose. Headings were sitting flush against their body
   copy; a heading needs more space above it than below so it groups with what
   it introduces rather than floating between two blocks. */
.prose h2, .prose--wide h2 { margin-bottom: var(--s-4); }
.prose h2:not(:first-child), .prose--wide h2:not(:first-child) { margin-top: var(--s-7); }
.prose h3, .prose--wide h3 { margin-top: var(--s-6); margin-bottom: var(--s-2); }
.prose h2 + p, .prose h3 + p,
.prose--wide h2 + p, .prose--wide h3 + p { margin-top: 0; }

/* ==========================================================================
   Page components — Phase 4, groups 5-8
   ========================================================================== */

/* The operator credit on every /liveaboard/* page — views/partials/
   liveaboard-banner.ejs, restored 19 Aug 2026. A quiet strip on the page's own
   surface, not the dark bar it was until 9 Aug: three of the four pages that
   carry it now open with a light title bar rather than a dark hero. */
.operator-credit { background: var(--shell-2); border-bottom: 1px solid var(--line-ink); }
.operator-credit p {
  padding-block: var(--s-4);
  font-size: .92rem;
  color: var(--txt-ink-dim);
  max-width: 68ch;
}
.operator-credit strong { color: var(--txt-ink); font-weight: 600; }

/* ---------- Partners ---------- */
.partner-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--s-5); margin-top: var(--s-6); }
.partner-card { padding: var(--s-5); border: 1px solid var(--line-ink); border-radius: var(--r); background: var(--shell-2); }
.partner-card__name { font-family: var(--font-display); font-size: 1.05rem; font-weight: 600; line-height: 1.25; }
.partner-card__meta { font-size: .7rem; text-transform: uppercase; letter-spacing: .14em; color: var(--txt-ink-dim); margin: var(--s-4) 0 6px; }
.partner-card__links { display: grid; gap: 4px; }
.partner-card__links a { font-size: .88rem; text-decoration: underline; text-underline-offset: .16em; color: var(--seagrass-deep); }
.partner-card__out { margin-top: var(--s-4); font-size: .88rem; }
.section--sand .partner-card { background: var(--shell-2); }
.section--deep .partner-card { background: var(--deep-800); border-color: var(--deep-600); }

/* THE PARTNER LOGO PLATE (15 Aug 2026).

   A LOGO IS `contain`, NEVER `cover`. Every other picture band on this site
   cover-crops, because cropping a photograph is a framing decision. Cropping a
   logo is defacing somebody's trademark, so the mark is fitted whole inside the
   plate and the plate carries the leftover space.

   THE PLATE IS WHITE AND NOT `--shell`, which is the one deliberate departure
   from the palette on this page. These marks were drawn against white by nine
   different designers; the cream shifts every one of them slightly and shows as
   a visible square inside any logo that carries its own white background
   (Reef Check Indonesia's does). White is the surface they were made for.

   `--logo-h` KEEPS THE ROW EVEN. The nine marks range from a wide horizontal
   lockup to a square roundel, so a plate sized by its content would step up and
   down across the grid. A fixed height plus `contain` gives every card the same
   band whatever shape the mark is — the build script already normalises the
   canvas, this holds the line in CSS as well so an owner's own upload of any
   proportion still lands correctly. */
.partner-card--logo { padding: 0; overflow: hidden; }
.partner-card--logo > :not(.partner-card__logo) { margin-left: var(--s-5); margin-right: var(--s-5); }
.partner-card--logo > .partner-card__name { margin-top: var(--s-5); }
.partner-card--logo > :last-child { margin-bottom: var(--s-5); }
/* THE PLATE'S INNER BOX IS 2.23:1 AND SO IS THE BUILT CANVAS — that agreement
   is load-bearing, not a coincidence. The build script normalises every mark
   onto one canvas so a roundel and a wide lockup carry the same weight; if the
   plate then fits that canvas at a DIFFERENT aspect, the mark is letterboxed
   twice and every logo shrinks. Measured at the common card width: inner box
   214x96 = 2.23, canvas 600x270 = 2.22. Change either and change the other:
   `--logo-h` minus 2x the block padding, over the card width minus 2x the
   inline padding, must equal BOX.w / BOX.h in scripts/build-partner-logos.js. */
.partner-card__logo {
  --logo-h: 120px;
  height: var(--logo-h);
  display: flex; align-items: center; justify-content: center;
  padding: var(--s-3) var(--s-5);
  background: #fff;
  border-bottom: 1px solid var(--line-ink);
  margin-bottom: var(--s-5);
}
/* FILL THE BOX AND LET `contain` DO THE FITTING — do NOT go back to
   `max-height: 100%; height: auto`. That version let the mark hang below the
   plate's bottom rule: the percentage resolves against the flex container in
   ways that depend on padding and box-sizing, and the Ayo mark (the tallest of
   the nine) escaped by several pixels. Giving the element the whole content box
   and letting `object-fit` letterbox inside it is the same construction the
   media frames use, and it has no percentage-height ambiguity at all. */
.partner-card__logo picture { display: block; width: 100%; height: 100%; }
.partner-card__logo img {
  display: block;
  width: 100%; height: 100%;
  object-fit: contain;
  object-position: center;
}
/* A few organisations publish ONLY a white-on-transparent mark, which is
   invisible on the plate above. Their own dark colourway is the proper fix and
   is being asked for (plan/16 §4.2); until then their card gets the surface the
   mark was actually drawn for. */
.partner-card__logo--dark { background: var(--deep-900); border-bottom-color: var(--deep-600); }
.section--deep .partner-card__logo { border-bottom-color: var(--deep-600); }

/* ---------- Team ---------- */
/* auto-FILL, not auto-fit, and the difference is the whole point here.
   auto-fit COLLAPSES the tracks it has not filled and hands their width to the
   ones it has, so a track is sized by how many people happen to be in that
   group rather than by the row. The three groups hold 5, 2 and 1, so at 1440
   the portrait frames measured 214x285, 502x669 and 1078x1437 — one card five
   times the size of its neighbours a section above it. auto-fill keeps the
   empty tracks, so every card is one column wide in every group.
   Same family as the pathway stepper on /expedition/dive-training: a grid track
   is sized by the row, not by what happens to be sitting in it. */
.team-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--s-5); margin-top: var(--s-6); }
.team-card { padding: var(--s-5); border: 1px solid var(--line-ink); border-radius: var(--r); background: var(--shell-2); }
.team-card__photo {
  aspect-ratio: 3 / 4; border-radius: var(--r-sm); background: var(--shell-3);
  display: flex; align-items: center; justify-content: center; margin-bottom: var(--s-4);
}
.team-card__photo .sketch { max-width: 60%; opacity: .35; }
/* An uploaded portrait fills the same frame the sketch sits in, cropped rather
   than fitted, so a landscape snapshot and a tall studio portrait come out the
   same size as each other and as every empty slot beside them. The frame is a
   .media box, so the picture element carries the size and object-fit can bite;
   align-self overrides the centring the sketch needs. */
/* `align-self: stretch` used to live here as a flex workaround for the
   missing height; the frame primitive gives the real box instead. */
.team-card__role { font-family: var(--font-display); font-size: 1.02rem; font-weight: 600; line-height: 1.25; }
.team-card__name { font-size: .95rem; margin-top: 4px; }
.team-card__bio { font-size: .9rem; color: var(--txt-ink); margin-top: var(--s-3); }

/* ---------- Reviews ---------- */
/* ---------- Volunteer World rating ---------- */
.rating {
  display: flex; align-items: center; gap: var(--s-5); flex-wrap: wrap;
  padding: var(--s-5); margin-bottom: var(--s-6);
  border: 1px solid var(--line-ink); border-radius: var(--r-lg); background: var(--shell-2);
}
/* THE BADGE CARRIED SPACE BELOW IT AND NONE ABOVE, so under a heading it sat
   flush against the type — measured 0px on the homepage against `--s-6` on its
   underside, and against the 48px every other section on that page puts between
   its heading and its first block. The margin matches the one below it, so the
   card now has equal air on both sides.
   `.display + .rating` rather than a bare `margin-top`, because on
   `/about/reviews` the badge is the FIRST child of its column: a top margin
   there collapses through the wrapper and pushes the whole section down instead
   of spacing anything. Keyed to the heading actually being there, so it fires
   where the problem is and any future page that puts the badge under a heading
   gets the gap without anyone remembering. */
.display + .rating { margin-top: var(--s-6); }
.section--deep .rating, .section--shell .rating { border-color: var(--line-light); }
/* THE BADGE IS AN OPAQUE CREAM CARD, so it keeps on-light ink whatever surface
   it lands on — same reasoning as `.empty` above. `.rating__value` and
   `.rating__outof` already carry their own colours; `.rating__text` and its
   `<strong>` carry none, so on the homepage's now-dark reviews section they
   inherited cream and measured 1.10:1 against the card they sit on. `.note` is
   included because `.section--deep .note` would otherwise paint the "Read them
   on Volunteer World" link on-dark inside the same cream box — it renders only
   when the CMS holds a profile URL, which is exactly the kind of branch that
   ships broken. */
.section--deep .rating { color: var(--deep-900); }
.section--deep .rating .note { color: var(--txt-ink-dim); }
.section--deep .rating .note a { color: var(--seagrass-deep); }
.rating__score { display: flex; align-items: baseline; gap: 4px; flex: none; }
.rating__value { font-size: 2.4rem; font-weight: 500; line-height: 1; color: var(--seagrass-deep); }
.rating__outof { font-size: .9rem; color: var(--txt-ink-dim); }
.rating__body { min-width: 0; }
/* Two stacked copies: a grey row of five, and a clipped gold row on top. A
   percentage width gives a partial star for free, which a per-star loop cannot
   do without half-star glyphs. */
.rating__stars { position: relative; display: inline-block; line-height: 1; font-size: 1.05rem; letter-spacing: .1em; }
.rating__stars-base { color: var(--sand); }
.rating__stars-fill {
  position: absolute; inset: 0; overflow: hidden; white-space: nowrap;
  color: var(--ochre);
}
.rating__text { margin-top: 6px; font-size: .95rem; }
@media (max-width: 560px) { .rating { gap: var(--s-4); } .rating__value { font-size: 2rem; } }

/* ---------- Homepage quote rail ---------- */
/* Scroll-snap, so it works as a swipeable list before any JS runs. */
.quote-rail { position: relative; margin-top: var(--s-5); }
.quote-rail__track {
  display: flex; gap: var(--s-5);
  overflow-x: auto; scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  padding-bottom: var(--s-4);
  /* Room for the focus ring on the cards, which would otherwise be clipped. */
  padding-inline: 3px;
  scrollbar-width: thin;
}
.quote-rail__item {
  flex: 0 0 min(420px, 82%);
  scroll-snap-align: start;
  display: flex;
}
.quote-rail__item > .quote { width: 100%; }
/* The rail shows an EXCERPT, and it has to, because the reviews it carries are
   not the length it was designed for. It was built against the old site's
   testimonials, most of which were a couple of sentences; the named Volunteer
   World reviews run 1,000–2,300 characters, which turned every card into a
   2,418px column — three phone screens tall inside a horizontal carousel, so
   you could neither read one nor swipe past it.
   Clamping is the honest way to shorten a quote: nothing is rewritten and the
   ellipsis says plainly that there is more, with the full unedited text one tap
   away under "All N reviews". Paraphrasing to fit is what rule 2 forbids —
   truncating with a visible marker and a link to the whole thing is not that. */
.quote-rail__item .quote blockquote p {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 9;
  line-clamp: 9;
  overflow: hidden;
}
@media (min-width: 901px) { .quote-rail__item .quote blockquote p { -webkit-line-clamp: 7; line-clamp: 7; } }
.quote-rail__item .quote figcaption { margin-top: var(--s-4); font-weight: 600; color: var(--txt-ink); }
.quote-rail__foot { margin-top: var(--s-5); }

/* Controls are injected by main.js — they do nothing without it, so they are
   not in the markup. */
.quote-rail__nav { display: flex; align-items: center; gap: var(--s-3); margin-top: var(--s-4); }
.quote-rail__btn {
  width: 44px; height: 44px; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1.5px solid var(--edge-ink); border-radius: 999px;
  background: transparent; color: inherit; cursor: pointer;
  transition: background var(--t-micro), border-color var(--t-micro), opacity var(--t-micro);
}
.quote-rail__btn:hover:not(:disabled) { border-color: var(--hover-ink); color: var(--hover-ink); }
.quote-rail__btn:disabled { opacity: .35; cursor: default; }
.quote-rail__btn:focus-visible { outline: 2.5px solid var(--seagrass-deep); outline-offset: 2px; }
/* The dot you see is 8px; the button around it is a real 28×44 box, and the
   boxes TILE rather than overlap. The previous version drew an 8px button and
   grew the target with `::before { inset: -18px }`, which does give 44px — but
   the dots sat 14px apart, so those 44px areas lay almost entirely on top of one
   another and the topmost one in the DOM took every tap. Measured: about 14px
   of exclusive target per dot, i.e. tapping the third dot usually selected the
   fourth. Sizing the button itself keeps each target its own. */
.quote-rail__dots { display: flex; gap: 0; margin-left: auto; }
.quote-rail__dot {
  width: 28px; height: 44px; padding: 0;
  border: 0; background: none; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
}
.quote-rail__dot::after {
  content: ""; width: 8px; height: 8px; border-radius: 999px;
  background: var(--sand); transition: background var(--t-micro);
}
.quote-rail__dot[aria-current="true"]::after { background: var(--seagrass-deep); }
/* The dot itself is 8px but the tappable box around it is 44 (WCAG 2.5.5). */
.quote-rail__dot { position: relative; }
.quote-rail__dot:focus-visible { outline: 2.5px solid var(--seagrass-deep); outline-offset: -4px; }

/* THE RAIL ON A DARK SURFACE. The homepage's reviews section carries a
   photograph now, which means it is `.section--deep`, and every rule above was
   written for cream. The quotes themselves need nothing — they are opaque
   `--shell-2` cards and everything inside one is still on light — but the
   arrows and the dots sit directly on the scrim, and they are injected by
   main.js, so they are easy to forget. This is the fifth time this file has had
   to learn the same lesson (`.note`, `.btn--quiet`, `.eyebrow--light`, `.todo`).

   `--edge-light` not `--line-light`: these are interactive boundaries and need
   3:1 (WCAG 1.4.11), which the decorative token cannot give (rule 25).
   `--seagrass` not `--seagrass-deep`: rule 18 — the deep variant is on-light
   only and measures under 2:1 here.
   The inactive dot goes to `--txt-light-dim` rather than staying `--sand`,
   which is a pale warm tone: on cream it is quieter than the active dot, on
   dark it would be BRIGHTER than it, inverting the state it is meant to read
   as. Not a contrast failure — a hierarchy one.
   The focus rings only restate their colour; the shorthand above already owns
   the width and offset, and repeating those is how two values drift apart.

   AND `--edge-light` IS NOT ENOUGH ON A PHOTOGRAPH. Its documented 3.01:1 is
   measured against a SOLID `--deep-900`; under a photographic scrim the
   background is whatever the picture is, and the brightest pixel this one has
   is effectively white. Composited through the scrim's own stops that lands on
   rgb(68,80,77), where .36 measures 2.34:1 and fails 1.4.11 outright. Solved
   rather than nudged: .50 is the first alpha that clears 3.0 (3.11) and .56 is
   used for the same headroom the header and footer overrides carry — 3.49:1.
   Verified before shipping: the two controls below are the ONLY `--edge-light`
   consumers inside any `.section--media` on the site, so this is a new failure
   of this section's own making rather than a pre-existing one.
   REDECLARED ON THE RAIL, not on `.section--media`: the science grid one
   section up is also `.section--media` and `.prog:hover` reads this token, so a
   broader scope would have quietly brightened a section the owner has asked
   twice to be left alone. */
.quote-rail,
.quote-rail__foot { --edge-light: rgba(244, 237, 225, .56); }
.section--deep .quote-rail__btn { border-color: var(--edge-light); }
.section--deep .quote-rail__btn:hover:not(:disabled) { border-color: var(--hover-lume); color: var(--hover-lume); }
.section--deep .quote-rail__btn:focus-visible { outline-color: #18e0c6; }
.section--deep .quote-rail__dot::after { background: var(--txt-light-dim); }
.section--deep .quote-rail__dot[aria-current="true"]::after { background: var(--seagrass); }
.section--deep .quote-rail__dot:focus-visible { outline-color: #18e0c6; }

@media (prefers-reduced-motion: reduce) {
  .quote-rail__track { scroll-behavior: auto; }
}

/* The way out of the clamp. Injected by main.js onto cards whose text is
   actually cut off, so it never appears under a quote you have already read in
   full. Styled as a link rather than a pill: it continues the sentence above it
   and a second solid button beside "All N reviews" would compete with it.
   It is a standalone control, not a link inside running text, so it takes the
   full 44px target at every width (rule 21) — the height comes from the box,
   not from the type, so the card grows by the target and nothing else. */
/* One rule serves both "read the whole thing" controls — the homepage review
   cards and the itinerary day cards. Both are injected by main.js, for the
   same reason: they open a dialog, and a dialog needs JS. */
.quote__more,
.day-card__more {
  display: inline-flex; align-items: center; gap: 8px;
  min-height: 44px; margin-top: var(--s-3);
  padding: 0; border: 0; background: none;
  font: inherit; font-size: .9rem; font-weight: 600;
  color: var(--seagrass-deep);
  text-decoration: underline; text-underline-offset: 3px;
  cursor: pointer;
}
.quote__more .icon, .day-card__more .icon { transition: transform var(--t-micro); }
.quote__more:hover .icon, .day-card__more:hover .icon { transform: translateX(3px); }
.quote__more:focus-visible,
.day-card__more:focus-visible { outline: 2.5px solid var(--seagrass-deep); outline-offset: 3px; border-radius: 2px; }

/* ---------- The full review, in a dialog ---------- */
/* Native <dialog> + showModal(), so the top layer, the backdrop, Escape and the
   focus trap are all the browser's rather than ours — and focus returns to the
   card you opened it from with no bookkeeping.
   PADDING LIVES ON __inner, NEVER HERE. main.js dismisses on a click whose
   target is the <dialog> itself, which is only sound while the element has no
   padding of its own for a click to land in. */
.review-modal {
  padding: 0;
  width: min(680px, calc(100vw - var(--pad) * 2));
  max-width: none; max-height: none;
  border: 1px solid var(--line-ink);
  border-radius: var(--r-lg);
  background: var(--shell-2);
  color: var(--txt-ink);
  box-shadow: var(--shadow-soft);
}
.review-modal::backdrop { background: rgba(6, 21, 18, .62); }
.review-modal__inner { padding: var(--s-6); }
.review-modal__head {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: var(--s-4);
  padding-bottom: var(--s-4);
  border-bottom: 1px solid var(--line-ink);
}
.review-modal__who {
  margin: 0; font-weight: 600; color: var(--deep-900);
  align-self: center;
}
.review-modal__close {
  width: 44px; height: 44px; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1.5px solid var(--edge-ink); border-radius: 999px;
  background: transparent; color: inherit; cursor: pointer;
  transition: border-color var(--t-micro), color var(--t-micro);
}
.review-modal__close:hover { border-color: var(--hover-ink); color: var(--hover-ink); }
.review-modal__close:focus-visible { outline: 2.5px solid var(--seagrass-deep); outline-offset: 2px; }
/* `svh`, not `vh`: on a phone `vh` is the height with the browser bar retracted,
   which is not what you can see the moment the dialog opens — the same trap the
   homepage timeline hit. `overscroll-behavior` stops a flick at the end of the
   quote from scrolling the page underneath. */
.review-modal__body {
  max-height: min(58svh, 520px);
  margin-top: var(--s-5);
  overflow-y: auto; overscroll-behavior: contain;
  scrollbar-width: thin;
}
.review-modal__body:focus-visible { outline: 2.5px solid var(--seagrass-deep); outline-offset: 4px; border-radius: 2px; }
.review-modal__body p { margin: 0; max-width: 68ch; line-height: 1.7; white-space: pre-line; }
@media (max-width: 560px) {
  .review-modal__inner { padding: var(--s-5); }
  .review-modal__body { max-height: 62svh; }
}
/* A landscape phone, measured: at 812x375 the 58svh version stood 369px tall in
   a 375px frame — inside it, and 3px from each edge, which reads as a dialog
   that only just fits rather than one that was placed. Height is the scarce
   axis here, not width, so this keys off the frame's height and not a
   breakpoint. */
@media (max-height: 520px) {
  .review-modal__inner { padding: var(--s-4) var(--s-5); }
  .review-modal__body { max-height: 44svh; margin-top: var(--s-4); }
}

/* CSS columns, not grid. These testimonials run from 12 words to nearly 700,
   and a grid row is as tall as its tallest cell — so one long quote would leave
   two cards of whitespace beside it. Columns pack them by height instead.
   `break-inside: avoid` on the card is what stops one splitting across a
   column boundary mid-sentence. */
.review-grid { columns: 3 300px; column-gap: var(--s-5); }
.review-grid > .review { break-inside: avoid; margin-bottom: var(--s-5); }

/* The longest testimonial runs full width above the columns: it is the one that
   describes what the place is actually like, and it earns the room. */
.review--lead { margin-bottom: var(--s-6); }
.review--lead blockquote { font-size: 1.08rem; max-width: 74ch; }
.review--lead blockquote p + p { margin-top: var(--s-4); }
.reviews__note { margin-bottom: var(--s-5); }
.review blockquote p + p { margin-top: var(--s-3); }
.review { padding: var(--s-6) var(--s-5); border: 1px solid var(--line-ink); border-radius: var(--r-lg); background: var(--shell-2); }
.review__mark { color: var(--sand); margin-bottom: var(--s-3); }
.review blockquote { font-size: 1.02rem; line-height: 1.55; color: var(--txt-ink); }
.review figcaption { margin-top: var(--s-4); display: grid; gap: 2px; }
.review__author { font-weight: 600; font-size: .94rem; }
.review__meta { font-size: .78rem; color: var(--txt-ink-dim); }

/* ---------- Gallery ----------
   CSS columns rather than a JS masonry: nothing to initialise, nothing to break,
   and it reflows for free. */
.masonry { columns: 3 260px; column-gap: var(--s-4); margin-top: var(--s-6); }
.masonry__item { break-inside: avoid; margin-bottom: var(--s-4); }
.masonry__item img { border-radius: var(--r); width: 100%; height: auto; }
.masonry__item figcaption { margin-top: 6px; }

/* ---------- Blog ---------- */
.card--field__cat { font-size: .7rem; text-transform: uppercase; letter-spacing: .14em; color: var(--seagrass-deep); }
.section--deep .card--field__cat { color: var(--seagrass); }
.card--field .media--card { margin: calc(var(--s-5) * -1) calc(var(--s-5) * -1) 0; border-radius: var(--r-lg) var(--r-lg) 0 0; }
.article-body { max-width: 68ch; margin-top: var(--s-6); }
.article-body p + p { margin-top: 1.1em; }

/* ---------- Liveaboard itinerary ---------- */
.trip-code { color: var(--seagrass-deep); margin-right: .6em; }
.section--deep .trip-code, .section--sand .trip-code { color: var(--seagrass-deep); }

/* A DAY IS A CARD IN A SLIDESHOW (owner, 14 Aug 2026: the itinerary pages were
   "too crowded with text"). The vertical rail became the village slideshow's
   construction — a scroll-snap track that main.js drives, one day on screen at
   a time, advancing every two seconds on a desktop and moved only by the bare
   arrows on a phone. The track, the arrows and the timer are all the shared
   .vshow block below; this block is only the card inside each slide.

   THE BODY CLAMPS ONLY UNDER .has-js. main.js injects the "Read more" control
   and the dialog it opens; with no script there is no way out of a clamp, so
   the no-JS page shows every word — the same contract the quote rail and the
   timeline keep. */
/* THE DECK FILLS THE SHELL ON THIS PAGE, where the village slideshow stops at
   1000px (owner, 14 Aug 2026: make the desktop card bigger). The village is
   capped because a 3:2 photograph at the full 1128px shell stands 752px tall —
   one picture, most of a laptop window. A day card is a picture BESIDE text,
   so its height comes from the picture COLUMN rather than the whole width, and
   the cap only made the card small.

   TWO-CLASS, AND MEASURED THE HARD WAY: `.vshow`'s own `max-width: 1000px` is
   a single class sitting LATER in this file, so a single-class modifier here
   lost the tie and the deck stayed at 1000px. */
.vshow.vshow--days { max-width: none; }

.day-card {
  width: 100%;
  /* 2:3 — the picture takes the smaller share now that it is portrait. The
     text column lands at ~600px against the body's own 58ch measure, so the
     copy still sets at a readable width rather than stretching. */
  display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
  gap: var(--s-7);
  align-items: center;
  padding: var(--s-7);
  background: var(--shell-2);
  border: 1px solid var(--line-ink);
  border-radius: var(--r-lg);
}
/* 3:4 PORTRAIT ON DESKTOP, at the owner's instruction — two-class because
   .vshow__figure's own 3:2 sits LATER in this file and would win a
   single-class tie. Uploaded photographs cover-crop to it; no second file. */
.vshow__figure.day-card__media { aspect-ratio: 3 / 4; }
.day-card__marker { font-size: .82rem; color: var(--seagrass-deep); }
.day-card__title { font-size: 1.45rem; margin-top: var(--s-2); margin-bottom: var(--s-3); }
.day-card__body { color: var(--txt-ink); max-width: 58ch; }
.has-js .day-card__body {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  /* EIGHT, NOT THE WHOLE DAY. The taller card has room for more of the text
     before "Read more", and at eight lines a 700-character day is still
     genuinely cut off — which is what keeps the control honest. main.js
     measures rather than assuming, so a short day simply has no control. */
  -webkit-line-clamp: 8;
  overflow: hidden;
}
/* The placeholder sits on the card's own --shell-2, which is also .ph--slide's
   fill — the dashed edge and the label are what mark the slot there. */
@media (max-width: 1000px) {
  .day-card { gap: var(--s-6); padding: var(--s-6); }
}
@media (max-width: 720px) {
  .day-card { grid-template-columns: 1fr; gap: var(--s-4); padding: var(--s-4); }
  .day-card__title { font-size: 1.3rem; }
  /* 16:9 rather than the portrait frame above: stacked at full column width a
     3:4 picture is taller than the phone screen before a word of the day is
     read. Same two-class specificity, and it sits after the desktop rule so it
     wins inside this query. */
  .vshow__figure.day-card__media { aspect-ratio: 16 / 9; }
  .has-js .day-card__body { -webkit-line-clamp: 5; }
}

/* ---------- "picture here" placeholder ----------
   Used wherever a photograph is expected and none has been supplied yet. It is
   deliberately UNMISTAKABLE — a dashed edge and a label — because the two
   alternatives are worse: a stock or generated picture would be a claim about a
   place (rule 22), and an empty box reads as an image that failed to load.
   --shell-3 is the INSET token; --sand is decorative and too dark to carry
   text at this size. */
.ph {
  display: grid; place-items: center; align-content: center; gap: 6px;
  width: 100%; height: 100%; min-height: 96px;
  padding: var(--s-4);
  text-align: center;
  background: var(--shell-3);
  border: 1px dashed var(--edge-ink);
  border-radius: var(--r);
  color: var(--txt-ink-dim);
}
.ph .icon { width: 22px; height: 22px; opacity: .55; }
.ph__label {
  font-family: var(--font-mono);
  font-size: .68rem; letter-spacing: .12em; text-transform: uppercase;
}

/* ---------- Homepage village slideshow ----------
   The owners' brief: the seven community workstreams as pictures with an arrow
   each side, a small label on the frame, and no other copy.

   SCROLL-SNAP, NOT A JS CAROUSEL — the same architecture as the quote rail
   above, and for the same reason: with no JS this is already a swipeable,
   trackpad-scrollable list where every slide is reachable. The arrows and the
   auto-advance are added on top by main.js, so a script that never runs costs
   the reader nothing. */
/* Capped, and CENTRED — `margin-inline: auto` is what makes it symmetrical on
   the page. Capped because at the full 1128px shell a 3:2 frame stands 752px
   tall, most of a laptop viewport for one picture; 1000px is also the width the
   `sizes` attribute declares, so the browser picks its variant against the box
   it actually gets. The first build capped it without the auto margins, which
   left all 128px of the difference on the right — visibly lopsided. */
.vshow {
  position: relative;
  margin: var(--s-6) auto 0;
  max-width: 1000px;
}
.vshow__track {
  display: flex; gap: var(--s-4);
  overflow-x: auto; scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  /* Room for the slide's focus ring, which the overflow would otherwise clip. */
  padding: 3px;
  scrollbar-width: none;
}
.vshow__track::-webkit-scrollbar { display: none; }
.vshow__slide { flex: 0 0 100%; scroll-snap-align: center; display: flex; }
.vshow__figure {
  position: relative; margin: 0; width: 100%;
  aspect-ratio: 3 / 2;
  border-radius: var(--r-lg); overflow: hidden;
  background: var(--shell-3);
}
/* Filling, positioning and the iOS aspect-ratio fix all come from the frame
   primitive near the top of this file. */
/* The placeholder IS the frame at this size, so it takes the frame's corners
   rather than drawing a second rounded box inside one.
   IT KEEPS ITS DASHED EDGE, and it does not keep --shell-3. Measured on the
   sand section this sits in: the inset token composites to rgb(235,224,206)
   against a section at rgb(236,226,208) — one to two units apart, so a slide
   with no photograph read as a hole in the page rather than as a marked slot.
   --shell-2 is the lightest surface and cannot collide with any section. */
.ph--slide {
  border-radius: var(--r-lg);
  border: 1.5px dashed var(--edge-ink);
  background: var(--shell-2);
  min-height: 0;
}

/* The label. Small, mono, uppercase — "like a marketing page view", and the
   same voice the eyebrows already use, so it reads as part of the site.

   A CHIP AT TOP LEFT, NOT A GRADIENT ACROSS THE FOOT (owner's call, and it is
   also the more legible of the two). A gradient has to stay subtle enough not
   to bury the bottom of the photograph, which caps how visible the words can
   get; a chip carries its own solid ground, so the label is equally readable
   over a white classroom wall and a dark village road and can be set larger
   without eating the frame. Its opacity is solved against the brightest input a
   photograph can supply — a pure-white pixel — exactly as the full-bleed
   section scrims are. Measured figure in the session log.

   It is deliberately the SAME chip on a slide with no photograph: two
   treatments for one label is a second thing to keep in step, and the dark chip
   reads correctly on the pale placeholder too. */
.vshow__label {
  position: absolute; top: var(--s-4); left: var(--s-4);
  padding: 7px 12px;
  font-family: var(--font-mono);
  font-size: .78rem; font-weight: 500;
  letter-spacing: .12em; text-transform: uppercase;
  color: var(--shell);
  background: rgba(6, 21, 18, .84);
  border-radius: var(--r-sm);
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
}

/* Arrows. Injected by main.js — dead without it, so not in the markup.
   Overlaid on the frame rather than under it: the brief asked for one each
   side of the picture.

   NO DISC AND NO RING (owner, 14 Aug 2026, with three phone screenshots: "the
   arrows and circles around it covers the pictures and cards — make them only
   arrow like < > without circle and transparent so everything can be read
   under it"). The filled 44px disc was sitting on top of a card title and a
   paragraph on every slide.

   THE GLYPH THEREFORE HAS TO CARRY ITS OWN CONTRAST, and this shadow stack is
   what replaces the disc — NOT a decoration. A cream chevron measures 1.06:1 on
   a white photograph, and the village slideshow really does have one: a
   whiteboard fills the English-lesson frame corner to corner. The shadow is a
   dark halo hugging the stroke, so the glyph keeps its edge against ANY
   backdrop while covering almost none of it.

   THREE PASSES, AND THE THIRD IS NOT BELT AND BRACES. Rendered offline and
   swept across every grey from black to white, taking at each one the better of
   (cream stroke vs backdrop) and (halo vs backdrop) — the glyph is identifiable
   if either edge carries. A cream chevron alone fails the middle of that range,
   and a two-pass shadow still left ONE band, around grey 145, at 2.93:1: the
   crossover where the stroke has gone too dim to carry and the halo has not yet
   taken over. Two tight opaque passes plus the soft one lift the worst point in
   the entire range to 3.66:1. Do not thin this stack out; re-run the sweep if
   the colour or the stroke width changes. Figures in the session log.

   THE BUTTON IS STILL 44x44. The visible mark shrank; the touch target did
   not, and shrinking it would trade one complaint for a worse one. */
.vshow__btn {
  position: absolute; top: 50%; transform: translateY(-50%);
  width: 44px; height: 44px; z-index: 2;
  display: inline-flex; align-items: center; justify-content: center;
  border: 0; padding: 0;
  background: none; color: var(--shell);
  filter: drop-shadow(0 0 1.5px rgba(6, 21, 18, 1))
          drop-shadow(0 0 3px rgba(6, 21, 18, 1))
          drop-shadow(0 0 7px rgba(6, 21, 18, .85));
  cursor: pointer;
  transition: color var(--t-micro), transform var(--t-micro) var(--ease);
}
/* THE SIZE HAS TO BE SET HERE, NOT ON THE SVG. `.icon` a few hundred lines up
   declares `width: 1.15em`, and a CSS declaration beats the element's own
   width/height presentation attributes — so the 26 that main.js writes would
   have rendered at 18.4 and the chevron would have stayed hairline-small. The
   path's `stroke-width` attribute survives, because a presentation attribute on
   an element does beat a value INHERITED from its parent. */
.vshow__btn .icon { width: 26px; height: 26px; }
.vshow__btn:hover { color: var(--hover-lume); }
.vshow__btn--prev:hover { transform: translateY(-50%) translateX(-2px); }
.vshow__btn--next:hover { transform: translateY(-50%) translateX(2px); }
/* The focus ring is the only box left, and it is the keyboard state — inset so
   it lands on the glyph rather than a 44px box of nothing. */
.vshow__btn:focus-visible {
  outline: 2.5px solid var(--seagrass-bright); outline-offset: -8px;
  border-radius: var(--r-sm);
}
.vshow__btn--prev { left: var(--s-3); }
.vshow__btn--next { right: var(--s-3); }
@media (prefers-reduced-motion: reduce) {
  .vshow__btn--prev:hover, .vshow__btn--next:hover { transform: translateY(-50%); }
}

.vshow__dots { display: flex; justify-content: center; gap: 0; margin-top: var(--s-3); }
.vshow__dot {
  width: 28px; height: 44px; padding: 0;
  border: 0; background: none; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
}
.vshow__dot::after {
  content: ""; width: 8px; height: 8px; border-radius: 999px;
  background: var(--edge-ink); transition: background var(--t-micro);
}
.vshow__dot[aria-current="true"]::after { background: var(--seagrass-deep); }
.vshow__dot:focus-visible { outline: 2.5px solid var(--seagrass-deep); outline-offset: -4px; }

/* ON-DARK DOTS. The village sits on --sand, so the base rules above are the
   on-light set; the landing page's science slider (14 Aug 2026) puts the same
   injected controls on a --deep section, where --edge-ink and --seagrass-deep
   are both invisible. Same two-value split, and the same figures, as the quote
   rail's own dark variant a few hundred lines up — this is the on-light /
   on-dark pairing rule 18 exists for, applied to a component whose markup does
   not exist until main.js runs.
   The arrows need no variant: since the disc came off they are a cream glyph
   over a dark drop-shadow, which is a pair that carries itself onto any
   surface — that is the whole point of the shadow. */
.section--deep .vshow__dot::after { background: var(--txt-light-dim); }
.section--deep .vshow__dot[aria-current="true"]::after { background: var(--seagrass); }
.section--deep .vshow__dot:focus-visible { outline-color: #18e0c6; }

.vshow__foot { margin-top: var(--s-5); }

@media (max-width: 700px) {
  .vshow__figure { aspect-ratio: 4 / 3; }
  /* Hard against the edge now that there is no disc to inset — a bare chevron
     4px from the frame reads as part of the frame rather than as something
     sitting on the picture. */
  .vshow__btn--prev { left: 0; }
  .vshow__btn--next { right: 0; }
  .vshow__label { top: 10px; left: 10px; padding: 6px 10px; font-size: .7rem; }
}
@media (prefers-reduced-motion: reduce) {
  .vshow__track { scroll-behavior: auto; }
}

/* ---------- Schedules & Rates ----------
   The layout the owners asked us to match from the Ayo Divers page: a filter
   bar over a list of departure cards. Ported into Barefoot's own tokens — the
   structure is theirs, the colour is ours.
   EVERY FIGURE ON THIS PAGE IS USD. `h.money()` emits "£" and is the expedition
   helper; the view has its own usd(). */
/* ONE BAR, TWO PAGES, IDENTICAL. Everything that is SHAPE lives here —
   spacing, alignment, type, the pill, the chevron, the border weight — so the
   schedules bar and the expedition-dates bar cannot look like near-misses of
   each other. `.palette-ayo` below is left carrying only what is genuinely a
   different COLOUR VALUE (and the chevron hex, which a data: URI cannot read
   from a custom property). It used to carry the typography and the border too,
   which is why the two bars diverged: measured side by side, the schedules
   select was DM Mono 10.56px in a .12-alpha border and this one was Hanken
   Grotesque 16px in a .48-alpha one. */
.filter-bar {
  display: flex; flex-wrap: wrap; gap: 14px;
  align-items: stretch;
  margin-bottom: 36px;
}
.filter-bar .field { margin-bottom: 0; flex: 1 1 180px; min-width: 0; }
/* The destination names are the longest strings on the bar — "Raja Ampat North
   & Central" was being clipped mid-word inside an equal-width column. Given its
   own larger basis rather than shortening the route's own name. */
.filter-bar .field:first-child { flex: 1.5 1 260px; }
.filter-bar .field label {
  font-family: var(--font-mono);
  font-size: .66rem;
  /* `.field > label` is 600 site-wide; on this bar it is not bold. */
  font-weight: 400;
  letter-spacing: .14em; text-transform: uppercase;
  color: var(--txt-ink);
}

/* THE FILTER SELECTS ARE PILLS WITH THEIR OWN CHEVRON — Ayo's `.filters select`,
   which is the one control it shapes differently from its ordinary form fields.
   Its general `.field` inputs are `--r-sm`, exactly like ours, so this is scoped
   to the bar and the forms are left alone.

   `appearance: none` is the point of it: without it the browser paints the
   native control, which on macOS is a squared box with a double-arrow — that is
   what was still showing here after the rest of the page had been matched.

   ONE DELIBERATE DEVIATION, AND IT IS NOW ON BOTH PAGES: Ayo borders these in
   `--line-ink`, the DECORATIVE token at .12 alpha. That border is the only
   thing identifying the control — the select's own fill is `--shell-2` on
   `--shell`, about 1.05:1 — so at .12 the box effectively vanishes and fails
   WCAG 1.4.11's 3:1 for a component boundary. Measured: 1.28:1.
   `--edge-ink` is 3.24:1 and is what rule 25 reserves for an interactive edge.
   It is declared HERE rather than in the palette block so the two bars carry
   the same weight of line; the palette still supplies the hue. Shape copied,
   contrast not — and this is what closes plan/16 §6a's third item, which was
   the one with a real cost attached. */
.filter-bar select {
  appearance: none;
  -webkit-appearance: none;
  border: 1.5px solid var(--edge-ink);
  border-radius: 999px;
  /* Ayo's own metrics. `min-height` stays at the site's 44px (rule 21) — Ayo
     sizes purely from padding and lands at 35px, which clears WCAG 2.5.8's
     24px but not this project's own floor. */
  font-family: var(--font-mono);
  font-size: .66rem;
  font-weight: 400;
  letter-spacing: normal;
  line-height: 1.62;
  padding: .7em 2.2em .7em 1em;
  min-width: 160px;
  cursor: pointer;
  background-color: var(--shell-2);
  color: var(--deep-900);
  /* The chevron is inlined rather than fetched, so it cannot 404 and costs no
     request. Stroke is `--deep-900`; a data URI cannot read a custom property,
     so changing the ink here means changing it in the URL too. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' fill='none' stroke='%23061512' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right .8em center;
  background-size: 1.1em;
}
/* `align-items: flex-end` is SHAPE, not colour, so it belongs here. The bar is
   `align-items: stretch`, so without it the buttons grow to the height of a
   field (label + select) — measured 68px against the schedules bar's 44px, and
   that was the last thing making the two bars different. */
.filter-bar__actions { display: flex; gap: 10px; flex: none; align-items: flex-end; }

.result-count { margin-bottom: var(--s-4); }

.trip-list { display: grid; gap: 18px; }

/* THE CARD IS AYO RAJA AMPAT DIVERS' `.trip-card`, VALUE FOR VALUE.
   The owners asked for exactly that design with two deliberate differences:
   the "Book now" button keeps Barefoot's ochre, and we add the route
   photograph column they asked for. Everything else below — the columns, the
   gap, the padding, the radius, the edge, the shadow, and every type size and
   weight down the card — is read off Ayo's public/site.css, not approximated.

   NO VISIBLE BORDER, A SOFT SHADOW INSTEAD. That is the owners' note and it is
   what Ayo does: a 1px `--line-ink` hairline (the DECORATIVE token, .13 alpha —
   barely a line) with `--shadow-soft` carrying the separation. A previous pass
   here raised it to a 1.5px `--edge-ink` edge to match the filter selects; that
   was the wrong reference and this is the correction.
   Barefoot's `--shadow-soft` is kept rather than Ayo's: ours is tinted with the
   brand dark instead of pure black, which is the right way to build a shadow
   and is invisible as a difference. */
.trip-card {
  display: grid;
  /* THREE COLUMNS FOR THREE CHILDREN. Ayo's own is `1.2fr 2fr auto` and so is
     this now: the fixed first column was the photograph, and the owner had it
     taken off on 16 Aug 2026 (see views/pages/schedules.ejs for why). Grid
     wraps what does not fit and does not tell you, so a column count here has
     to match the card's child count on every step down. */
  grid-template-columns: 1.2fr 2fr auto;
  gap: 24px;
  align-items: center;
  background: var(--shell-2);
  border: 1px solid var(--line-ink);
  border-radius: var(--r-lg);
  padding: clamp(18px, 2.4vw, 28px);
  box-shadow: var(--shadow-soft);
}


.trip-card__dates { display: flex; flex-direction: column; gap: 4px; align-items: flex-start; }
.trip-card__code { font-size: .66rem; letter-spacing: .1em; color: var(--seagrass-deep); text-transform: uppercase; }
/* SOLID, not the translucent ink — Ayo sets `color: var(--abyss)` on its date,
   its title and its price, and only the supporting lines in `--txt-ink`. Doing
   it all in translucent ink is what made this read soft beside theirs. */
.trip-card__range { font-family: var(--font-display); font-weight: 800; font-size: 1.32rem; color: var(--deep-900); line-height: 1.1; }
.trip-card__len { font-size: .74rem; letter-spacing: .06em; color: var(--txt-ink); }

.trip-card__title { font-family: var(--font-display); font-weight: 700; font-size: 1.18rem; margin-bottom: 4px; }
.trip-card__title a { color: var(--deep-900); }
.trip-card__title a:hover { color: var(--hover-ink); }
.trip-card__route { color: var(--txt-ink); font-size: .95rem; max-width: 60ch; }
.trip-card__prices { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 12px; }
.trip-card__prices span { font-size: .82rem; color: var(--txt-ink); }
.trip-card__prices b { font-family: var(--font-mono); color: var(--deep-900); font-weight: 500; }
.trip-card__morewrap { margin-top: 2px; }
.trip-card__more {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: 44px;
  color: var(--seagrass-deep); font-weight: 600;
  text-decoration: underline; text-underline-offset: .16em;
}

.trip-card__aside {
  display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
  text-align: right; min-width: 170px;
}
.trip-card__from { font-size: .74rem; color: var(--txt-ink); }
/* MONO, NOT THE DISPLAY FACE — CLAUDE.md rule 17: "Every number, price,
   measurement, week and coordinate is set in DM Mono. Never in Hanken." This
   set --font-display to match the sister site value-for-value, which silently
   beat the `mono` class schedules.ejs puts on the same span (both are one
   class, and this rule sits 3,000 lines later). Weight drops 900 → 500 with
   the face: DM Mono has no 900 and the browser was synthesising it. */
.trip-card__price { font-family: var(--font-mono); font-weight: 500; font-size: 2rem; color: var(--deep-900); line-height: 1; }
.trip-card__was { font-size: .86rem; color: var(--txt-ink); text-decoration: line-through; opacity: .7; }
/* Ayo sets this in --coral. Barefoot's on-light clay is the equivalent token
   and measures 5.72:1; --clay itself is large-text-only at 4.01. */
.trip-card__save { font-size: .7rem; font-weight: 500; color: var(--clay-deep); }

.badge {
  display: inline-flex; align-items: center; gap: .45em;
  font-family: var(--font-mono);
  font-size: .66rem; letter-spacing: .08em; text-transform: uppercase;
  gap: .4em; padding: .4em .8em; border-radius: 999px;
}
/* --seagrass-deep on a pale seagrass wash, not --seagrass: the on-dark variant
   would fail against this background (rule 18). */
.badge--live { background: rgba(23, 101, 76, .12); color: var(--seagrass-deep); }
.badge--full { background: rgba(6, 21, 18, .1); color: var(--txt-ink); }
/* Ayo's live dot carries a soft halo ring rather than sitting flat. Same
   construction, Barefoot's green. */
.dot-live {
  width: 8px; height: 8px; border-radius: 50%; flex: none;
  background: var(--seagrass-deep);
  box-shadow: 0 0 0 4px rgba(23, 101, 76, .18);
}

/* ONE ROW ALL THE WAY DOWN TO THE STACKED STEP, which is what the dates page
   already does and is now possible here too. There used to be a 1100px tier
   that dropped `main` onto a second row, because a 168px picture column left
   the other three nothing to work with. With the picture gone (16 Aug 2026)
   the single row holds: measured at 1100 / 1000 / 940 / 880 / 830 it stays one
   row, 193–276px tall, with the route sentence still 304px wide at the
   narrowest and no overflow. Reinstating that tier costs 200px of card height
   — the two-row version measured 449–506px. */
@media (max-width: 820px) {
  .trip-card { grid-template-columns: 1fr; gap: var(--s-4); }
  .trip-card__aside { align-items: flex-start; text-align: left; min-width: 0; }
  .filter-bar .field { flex: 1 1 100%; }
  .filter-bar__actions { flex: 1 1 100%; }
}

/* ---------- Expedition start dates ----------
   The owner asked for this page to be the same design as Schedules & Rates,
   having seen the two side by side: a filter bar, then cards, not an
   eight-column data table. It shares `.filter-bar`, `.result-count`, `.badge`
   and `.dot-live` with that page — those were renamed off their `sched-` prefix
   when this page took them, so there is ONE filter bar on the site rather than
   two that drift apart.

   The card is `.trip-card`'s geometry with its own content, and deliberately
   not a reuse of that class: a departure has a photograph, three cabin rates
   and a headline price; a start date has none of the three. Same grid, gap,
   padding, radius, edge, shadow and type scale, so the two pages read as one
   site — with three columns instead of four, because there is no route
   photograph to show.

   PALETTE IS BAREFOOT'S, not `.palette-ayo`. That scope covers the three
   liveaboard routes, which describe the operator's own product; an expedition
   start is Barefoot's. Structure shared, colour ours — which is exactly what
   the schedules block above already says it does.

   NO PRICE COLUMN, and that is a decision rather than an omission. A start date
   does not have a price: the cost depends on how many weeks you stay, which is
   /expedition's job. The owners' 7 Aug note was that the site had begun selling
   on price, and a "from £…" on all 37 cards is what that asked us to stop. The
   aside carries the stay range and the Apply button instead. */
.date-list { display: grid; gap: 18px; }

.date-card {
  display: grid;
  /* THREE COLUMNS FOR THREE CHILDREN — the 220px photograph column came off
     on 16 Aug 2026 (see views/pages/dates.ejs). */
  grid-template-columns: 1.1fr 2fr auto;
  gap: 24px;
  align-items: center;
  background: var(--shell-2);
  border: 1px solid var(--line-ink);
  border-radius: var(--r-lg);
  padding: clamp(18px, 2.4vw, 28px);
  box-shadow: var(--shadow-soft);
}


.date-card__when { display: flex; flex-direction: column; gap: 4px; align-items: flex-start; }
.date-card__code { font-size: .66rem; letter-spacing: .1em; color: var(--seagrass-deep); text-transform: uppercase; }
/* Solid, not the translucent ink — the same call the trip card's date makes.
   Tabular figures so a column of dates lines up digit for digit: this is the
   one number on the card and it is the reason somebody is on this page. */
.date-card__date {
  font-family: var(--font-display); font-weight: 800; font-size: 1.32rem;
  color: var(--deep-900); line-height: 1.1;
  font-variant-numeric: tabular-nums;
}
.date-card__day { font-size: .74rem; letter-spacing: .06em; color: var(--txt-ink); }

.date-card__title { font-family: var(--font-display); font-weight: 700; font-size: 1.18rem; color: var(--deep-900); margin-bottom: 4px; }
.date-card__note { color: var(--txt-ink); font-size: .95rem; max-width: 60ch; }
.date-card__morewrap { margin-top: 2px; }
.date-card__more {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: 44px;
  color: var(--seagrass-deep); font-weight: 600;
  text-decoration: underline; text-underline-offset: .16em;
}

.date-card__aside {
  display: flex; flex-direction: column; align-items: flex-end; gap: 10px;
  text-align: right; min-width: 170px;
}
.date-card__stay { font-size: .74rem; color: var(--txt-ink); }

/* A past start or departure, listed because its "show on the public site"
   tick is on (owner, 10 Nov 2026): flat and dashed so it reads as closed beside
   the open ones, with a line in place of the button. The words keep their
   colours. The liveaboard schedules card shares it. */
.date-card--past,
.trip-card--past { background: var(--shell-3); border-style: dashed; box-shadow: none; }
.date-card__closed,
.trip-card__closed { font-size: .82rem; font-weight: 600; color: var(--txt-ink); max-width: 20ch; }
/* The liveaboard palette's turquoise is 4.93:1 on an open card and 3.85:1 on
   the greyed one, so on a past card the code and the itinerary link take the
   ink instead (6.48:1). */
.trip-card--past .trip-card__code,
.trip-card--past .trip-card__more { color: var(--txt-ink); }

@media (max-width: 1100px) {
  /* ONE ROW ALL THE WAY DOWN TO THE STACKED STEP — only the picture narrows.
     The trip card wraps its `main` onto a second row here and copying that was
     wrong for this card: measured, it took the card from 222px to 522px tall.
     The trip card can afford it because its middle column carries three cabin
     rates; ours carries one sentence with a 60ch cap.
     THREE COLUMNS FOR THREE CHILDREN, and the count is the whole rule: grid
     wraps what does not fit and does not tell you, so a card with more
     children than columns silently drops one onto a second row. It was
     `190px 1fr 1.6fr auto` while the photograph held column one. */
  .date-card { grid-template-columns: 1fr 1.6fr auto; gap: 20px; }
  .date-card__aside { min-width: 150px; }
}
@media (max-width: 900px) {
  .date-card { grid-template-columns: 1fr; gap: var(--s-4); }
  .date-card__aside { align-items: flex-start; text-align: left; min-width: 0; }
}

/* ---------- Support ---------- */
.support-list { display: grid; gap: 0; }
.support-option {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); gap: var(--s-7);
  padding-block: var(--s-6); align-items: start;
  border-top: 1.5px dashed var(--sand);
}
.support-option:last-child { border-bottom: 1.5px dashed var(--sand); }
.support-option__head { display: flex; gap: var(--s-4); align-items: baseline; }
/* --sand is a DECORATIVE token (rules, dividers) and measured 1.36:1 as text
   on --shell — the step numbers were effectively invisible. --seagrass-deep
   is the on-light accent (6.01:1); --seagrass is its on-dark counterpart. */
.support-option__index { color: var(--seagrass-deep); font-size: .82rem; }
.section--deep .support-option__index { color: var(--seagrass); }
.support-option__title { font-family: var(--font-display); font-size: clamp(1.3rem, 2.2vw, 1.8rem); font-weight: 600; }
.support-option__body p { color: var(--txt-ink); max-width: 66ch; }
@media (max-width: 860px) { .support-option { grid-template-columns: 1fr; gap: var(--s-3); } }

/* A table cell that holds a label plus a secondary line. In the stacked mobile
   view the cell itself is a flex row, so its children would otherwise become
   separate flex items and sit side by side. */
.cell-stack { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; text-align: left; }
@media (max-width: 720px) { .cell-stack { align-items: flex-end; text-align: right; } }

/* ==========================================================================
   THE AYO PALETTE — `.palette-ayo`
   ==========================================================================
   Applied to `/liveaboard/schedules`, `/liveaboard/book` and
   `/liveaboard/book/thanks`.

   The owners' instruction, given twice and unambiguously: these pages must be
   indistinguishable from the sister site's — "if I see both filtering areas at
   the same time I couldn't tell which is which" — with TWO exceptions they
   named themselves: the "Book now" / submit button keeps Barefoot's ochre, and
   we add the route photograph.

   THAT INCLUDES THE COLOUR VALUES, not just the layout. Everything below is
   read out of ~/Desktop/AI/Claude Code/Ayo Divers (public/styles.css :root and
   public/site.css .filters / .trip-card), never sampled from a screenshot.

   It is done by REDECLARING THE TOKENS on a page scope rather than by rewriting
   the components: every rule on this page already reads `--txt-ink`,
   `--seagrass-deep` and friends, so remapping those to Ayo's values converts the
   whole page at once and cannot miss an element. Rule 16 is intact — the global
   token block is untouched and this is an override, not a second source.

   THREE MEASURED CONSEQUENCES, ACCEPTED ON THE OWNERS' INSTRUCTION and written
   into plan/16 so the choice is theirs on the record:
     1. `--coral` on the card ("You save 15%") measures 3.05:1 — below the 4.5
        AA floor for small text.
     2. `--teal-deep` on the live badge measures 4.47:1 against a 4.50 floor —
        adrift by 0.03, i.e. the same colour to any eye.
     3. Ayo borders the selects in `--line-ink`, which measures 1.28:1 against
        the page and fails WCAG 1.4.11's 3:1 for a control boundary. This was
        raised once and overruled; it is their site and their call.
   Rule 21's 44px target is kept at touch widths only — see the media query at
   the end of this block. */
.palette-ayo {
  --deep-900: #03171d;                    /* Ayo --abyss */
  --deep-700: #0c3a47;                    /* Ayo --abyss-3 — .btn--ink hover */
  --txt-ink: rgba(3, 23, 29, .72);        /* Ayo --txt-ink */
  --txt-ink-dim: rgba(3, 23, 29, .72);    /* Ayo has ONE on-light ink, not two */
  --line-ink: rgba(3, 23, 29, .12);       /* Ayo --line-ink */
  /* .12 WAS AYO'S VALUE AND IT IS DELIBERATELY NOT USED. Ayo puts its
     decorative --line-ink on the select, the Reset button and every field on
     the booking form; at .12 those edges measure 1.28:1 against the page and
     fail WCAG 1.4.11's 3:1 for a component boundary — and that border is the
     only thing identifying the control, since the fill is --shell-2 on --shell
     at about 1.05:1. It was logged as a shipped cost in plan/16 §6a.
     .48 is the alpha Barefoot's own --edge-ink carries (3.24:1), so the two
     filter bars on this site now draw the same WEIGHT of line in their own
     hues, which is what the owner asked for — and roughly thirty booking-form
     inputs stop failing as a side effect. */
  --edge-ink: rgba(3, 23, 29, .48);
  --seagrass: #18e0c6;                    /* Ayo --turq */
  --seagrass-deep: #0c7d70;               /* Ayo --teal-deep */
  --clay-deep: #ff5a38;                   /* Ayo --coral */
  --shell: #f7f2e6;                       /* Ayo --paper — already the global value */
  --shell-2: #fffdf6;                     /* Ayo --paper-2 — already the global value */
  --shadow-soft: 0 18px 50px -30px rgba(0, 0, 0, .55);   /* Ayo's, black rather than tinted */
}
/* --- the ON-DARK half. The booking pages carry dark bands (the hero on the
   thanks page, the closing CTA) where the schedules page does not, so the
   light-on-dark tokens have to be remapped too or those bands would keep
   Barefoot's creams over Ayo's near-black.
   `--shell` is NOT remapped: it is the page BACKGROUND. Ayo keeps a separate
   third cream, `--foam` #f3efe4, for text and rules on dark, so the handful of
   places that use `--shell` as an on-dark colour are set literally instead.
   Every pairing here was measured against #03171d before it was applied:
   foam 15.97, --txt-light 9.04, --txt-light-dim 5.01, --turq 10.94,
   --edge-light 3.03, --coral 5.92 — all clear. */
.palette-ayo {
  --txt-light: rgba(243, 239, 228, .74);
  --txt-light-dim: rgba(243, 239, 228, .52);
  --line-light: rgba(243, 239, 228, .14);
  --edge-light: rgba(243, 239, 228, .36);
}
.palette-ayo .section--deep,
.palette-ayo .cta-full,
.palette-ayo .site-footer { color: #f3efe4; }

/* Ayo's badge wash and dot halo are literals in its stylesheet, not tokens. */
.palette-ayo .badge--live { background: rgba(24, 224, 198, .16); }
.palette-ayo .badge--full { background: rgba(3, 23, 29, .1); }
.palette-ayo .dot-live { background: #18e0c6; box-shadow: 0 0 0 4px rgba(24, 224, 198, .22); }
/* `.palette-ayo .stepbar__item.is-current .stepbar__dot` lived here until
   14 Aug 2026, when the liveaboard booking form was rebuilt as a copy of Ayo
   Raja Ampat Divers' own form. Their step indicator is a row of `.wizard__rail`
   pills, not a dotted bar, and it takes its current-step colour from
   `--clay-deep` — which `.palette-ayo` already remaps — so the override had
   nothing left to style and no literal left to keep in step. */

/* ---- the filter bar, to Ayo's numbers ----
   Ayo nests the select INSIDE the label and gives it `font: inherit`, so the
   control renders in DM Mono at .66rem. Our markup is a `.field` wrapper with
   the label as a SIBLING (it needs a `for`/`id` pair to stay reachable), so the
   same result has to be stated rather than inherited. Verified in the browser
   that Ayo's construction yields mono/.66rem/`text-transform:none` — a UA
   stylesheet drops the label's uppercase and tracking at the control, which is
   why their select reads in mixed case. */
/* Ayo's `.filters` sets no `align-items`, so the fields STRETCH and its
   `.filters__actions` bottom-aligns the buttons inside its own full-height box.
   Ours did it the other way round. Identical while every label is one line, and
   only the same construction stays identical when one is not. */
/* The bar's SHAPE now lives in the shared `.filter-bar` block above — spacing,
   alignment, type, the pill and the border weight — so the two bars on this
   site cannot drift into near-misses of each other again. What is left here is
   colour, plus the chevron, whose stroke is a literal because a data: URI
   cannot read a custom property. */
.palette-ayo .filter-bar__actions { align-items: flex-end; }
/* `.btn--ink`'s label is `--shell` site-wide. Ayo's is `--foam` #f3efe4, which
   is a DIFFERENT value from its `--paper` — a third cream it keeps for text and
   rules on dark. Four RGB units, but the brief was every colour. */
.palette-ayo .btn--ink { color: #f3efe4; }
.palette-ayo .filter-bar select {
  /* Ayo's chevron — its own stroke colour (--abyss-3). A data: URI cannot read
     a custom property, so this hex and --deep-700 above have to move together.
     The rest of the control's shape comes from the shared block. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' fill='none' stroke='%230c3a47' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E");
}

/* ---------- /about/impact (4 Nov 2026) ----------
   The owners sent the content as two screenshots: six dark figure cards under
   "What the work adds up to", then "Along the way" — a heading on the left and
   a ticked list with hairlines between the lines on the right. Built from the
   site's own pieces rather than copied pixel for pixel: the figures set in DM
   Mono (rule 17) on the dark photograph band every other page uses, the list
   on paper with the two photographs under its heading.

   THE CARD CARRIES ITS OWN DARK FILL, so its text never depends on the
   photograph. Worst case — a pure-white pixel under the scrim's .75 middle
   stop, then this .82 fill — composites to about rgb(16,27,25): the sentence
   (--txt-light) is roughly 9:1 there and the figure (--seagrass-bright) over
   10:1. */
.impact-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-5);
  margin: var(--s-7) 0 0;
  padding: 0;
  list-style: none;
}
.impact-card {
  padding: clamp(24px, 3vw, 36px);
  background: rgba(6, 21, 18, .82);
  border: 1px solid var(--line-light);
  border-radius: var(--r-lg);
}
.impact-card__fig { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; margin: 0; }
.impact-card__num {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: clamp(2.4rem, 4.2vw, 3.5rem);
  font-weight: 500;
  letter-spacing: -.03em;
  line-height: 1;
  color: var(--seagrass-bright);
}
.impact-card__unit {
  font-family: var(--font-display);
  font-size: clamp(1.1rem, 1.6vw, 1.35rem);
  font-weight: 600;
  color: var(--shell);
}
.impact-card__text { margin-top: var(--s-4); font-size: .95rem; line-height: 1.55; color: var(--txt-light); }

/* "Along the way" (owner, 4 Nov 2026, second layout): the heading across the
   top, then the two photographs on the left and the list on the right, the
   pair lining up exactly with the list — top of the first photograph with the
   first line, bottom of the second with the last rule.

   THE LIST SETS THE ROW'S HEIGHT AND THE PHOTOGRAPHS FILL IT. The pair sits in
   an absolutely positioned inner box, so it contributes no height of its own;
   each frame is `flex: 1`, so they split whatever the list measures. Their
   proportions therefore follow the list (about 1.7:1 at 1280, near square at
   901), and the 4:3 masters cover-crop into that — both subjects are centred,
   so only water is trimmed. */
.impact-way {
  display: grid;
  grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
  grid-template-areas: "h h" "pics list" ". more";
  column-gap: clamp(32px, 5vw, 72px);
}
.impact-way__h { grid-area: h; margin-bottom: var(--s-7); }
.impact-way__list { grid-area: list; }
.impact-way__more { grid-area: more; margin: 0; }
.impact-way__pics { grid-area: pics; position: relative; }
.impact-way__pics-in {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; gap: var(--s-4);
}
.impact-way__pics .media { flex: 1 1 0; min-height: 0; aspect-ratio: auto; }
/* The two divers' heads are in the top tenth of the scuba frame, which a
   centred crop into a wide frame cuts off. Anchored to the top, only sand and
   fins go. */
.impact-way__pics .media--keep-top img { object-position: 50% 0; }
.impact-list { margin: 0; padding: 0; list-style: none; }
.impact-list li {
  display: flex; gap: 14px; align-items: flex-start;
  padding: 18px 0;
  border-bottom: 1px solid var(--line-ink);
  font-size: 1.05rem; line-height: 1.55; color: var(--deep-900);
}
.impact-list li:first-child { padding-top: 0; }
.impact-list .icon { flex: none; margin-top: .32em; color: var(--seagrass-deep); }
/* Orange as in the owners' screenshot: --clay-deep is the palette's on-light
   orange (5.72:1 on --shell). Underlined, because colour alone is not a link.
   The icon is `inline-block` because the reset makes every svg a block, which
   put the arrow on a line of its own under the words. The padding keeps the
   target at 44px. */
.impact-report {
  display: inline-block; padding-block: 11px; line-height: 1.5;
  margin-top: var(--s-5);
  font-weight: 600; color: var(--clay-deep);
  text-decoration: underline; text-underline-offset: 4px;
}
.impact-report:hover { text-decoration-thickness: 2px; }
.impact-report__end { white-space: nowrap; }
.impact-report .icon { display: inline-block; margin-left: 6px; vertical-align: -.15em; transition: transform var(--t-micro); }
.impact-report:hover .icon { transform: translateX(3px); }
@media (prefers-reduced-motion: reduce) { .impact-report:hover .icon { transform: none; } }

@media (max-width: 960px) {
  .impact-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* On a phone the pair is too narrow to sit beside the list, so it drops below
   the link at its own 4:3, two across and then one. */
@media (max-width: 900px) {
  .impact-way { grid-template-columns: minmax(0, 1fr); grid-template-areas: "h" "list" "more" "pics"; }
  .impact-way__h { margin-bottom: var(--s-5); }
  .impact-way__pics { position: static; margin-top: var(--s-7); }
  .impact-way__pics-in { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-4); }
  .impact-way__pics .media { aspect-ratio: 4 / 3; }
}
@media (max-width: 600px) {
  .impact-grid { grid-template-columns: minmax(0, 1fr); }
  .impact-way__pics-in { grid-template-columns: minmax(0, 1fr); }
}
