/* ============================================================================
   Grootgeluk Bush Camp — site stylesheet.

   RULE: this file declares NO hex value, NO raw font size and NO raw spacing.
   Every colour, size, space, radius and shadow is a `--gg-*` token from
   tokens.css, which is a byte-for-byte copy of Iris's design SSOT at
   `Deliverables/2026-08-15-grootgeluk-brand-tokens.css`. If a value is missing
   here, the answer is to ask Iris for a token, not to type a hex.

   The only literal lengths below are structural (1px hairlines, 100%, 0,
   border-widths and the 44px target floor, which is itself a stated project
   bar) — never a colour, never a type size, never a spacing step.
   ============================================================================ */

/* --- latin-ext supplements -------------------------------------------------
   tokens.css declares one file per family. These add the latin-ext subset as a
   second @font-face on the same family, so the extended range is only fetched
   when a page actually needs it. Same family name, disjoint-then-overlapping
   ranges: the browser picks per codepoint. tokens.css is not edited. */
@font-face {
  font-family: 'Fraunces';
  src: url('/fonts/fraunces-var-latin-ext.woff2') format('woff2-variations');
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
  font-variation-settings: 'SOFT' 0, 'WONK' 0;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
    U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Source Sans 3';
  src: url('/fonts/source-sans-3-var-latin-ext.woff2') format('woff2-variations');
  font-weight: 300 700;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
    U+2C60-2C7F, U+A720-A7FF;
}

/* ============================================================================
   SCRIM — the one token family this file declares itself, and why.

   GL-003 / tokens.css has NO scrim token family. Iris's §4.5 plan was a
   type-led hero on a flat ground, so no photograph was ever supposed to sit
   behind text and no scrim was needed. Michael asked for photographic
   backgrounds on 2026-08-16; that is a genuine design-system gap and it is
   PARKED FOR IRIS, not quietly absorbed.

   What is declared here is an OPACITY, not a colour. The colour is
   `--gg-surface-deep` in both themes — Iris's token, untouched, imported not
   re-declared. This file still contains no hex value.

   HOW THE NUMBER WAS DERIVED. `--gg-photo-veil` is the opacity of the
   photograph itself, painted over an opaque `--gg-surface-deep` ground. That
   composites identically to an ink scrim at (1 - veil) over the photograph,
   in one layer instead of two, which means there is exactly one number to
   reason about and no stacking order to get wrong.

   Solve for the WORST POSSIBLE PIXEL — a blown-out pure white sky, which is
   literally in the top of `bg-zebra` — and require `--gg-text-on-dark`
   (--gg-paper #f2ede3, relative luminance 0.8498) to clear 4.5:1 against it:

       composited = veil x 255 + (1 - veil) x 23|20|18       (per channel)
       veil = 0.34  ->  rgb(101,100,98)  ->  L = 0.1258  ->  5.12:1

   So ~5.1:1 is the FLOOR, not the average: no photograph, and no region of any
   photograph, can do worse than that with paper text on it. That margin is
   deliberate — Michael's readers are families on phones in Limpopo daylight,
   and 4.5:1 exactly is a pass on paper and a squint in the sun.

   MEASURED, not just derived: the harness's worst reading across 14 pages,
   three widths and both themes is 5.15:1 (2026-08-17, home h1 at 320px; it was
   5.05:1 on 2026-08-16 before bg-home was re-cropped), against a predicted
   5.12:1. Measured readings land either side of the prediction because the
   browser rounds the composite (255 x 0.34 = 86.7 lands on 87) and the true
   worst pixel in a given photograph is not actually pure white — not a hole in
   the argument, since the derivation bounds it. But the honest number to quote
   is the measured one, and if it ever drifts BELOW ~5.0 something has changed
   that this comment does not describe. Do not update this paragraph from the
   arithmetic; update it from a harness run. RE-MEASURE AFTER ANY CROP CHANGE:
   a crop is a change of ground.

   DARK THEME veils further (0.26 -> ~6.6:1 floor on the near-black ground).
   That is a GLARE fix, not a contrast fix: a bright photograph on a dark-mode
   page is the "washed out / glaring in dark mode" failure Vera caught
   NightsBridge committing, and the fix is less photograph, not more ink.

   `--gg-scrim-depth` is a SECOND, optional gradient layer. It may only ever
   ADD ink (transparent -> `--gg-surface-deep`), never remove it, so the floor
   above survives it untouched. If you change it, keep that property: the whole
   argument for the floor is that no local edit can raise a pixel above it.
   ============================================================================ */
:root {
  --gg-photo-veil: 0.34;
  --gg-scrim-depth: 0.34;
}
[data-theme='dark'] {
  --gg-photo-veil: 0.26;
  --gg-scrim-depth: 0.4;
}

/* --- reset ---------------------------------------------------------------- */
*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
}

body {
  margin: 0;
  background: var(--gg-canvas);
  color: var(--gg-text-body);
  font-family: var(--gg-font-body);
  font-size: var(--gg-fs-body);
  font-weight: var(--gg-weight-body);
  line-height: var(--gg-leading-body);
  min-height: 100vh;
}

img {
  max-width: 100%;
  height: auto;
}

/* Iris: every photograph carries the inset hairline, or a pale sky bleeds
   straight into the warm canvas. */
img.gg-photo,
picture > img {
  border-radius: var(--gg-radius-sm);
  box-shadow: var(--gg-shadow-inset-image);
  display: block;
  width: 100%;
}

h1,
h2,
h3 {
  font-family: var(--gg-font-display);
  color: var(--gg-text);
  line-height: var(--gg-leading-head);
  margin: 0 0 var(--gg-space-4);
  text-wrap: balance;
}

h1 {
  font-size: var(--gg-fs-h1);
  font-weight: var(--gg-weight-display);
  letter-spacing: var(--gg-tracking-display);
}
h2 {
  font-size: var(--gg-fs-h2);
  font-weight: var(--gg-weight-medium);
}
h3 {
  font-size: var(--gg-fs-h3);
  font-weight: var(--gg-weight-medium);
}
h4 {
  font-family: var(--gg-font-body);
  font-size: var(--gg-fs-h4);
  font-weight: var(--gg-weight-medium);
  color: var(--gg-text);
  margin: 0 0 var(--gg-space-2);
  line-height: var(--gg-leading-head);
}

p {
  margin: 0 0 var(--gg-space-4);
  max-width: var(--gg-measure);
}

a {
  color: var(--gg-accent);
  text-underline-offset: 0.15em;
}
a:hover {
  color: var(--gg-accent-hover);
}

/* Journal note to self: a focus rule ported from a form-heavy build silently
   skips anchors. Anchors are named explicitly here. */
a:focus-visible,
button:focus-visible,
summary:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[tabindex]:focus-visible {
  outline: 3px solid var(--gg-focus-ring);
  outline-offset: 2px;
  border-radius: var(--gg-radius-xs);
}

/* Controls that can sit over photography carry the dual ring — no single hue
   clears 3:1 against an arbitrary photograph. Never delete the ring that looks
   redundant on the page you happen to be building.

   The class name is `gg-focus-on-image`, spelled exactly as Iris declares it in
   tokens.css §8. An earlier local rule here was called `.gg-on-image`, which
   matched nothing and was a second spelling of a canonical pattern waiting to
   drift; it is deleted. tokens.css already carries this declaration — it is
   repeated here only so the pattern is visible where the rest of the focus
   rules live. Do not rename it.

   N5: applied to the hero's primary CTA NOW, via markup, not via a
   `.hero .btn--primary` rule. Iris §4 plans hero photography; the day an image
   lands behind that button a single ring silently stops clearing 3:1, and the
   fix must already be in place rather than depend on someone remembering. */
.gg-focus-on-image:focus-visible {
  outline: 3px solid var(--gg-focus-ring);
  outline-offset: 2px;
}

/* ============================================================================
   FOCUS RING ON INVERTED SURFACES — WCAG 2.2 SC 1.4.11, light mode.
   Vera measured this on the wire: 154 instances.

   `--gg-focus-ring` is rooi-700 rgb(122,53,36). On `--gg-surface-deep`
   rgb(23,20,18) that is 2.06:1, against a 3:1 requirement. 140 footer links,
   14 theme toggles, and both buttons in the home page's deep "Book direct"
   section. Dark mode was already clean at 7.88:1.

   Worse than the ratio suggests: the ring colour is BYTE-IDENTICAL to
   `.btn--primary`'s own background, so on a primary button the indicator is
   1:1 on its inner edge and 2.06:1 on its outer. In Vera's screenshot the
   FOCUSED primary button reads as unfocused beside the UNFOCUSED secondary
   button's ordinary border. A focus indicator that makes a control look less
   focused than its neighbour is worse than no indicator.

   `--gg-paper` is declared at `:root` and NOT re-declared in the dark block, so
   it compiles in both themes: 15.72:1 light, 16.14:1 dark on the deep ground.
   Deliberately left unscoped by theme — it improves light without regressing
   dark. Iris's tokens are untouched; this is a local re-point, not a new token.

   THE `:not(.gg-focus-on-image)` IS LOAD-BEARING — added on top of the fix as
   handed over, and this is the one deviation, so it is spelled out.
   `.hero` carries `section--deep`, so an unqualified rule would also repaint the
   hero CTA's ring. Iris's dual ring deliberately pairs a DARK accent ring with a
   LIGHT halo so that one of the two always clears 3:1 against an arbitrary
   photograph. Repainting the ring to paper leaves paper-on-bark-50 — two
   near-white rings — which is fine on today's flat deep ground and defeats the
   pattern the moment Iris §4's hero photography lands behind it. That is exactly
   the regression N5 was fixed to prevent this morning.

   This costs nothing in coverage: none of Vera's 154 instances carry
   `gg-focus-on-image` — only the two hero buttons do, and she confirmed those
   already clear the bar via the 16.58:1 halo. So the rule below fixes 154/154
   and leaves the on-image pattern intact.

   --- RE-VERIFIED 2026-08-16, WHEN PHOTOGRAPHY ACTUALLY LANDED. -------------
   The paragraph above was written while every deep section was a flat ink
   ground and the photography was hypothetical. It now is not, so the exemption
   was re-checked rather than assumed, and it needed one change.

   `:not(.gg-focus-on-image)` was doing exactly the right thing for the two
   controls that CARRY the class. It was doing the wrong thing for any control
   that does not: `outline-color: var(--gg-paper)` is a single near-white ring,
   which is 15.72:1 on flat ink and as little as 1.11:1 on a blown-out sky.
   Under a 66% ink scrim the worst ground a photograph can present is rgb(101,
   100,98), where paper is 5.12:1 — so it does not actually fail today. But it
   is one image swap away from failing, it fails silently, and no static check
   can see a focus ring. Relying on the scrim to rescue an indicator that was
   never designed for photography is exactly the shape of assumption that put
   154 instances on the wire last time.

   So the rule is narrowed to `:not(.has-photo)` at the SECTION level as well.
   A photo-backed section now falls through to the base rule, and every
   focusable inside one is required by build gate `focus-ring-over-photography`
   to carry `gg-focus-on-image` — because a selector that depends on a class
   always being present is a promise about markup enforced by nothing, and the
   last time this project relied on one it was `:not(.btn)`.

   Coverage is unchanged on the 154: none of them is inside a `.has-photo`
   section (they are footer links, theme toggles and the flat "Book direct"
   band). Verified by the harness, not by reading.
   ============================================================================ */
.section--deep:not(.has-photo) :focus-visible:not(.gg-focus-on-image),
.site-footer:not(.has-photo) :focus-visible:not(.gg-focus-on-image) {
  outline-color: var(--gg-paper);
}

/* --- utilities ------------------------------------------------------------ */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  left: var(--gg-space-2);
  top: var(--gg-space-2);
  z-index: 100;
  transform: translateY(-200%);
  background: var(--gg-surface);
  color: var(--gg-accent);
  padding: var(--gg-space-3) var(--gg-space-4);
  border: 1px solid var(--gg-border);
  border-radius: var(--gg-radius-sm);
  font-weight: var(--gg-weight-medium);
  text-decoration: none;
}
.skip-link:focus {
  transform: translateY(0);
}

.container {
  width: 100%;
  max-width: var(--gg-container);
  margin-inline: auto;
  padding-inline: var(--gg-gutter);
}
.container--narrow {
  max-width: var(--gg-container-narrow);
}

.section {
  padding-block: var(--gg-section-y);
}
.section--quiet {
  background: var(--gg-surface-quiet);
}
.section--deep {
  background: var(--gg-surface-deep);
  color: var(--gg-text-on-dark);
}
.section--deep h1,
.section--deep h2,
.section--deep h3,
.section--deep h4 {
  color: var(--gg-text-on-dark);
}

.overline {
  font-size: var(--gg-fs-overline);
  font-weight: var(--gg-weight-medium);
  letter-spacing: var(--gg-tracking-overline);
  text-transform: uppercase;
  color: var(--gg-text-muted);
  margin: 0 0 var(--gg-space-3);
  display: block;
}
.section--deep .overline {
  color: var(--gg-highlight);
}

.lede {
  font-size: var(--gg-fs-lede);
  color: var(--gg-text-body);
}
.section--deep .lede {
  color: var(--gg-text-on-dark);
}

.small {
  font-size: var(--gg-fs-sm);
  color: var(--gg-text-muted);
}
.fine {
  font-size: var(--gg-fs-xs);
  color: var(--gg-text-muted);
}

/* ============================================================================
   INVERTED-SURFACE COLOUR CONTRACT — read this before adding any `color:` rule.

   There are exactly three inverted (dark-ground) subtrees on this site:
       .section--deep      inverted section band, and the hero
       .site-footer        the footer
       .card__typeplate    the no-photograph type panel

   In LIGHT mode `--gg-text` (#171412) is EXACTLY `--gg-surface-deep` (#171412).
   Any rule that sets `color: var(--gg-text)` and can land in one of those three
   subtrees therefore renders at 1:1 — literally invisible, with no visual cue
   that anything is wrong. `--gg-text-body` (1.4:1) and `--gg-text-muted`
   (~1.9:1) fail the same way, less totally. `--gg-accent` (rooi-700) computes
   to ~2:1 there.

   Vera found this shipped on `.wordmark__name` in the footer: the business name
   was a blank gap on all 14 pages. It is a CLASS of bug, not one instance —
   `.small`, `.fine`, `h2` and `a` had each been patched individually as they
   were spotted. So this block is now exhaustive and organised by token, and
   `tools/qa.mjs` asserts it live in both themes on every page. Add a new
   `color:` rule anywhere above, and add it here in the same commit.
   ============================================================================ */

/* --- 1. things set in --gg-text (ink). 1:1 on the deep ground in light mode. */
.section--deep h1,
.section--deep h2,
.section--deep h3,
.section--deep h4,
.section--deep .wordmark__name,
.section--deep .facts__value,
.section--deep .score__value,
.site-footer h1,
.site-footer h2,
.site-footer h3,
.site-footer h4,
.site-footer .wordmark__name, /* B1: the business name, invisible on 14 pages */
.site-footer .facts__value,
.site-footer .score__value,
.card__typeplate .wordmark__name {
  color: var(--gg-text-on-dark);
}

/* --- 2. things set in --gg-text-body / --gg-text-muted. ~1.4:1 and ~1.9:1. */
.section--deep .lede,
.section--deep .small,
.section--deep .fine,
.section--deep .score__meta,
.section--deep .facts__label,
.section--deep .wordmark__descriptor,
.section--deep .subscore__label,
.section--deep .crumbs,
.section--deep .field__help,
.site-footer .lede,
.site-footer .small,
.site-footer .fine,
.site-footer .score__meta,
.site-footer .facts__label,
.site-footer .wordmark__descriptor,
.site-footer .subscore__label,
.site-footer .crumbs,
.site-footer .field__help,
.card__typeplate .small,
.card__typeplate .fine {
  color: var(--gg-text-on-dark);
}

/* --- 3. things set in --gg-accent. rooi-700 on bark-950 is about 2:1.
   `.site-footer a` was already re-pointed to --gg-highlight (9.25:1 on the deep
   ground); `.section--deep a` and `.crumbs a` never were. Same token, so
   whatever Iris rules on N6 applies to all of them at once. */
/* `:not(.btn)` is essential and was learned the hard way: without it this rule
   (0,2,0) outranks `.btn--primary` (0,1,0) and repaints the hero CTA's label in
   --gg-highlight on --gg-accent — 4.40:1 in light, 1.18:1 in dark. A button
   carries its own foreground/background pair; a sweep must not reach into it.
   The QA harness caught this within one run of being written. */
.section--deep a:not(.btn),
.section--deep .crumbs a:not(.btn),
.card__typeplate a:not(.btn) {
  color: var(--gg-highlight);
}
.section--deep a:not(.btn):hover,
.card__typeplate a:not(.btn):hover {
  color: var(--gg-text-on-dark);
}

/* --- 4. overlines. Already highlight on .section--deep; the footer was not. */
.site-footer .overline,
.card__typeplate .overline {
  color: var(--gg-highlight);
}

/* --- 5. THE OTHER DIRECTION, and the half that is easy to miss.
   A component that supplies its OWN light background but no `color` inherits
   `--gg-text-on-dark` (#f2ede3) from the surrounding dark subtree and paints
   paper on white — about 1.06:1, worse than B1's 1:1 is honest about. None of
   these render inside a deep block today (only home.ts has one), but a card in
   an inverted band is an entirely ordinary thing to write next week. */
.section--deep .card,
.section--deep .note,
.section--deep .table-wrap,
.section--deep .booking-frame,
.section--deep .score-inline,
.site-footer .card,
.site-footer .note,
.site-footer .table-wrap {
  color: var(--gg-text-body);
}
.section--deep .card a:not(.btn),
.section--deep .note a:not(.btn),
.section--deep .table-wrap a:not(.btn),
.section--deep .booking-frame a:not(.btn) {
  color: var(--gg-accent);
}
.section--deep .card h1,
.section--deep .card h2,
.section--deep .card h3,
.section--deep .card h4,
.section--deep .card .card__price,
.section--deep .note .note__title,
.section--deep .table-wrap th {
  color: var(--gg-text);
}
.section--deep .card .small,
.section--deep .card .fine,
.section--deep .card .card__price .from,
.section--deep .table-wrap caption {
  color: var(--gg-text-muted);
}

/* --- buttons -------------------------------------------------------------- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--gg-space-2);
  min-height: 44px; /* project bar, exceeds SC 2.5.8 on purpose */
  min-width: 44px;
  padding: var(--gg-space-3) var(--gg-space-6);
  border-radius: var(--gg-radius-sm);
  font-family: var(--gg-font-body);
  font-size: var(--gg-fs-body);
  font-weight: var(--gg-weight-medium);
  text-decoration: none;
  border: 1px solid transparent;
  cursor: pointer;
  text-align: center;
}
.btn--primary {
  background: var(--gg-accent);
  color: var(--gg-text-on-accent);
}
.btn--primary:hover {
  background: var(--gg-accent-hover);
  color: var(--gg-text-on-accent);
}
.btn--secondary {
  background: transparent;
  color: var(--gg-accent);
  border-color: var(--gg-accent);
}
.btn--secondary:hover {
  background: var(--gg-accent-quiet);
  color: var(--gg-accent-hover);
}
.section--deep .btn--secondary {
  color: var(--gg-text-on-dark);
  border-color: var(--gg-text-on-dark);
}
.section--deep .btn--secondary:hover {
  background: transparent;
  color: var(--gg-highlight);
  border-color: var(--gg-highlight);
}

.btn-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gg-space-3);
  margin-top: var(--gg-space-6);
}

/* --- header --------------------------------------------------------------- */
.site-header {
  background: var(--gg-surface);
  border-bottom: 1px solid var(--gg-border);
  position: sticky;
  top: 0;
  z-index: 20;
}
.site-header__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gg-space-4);
  min-height: 44px;
  padding-block: var(--gg-space-3);
}

.wordmark {
  display: inline-flex;
  flex-direction: column;
  text-decoration: none;
  line-height: 1;
  padding-block: var(--gg-space-1);
}
.wordmark__name {
  font-family: var(--gg-font-display);
  font-weight: var(--gg-weight-display);
  font-size: var(--gg-fs-h3);
  letter-spacing: 0.04em;
  color: var(--gg-text);
}
.wordmark__descriptor {
  font-size: var(--gg-fs-overline);
  font-weight: var(--gg-weight-medium);
  letter-spacing: var(--gg-tracking-overline);
  text-transform: uppercase;
  color: var(--gg-text-muted);
  margin-top: var(--gg-space-1);
}

.nav-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--gg-space-2);
  min-height: 44px;
  min-width: 44px;
  padding-inline: var(--gg-space-3);
  background: transparent;
  border: 1px solid var(--gg-border);
  border-radius: var(--gg-radius-sm);
  color: var(--gg-text);
  font-family: var(--gg-font-body);
  font-size: var(--gg-fs-sm);
  font-weight: var(--gg-weight-medium);
  cursor: pointer;
}

/* The menu panel lives inside the header, so it gets its OWN opaque surface —
   a translucent or inherited background here is the classic bleed-through bug
   and it is unreadable over content. Belt and braces: the header is opaque too. */
.site-nav {
  display: none;
  background: var(--gg-surface);
  border-top: 1px solid var(--gg-border);
  padding-block: var(--gg-space-2) var(--gg-space-4);
}
.site-nav[data-open='true'] {
  display: block;
}
.site-nav__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}
/* N1, second instance. `min-width` matters as much as `min-height` — "Home" is
   a short label and measured 39x44 at 1280px on all 14 pages. It survived the
   first pass because the target-size check ran only at 390px, where this nav is
   `display: none` and was skipped as hidden. Height-only rules keep producing
   this: footer "About" failed the same way at 43x44. Both axes, always. */
.site-nav__list a {
  display: flex;
  align-items: center;
  min-height: 44px;
  min-width: 44px;
  padding: var(--gg-space-2) 0;
  color: var(--gg-text);
  text-decoration: none;
  font-weight: var(--gg-weight-medium);
  border-bottom: 1px solid var(--gg-border);
}
.site-nav__list a:hover {
  color: var(--gg-accent);
}
.site-nav__list a[aria-current='page'] {
  color: var(--gg-accent);
  box-shadow: inset 3px 0 0 0 var(--gg-accent);
  padding-left: var(--gg-space-3);
}
.header-cta {
  display: none;
}

@media (min-width: 62rem) {
  .nav-toggle {
    display: none;
  }
  .site-nav {
    display: block;
    background: transparent;
    border-top: 0;
    padding: 0;
    flex: 1 1 auto;
  }
  .site-nav__list {
    flex-direction: row;
    justify-content: flex-end;
    gap: var(--gg-space-5);
    flex-wrap: wrap;
  }
  .site-nav__list a {
    border-bottom: 0;
    font-size: var(--gg-fs-sm);
    padding: 0;
    /* The 44px floor is inherited from the base rule; centre the label inside it
       so a short one is not left-hanging in its own hit area. */
    justify-content: center;
  }
  .site-nav__list a[aria-current='page'] {
    box-shadow: inset 0 -2px 0 0 var(--gg-accent);
    padding-left: 0;
  }
  .header-cta {
    display: inline-flex;
  }
}

/* ============================================================================
   FULL-BLEED PHOTOGRAPHIC SECTION BACKGROUNDS
   Added 2026-08-16. Michael: "make the backgrounds images and not color only...
   make sure the text remains readable."

   THREE STRUCTURAL DECISIONS, each of which has a reason you cannot see in the
   rendered page:

   1. `<img>`, NOT `background-image`. The CSP is `style-src 'self'` with no
      'unsafe-inline' and a build gate that fails on any inline style attribute,
      so a per-page CSS background would mean a utility class per photograph.
      More importantly an <img> is the only version of this that gets real
      `srcset`/`sizes` (a 390px phone must not download the 1280px file),
      real `loading="lazy"` (a CSS background on an off-screen element is NOT
      reliably deferred), and real `fetchpriority`. On mobile data that is the
      difference between a nice idea and a regression.

   2. EVERY photo section ALSO carries `.section--deep`. There is no parallel
      colour contract for photo sections and there must never be one. The
      inverted-surface block further up this file is exhaustive and organised
      by token precisely because scattered per-case patches are what produced
      the 1:1 footer bug. `.section--deep` also gives the section an opaque ink
      `background` — which is what the veil composites against, AND the
      readable fallback ground if the image 404s or is blocked.

   3. `.has-photo` marks the section as photo-backed. It changes no colour. It
      exists so the focus-ring rule below can exclude it and so a build gate can
      assert what must be true inside it. Adding a background image without
      adding this class is the silent regression this whole block guards.
      ============================================================================ */
.has-photo {
  position: relative;
  isolation: isolate;
}
.has-photo > .container {
  position: relative;
  z-index: 1;
}

.photoback {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  /* The ink the veil composites against. Belt and braces with .section--deep:
     if this element ever appears outside a deep section, it still supplies its
     own correct ground rather than compositing over the light canvas, which
     would invert the whole contrast argument without any visible warning. */
  background: var(--gg-surface-deep);
}
.photoback picture {
  display: block;
  position: absolute;
  inset: 0;
}
/* Overrides `picture > img` above, which gives every content photograph the
   mandatory inset hairline and a radius. Both are wrong here and for the same
   reason: that hairline exists to stop a pale sky bleeding into the canvas at
   the photo's EDGE, and a full-bleed background has no edge — it is clipped by
   the section. Leaving them on paints a 1px line and rounded corners across the
   top of the viewport. */
.photoback img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  display: block;
  border-radius: 0;
  box-shadow: none;
  /* THE SCRIM. See the derivation at the top of this file. This single opacity
     is what guarantees 5.12:1 for paper text over a pure-white pixel. Do not
     raise it, and do not "simplify" it away because a particular photograph
     looks fine without it — the photographs are data and they will change. */
  opacity: var(--gg-photo-veil);
}
/* Where to crop from when `cover` has to discard part of the frame. The subject
   of a landscape photograph is usually not in its vertical middle, so `center`
   can throw away the reason the photograph was chosen.

   THESE ONLY DO ANYTHING WHEN THE BOX IS PROPORTIONALLY WIDER THAN THE IMAGE.
   `cover` scales to whichever axis is short: a box wider than 16:9 scales to
   WIDTH and crops vertically (these classes bite), a box taller than 16:9
   scales to HEIGHT and the full frame height is already on screen (they are a
   silent no-op). `node tools/hero-geom.mjs` prints which case a page is in at
   320/390/768/1280/1440 — use it, do not reason about it. Measured
   2026-08-17: the short `.page-head` bands are 3.0-5.4 wide (bg-meerkat's
   `bottom` is real work), and the tall home hero is 0.91-1.67, where a `focus`
   value did nothing whatsoever for a day and looked correct in review.

   Utility classes rather than inline styles because `style-src 'self'` carries
   no 'unsafe-inline' and a build gate fails on any style attribute. */
.photoback--top img {
  object-position: center top;
}
.photoback--bottom img {
  object-position: center bottom;
}

/* The depth gradient. Adds ink toward the bottom, where the copy and the
   buttons sit, and leaves the top of the frame more open so the photograph is
   actually visible. ADDITIVE ONLY — it can never make a pixel lighter than the
   floor, which is the property that lets the floor be stated as a guarantee. */
.photoback::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image: linear-gradient(to bottom, transparent 0%, var(--gg-surface-deep) 100%);
  opacity: var(--gg-scrim-depth);
  pointer-events: none;
}

/* ============================================================================
   THE HERO PHOTOGRAPH IS CAPPED ON PHONES, and this is a layout fix for a
   problem that looks like an image-choice problem.

   The home hero is ~1100px tall on a 390px phone, because the lede is honest
   and therefore long. `object-fit: cover` on a 16:9 photograph in a box that
   shape scales to the HEIGHT — 390x1100 needs a 1.27x vertical scale, so the
   rendered image is ~1950px wide and about 80% of the frame is cropped away
   horizontally. What survives is a narrow vertical strip through the middle.
   For bg-home that strip was the thorn tree; the pool, which is the entire
   reason that photograph was chosen, was reduced to a blue sliver at the very
   bottom under the depth gradient. The QA screenshot showed it plainly.

   `object-position` CANNOT fix this. When the container is proportionally
   taller than the image, the full image height is already shown and the
   vertical component is a no-op — I checked that before reaching for it.

   THE SAME FACT IS ALSO WHY A BAD OBJECT IN THE FRAME IS A FILE PROBLEM HERE.
   Because the crop is always HORIZONTAL on this hero, the centre 61% (320px) to
   94% (1440px) of the frame is on screen at every viewport (measured, 2026-08-17,
   tools/hero-geom.mjs). There is no width at which something 19-29% across the
   frame is hidden, and at 1440 there is only 3% of margin on each side to hide
   anything in at all. So when the pool's "NO DIVING" safety sign turned out to
   be reading through the H1, shifting `object-position` was not a candidate fix
   — the crop box in tools/build-images.py was the only lever. Reframe the
   source; never retouch the photograph.

   The other candidate fix was a portrait crop served via `<picture media>`.
   The vertical original supports it, so it was honest, and it was built and
   measured: 48-75 KB at 480px and 91-143 KB at 720px, because a 1100px-tall
   field of pool ripple is irreducibly expensive to encode. That is a bad trade
   for a decorative background on mobile data in Limpopo.

   Capping the height instead is better on every axis at once:
     - the box becomes ~390x330, which is WIDER than it is tall relative to the
       photograph, so the whole frame is visible and the pool is in it;
     - it needs FEWER pixels, not more — no new file, no extra request;
     - the lede and both buttons come off the photograph onto flat ink, which
       makes the type read better and the contrast argument trivial.
   The gradient runs to full ink at the bottom edge so there is no seam between
   the photograph and the ground it dissolves into.
   ============================================================================ */
@media (max-width: 47.9375rem) {
  .hero .photoback {
    bottom: auto; /* `inset: 0` set bottom; height cannot win against it */
    height: clamp(14rem, 52vw + 8rem, 24rem);
  }
  .hero .photoback::after {
    /* Full opacity so the gradient actually REACHES the ground colour at the
       bottom edge. Still additive-only — it only ever adds ink — so the 5.12:1
       floor is untouched. */
    opacity: 1;
    background-image: linear-gradient(
      to bottom,
      transparent 0%,
      transparent 30%,
      var(--gg-surface-deep) 100%
    );
  }
}

/* A photo band with NO text over it. This is the escape valve in the brief:
   where an image is too pretty to scrim and has nothing to say, it gets a
   section of its own and is shown honestly instead of being forced behind a
   headline it cannot carry. No scrim, no veil, no text, `alt=""`, and it is
   `aria-hidden` in the markup so a screen reader is not told about a decorative
   band it cannot see. */
.photoband {
  position: relative;
  height: clamp(12rem, 30vw, 22rem);
  overflow: hidden;
  background: var(--gg-surface-deep);
}
.photoband img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  border-radius: 0;
  box-shadow: none;
  /* NOTE FOR THE NEXT PERSON, AND FOR QA: this band is deliberately outside the
     composited-contrast pass, because that pass measures TEXT over photography
     and there is no text here. It is not an oversight and it is not exempt from
     scrutiny — it is exempt from THAT check. What it is not exempt from is the
     dark-mode rule below. */
  opacity: var(--gg-photoband-veil);
}
/* DARK MODE: the one unscrimmed photograph on the site needs its own answer,
   and it did not have one until it was looked at.

   In light mode this band sits between two cream sections and reads as
   continuous with the page. In dark mode it sits between two near-black ones,
   and the gravel floor of the boma is a mid-grey plate — so scrolling a dark
   page produced a bright rectangle. No text, so no contrast failure, and
   nothing in the harness could see it: the composited pass walks `.has-photo`,
   axe needs text, and the build gate counts `.photoback`. Three checks that
   each correctly skip it, and a summary that prints clean.

   This is exactly the "images must not wash out or glare in dark mode" case.
   The ink ground is already behind the image, so an opacity here composites the
   same way `--gg-photo-veil` does on a scrimmed background. 0.8 takes the glare
   off without giving up the thing this band exists for — being the one
   photograph on the site shown in full colour. Dark mode is opt-in; someone who
   chose it has asked for a quieter page. */
:root {
  --gg-photoband-veil: 1;
}
[data-theme='dark'] {
  --gg-photoband-veil: 0.8;
}

/* --- hero (type-led — the TYPE still leads; the photograph is a ground) ----
   Iris §4.4 rule 3 sets a 2000px hard floor for a hero PHOTOGRAPH and says that
   below it the hero is type on `--gg-surface-deep`. Our best sharp asset is
   1536px, so that rule is not met and this is a DOCUMENTED DEVIATION, directed
   by Michael on 2026-08-16 and parked for Iris — not a quiet reinterpretation.
   The defence: at 34% opacity under a depth gradient this is a toned ground,
   not a hero photograph. The type is still what reads first, which is the thing
   rule 3 was actually protecting. If Iris disagrees, the fix is one data edit. */
.hero {
  background: var(--gg-surface-deep);
  color: var(--gg-text-on-dark);
  padding-block: var(--gg-section-y);
}
.hero__title {
  font-size: var(--gg-fs-display);
  font-weight: var(--gg-weight-display);
  letter-spacing: var(--gg-tracking-display);
  line-height: var(--gg-leading-display);
  color: var(--gg-text-on-dark);
  margin-bottom: var(--gg-space-6);
  max-width: 20ch;
}
.hero__lede {
  font-size: var(--gg-fs-lede);
  color: var(--gg-text-on-dark);
  max-width: var(--gg-measure);
}
.hero__rule {
  border: 0;
  border-top: 2px solid var(--gg-accent);
  width: var(--gg-space-16);
  margin: 0 0 var(--gg-space-6);
}
.section--deep .hero__rule,
.hero .hero__rule {
  border-top-color: var(--gg-highlight);
}

/* The hero is a deep section and must carry .section--deep in the markup —
   that class is what supplies the inverted button, overline and muted-text
   rules. A secondary button styled with the light-mode accent on this ground
   computes to roughly 1.8:1. */
.hero .score {
  margin-top: var(--gg-space-8);
}

/* A type plate used OUTSIDE a card (on a unit page, where it stands in for the
   photograph) must not keep the card's negative bleed margins — they push it
   past the container and cause horizontal overflow on a phone. */
.feature__media > .card__typeplate {
  margin: 0;
  border-radius: var(--gg-radius-md);
}

/* --- facts strip ---------------------------------------------------------- */
.facts {
  list-style: none;
  margin: var(--gg-space-8) 0 0;
  padding: 0;
  display: grid;
  gap: var(--gg-space-4);
  grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
}
.facts li {
  border-top: 1px solid var(--gg-border);
  padding-top: var(--gg-space-3);
}
.hero .facts li,
.section--deep .facts li {
  border-top-color: var(--gg-border-strong);
}
.facts__value {
  display: block;
  font-family: var(--gg-font-display);
  font-size: var(--gg-fs-h3);
  font-weight: var(--gg-weight-medium);
  color: var(--gg-text);
}
.hero .facts__value,
.section--deep .facts__value {
  color: var(--gg-highlight);
}
.facts__label {
  display: block;
  font-size: var(--gg-fs-sm);
  color: var(--gg-text-muted);
}
.hero .facts__label,
.section--deep .facts__label {
  color: var(--gg-text-on-dark);
}

/* --- review score --------------------------------------------------------- */
.score {
  display: flex;
  align-items: baseline;
  gap: var(--gg-space-3);
  flex-wrap: wrap;
}
.score__value {
  font-family: var(--gg-font-display);
  font-size: var(--gg-fs-h2);
  font-weight: var(--gg-weight-display);
  color: var(--gg-text);
  line-height: 1;
}
.hero .score__value,
.section--deep .score__value {
  color: var(--gg-highlight);
}
.score__meta {
  font-size: var(--gg-fs-sm);
  color: var(--gg-text-muted);
}
.hero .score__meta,
.section--deep .score__meta {
  color: var(--gg-text-on-dark);
}

.score-inline {
  display: inline-flex;
  align-items: center;
  gap: var(--gg-space-2);
  padding: var(--gg-space-2) var(--gg-space-3);
  background: var(--gg-accent-quiet);
  border: 1px solid var(--gg-border);
  border-radius: var(--gg-radius-xs);
  font-size: var(--gg-fs-sm);
  color: var(--gg-text);
}

/* Sub-scores: a labelled bar, never colour alone. */
.subscores {
  list-style: none;
  margin: var(--gg-space-6) 0 0;
  padding: 0;
  display: grid;
  gap: var(--gg-space-4);
  grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr));
}
.subscore__label {
  display: flex;
  justify-content: space-between;
  gap: var(--gg-space-2);
  font-size: var(--gg-fs-sm);
  color: var(--gg-text-body);
  margin-bottom: var(--gg-space-1);
}
/* `display: block` is load-bearing, not tidiness. This is a <span>, so it
   defaulted to `display: inline` — and `height` DOES NOT APPLY to an inline
   box. The 8px track was rendering 139px tall, the fill 85px, and each bar
   overflowed its line box far enough to paint over the next row's label. That
   is also why axe returned `color-contrast` as INCOMPLETE with messageKey
   `bgOverlap` on / and /about/ rather than as a violation — and why the old
   harness, which read only `violations`, never mentioned it. */
.subscore__track {
  display: block;
  height: var(--gg-space-2);
  background: var(--gg-surface);
  border: 1px solid var(--gg-border-strong);
  border-radius: var(--gg-radius-pill);
  overflow: hidden;
}
.subscore__fill {
  display: block;
  height: 100%;
  background: var(--gg-support);
}

/* --- cards ---------------------------------------------------------------- */
.grid {
  display: grid;
  gap: var(--gg-space-6);
  grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
  margin: 0;
  padding: 0;
  list-style: none;
}
.grid--wide {
  grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
}

.card {
  background: var(--gg-surface);
  border: 1px solid var(--gg-border);
  border-radius: var(--gg-radius-md);
  box-shadow: var(--gg-shadow-1);
  padding: var(--gg-space-6);
  display: flex;
  flex-direction: column;
  gap: var(--gg-space-3);
}
.card > p {
  margin-bottom: 0;
}
/* N1. The unit cross-link — the card title, and the primary way a guest moves
   between units. It measured 25px high at 320px and 32px at 1280px, and the
   harness excused it because `li.card` put it inside an `li`. `inline-flex`
   rather than `inline-block` so a single-line title is optically centred in its
   own 44px box; text still wraps normally inside the anonymous flex item, which
   the 320px overflow check confirms. */
.card h3 a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  min-width: 44px;
}
.card__media {
  margin: calc(var(--gg-space-6) * -1) calc(var(--gg-space-6) * -1) 0;
  border-radius: var(--gg-radius-md) var(--gg-radius-md) 0 0;
  overflow: hidden;
}
.card__media img {
  border-radius: 0;
  aspect-ratio: 3 / 2;
  object-fit: cover;
}
/* A unit with no published photograph gets a type panel, not a grey box.
   This is the day-one design — see src/data/units.ts. */
.card__typeplate {
  margin: calc(var(--gg-space-6) * -1) calc(var(--gg-space-6) * -1) 0;
  border-radius: var(--gg-radius-md) var(--gg-radius-md) 0 0;
  background: var(--gg-surface-deep);
  color: var(--gg-text-on-dark);
  aspect-ratio: 3 / 2;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-end;
  padding: var(--gg-space-6);
  gap: var(--gg-space-2);
}
.card__typeplate .typeplate__name {
  font-family: var(--gg-font-display);
  font-size: var(--gg-fs-h3);
  font-weight: var(--gg-weight-display);
  color: var(--gg-highlight);
  line-height: var(--gg-leading-head);
}
.card__typeplate .typeplate__note {
  font-size: var(--gg-fs-sm);
  color: var(--gg-text-on-dark);
}
.card__foot {
  margin-top: auto;
  padding-top: var(--gg-space-3);
  border-top: 1px solid var(--gg-border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gg-space-3);
  flex-wrap: wrap;
}
.card__price {
  font-family: var(--gg-font-display);
  font-size: var(--gg-fs-h4);
  color: var(--gg-text);
}
.card__price .from {
  font-family: var(--gg-font-body);
  font-size: var(--gg-fs-sm);
  color: var(--gg-text-muted);
}

/* --- specs, lists --------------------------------------------------------- */
.spec-list {
  list-style: none;
  margin: 0 0 var(--gg-space-4);
  padding: 0;
}
.spec-list li {
  padding: var(--gg-space-2) 0 var(--gg-space-2) var(--gg-space-6);
  border-bottom: 1px solid var(--gg-border);
  position: relative;
  max-width: var(--gg-measure);
}
.spec-list li::before {
  content: '';
  position: absolute;
  left: 0;
  top: calc(var(--gg-space-4) - 1px);
  width: var(--gg-space-3);
  height: 2px;
  background: var(--gg-accent);
}

.plain-list {
  padding-left: var(--gg-space-5);
  max-width: var(--gg-measure);
}
.plain-list li {
  margin-bottom: var(--gg-space-2);
}

/* --- notes / callouts ----------------------------------------------------- */
.note {
  border-left: 3px solid var(--gg-support);
  background: var(--gg-surface);
  padding: var(--gg-space-4) var(--gg-space-5);
  border-radius: 0 var(--gg-radius-sm) var(--gg-radius-sm) 0;
  margin-block: var(--gg-space-6);
}
.note > :last-child {
  margin-bottom: 0;
}
.note--warn {
  border-left-color: var(--gg-status-warning);
}
.note--accent {
  border-left-color: var(--gg-accent);
}
.note__title {
  font-family: var(--gg-font-body);
  font-weight: var(--gg-weight-medium);
  color: var(--gg-text);
  display: flex;
  align-items: center;
  gap: var(--gg-space-2);
  margin-bottom: var(--gg-space-2);
}

/* --- tables --------------------------------------------------------------- */
.table-wrap {
  overflow-x: auto;
  margin-block: var(--gg-space-6);
  border: 1px solid var(--gg-border);
  border-radius: var(--gg-radius-md);
  background: var(--gg-surface);
}
table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--gg-fs-sm);
}
caption {
  text-align: left;
  padding: var(--gg-space-4) var(--gg-space-4) 0;
  color: var(--gg-text-muted);
  font-size: var(--gg-fs-sm);
}
th,
td {
  text-align: left;
  padding: var(--gg-space-3) var(--gg-space-4);
  border-bottom: 1px solid var(--gg-border);
}
th {
  font-weight: var(--gg-weight-medium);
  color: var(--gg-text);
  white-space: nowrap;
}
tbody tr:last-child td {
  border-bottom: 0;
}
td.num,
th.num {
  font-family: var(--gg-font-mono);
  text-align: right;
  white-space: nowrap;
}

/* --- booking -------------------------------------------------------------- */
.booking-frame {
  border: 1px solid var(--gg-border);
  border-radius: var(--gg-radius-md);
  background: var(--gg-surface);
  overflow: hidden;
  margin-block: var(--gg-space-6);
}
.booking-frame iframe {
  display: block;
  width: 100%;
  border: 0;
  min-height: 40rem;
  background: var(--gg-surface);
}
/* N4. An <iframe> resolves `outline-style: none` and takes focus as a single
   stop, so a keyboard user tabs INTO the booking engine with no indication that
   anything happened — the page simply stops responding to Tab as they expect.
   The frame's own document is a third party's and we cannot style it, so the
   visible indicator has to be drawn on our wrapper. `:focus-within` fires when
   focus is on the iframe itself or on anything inside it. */
.booking-frame:focus-within {
  outline: 3px solid var(--gg-focus-ring);
  outline-offset: 2px;
  box-shadow: 0 0 0 6px var(--gg-focus-halo);
}

/* --- gallery -------------------------------------------------------------- */
.gallery {
  display: grid;
  gap: var(--gg-space-4);
  grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
  list-style: none;
  margin: 0;
  padding: 0;
}
.gallery figure {
  margin: 0;
}
.gallery figcaption {
  font-size: var(--gg-fs-sm);
  color: var(--gg-text-muted);
  margin-top: var(--gg-space-2);
}
.gallery img {
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

/* --- feature rows --------------------------------------------------------- */
.feature {
  display: grid;
  gap: var(--gg-space-6);
  align-items: center;
  margin-bottom: var(--gg-space-12);
}
.feature:last-child {
  margin-bottom: 0;
}
@media (min-width: 48rem) {
  .feature {
    grid-template-columns: 1fr 1fr;
    gap: var(--gg-space-10);
  }
  .feature--flip .feature__media {
    order: 2;
  }
}

/* --- forms ---------------------------------------------------------------- */
.field {
  margin-bottom: var(--gg-space-5);
  max-width: var(--gg-container-narrow);
}
.field label {
  display: block;
  font-weight: var(--gg-weight-medium);
  color: var(--gg-text);
  margin-bottom: var(--gg-space-2);
}
.field input,
.field textarea {
  width: 100%;
  min-height: 44px;
  padding: var(--gg-space-3) var(--gg-space-4);
  font-family: var(--gg-font-body);
  font-size: var(--gg-fs-body);
  color: var(--gg-text);
  background: var(--gg-surface);
  border: 1px solid var(--gg-border-strong);
  border-radius: var(--gg-radius-sm);
}
.field textarea {
  min-height: var(--gg-space-32);
  resize: vertical;
}
.field__help {
  font-size: var(--gg-fs-sm);
  color: var(--gg-text-muted);
  margin-top: var(--gg-space-2);
}

/* --- footer --------------------------------------------------------------- */
.site-footer {
  background: var(--gg-surface-deep);
  color: var(--gg-text-on-dark);
  padding-block: var(--gg-section-y) var(--gg-space-10);
  margin-top: var(--gg-section-y);
}
.site-footer a {
  color: var(--gg-highlight);
}
.site-footer a:hover {
  color: var(--gg-text-on-dark);
}
.site-footer h2 {
  font-size: var(--gg-fs-h4);
  font-family: var(--gg-font-body);
  color: var(--gg-text-on-dark);
}
.footer-grid {
  display: grid;
  gap: var(--gg-space-8);
  grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
}
.footer-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.footer-list li {
  margin-bottom: var(--gg-space-2);
}
/* N1. `min-height` alone was here and "About" still measured 43x44 — the
   failing dimension was WIDTH, and a short nav label is exactly the case a
   height-only rule cannot reach. Both axes, always. */
.footer-list a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  min-width: 44px;
}
.site-footer__legal {
  margin-top: var(--gg-space-10);
  padding-top: var(--gg-space-5);
  border-top: 1px solid var(--gg-border-strong);
  font-size: var(--gg-fs-xs);
  color: var(--gg-text-on-dark);
}

/* --- theme toggle --------------------------------------------------------- */
/* Light is the default and carries the whole site. Dark is opt-in only — this
   is a button, never a prefers-color-scheme auto-flip. */
.theme-toggle {
  background: transparent;
  border: 1px solid currentColor;
  color: inherit;
  border-radius: var(--gg-radius-sm);
  min-height: 44px;
  min-width: 44px;
  padding-inline: var(--gg-space-4);
  font-family: var(--gg-font-body);
  font-size: var(--gg-fs-sm);
  cursor: pointer;
}

/* --- breadcrumb ----------------------------------------------------------- */
.crumbs {
  font-size: var(--gg-fs-sm);
  color: var(--gg-text-muted);
  padding-top: var(--gg-space-6);
}
/* N1. Breadcrumb links measured 38x21 and 103x21. WCAG 2.5.8 is not breached
   (they clear the 24x24 floor), but Iris §3.4 sets a 44px project bar and these
   are navigation, not prose. They were excused because the QA harness exempted
   any anchor inside an `li` — an exemption written for links inside a SENTENCE
   that silently covered every navigation list on the site. The harness is
   narrowed in the same commit; without that, this rule is unverifiable. */
.crumbs a {
  color: var(--gg-text-muted);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  min-width: 44px;
  padding-inline: var(--gg-space-1);
}
.crumbs a:hover {
  color: var(--gg-accent);
}
.crumbs__list {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gg-space-2);
  margin: 0;
  padding: 0;
}
/* Separators and the current-page crumb are plain text; matching their height
   keeps the row on one baseline now that the links are 44px boxes. */
.crumbs__list li {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}

/* --- width utilities ------------------------------------------------------
   Used only by the review sub-score bars. These exist so the bar width can be
   data-driven WITHOUT an inline `style` attribute — an inline style would
   force `style-src 'unsafe-inline'` into the Content-Security-Policy, and
   weakening the policy to save a stylesheet rule is a bad trade. */
.w-70 { width: 70%; }
.w-72 { width: 72%; }
.w-74 { width: 74%; }
.w-76 { width: 76%; }
.w-78 { width: 78%; }
.w-80 { width: 80%; }
.w-82 { width: 82%; }
.w-84 { width: 84%; }
.w-86 { width: 86%; }
.w-88 { width: 88%; }
.w-90 { width: 90%; }
.w-92 { width: 92%; }
.w-94 { width: 94%; }
.w-96 { width: 96%; }
.w-98 { width: 98%; }
.w-100 { width: 100%; }

/* --- page head ------------------------------------------------------------ */
.page-head {
  padding-block: var(--gg-space-10) var(--gg-space-2);
}
.page-head p {
  font-size: var(--gg-fs-lede);
}

.stack > * + * {
  margin-top: var(--gg-space-6);
}

/* ============================================================================
   THE ONE COLOUR OVERRIDE PHOTOGRAPHY NEEDS, and why it is exactly one.

   The scrim floor is solved for `--gg-text-on-dark` (--gg-paper). It is NOT
   solved for `--gg-highlight` (veld-300), which is a mid-luminance sand colour:
   9.08:1 on flat ink, and the composited harness measured it at 3.05:1 on the
   overline and 4.07–4.48:1 on the facts and score figures over real
   photography. Genuine AA failures, invisible to every check this project had
   before today, and found at three different viewport widths because `cover`
   crops a different part of the photograph behind the same sentence.

   TWO WAYS TO FIX IT, and the arithmetic decides. Making highlight clear 4.5:1
   against a blown-out white pixel needs the veil down at 0.21 — a 79% scrim,
   which is not a photograph any more, it is a dark rectangle with a rumour in
   it. Re-pointing highlight to paper costs one accent colour on eight sections
   and keeps the 5.12:1 floor. So: paper.

   This is a DEVIATION from Iris's `--gg-highlight` overline treatment and it is
   scoped as narrowly as it can be — `.has-photo` only. Every flat
   `.section--deep` on the site keeps the sand overline exactly as she specified
   it. Parked for Iris with the scrim-token gap.

   Organised by TOKEN, not by symptom, and exhaustive: this is the same shape as
   the inverted-surface block above, and for the same reason. Every rule in this
   file that resolves to `--gg-highlight` inside a section that can carry
   `has-photo` is listed here. A new one has an obvious place to be registered.

   ITS POSITION AT THE END OF THIS FILE IS LOAD-BEARING. `.has-photo .overline`
   and `.hero .overline` are both specificity (0,2,0) — a tie, broken purely by
   source order. Written where the rest of the photo CSS lives (above the hero
   block) this contract LOST every tie and the harness still measured 4.07:1 on
   the score figure and 4.25–4.48:1 on the facts strip, with the CSS looking
   entirely correct on the screen in front of me. That is the same class of
   mistake as `.section--deep a:not(.btn)` outranking `.btn--primary`: reasoning
   about a cascade instead of measuring it.

   So this is the LAST WORD in the stylesheet, deliberately. Do not move it up,
   and do not add a `.has-photo` colour rule anywhere else — put it here.
   ============================================================================ */
.has-photo .overline,
.has-photo .facts__value,
.has-photo .facts__label,
.has-photo .score__value,
.has-photo .score__meta,
.has-photo .wordmark__descriptor,
.has-photo .crumbs a,
.has-photo a:not(.btn) {
  color: var(--gg-text-on-dark);
}
/* The rule under the hero headline is a 2px line, not text — SC 1.4.11's 3:1
   applies, and paper clears it at 5.12:1 where sand does not. */
.has-photo .hero__rule {
  border-top-color: var(--gg-text-on-dark);
}
/* `.score-inline` supplies its OWN opaque light surface inside a deep section
   (see the inverted-surface block). That still holds over a photograph — the
   chip is opaque, so the photograph is not its background — but it is worth
   naming here, because the composited harness samples the pixels behind the
   chip if the chip is ever made translucent. Do not give it a transparent
   background: the text inside it is bark-700, which is ~1.1:1 on a scrimmed
   photograph. */
