  /* ============================================================
     PAGE BASE
     ============================================================ */
  /* Goo-Blob-Outset = der optische ~10px-Gutter, den jede gebleedete Kachel
     hat. Page-scoped hier in landing.css statt in tokens.css; bei Gelegenheit
     nach tokens.css promotebar. */
  :root { --goo-bleed: 10px; --goo-soft-edge: 2px; }
  /* The heading scale — including the tablet-ramped --text-3xl / --text-2xl /
     --text-xl — now lives SOLELY in tokens.css (single source of truth). The
     former :root override here shadowed those tokens and caused edits in
     tokens.css to silently no-op; removed on consolidation. */
  /* `overflow-x: clip` (instead of `hidden`) hides the intentional section
     bleeds (left-bleeding headline, right-bleeding About "Ausbildung" card)
     that run past the viewport edge, WITHOUT creating a scroll container —
     so fixed-positioned descendants like the sticky CTA stay anchored to the
     viewport edge (clip, unlike hidden, never establishes a scroll/clip
     context for fixed children; an iOS-Safari quirk can otherwise hide them).
     The clip MUST live on `body`, not `html`: overflow set on the root is
     propagated to the viewport, where it fails to contain right-side bleed as
     scrollable area — a real horizontal scrollbar appears. Left bleeds never
     exposed this (negative-x overflow is never scrollable); the right-bleeding
     About card did. Clipping on body (a non-propagating box sized to the
     viewport) actually contains both edges. */
  html {
    overflow-x: clip;
    /* Anchor jumps (#leistungen/#audio/#about/#kontakt) must clear the fixed
       header, otherwise a section's top lands at y=0 — behind the header. Offset
       every in-page jump by the header height + a small gap. Content-robust
       (driven by --header-height), and NO scroll-behavior:smooth (deliberately
       off — see project notes). The document is the scroller, so this lives on
       html. */
    scroll-padding-top: calc(var(--header-height) + 1rem);
  }
  body {
    margin: 0;
    background: var(--color-page-bg);
    color: var(--color-fg);
    overflow-x: clip;
  }
  /* Touch devices still rubber-band HORIZONTALLY at the page edges even though
     overflow-x is clipped (nothing actually scrolls sideways) — the elastic
     overscroll just bounces and reveals the page bg, which reads as a glitch.
     Kill the horizontal bounce. Scoped to coarse pointers so desktop trackpad
     swipe-back nav (which rides horizontal overscroll) is untouched. Sits on
     html: overscroll-behavior on the root drives the viewport; on body it does
     NOT propagate. */
  @media (pointer: coarse) {
    html { overscroll-behavior-x: none; }
    /* Vertical bounce (the elastic stretch) stays ON at BOTH edges — it's a
       wanted feel. The bottom-edge problem (the fixed .stage-bg slideshow
       flashing through as the footer rubber-bands up off it) is NOT solved by
       killing the bounce, but by masking it: the footer carries a solid Oxford
       "overscroll skirt" that bounces with it and covers the slideshow in the
       gap (see .site-footer below). So the stretch reveals "more footer", never
       the image — and overscroll-behavior-y is left at its default (auto). */
  }

  /* ============================================================
     1. SLIDESHOW LAYER (fixed, behind everything)
     ============================================================ */
  .stage-bg {
    position: fixed;
    inset: 0;
    z-index: 0;
    overflow: hidden;
    background: var(--color-page-bg);
  }
  .stage-bg__slide {
    position: absolute;
    inset: 0;
    opacity: 0;
    filter: contrast(1.05) saturate(0.85);
  }
  /* The rendered <picture>/<img> fills each slide. Always anchored top-centre:
     on tall/narrow viewports the cover-crop keeps the upper part of the photo
     (heads, horizon) visible instead of trimming it away. */
  .stage-bg__slide img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center top;
  }
  /* No-JS / pre-JS / reduced-motion default: only the first slide, static. */
  .stage-bg__slide:first-child {
    opacity: 1;
  }
  /* JS takes over: fade between slides regardless of how many there are. JS
     cycles .is-active across the N rendered slides (count-agnostic); CSS just
     transitions opacity. */
  .stage-bg.is-playing .stage-bg__slide {
    opacity: 0;
    transition: opacity 1.5s ease;
  }
  .stage-bg.is-playing .stage-bg__slide.is-active {
    opacity: 1;
  }
  /* Reduced motion: never fade, keep only the first slide visible even when
     JS has flagged .is-playing. */
  @media (prefers-reduced-motion: reduce) {
    /* Cover .is-active too (equal specificity, later source order → wins), so a
       mid-session switch to reduce can't leave an active non-first slide blended
       over the first. */
    .stage-bg.is-playing .stage-bg__slide,
    .stage-bg.is-playing .stage-bg__slide.is-active {
      opacity: 0;
      transition: none;
    }
    .stage-bg.is-playing .stage-bg__slide:first-child {
      opacity: 1;
    }
  }
  .stage-bg::after {
    content: "";
    position: absolute;
    inset: 0;
    background: radial-gradient(ellipse at center, transparent 30%, color-mix(in srgb, var(--color-oxford) 45%, transparent) 100%);
    pointer-events: none;
  }
  /* Decorative dot texture tiled across the whole slideshow layer. Sits
     above the photo slides (z-index lifts it over the otherwise-auto
     slides and the ::after vignette) so the pattern reads on top of the
     imagery. Native tile size — full-bleed, no media queries, no JS. */
  .stage-bg::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 1;
    background: url("../images/subtle-dots.d3fddeea00fb.png") repeat;
    pointer-events: none;
  }
  body:has(.nav[open]) .stage-bg {
    filter: blur(18px);
    transform: scale(1.04);
  }
  body:has(.nav[open]) main,
  body:has(.nav[open]) .footer-banderole,
  body:has(.nav[open]) .section--partner,
  body:has(.nav[open]) .site-footer {
    filter: blur(18px);
  }
  /* Baseline page-scroll lock while the nav overlay is open (Chrome/Firefox honour
     overflow:hidden on the viewport). Safari/iOS ignore it, so landing.js also
     pins the body — see the nav scroll-lock there. The overlay is its own scroll
     container (overflow-y:auto), so it keeps scrolling on short viewports. */
  html:has(.nav[open]),
  body:has(.nav[open]) {
    overflow: hidden;
  }

  /* ============================================================
     2. COLOR-WIPE TRANSITION ENGINE
     Each section is preceded by a "wipe" zone where the OLD color
     persists at the top, then the section's OWN color takes over.
     We do this by giving every section a ::before that paints the
     PREVIOUS color on the top portion — creating a hard-edge wipe
     when scrolled past.
     ============================================================ */
  .section {
    position: relative;
    min-height: auto;
    height: auto;
    padding-block: var(--section-pad-block);
    padding-inline: var(--section-pad-x);
    /* NB: deliberately NOT a query container. container-type's size containment on
       a .section revived a Safari diacritic-repaint bug (Ü dots stuck at the prev
       colour through the goo colour-morph); hosting it on the page-tall .section-
       stack instead caused heavy scroll jank (re-compositing the WebGL canvases).
       So the responsive tile stages use viewport @media queries, not @container. */
    /* 12 col grid, content-driven row tracks so sections shrink to their
       tile content. The previous `repeat(8, minmax(min-content, 1fr))`
       paired with `min-height: 100svh` stretched every section to one
       viewport, creating unpredictable empty rows between tile clusters
       and inconsistent inter-section rhythm. */
    display: grid;
    grid-template-columns: repeat(12, 1fr);
    grid-template-rows: repeat(8, auto);
    gap: 0; /* tiles meet only at corners, never edge-to-edge */
    isolation: isolate;
    box-sizing: border-box;
    /* Bind --color-accent (the variable that the base h1/h2 rule reads in
       base.css) to the section's foreground token, so a default heading
       (no .is-accent modifier — e.g. About's section-title) reads in the
       section's body colour rather than its accent colour. Without this,
       --color-accent is undefined and the heading falls back to
       currentColor; visually it ends up at the same colour, but the
       token chain in DevTools doesn't resolve back to a named palette
       colour. Binding makes the lineage explicit: h1 → --color-accent
       → --section-fg → palette token. Accented headings (.is-accent)
       are handled separately via --tile-accent. */
    --color-accent: var(--section-fg);
  }
  /* Hero is locked to one viewport so the scroll-hint stays visible */
  .section--hero { height: 100svh; }
  .section > * { z-index: 1; }

  /* Co-scrolling Goo canvas: a CHILD of its section (position:absolute), so it
     scrolls as ONE compositor layer with the section + its photo — removing the
     fixed-canvas scroll lag that lets the colour fill peek out from behind the
     DOM photo on fast scroll (the "blitzer"). Sits BEHIND the tiles, over the
     section background. Size/position is set by JS (measured Goo bbox +
     --goo-bleed + neck region) via style.left/top/width/height. Only exists
     when JS runs → No-JS path untouched. The `.section >` prefix wins the
     z-index race against `.section > * { z-index: 1 }` (same specificity, but
     this targets the canvas class explicitly and comes later). */
  .section > .goo-section-canvas {
    position: absolute;
    pointer-events: none;
    z-index: 0;
  }

  /* The home page's <main> stacks its content sections with ONE uniform gap
     (Every-Layout Stack) — every inter-section space is identical and scales
     with viewport height. Replaces per-section top/bottom padding AND the old
     mobile `.section + .section` margin reveal: one value, one source. */
  .section-stack { display: flex; flex-direction: column; gap: var(--section-gap); }
  /* In the stack the gap owns the vertical spacing, so the content sections drop
     their own block padding (the gutter padding-inline stays). Hero keeps its
     custom padding — it's the viewport-locked intro (header offset on top, card
     lift on bottom), so its lead-in gap reads a touch larger by design. */
  .section-stack > .section:not(.section--hero) { padding-block: 0; }

  /* ALL tiles in a section use the section's --section-color via the parent var.
     Die .tile-Box trägt Hintergrund, Position und Mindesthöhe — Padding und das
     vertikale Layout des Inhalts liegen ausschließlich auf .tile__content, dem
     Wrapper innerhalb jedes Tiles. So lässt sich die Content-Spalte pro Section
     gezielt verschmälern oder zentrieren, ohne in einzelne H2/p/Spans zu greifen. */
  .tile {
    background: var(--tile-color, var(--section-color));
    color: var(--tile-fg, var(--section-fg, var(--color-oxford)));
    position: relative;
    /* Freestanding cards use --radius-lg (16px) so every card reads as the
       same rounded family — and so the WebGL goo shape (drawn with the same
       radius) matches the DOM cards exactly. Goo tiles paint their fill via a
       bled ::before (see the goo block below); when WebGL is active the gate
       removes that fill and the canvas paints the shape. Photo tiles clip via
       overflow:hidden, so the radius rounds the image too. Bleed tiles flatten
       their viewport-edge corners below. */
    border-radius: var(--radius-lg);
    /* Content-basierte Höhe: Tile wächst mit Content, hat aber 30svh als Floor.
       Intro-/Lead-Tiles erlauben mehr Spielraum (siehe .tile--lead unten). */
    align-self: start;
    min-height: 30svh;
    /* Flex-Column auf .tile-Ebene, damit .tile__content via `flex: 1` die volle
       Tile-Höhe einnimmt — sonst wäre das Wrapper-Padding nutzlos, weil das
       Wrapper-Element selbst nur Content-Höhe hätte. */
    display: flex;
    flex-direction: column;
  }
  .tile__content {
    flex: 1;
    display: flex;
    flex-direction: column;
    /* Inhalt vertikal mittig (kann pro Tile via Modifier überschrieben werden) */
    justify-content: center;
    gap: var(--space-sm);
    padding: var(--tile-pad);
    min-width: 0;
  }
  /* Lead/Intro-Tile pro Section — content-basiert, erbt align-self: start und
     den 30svh-Floor von .tile. Braucht keine eigene Basis-Regel; section-
     spezifische Höhen werden per .section--xxx .t-yyy gesetzt (siehe Hero mit
     min-height: 50svh), der Mobile-Reset via .tile--lead { min-height: auto }. */

  /* Content-Tiles nutzen das uniforme Basis-Padding (--tile-pad auf allen
     vier Seiten) — kein bottom-lastiger Override mehr. Die frühere
     Asymmetrie (unten > oben) brachte rotierte/handschriftliche Elemente
     in Bedrängnis; .tile--content trägt deshalb aktuell keine eigene Regel.
     BEWUSST BEIBEHALTEN (nicht entfernen): semantischer Markup-Marker auf den
     Text-Kacheln + stabiler Hook, falls wir Text-Tiles je gemeinsam ansprechen
     wollen (z. B. measure/max-width auf der Copy). Nicht mit dem load-bearing
     Wrapper .tile__content verwechseln. */

  /* ============================================================
     COLOR MORPH — scroll-driven via animation-timeline: view()
     Registered custom properties so colors interpolate smoothly.
     Fallback (no view() support): tiles statically use section color.
     ============================================================ */
  @property --tile-color  { syntax: "<color>"; inherits: true; initial-value: transparent; }
  @property --tile-fg     { syntax: "<color>"; inherits: true; initial-value: currentcolor; }
  @property --tile-accent { syntax: "<color>"; inherits: true; initial-value: currentcolor; }

  /* Default (fallback): each section uses ITS OWN color. Hero too. */
  .section .tile {
    --tile-color: var(--section-color);
    --tile-fg: var(--section-fg);
    --tile-accent: var(--section-accent);
    /* Tie --color-accent (read by the base h1/h2 rule in base.css) to
       --tile-accent so headlines WITHOUT an explicit .is-accent class
       track the snapping accent colour rather than a static section value.
       Because the whole palette SNAPS in one frame (see below), the heading
       never sits on a mid-morph grey — so every section, About included, is
       contrast-safe with this one mechanism (no per-section override). */
    --color-accent: var(--tile-accent);
  }
  /* Heading colour is role-driven and rides --tile-accent (--color-accent:
     var(--tile-accent) above). TEXT colour SNAPS: body (--tile-fg) and accent
     (--tile-accent) both flip in the SAME frame (see the @supports block + the
     JS transition rule), so a heading never sits on a mid-morph grey, and Safari
     never has an intermediate text colour to mis-raster on thin Ä/Ö/Ü strokes.
     The FILL (--tile-color + WebGL) morphs soft over 450ms underneath. Each side
     of the text snap is a complete, internally-valid palette, so contrast is
     structural; About no longer needs the bespoke per-section keyframes it once
     did (its Pear→Marian accent straddled its Oxford→Ivory bg sweep). */

  @supports (animation-timeline: view()) {
    /* All tiles in a section share ONE timeline so they morph in sync,
       even when they sit in different parts of the viewport. */
    .section { view-timeline-name: --section-view; view-timeline-axis: block; }

    /* Scroll-driven colour morph, split into TWO passes that share the section
       timeline but differ in feel:
         • tileTextSnap — ALL text colour (BODY --tile-fg AND ACCENT/headlines
           --tile-accent) flips HARD (step-end) at the snap point. Hard snap
           protects readability (a soft fg crossfade lands grey-on-grey mid-blend
           in About / Leistungen) AND avoids the Safari diacritic-repaint bug:
           WebKit never rasters an intermediate text colour, so the thin Ä/Ö/Ü
           strokes can't lag. Mirrors the JS path (both text colours snap there).
         • tileColorBlend — only the FILL (--tile-color) interpolates SOFTLY over
           a short cover window, so the surface cross-fades rather than klacking.
           The registered @property <color> prop interpolates cleanly.
       Both run on --section-view. The text snap sits at the MIDPOINT of the blend
       window so text flips while the surface is mid-crossfade. The WebGL fill
       mirrors tileColorBlend with the SAME cover window (see goo-webgl.js) so the
       surface never drifts from the cards. */
    @keyframes tileTextSnap {
      from {
        --tile-fg:     var(--prev-fg, var(--section-fg));
        --tile-accent: var(--prev-accent, var(--section-accent));
      }
      to {
        --tile-fg:     var(--section-fg);
        --tile-accent: var(--section-accent);
      }
    }
    @keyframes tileColorBlend {
      from { --tile-color: var(--prev-color, var(--section-color)); }
      to   { --tile-color: var(--section-color); }
    }
    .section .tile {
      animation: tileTextSnap step-end both,
                 tileColorBlend linear both;
      animation-timeline: --section-view, --section-view;
      /* NO-JS fallback colour window (scroll-position driven). DIAL VALUE —
         PAIR with the JS RECOLOR_ON/OFF in goo-webgl.js so both paths recolour
         at a similar cover point. The fill crossfade runs cover 30%→45% and the
         text (fg + accent) snaps at cover 43%. (The JS path makes this a time-based event;
         this scroll window is only the no-JS / no-WebGL fallback.) Earlier than
         the dock-end is fine — the recolour reads cleanly even mid-dock.
         LINEAR (not ease) so the CSS accent matches the WebGL fill's linear
         lerp (no mid-blend drift). Symmetric on a scroll-position timeline →
         no up-scroll flicker. */
      animation-range:
        cover 30% cover 43%,
        cover 30% cover 45%;
    }
    /* Hero has no predecessor for color morph. */
    .section--hero .tile {
      animation: none;
    }

    /* ── JS-active colour path (html.goo-js) ────────────────────────────────
       When the goo enhancer is active (desktop/tablet, JS on) the colour morph
       becomes a TIME-BASED, scroll-position-TRIGGERED event instead of a
       scroll-frozen sweep: JS adds .is-recolored to a section when it crosses a
       cover threshold, and the palette then transitions over a fixed duration
       regardless of further scrolling (and reverses on the way back up). The
       WebGL surface runs the same time-tween in lockstep (see goo-webgl.js).

       So under html.goo-js we DROP the scroll-driven colour animations
       (tileTextSnap / tileColorBlend) — the MOVEMENT animations (goo-tileDockIn,
       tileBleedScaleIn) stay scroll-driven and are untouched. The no-JS path
       above is the fallback and is left exactly as-is. */
    html.goo-js .section .tile {
      animation: none;
      /* Pre-trigger default = PREV palette; .is-recolored flips to section. */
      --tile-color:  var(--prev-color, var(--section-color));
      --tile-fg:     var(--prev-fg, var(--section-fg));
      --tile-accent: var(--prev-accent, var(--section-accent));
      /* The FILL fades over 0.45s linear (MUST match the WebGL tween dur +
         easing). The TEXT colour SNAPS — body (--tile-fg) AND accent/headlines
         (--tile-accent) both flip once at ~mid (0s, delayed 0.22s), never
         grey-on-grey mid-fade. Snapping the accent (instead of a smooth
         @property <color> morph) is what fixes the Safari diacritic-repaint bug:
         WebKit never rasters intermediate text colours, so the thin Ä/Ö/Ü
         strokes can't lag behind the glyph. The fill still morphs soft (WebGL +
         --tile-color), so the surface crossfade is unchanged. */
      /* RÜCKWEG (.is-recolored ENTFERNT → Transition zum Basis-Zustand): der
         Text-Snap liegt hier beim KOMPLEMENT des Hinweg-Delays, damit das Snap
         in BEIDE Scroll-Richtungen am kontrast-sicheren Punkt sitzt. Der HINWEG
         steht auf .is-recolored .tile (unten). */
      transition:
        --tile-color  0.45s linear,
        --tile-accent 0s linear calc(0.45s - var(--text-snap, 0.22s)),
        --tile-fg     0s linear calc(0.45s - var(--text-snap, 0.22s));
    }
    /* Goo tiles keep their scroll-driven dock-in; only the colour anims drop.
       Childhood rides along (translate dock-in) though it's NOT a goo/merge tile —
       JS never selects it (.tile--goo only), so it slides without joining the
       WebGL shape; its colour still tweens via the .is-recolored path below. */
    html.goo-js .section .tile--goo,
    html.goo-js .section--about .t-childhood {
      animation: goo-tileDockIn linear both;
      animation-timeline: --section-view;
      animation-range:
        entry calc(var(--goo-dock-start) * 1%) entry calc(var(--goo-dock-end) * 1%);
    }
    html.goo-js .section.is-recolored .tile {
      --tile-color:  var(--section-color);
      --tile-fg:     var(--section-fg);
      --tile-accent: var(--section-accent);
      /* HINWEG (.is-recolored HINZUGEFÜGT → Transition zu diesem Zustand): Text
         snappt nach --text-snap (helle Ziele spät, dunkle früh), Fill morpht
         weiterhin 0.45s linear. */
      transition:
        --tile-color  0.45s linear,
        --tile-accent 0s linear var(--text-snap, 0.22s),
        --tile-fg     0s linear var(--text-snap, 0.22s);
    }
    /* The Leistungen "Mehr als Saxophon?" island stays a static Pear accent in
       the JS path too (mirrors its scroll-timeline animation:none). Done as a
       SEPARATE override (not :not(.t-closing) on the rules above) on purpose —
       a :not() there would out-specify the goo-tileDockIn movement rule and
       freeze the goo tiles' dock-in. Both selectors beat the default + the
       .is-recolored colour rules without touching the movement specificity. */
    html.goo-js .section--leistungen .t-closing,
    html.goo-js .section--leistungen.is-recolored .t-closing {
      transition: none;
      --tile-color:  var(--color-pear);
      --tile-fg:     var(--color-oxford);
      --tile-accent: var(--color-oxford);
    }
    /* Hero never recolours (no predecessor) — keep it static under goo-js too. */
    html.goo-js .section--hero .tile {
      transition: none;
      --tile-color:  var(--section-color);
      --tile-fg:     var(--section-fg);
      --tile-accent: var(--section-accent);
    }
    @media (prefers-reduced-motion: reduce) {
      html.goo-js .section .tile { transition: none; }
    }

    /* Safari diacritic-repaint: the accent text colour now SNAPS (see the
       transition rule above — --tile-accent 0s) instead of morphing smoothly, so
       WebKit never has an intermediate text colour to mis-raster on the thin
       Ä/Ö/Ü strokes. No `will-change: color` / translateZ hacks needed (both were
       tried and were partial/worse). The .closing-line tilt lives on its base
       rule; nothing to re-assert here. */
  }

  /* JS-driven fly-in. Default state (no JS / fallback) = at final position. */
  .tile {
    transition: transform 1100ms cubic-bezier(.2,.7,.2,1);
  }
  .js-flyin .tile--prep {
    transform: translateY(var(--tile-flyin-offset));
    transition-delay: var(--flyin-delay, 0ms);
  }
  .js-flyin .tile--prep.is-in-view {
    transform: none;
  }

  @media (prefers-reduced-motion: reduce) {
    .section .tile { animation: none !important; transition: none !important; transform: none !important; }
  }

  /* Modifier greifen jetzt auf den .tile__content-Wrapper durch — das eigentliche
     Content-Layout sitzt dort. Diese drei (justify-end/center/row) sind bewusst
     vorgehaltene, aktuell ungenutzte „available modifiers" — nicht tot, nicht
     entfernen (siehe CSS-Dead-Code-Audit 2026-06-03). Gleiches gilt für
     .tile-h3 und .tile--bleed-right weiter unten. */
  .tile--justify-end > .tile__content { justify-content: flex-end; }
  .tile--center      > .tile__content { justify-content: center; align-items: center; text-align: center; }
  .tile--row         > .tile__content { flex-direction: row; align-items: center; justify-content: space-between; }

  /* Per-section colors (with accent FG used for headlines on dark sections).
     The ivory-ink (dark-ground) sections are listed once in the consolidated
     "Dark surfaces" rule further down, which owns all optical weight compensation. */
  .section--hero        { --section-color: var(--color-pear);       --section-fg: var(--color-oxford); --section-accent: var(--color-oxford); }
  .section--about       { --section-color: var(--color-ivory);      --section-fg: var(--color-oxford); --section-accent: var(--color-marian); }
  .section--leistungen  { --section-color: var(--color-marian);     --section-fg: var(--color-ivory);  --section-accent: var(--color-pear); }
  .section--audio       { --section-color: var(--color-oxford);     --section-fg: var(--color-ivory);  --section-accent: var(--color-pear); }
  .section--kontakt     { --section-color: var(--color-pear);       --section-fg: var(--color-oxford); --section-accent: var(--color-oxford); }
  .section--unterricht  { --section-color: var(--color-marian);     --section-fg: var(--color-ivory);  --section-accent: var(--color-pear); }
  .section--partner     { --section-color: var(--color-marian);     --section-fg: var(--color-ivory);  --section-accent: var(--color-pear); }

  /* ── Dark surfaces — single source of dark-surface defaults ───────────────
     This is the ONE place that marks something "dark" — add a new dark surface
     to this list. Font weights stay on discrete installed cuts (tokens.css);
     plain inherited text is pinned to Regular, body copy steps down to Light,
     H3 headings step down to Semibold, UI headings/buttons to Medium, and focus rings switch to a
     light colour for visibility. */
  .section--leistungen,
  .section--audio,
  .section--unterricht,
  .section--partner,
  .site-footer,
  .nav-overlay,
  body[data-section="impressum"],
  body[data-section="leistungen"] .site-header,
  body[data-section="audio"]      .site-header,
  body[data-section="unterricht"] .site-header,
  body[data-section="partner"]    .site-header {
    font-weight: var(--weight-regular);
    --h3-weight: var(--weight-semibold);
    /* Light-on-dark also needs a light focus ring — the Oxford default would be
       invisible on these surfaces. (Impressum is a whole-page dark surface, so
       it's listed body-level; its header is covered by the body selector. The
       scrolling landing sections stay header-scoped so the flag doesn't bleed
       onto the rest of the page while that section is the current one.) */
    --focus-ring-color: var(--color-marian-light);
  }

  /* Light headings on dark grounds read optically heavier. Keep display/UI
     headings on an installed cut by dropping to Medium; H3 has its own
     surface-aware --h3-weight (Bold on light, Semibold on dark). */
  .section--leistungen :is(h1, h2, h4):not(.tile-subhead),
  .section--audio :is(h1, h2, h4):not(.tile-subhead),
  .section--unterricht :is(h1, h2, h4):not(.tile-subhead),
  .section--partner :is(h1, h2, h4):not(.tile-subhead),
  .site-footer :is(h1, h2, h4):not(.tile-subhead),
  .nav-overlay :is(h1, h2, h4):not(.tile-subhead),
  body[data-section="impressum"] :is(h1, h2, h4):not(.tile-subhead),
  body[data-section="impressum"] .legal__prose dt {
    font-weight: var(--weight-medium);
  }

  .section--leistungen :is(.body-copy, .lead, .hero-lede, p, li, dd),
  .section--audio :is(.body-copy, .lead, .hero-lede, p, li, dd),
  .section--about :is(.body-copy, .lead, .hero-lede, p, li, dd),
  .section--unterricht :is(.body-copy, .lead, .hero-lede, p, li, dd),
  .section--partner :is(.body-copy, .lead, .hero-lede, p, li, dd),
  .site-footer :is(.body-copy, .lead, .hero-lede, p, li, dd),
  .nav-overlay :is(.body-copy, .lead, .hero-lede, p, li, dd),
  body[data-section="impressum"] .legal__prose {
    font-weight: var(--weight-light);
  }

  .section--leistungen .t-closing :is(.body-copy, .lead, .hero-lede, p, li, dd),
  body[data-section="impressum"] .legal__card :is(.body-copy, .lead, .hero-lede, p, li, dd) {
    font-weight: var(--weight-regular);
  }

  /* Per-section PREVIOUS-color + foreground + accent (used as starting tile state). */
  /* New section order: hero → leistungen → audio → about → kontakt → unterricht */
  .section--leistungen { --prev-color: var(--color-pear);   --prev-fg: var(--color-oxford); --prev-accent: var(--color-oxford); }
  .section--audio      { --prev-color: var(--color-marian); --prev-fg: var(--color-ivory); --prev-accent: var(--color-pear); }
  .section--about      { --prev-color: var(--color-oxford); --prev-fg: var(--color-ivory); --prev-accent: var(--color-pear); }
  .section--kontakt    { --prev-color: var(--color-ivory);  --prev-fg: var(--color-oxford); --prev-accent: var(--color-marian); }
  .section--unterricht { --prev-color: var(--color-pear);   --prev-fg: var(--color-oxford); --prev-accent: var(--color-oxford); }

  /* ── Text-snap timing (Hin-/Zurück-asymmetrisch) ───────────────────────────
     Der FILL morpht 0.45s linear; der TEXT (fg + accent) snappt HART an einem
     Punkt. Wann der sicher liegt, hängt von der Helligkeit des ZIEL-Texts ab:
       • Ziel-Text HELL (Leistungen/Unterricht: ivory/pear) → erst muss der Fill
         dunkel genug sein → SPÄT snappen.
       • Ziel-Text DUNKEL (About: oxford/marian) → bevor der Fill hell wird →
         FRÜH snappen.
     --text-snap ist der HINWEG-Delay (.is-recolored hinzugefügt). Der RÜCKWEG
     (Klasse entfernt) ist automatisch das Komplement calc(0.45s − --text-snap),
     denn dort ist das Ziel die jeweils andere Palette — die Asymmetrie fällt so
     in BEIDE Scroll-Richtungen richtig, ganz ohne JS. Sections ohne Wert nutzen
     den 0.22s-Default (~symmetrisch, wie bisher).

     NUR Leistungen + Unterricht brauchen den Eingriff: ihr Akzent-Ziel ist HELL
     (pear) auf einem abDUNKELnden Fill (pear→marian) — da muss der Fill erst dunkel
     genug sein → SPÄT (0.36s; verifiziert: Headline-Kontrast ≥3.2 in beide Scroll-
     Richtungen). About bleibt bewusst beim Default: sein Akzent pear→marian ist ein
     von Natur aus kontrastarmes Paar (beide mittel/dunkel), das KEIN Snap-Timing
     ganz sauber bekommt — der bisherige 0.22s-Mittelpunkt ist dort das Optimum,
     und „früh" würde dunkles marian auf noch-dunklem Fill unsichtbar machen. */
  .section--leistungen,
  .section--unterricht { --text-snap: 0.36s; }

  /* The single section accent lives on the .section-title by ROLE — no
     .is-accent toggle needed. In-tile titles inherit it automatically
     (`.section .tile { --color-accent: var(--tile-accent) }` below); the
     section default `--color-accent: var(--section-fg)` (above) keeps every
     other heading in body colour, and eyebrows follow body colour too (base
     `.eyebrow { color: inherit }`). The ONE title that sits directly in a
     .section without .tile chrome is Unterricht's — bind ITS --color-accent
     to the section accent so it reads Marian, like the in-tile titles read
     theirs. Scoped to section + element, it matches the plain .section-title
     the header partial emits (no .is-accent dependency). */
  .section--unterricht .section-title {
    --color-accent: var(--section-accent);
  }

  /* Role semantics: a subhead is body-text, never the accent. Overrides the
     base h3 rule (base.css: h3 reads --color-accent) so .tile-subhead tracks
     the tile's morphing foreground (--tile-fg), readable on light and dark
     sections alike. The single accent in a section is the .section-title. */
  .section .tile-subhead {
    color: var(--tile-fg, var(--section-fg, currentColor));
  }

  /* ============================================================
     3. TYPOGRAPHY
     ============================================================ */
  .hero-title {
    font-family: var(--font-display);
    font-feature-settings: var(--feature-display);
    font-weight: var(--weight-bold);
    font-size: var(--text-3xl);
    line-height: 0.78;
    letter-spacing: 0.01em;
    margin: 0;
    text-transform: uppercase;
    display: flex;
    flex-direction: column;
    gap: 0;
  }
  /* Lines stay inline-flow so word-internal markup (e.g. .u-cap spans)
     doesn't pick up flex gap between glyphs. The --live variant's leading
     red rule rides on margin instead of flex-gap. */
  .hero-title__line { white-space: nowrap; }
  .hero-title__rule {
    display: inline-block;
    width: calc(var(--spark-marker-width) + 1px);
    height: 0.7em;
    background: var(--color-spark);
    border-radius: 2px;
    margin-right: 15px;
    vertical-align: baseline;
  }
  .hero-lede {
    font-family: var(--font-body);
    font-weight: var(--weight-regular);
    font-size: var(--text-lead);
    line-height: 1.45;
    letter-spacing: 0.005em;
    max-width: 52ch;
    text-wrap: balance;
    color: inherit;
    opacity: 0.9;
  }
  .hero-cta {
    /* `margin-top: auto` schiebt den CTA als letztes Tile-Kind ans untere
       Ende des Stacks — Eyebrow, H1 und Lede gruppieren sich oben, der
       Button sitzt rechtsbündig am Tile-Bottom. */
    margin-top: auto;
    display: flex;
    justify-content: flex-end;
  }
  .hero-cta .btn {
    font-size: calc(var(--text-base) * 0.88);
    font-weight: var(--weight-semibold);
    /* Vertical padding tracks the space scale; horizontal is its own fluid clamp
       instead of --space-lg·0.88 (which floored at ~23px and stayed there on
       phones, making the button feel over-padded next to its text on narrow
       tiles). Now it floors at 16px on phones and ramps to the previous ~26px on
       wide screens — dynamic, no media query, desktop effectively unchanged. */
    padding: calc(var(--space-xs) * 0.88) clamp(1rem, 0.55rem + 2.2vw, 1.65rem);
    white-space: nowrap;
  }

  /* ── Heading-group: eyebrow + headline as one tight Stack ─────────────────
     Every-Layout Stack primitive. Keeps the eyebrow↔headline gap CONSTANT and
     small everywhere (home tiles, hero, subpages) — it's a label pinned to its
     headline, not a separate block. The gap from this group DOWN to the body /
     lede is the SURROUNDING container's flex gap, set per context (hero
     --space-lg for the big H1, subpages --space-md for H2). */
  .heading-group {
    display: flex;
    flex-direction: column;
    gap: var(--space-2xs);
  }
  /* Inline-Icon-Utility — Buttons mit `.btn__icon` als Sibling neben dem
     Label bekommen einen kompakteren Gap als der `.btn`-Default, damit das
     Icon optisch zum Wort gehört. */
  .btn:has(.btn__icon) { gap: 0.4em; }
  .btn__icon {
    width: 1.1em;
    height: auto;
    flex-shrink: 0;
  }
  /* Hero CTA sits on the almost-black Oxford pill: keep the highlight
     cool-gray and crisp so it reads as light, not Pear decoration. */
  .hero-cta .btn--primary {
    --btn-glow-color: color-mix(in srgb, var(--color-cool-gray) 70%, var(--color-ivory) 30%);
    --btn-glow-stop: 13%;
  }
  .hero-cta .btn--primary::before {
    opacity: 0.38;
  }
  .hero-cta .btn--primary:hover::before {
    opacity: 0.52;
  }
  .section-title {
    font-family: var(--font-display);
    font-feature-settings: var(--feature-display);
    font-weight: var(--weight-bold);
    font-size: var(--text-2xl);  /* token-driven so scale changes in tokens.css apply */
    line-height: 0.92;
    letter-spacing: 0.01em;
    text-transform: uppercase;
    /* The 0.92 line box leaves ~0.18em of empty descender leading below the
       uppercase caps (measured, stable across the responsive size range). Pull
       the following content up by that amount so the visible gap equals the
       intended stack gap — a browser-agnostic stand-in for text-box-trim, which
       Firefox can't do yet. Scales with the heading via em. (The hero H1's tighter
       0.78 line-height already clips this to 0, so it needs no compensation.) */
    margin: 0 0 -0.18em;
  }
  /* ── Headline accent-morph crossfade (iPad/WebKit diacritic-repaint fix) ──────
     iOS Safari fails to re-rasterise the thin Ä/Ö/Ü diacritic dots when a headline
     recolours IN PLACE through the accent morph — they stick at the prev colour
     (and translateZ(0) drops them entirely). Fix (Codex): render the headline TWICE,
     each copy painted ONCE in a fixed colour (--from = prev accent, --to = section
     accent), stacked on the same grid cell, and CROSSFADE THEIR OPACITY instead of
     mutating colour. No glyph is ever recoloured → nothing to mis-raster → the morph
     is preserved and the bug is structurally impossible. Opt-in per headline via the
     section_header `crossfade` flag. Generic (keyed on the stack + the section's
     .is-recolored), so it works for any section that opts in. */
  .section-title-stack { display: grid; }
  .section-title-stack > .section-title { grid-area: 1 / 1; }
  /* No-JS / mobile fallback: only the real (--from) headline shows; it morphs via
     the scroll-timeline snap exactly as before. The --to twin stays inert. */
  .section-title--to { display: none; }
  /* JS path (html.goo-js) drives the crossfade off the section's .is-recolored
     toggle — independent of animation-timeline support. */
  html.goo-js .section-title--from { color: var(--prev-accent, var(--section-accent)); }
  html.goo-js .section-title--to {
    display: block;
    color: var(--section-accent);
    opacity: 0;
    pointer-events: none;
  }
  /* INSTANT opacity swap (no crossfade overlap) so there is no blend window where
     the ultra-thin Ü dots flash an in-between colour — the headline flips in one
     frame (the original "snap" intent), while the section FILL still crossfades
     softly via WebGL. The swap point uses the SAME asymmetric --text-snap timing
     as the body text (see --tile-fg above): on the way IN it waits until the FILL
     is dark/contrasty enough for the new accent (helle Ziele spät), on the way
     BACK the complement — so the pear/ivory headline never flashes on a still-too-
     light fill in EITHER scroll direction. RÜCKWEG = Basis-Regel (Komplement). */
  html.goo-js .section-title-stack > .section-title {
    transition: opacity 0s linear calc(0.45s - var(--text-snap, 0.22s));
  }
  /* HINWEG = .is-recolored-Regel (--text-snap-Delay). */
  html.goo-js .section.is-recolored .section-title-stack > .section-title {
    transition: opacity 0s linear var(--text-snap, 0.22s);
  }
  html.goo-js .section.is-recolored .section-title--from { opacity: 0; }
  html.goo-js .section.is-recolored .section-title--to   { opacity: 1; }
  @media (prefers-reduced-motion: reduce) {
    html.goo-js .section-title-stack > .section-title,
    html.goo-js .section.is-recolored .section-title-stack > .section-title { transition: opacity 0s; }
  }
  .body-copy {
    font-family: var(--font-body);
    font-size: var(--text-base);
    line-height: 1.3;
    max-width: var(--measure);
    margin: 0;
  }
  /* RichText-Reconciliation (Phase C2): |richtext liefert eigene <p> im
     .body-copy-Container. Die Maße/das Margin oben hängen an .body-copy selbst;
     der innere Absatz bekommt sie hier gezielt weitergereicht, sonst kämen
     UA-Default-Margins zurück und die max-width fiele weg. */
  .body-copy > p {
    margin: 0;
    max-width: var(--measure);
  }
  /* Mehr-Absatz-Bodies (z. B. about_today): heute liegen die einzelnen
     <p class="body-copy"> als Flex-Geschwister direkt in .tile__content und
     werden vom dortigen `gap: var(--space-sm)` getrennt. Im Container ist der
     innere <p> kein Flex-Kind mehr → der Abstand muss explizit gesetzt werden,
     und zwar mit demselben Token, damit der vertikale Rhythmus 1:1 erhalten
     bleibt (final in Task 6/About geprüft). */
  .body-copy > p + p {
    margin-block-start: var(--space-sm);
  }
  /* German hyphenation across the prose so long compound words break inside the
     word on narrow widths instead of overflowing. Relies on <html lang="de">.
     overflow-wrap is the fallback for words the hyphenation dictionary can't
     split. Scoped to reading text — headings/buttons must not hyphenate. */
  .body-copy,
  .hero-lede,
  .lead,
  .closing-line,
  .legal__prose :is(p, li, dd) {
    /* Hyphenation is MOBILE-ONLY (see the media block). On desktop/tablet we
       keep `text-wrap: pretty` WITHOUT hyphens: Chrome hyphenates together with
       pretty while Safari does not, so enabling it globally made the two render
       differently. Mobile turns pretty off + hyphens on. */
    /* Safety net: a word wider than its column breaks instead of overflowing. */
    overflow-wrap: break-word;
  }
  /* Tile-Subhead — Asap-Condensed H3 on the H4 scale. Weight follows the
     surface: Bold on light, Semibold on dark (via --h3-weight). */
  .tile-subhead {
    font-family: var(--font-ui);
    font-weight: var(--h3-weight, var(--weight-bold));
    font-size: var(--text-l);
    line-height: 1.1;
    letter-spacing: var(--tracking-h3);
    margin: 0;
  }
  /* Tile-Card-Headline — etwas größer/displayhafter als .tile-subhead, für
     Service-Kacheln (Hochzeit, Sax + Gitarre, Sax + Band) und ähnliche
     "Card-Title"-Situationen. */
  .tile-h3 {
    font-family: var(--font-ui);
    font-weight: var(--weight-bold);
    font-size: var(--text-l);
    line-height: 1;
    margin: 0;
  }
  /* Greeting-Caveat — large handwritten greeting, tilted for personal touch.
     It rides the tile accent: Pear while About still uses the previous Oxford
     palette, Marian once About has recoloured. Rotation lives on the inner
     element so the parent .tile can still fly in via translateY without
     conflicting transforms. */
  .greeting-caveat {
    font-family: var(--font-accent);
    font-size: clamp(1.75rem, 1.45rem + 1.1vw, 2.125rem);  /* 28 → 34 px — mobile/tablet lifted (tablet 768→31.6) */
    word-spacing: var(--word-spacing-accent);
    font-weight: var(--weight-bold);
    line-height: 1;
    letter-spacing: 0.03em;
    color: var(--tile-accent, var(--color-marian));
    align-self: center;                             /* zentriert im Content-Bereich neben dem Foto */
    margin: clamp(0.075rem, -0.425rem + 1vw, 1.075rem) 0 0 0;  /* nochmal 10 px Top-Margin reduziert */
    transform: rotate(var(--tilt-accent));
    transform-origin: center;
  }
  /* Tile-Bleed — opt-in modifiers that negate the section's inline padding so
     a tile reaches the canvas edge. Only applied on desktop; mobile stack
     ignores bleed (tiles already span full width). */
  /* Mobile-Tile-Anchor system — three modifier classes that define how a
     full-width-stacking tile relates to the viewport edges on mobile:
       • .tile--bleed-left   — docks to the left viewport edge
       • .tile--bleed-right  — docks to the right viewport edge
       • .tile--inset        — centred, slightly narrower (visual pause)
     A non-modified tile defaults to full content-width. Mixed across a
     section, the three modifiers create rhythm and break the monotony
     of identical stacked cards. Bleed modifiers also apply on desktop
     (they're the original bleed pattern); .tile--inset only takes
     effect on mobile because at desktop the 12-col grid already shapes
     each tile's width. */
  .tile--bleed-left {
    margin-inline-start: calc(var(--section-pad-x) * -1);
    /* Straight edge where the card meets the viewport — only the inner
       corners round, matching how the goo headline sits flush on the left. */
    border-start-start-radius: 0;
    border-end-start-radius: 0;
  }
  .tile--bleed-right {
    margin-inline-end:   calc(var(--section-pad-x) * -1);
    border-start-end-radius: 0;
    border-end-end-radius: 0;
  }
  /* Content kompensiert den Bleed, damit Text auf gleicher Spalte bleibt wie
     bei nicht-bleedenden Tiles. Padding sitzt jetzt auf dem Wrapper. */
  .tile--bleed-left  > .tile__content { padding-inline-end:   calc(var(--tile-pad) + var(--space-md)); }
  .tile--bleed-right > .tile__content { padding-inline-start: calc(var(--tile-pad) + var(--space-md)); }
  /* Tile-Ghost — transparent background so the slideshow shows through.
     !important needed because .tile's background uses an animated
     custom-property (--tile-color) that wins normal cascade. */
  .tile--ghost { background: transparent !important; }
  /* Quote on slideshow — large Caveat, slight tilt. This is a slideshow-overlay
     quote, not a tile accent: keep it Ivory through the whole About morph. */
  .ghost-quote {
    font-family: var(--font-accent);
    font-size: clamp(2.125rem, 1.6rem + 2.2vw, 3rem);  /* 34 → 48 px — mobile/tablet lifted (tablet 768→42.5), desktop max unchanged */
    word-spacing: var(--word-spacing-accent);
    line-height: 1.05;
    letter-spacing: 0.03em;
    text-align: center;
    color: var(--color-ivory);
    transform: translateX(-3rem) translateY(2.5rem) rotate(var(--tilt-accent));
    transform-origin: left center;
    margin: 0;
    /* Soft, wide-spread Oxford halo — barely perceptible as a shadow but
       lifts the ivory handwriting off any slideshow frame behind it.
       Three layered shadows trade tight contour for diffuse falloff: a
       small core, then two progressively wider/fainter glow rings. */
    text-shadow:
      0 0 24px color-mix(in srgb, var(--color-oxford) 22%, transparent),
      0 0 56px color-mix(in srgb, var(--color-oxford) 14%, transparent),
      0 0 96px color-mix(in srgb, var(--color-oxford)  8%, transparent);
  }
  /* RichText-Reconciliation (Phase C2): die About-Quote kommt aus rich_body als
     <div class="ghost-quote"><p>…<br>…</p></div>. Typo/Positionierung oben liegen
     auf dem Container und vererben sich (Font, Farbe, line-height, text-align,
     text-shadow); der innere <p> brächte nur UA-Default-Margins zurück, darum hier
     gezielt genullt — analog .body-copy > p (Task 4). Der <br> bleibt erhalten. */
  .ghost-quote > p {
    margin: 0;
  }

  /* ============================================================
     4. SECTION GRID LAYOUTS
     Each section uses a 12 × 8 grid. Tiles span specific cells.
     EMPTY cells (no .tile) let the slideshow show through.
     ============================================================ */

  /* HERO — special layout: the tile flows in normal layout (so its
     intrinsic content height is respected, no matter how much copy
     anyone puts inside), but it sits in the BOTTOM-RIGHT of the
     section. The section itself is at least 100svh, but grows
     organically when the tile needs more room. No JS, no fixed
     min-heights — pure flow.

     We override the parent grid for hero only: switch to flex with
     end-alignment, which behaves identically to "anchored to bottom"
     while still letting the tile push the section taller when it
     overflows the viewport. */
  .section--hero {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: end;
    /* min-height instead of locked height so tall content can grow.
       The scroll-hint is viewport-anchored via `position: fixed` (see
       its rule down below), so it stays at the bottom of the fold even
       if the hero section grows past 100svh on short viewports. */
    min-height: 100svh;
    height: auto;
    /* Reserve breathing room above + below the tile */
    /* Top: header height + horizontal section gutter + sticky-CTA width
       so the optical top breathing space matches the right edge
       (where the sticky CTA visually shifts the perceived margin).
       Bottom: room for the sticky CTA. */
    padding-block: calc(var(--header-height) + var(--section-pad-x) + clamp(1.1rem, 0.75rem + 0.7vw, 2.2rem))
                   var(--section-pad-y);
  }
  .section--hero .t-intro {
    position: relative;
    /* 52% of the (uncapped) section width, hard-capped at 560px. The cap stops
       the tile from stranding the (now plateaued) H1 in a too-wide card on
       laptops/desktops — laptop/desktop sit at the 560 cap. The 52% (raised from
       46%) only bites BELOW the cap, i.e. on tablet, where it lets the lede run a
       little wider (fewer lines); the cap is reached ~1145px so the tablet→laptop
       transition stays smooth. Mobile (≤720px) overrides this further down. */
    width: min(52%, 560px);
    min-height: 50svh;
    height: auto;
    isolation: isolate;
    margin-top: auto;
    align-self: end;  /* override .tile { align-self: start } so hero tile stays right-aligned in flex */
  }
  /* Content-Layout des Hero-Tiles wandert auf den Wrapper, da .tile selbst
     kein Padding/Gap mehr trägt. Der gap ist der heading-group→lede→CTA-Rhythmus
     (H1-Kontext: --space-lg, mehr Luft als die H2-Subpages mit --space-md). Der
     Eyebrow↔H1-Abstand lebt im heading-group (konstantes --space-2xs). */
  .section--hero .t-intro > .tile__content {
    gap: var(--space-lg);
    /* Goo-Bleed-Ausgleich: Hero hat keine gebleedete Goo-Kachel, also fehlt
       der ~10px-Gutter, den alle anderen Kacheln haben → rundum addieren.
       Bewusst getrennt vom uniformen --tile-pad. */
    padding-block: calc(var(--tile-pad) + var(--goo-bleed));
    /* Horizontal floors LOWER than vertical on very narrow phones. The hero tile
       sizes to its widest child — the white-space:nowrap headline, which can't
       shrink — so below ~365px the tile's content (headline + 2× this padding)
       plus its 2×16px side margin no longer fits the viewport, and the LEFT
       margin gets squeezed away (body has overflow-x:clip). Shrinking this inline
       padding from ~26px toward 16px on narrow phones gives the tile room to keep
       its full 16px left margin down to the 280px target. Continuous at ~365px
       (meets --tile-pad+--goo-bleed there) so tablet/desktop are unchanged. */
    padding-inline: clamp(1rem, -1.1875rem + 12.5vw, calc(var(--tile-pad) + var(--goo-bleed)));
  }
  .section--hero .t-intro::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background-color: var(--color-marian);
    -webkit-mask: url("../images/logo-heis.a394e7455a86.svg") no-repeat center / 110% auto;
    mask: url("../images/logo-heis.a394e7455a86.svg") no-repeat center / 110% auto;
    opacity: 0.07;
    pointer-events: none;
  }

  /* ÜBER MICH — 5-tile composition. Intro-tile is composite (photo bleed-left
     + eyebrow/H2/greeting on right) so they share one tile background.
     Transparent ghost-quote sits bottom-right on slideshow. */

  /* About hat content-basierte Row-Tracks: damit Tiles aneinander andocken,
     egal wie hoch die einzelnen Inhalte sind (sonst entsteht Gap durch
     1fr-Verteilung der 100svh über die 8 Rows). */
  .section--about {
    /* 9 explicit rows (lines 1–10) cover every line the tiles use (today/quote
       end at line 10). Empty auto rows are 0-height, so the extra explicit
       lines are layout-neutral for the tiles. */
    grid-template-rows: repeat(9, auto);
  }

  .section--about .t-intro     { grid-column: 1 / 7;   grid-row: 1 / 4; }   /* intro: photo + content, ≤ 50% viewport with bleed-left */
  .section--about .t-childhood {
    grid-column: 8 / 13;
    grid-row: 1 / 3;   /* wide + flat top-right */
    /* Content-driven (like Leistungen): drop the inherited 30svh floor so the
       short "Wie alles begann" copy sizes to its content instead of being
       stretched/over-padded. t-childhood is align-self:start and NOT part of the
       goo merge, so shrinking it can't open a neck gap. min-height:0 only makes
       it smaller → no overlap with the Ausbildung tile below (the old
       min(30svh,20rem) cap guarded against OVER-inflation, which is moot now). */
    min-height: 0;
  }
  .section--about .t-training  {
    grid-column: 7 / 13;
    grid-row: 4 / 8;                                                    /* 4 Rows, damit today bei Row 6 exakt auf der Mitte andocken kann */
    /* Rechter Bleed bis zum Viewport-Rand + abgeflachte rechte Ecken kommen
       jetzt von .tile--bleed-right (im Markup) — nicht mehr per eigenem margin. */
  }   /* corner-touches intro; align-self/min-height kommen von .tile */
  /* Content bleibt an der Section-Right-Edge ausgerichtet, obwohl die Tile
     selbst per margin nach rechts bleedet: kompensiert den Bleed im Wrapper. */
  .section--about .t-training > .tile__content {
    padding-inline-end: calc(var(--tile-pad) + var(--section-pad-x));
  }
  .section--about .t-today     {
    grid-column: 1 / 7;
    grid-row: 6 / 10;
    /* Linke Kante exakt am Foto-Ende der Intro-Tile: Tile beginnt im Grid bei
       col 1 (= section-pad-x) und wird per margin auf die Foto-Breite
       hereingeschoben, sodass die Tile genau die "helle" Content-Fläche der
       Intro-Tile abbildet. Token --about-photo-width hält die beiden
       Stellen (hier + .t-intro__photo) synchron. */
    margin-inline-start: calc(var(--about-photo-width) - var(--section-pad-x));
  }

  .section--about .t-quote     {
    /* Eigene Grid-Zelle direkt unter Training: cols 8–12 (zentriert wie
       Childhood-Tile darüber), rows 8–9 (= zwischen Training-Bottom und
       Today-Bottom). Die Zelle füllt damit exakt den Freiraum unter Training,
       der Quote-Text wird per `.tile { justify-content: center }` vertikal
       mittig ausgerichtet.
       Layout-Invariante: quote_bottom == today_bottom hält, solange today
       mindestens so hoch ist wie quote (beide haben min-height: 30svh). Die
       CMS-Felder von today liefern realistisch immer mehrere Absätze, das ist
       der Default-Pfad. Wird quote länger als 30svh, bleibt der Tile sichtbar,
       extending past today_bottom — das ist akzeptabel für ein Ghost-Tile, das
       eh nur Slideshow-Hintergrund hat. */
    grid-column: 8 / 13;
    grid-row: 8 / 10;
    /* Since the content-driven change (.section--about .tile min-height:0) the
       ghost quote sits closer to the Training tile above → give it a bit more
       air on top. Moderate (--space-lg), tunable. The ghost tile has no own
       background, is align-self:start and necks with nothing → no merge/overlap
       risk; this only pushes the text start down within its cell. */
    margin-block-start: var(--space-lg);
  }

  /* Intro-tile internal layout: photo left fills full tile height (cropped on
     sides via object-fit: cover), text right (fills remaining width). */
  .section--about .t-intro {
    flex-direction: row;
    padding: 0;
    align-items: stretch;  /* photo stretches to full tile height */
    gap: 0;
  }
  .section--about .t-intro__photo {
    flex: 0 0 auto;
    width: var(--about-photo-width);
    /* height comes from parent flex stretch; no aspect-ratio — image cover crops to fit */
  }
  .section--about .t-intro__photo img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: 0% center;  /* left edge — keeps the figure/saxophone composition on the left of the original image */
    /* Marian-Blau-Duotone wie auf den Unterseiten — direkt auf dem Bild, damit
       der Tint mit dem Bild geklippt/skaliert wird (kein Overlay-Rahmen beim
       Reveal). grayscale→sepia→hue-rotate kippt das Bild in den Marian-Ton
       (#3D3B8E). Werte zentral via --marian-duotone (tokens). */
    filter: var(--marian-duotone);
  }
  .section--about .t-intro__content {
    flex: 1;
    padding: var(--tile-pad);
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: var(--space-sm);
    min-width: 0;  /* allow text to wrap inside flex */
  }

  /* LEISTUNGEN — composition per katha's sketch.
     5-row grid (auto-sized) so Services and Closing can overlap vertically:
       Row 1            — Headline (cols 1/6, bleeds LEFT to viewport edge)
                          + Anlässe (cols 8/11).
       Rows 2–4         — Services (cols 3/8), the long middle tile.
       Rows 3–5         — Closing (cols 9/12), pear "new topic" tile.
                          Starts one row below Services' top so its top edge
                          lands at Services' midpoint and ends one row below
                          Services' bottom for generous air above it. */
  .section--leistungen {
    grid-template-rows: auto auto auto auto auto;
  }
  .section--leistungen .t-headline  { grid-column: 1 / 6;   grid-row: 1; }
  .section--leistungen .t-occasions { grid-column: 8 / 11;  grid-row: 1; min-height: 0; }
  /* Services (Sektempfang/Dinner/Party): one grid column narrower, the dropped
     column taken off the LEFT, so the offers-card sits one column further right
     (4/8 instead of 3/8). Right edge stays at line 8 (meets Occasions), so the
     merge neck region is unchanged; the WebGL shape re-measures the settled box. */
  .section--leistungen .t-services  { grid-column: 4 / 8;   grid-row: 2 / 5; }
  .section--leistungen .t-closing   { grid-column: 9 / 12;  grid-row: 3 / 6; }

  /* Tablet-range widening — between the mobile-stack breakpoint (720px)
     and a comfortable desktop width (1024px), a few narrow tiles look
     cramped and get one extra grid column. Each extension is constrained
     by neighbours: Occasions and Closing extend toward the right edge
     (preserving the desktop offset); Today keeps its right edge to avoid
     intruding into Training and instead reclaims room on the left by
     dropping the desktop photo-edge inset. */
  @media (min-width: 721px) and (max-width: 1024px) {
    .section--about      .t-today {
      grid-column: 1 / 7;
      margin-inline-start: 0;
    }

    /* About at tablet — the H2 "Klassisch geprägt. Modern interpretiert."
       overflows the 6-col intro tile, so widen the intro to 10 of 12 columns
       (grid-column 1 / 11, nearly full-width). Childhood drops below the intro into its own row
       with a generous block-gap, slightly inset from the left edge so
       it reads as a calmer sub-tile rather than another full-width
       block. Training/Today/Quote shift down accordingly. */
    .section--about .t-intro     { grid-column: 1 / 11; grid-row: 1 / 4; }
    /* Photo gets ~one grid-column more breathing room on tablet so the
       portrait isn't squeezed against the headline column. */
    .section--about .t-intro__photo {
      width: calc(var(--about-photo-width) + var(--space-2xl));
    }
    .section--about .t-childhood {
      grid-column: 3 / 11;
      grid-row: 5 / 7;
      margin-block-start: var(--space-lg);
    }
    .section--about .t-training  { grid-row: 7 / 11; }
    .section--about .t-today     { grid-row: 9 / 13; }
    .section--about .t-quote     { grid-row: 11 / 13; }
    .section--about { grid-template-rows: repeat(12, auto); }
  }

  /* Both top-tier tiles fill Row 1 — that way Anlässe's bottom edge reaches
     the Row 1/2 boundary at col 8, where Services' top-left corner meets it. */
  .section--leistungen .t-headline,
  .section--leistungen .t-occasions { align-self: stretch; }

  /* Headline ("Euer Tag. Mein Sound.") keeps its box top-left and bleeding to
     the viewport edge (tile--bleed-left) — but the TEXT was sitting only
     --tile-pad from the screen edge and read cramped. Push the CONTENT right by
     ~one grid column so it starts around the col-2 grid line: section gutter +
     one 12-col track (gap is 0, so a track = (content-width)/12). Desktop only —
     on mobile the tiles stack full-width and the bleed/grid don't apply. */
  /* Headline content keeps the ~1-column left air ONLY on desktop (≥1201px). On
     tablet/mobile the section is narrower and that indent eats too much of the
     headline width → there the content uses the FULL tile width again (incl. its
     left column). Viewport @media on purpose: container-type on the sections/stack
     broke WebKit (diacritic-repaint on .section, scroll jank on .section-stack), so
     the tablet stages are viewport-driven. One grid column = (100vw - 2·gutter)/12
     (gap is 0); the tile bleeds to the viewport edge, so + the gutter lands the text
     on the col-2 grid line. */
  @media (min-width: 1201px) {
    .section--leistungen .t-headline > .tile__content {
      padding-inline-start: calc(var(--section-pad-x) + (100vw - 2 * var(--section-pad-x)) / 12);
    }
  }
  /* Leistungen — tablet band (721–1200px → iPad portrait + landscape, above the
     mobile stack, below desktop). */
  @media (min-width: 721px) and (max-width: 1200px) {
    /* "Für Momente, die bleiben." — whole tile one column wider to the RIGHT,
       8/12 → 8/13 (reaches the right grid edge). */
    .section--leistungen .t-occasions { grid-column: 8 / 13; }
    /* Closing keeps its tablet widen. */
    .section--leistungen .t-closing   { grid-column: 9 / 13; }
  }

  /* Closing — themed as its own "new topic" (Mehr als Saxophon? — band,
     vocals, DJ). Pear background + oxford ink, no section-color morph. */
  .section--leistungen .t-closing {
    --tile-color: var(--color-pear);
    --tile-fg: var(--color-oxford);
    --tile-accent: var(--color-oxford);
    /* Light island inside a dark section: oxford ink on pear, with a dark focus
       ring for Pear. */
    font-weight: var(--weight-regular);
    --h3-weight: var(--weight-bold);
    --focus-ring-color: var(--color-oxford);
    animation: none;
    color: var(--color-oxford);
  }

  /* Section runs compact — content-driven tile heights, no 30svh floor. */
  .section--leistungen .tile { min-height: 0; }
  /* About likewise content-driven so its top/bottom padding reads like the other
     sections (was inheriting the 30svh floor → over-padded cards). Intro and Today
     stay align-self:stretch and fill their auto grid rows so their merge necks hold
     at the shared grid lines; t-training is content-height (see the align-self note
     further below) and the WebGL shape re-measures its settled box, so the merge
     still adapts (verified). */
  .section--about .tile { min-height: 0; }
  /* Audio/Kontakt content cards are content-sensitive too — no inherited 30svh
     floor. Photo CTA tiles keep their size via aspect-ratio rules below. */
  .section--audio .t-title,
  .section--audio .t-player,
  .section--kontakt .t-line { min-height: 0; }

  /* ── Goo / metaball merge ─────────────────────────────────────────────
     The liquid MERGE between adjacent same-colour goo tiles (the connecting
     necks) is rendered by WebGL behind the DOM tiles — see goo-webgl-
     progressive-enhancement-plan.md. WITHOUT that WebGL layer (the no-JS /
     no-WebGL fallback) the `.tile--goo` cards are simply normal rounded,
     section-coloured cards: they keep their CSS dock-in MOVEMENT and CSS
     colour/text MORPH — only the merge is absent. Nothing is broken.

     Services is content-height + top-aligned by default; stretch it so the
     card fills its grid cell (the WebGL shape is measured from the tile box). */
  .section--leistungen .t-services { align-self: stretch; }

  /* Goo tiles are normal section-coloured rounded cards by default — the box
     keeps the solid --tile-color background it inherits from the base `.tile`
     (NOT transparent), so there is never an invisible state at any width. The
     merge is added by WebGL; only AFTER a successful WebGL init does the gate
     below turn the box transparent (the canvas then paints the shape). The
     `.section` prefix raises specificity above the base
     `.section .tile` colour-morph animations so the goo dock-in (set in the
     @supports block below) wins for the transform animation slot. */
  .section .tile--goo {
    transform: none;
  }
  /* Additive fallback outset for goo cards: a --goo-bleed bleed layer ON TOP of
     the solid box (exactly the plain-tile Goo-Rand ::before pattern — solid box
     + bleeding ::before) so a settled goo card reads ~--goo-bleed larger, the
     same size as the plain cards around it. Reads the morphing --tile-color so
     it colour-snaps with the section. `border-radius: inherit` so bleed-left/
     -right goo tiles (which flatten their viewport-edge corners on the box)
     keep that flattening on the bleed layer too. Desktop/tablet only — on mobile
     the cards stack flush (no outset). WebGL later draws the shape with the same
     bleed baked in, then the gate hides this layer. */
  @media (min-width: 721px) {
    .section .tile--goo::before {
      content: "";
      position: absolute;
      inset: calc(var(--goo-bleed) * -1);
      background: var(--tile-color, var(--section-color));
      border-radius: inherit;
      z-index: -1;
    }
    /* The fallback fill GROWS on dock-in (scale 0.92→1.0) — the modern
       equivalent of the old goo-blobDockIn `scale(var(--s))`: the background
       SHAPE grows while the tile CONTENT only translates (goo-tileDockIn), so
       text never scales. Driven by the SAME dock range as the slide, so grow +
       slide settle together. transform-origin:center → grows around the card
       centre. When WebGL is active this ::before is display:none and the shader
       grows the shape instead (goo-webgl.js, same range) — only ONE of the two
       paths is ever visible, so they never double up. */
    @supports (animation-timeline: view()) {
      .section .tile--goo::before {
        animation: tileBleedScaleIn cubic-bezier(0.65, 0, 0.35, 1) both;
        animation-timeline: --section-view;
        animation-range:
          entry calc(var(--goo-dock-start) * 1%) entry calc(var(--goo-dock-end) * 1%);
        transform-origin: center;
      }
    }
  }

  /* WebGL init gate: ONLY after JS has created the context, compiled the
     shader and drawn once does it set `html.goo-webgl` + `.section.is-goo-webgl`
     per section. THEN the solid box AND its bleed layer go transparent/hidden so
     the WebGL shape shows through. `--tile-color` deliberately stays SET — JS
     reads the real section colour from it, and the CSS colour/text morph stays
     CSS-owned. */
  html.goo-webgl .section.is-goo-webgl .tile--goo { background: transparent; }
  html.goo-webgl .section.is-goo-webgl .tile--goo::before { display: none; }
  /* t-today docks ALONE on mobile (the JS skips its WebGL shape), so the global
     gate above must NOT hide ITS bled ::before fill — otherwise it'd be a
     transparent box with no shape behind = invisible card. Keep it painted. */
  @media (max-width: 720px) {
    html.goo-webgl .section--about.is-goo-webgl .t-today::before { display: block; }
    /* t-childhood joins the Ivory merge on mobile only (via .tile--goo-mobile +
       the JS shape loop). It has no .tile--goo, so the global gate above doesn't
       reach it — drop its solid box here once WebGL paints so the merged shape
       (Intro ↔ Wie alles begann ↔ Zwischen Klassik und Pop) shows through. The
       text in .tile__content rides over the shape, exactly like the other goo
       content tiles. Desktop is untouched (this is ≤720px + JS-gated). The global
       gate (line ~1214) only hides ::before for .tile--goo, so t-childhood —
       being .tile--goo-mobile — keeps the non-goo ::before fill and would
       double-paint a solid card over the merge; hide it here too. */
    html.goo-webgl .section--about.is-goo-webgl .t-childhood { background: transparent; }
    html.goo-webgl .section--about.is-goo-webgl .t-childhood::before { display: none; }
  }

  /* ── Photos GROW with their card's shape ──────────────────────────────────
     A goo card's fill/shape scales 0.92→1.0 on dock-in, but a photo riding on
     it stays full-size and OVERHANGS the still-small shape on its bled edges.
     So the photo scales on the SAME dock range as the shape (inherits the
     tile's --goo-dock-*), clipped by each photo's own overflow:hidden. Origin
     = centre: for the full-cover Kontakt CTA photo that tracks the shape edge
     exactly; for the side-set About / Audio photos the full-height axis tracks
     exactly and the narrow axis tracks closely (the residual is a few px of the
     interior edge, hidden under the same-colour fill). Runs in BOTH paths — the
     fallback ::before scales too, so the photo must track it as well. */
  /* ONE shared layer for the photo grow — desktop AND mobile. The photo scales
     on the SAME --section-view + per-tile dock range as the goo dock-in, the
     ::before fill and the WebGL shape, so it stays in lockstep with the growing
     shape at every width (no separate mobile timeline). The scaling ORIGIN is
     the card centre, which sits BESIDE the photo on desktop and BELOW it on
     mobile — set inline per layout by JS (updateCompositePhotoOrigins on
     desktop, setMobilePhotoOrigins on mobile); the CSS origins here are the
     no-JS fallbacks. */
  @supports (animation-timeline: view()) {
    .section--about .t-intro__photo,
    .section--audio .t-player__photo,
    .section--kontakt .t-cta.tile--goo > picture > img {
      animation: tileBleedScaleIn cubic-bezier(0.65, 0, 0.35, 1) both;
      animation-timeline: --section-view;
      animation-range:
        entry calc(var(--goo-dock-start) * 1%) entry calc(var(--goo-dock-end) * 1%);
      transform-origin: center;
    }
    /* No-JS fallback for the desktop side-set About-Intro photo (~147% lands the
       card centre on its narrow axis); JS pins the exact per-layout origin. */
    .section--about .t-intro__photo { transform-origin: 147% 50%; }
  }
  @media (prefers-reduced-motion: reduce) {
    .section--about .t-intro__photo,
    .section--audio .t-player__photo,
    .section--kontakt .t-cta.tile--goo > picture > img {
      animation: none;
      transform: none;
    }
  }

  /* ── Scroll-driven dock-in (per tile) ─────────────────────────────────
     With animation-timeline: view(), every goo card animates from its seeded
     drift state to the settled state AS IT PHYSICALLY ENTERS THE VIEWPORT.
     Fast scrollers see Services dock from below as it appears — independent of
     any IO threshold. The WebGL shape reads the same --goo-dock-* range and
     --dx/--dy, so the merge shape tracks the card with no drift.            */
  @keyframes goo-tileDockIn {
    from { transform: translate(var(--dx, 0), var(--dy, 0)); }
    to   { transform: translate(0, 0); }
  }
  /* Per-tile drift directions, consumed by goo-tileDockIn's `from`. Section
     default for the dock RANGE lives in --goo-dock-start/--goo-dock-end (as
     bare entry-percentages); per-tile overrides below tune the stagger. The
     same vars are the SINGLE SOURCE OF TRUTH for the WebGL progress maths, so
     CSS movement and WebGL shape can't drift (see the plan doc). */
  .section .tile--goo { --goo-dock-start: 35; --goo-dock-end: 75; }
  .section--leistungen .t-headline  { --dx: -110px; --dy:  40px; --goo-dock-start: 35; --goo-dock-end:  75; }
  .section--leistungen .t-occasions { --dx:   60px; --dy:  40px; --goo-dock-start: 78; --goo-dock-end: 100; }
  .section--leistungen .t-services  { --dx:    0px; --dy: 240px; --goo-dock-start: 55; --goo-dock-end:  92; }
  .section--audio .t-title  { --dx: -80px; --dy:  30px; --goo-dock-start: 30; --goo-dock-end: 72; }
  .section--audio .t-player { --dx:   0px; --dy: 140px; --goo-dock-start: 50; --goo-dock-end: 92; }

  @supports (animation-timeline: view()) {
    /* Deterministic dock-in ORDER. All goo tiles run against the SHARED
       section timeline (--section-view) instead of each card's own view() —
       so the sequence is fixed regardless of where a card sits in the grid.
       Without this, "Für Momente" (top-right) docks first just because it
       enters the viewport earliest, which reads as unmotivated. The staggered
       ranges below give:
         1) Headline   — from the left   (early)
         2) Services    — from below      (middle)
         3) Für Momente — from the right  (late)
       so the right→left dock of the last card is seen deliberately, after
       Services has already settled.

       Slot 1 = per-tile dock-in (entry range, --goo-dock-*). Slots 2+3 = the
       split colour morph (same as the base tile): text (fg + accent) snaps hard,
       fill crossfades softly over cover 30%→45% (no-JS fallback window — PAIR with
       the JS RECOLOR_ON/OFF; see the base .section .tile comment). The goo tile
       owns its own background, so fill morphs here too; the WebGL fill mirrors
       the same cover point. */
    .section .tile--goo,
    .section--about .t-childhood {
      animation: goo-tileDockIn linear both,
                 tileTextSnap step-end both,
                 tileColorBlend linear both;
      animation-timeline: --section-view, --section-view, --section-view;
      animation-range:
        entry calc(var(--goo-dock-start) * 1%) entry calc(var(--goo-dock-end) * 1%),
        cover 30% cover 43%,
        cover 30% cover 45%;
    }
  }

  /* ── ABOUT — Goo merge ────────────────────────────────────────────────
     The photo-intro plus text tiles melt into ONE Ivory shape, mirroring the
     Leistungen/Audio merges. Membership differs per breakpoint (see markup
     comment): desktop = Intro → Training → Today (Childhood stays out);
     mobile = Intro → Childhood → Training (Today docks alone). The ghost-quote
     always stays out. Placed AFTER the base goo @supports so
     the About animation override below wins, but BEFORE the reduced-motion
     and mobile blocks so those still disable the merge there. */

  /* Intro and Today fill their full row-span so each card matches its grid cell
     (the base .tile is align-self:start, content-height) — the WebGL shape is
     measured from the settled tile box. t-training is deliberately NOT stretched:
     it spans auto rows that overlapping neighbours inflate, so stretch pulled it
     taller than its content at every width (worst in the 1100–1280px range,
     +120–160px). It stays content-sensitive (inherits align-self:start from
     .tile) on every device — desktop, tablet and mobile alike. */
  .section--about .t-intro,
  .section--about .t-today { align-self: stretch; }

  /* Photo rides OVER the intro card, above the WebGL Ivory shape. The shape
     (and the fallback ::before fill) extends --goo-bleed past the tile box top
     and bottom, so the photo must over-cover EXACTLY that outset there — bleed
     it by --goo-bleed block-wise (the photo is full tile height via the flex
     stretch, so left/right are already covered). Coupled to --goo-bleed so it
     stays exact for any content (the old -12px was the SVG-blur lip, gone with
     WebGL).
     No-JS default: photo sits exactly in the tile box (margin-block: 0). Only
     when WebGL has drawn (html.goo-webgl) does the photo bleed out to cover the
     shape's outset. */
  .section--about .t-intro__photo {
    position: relative;
    z-index: 1;
    margin-block: 0;
    overflow: hidden;
  }
  /* Gated per-section: the photo bleeds only once THIS section's own shape has
     painted (.is-goo-webgl), not merely when the global html.goo-webgl is set by
     some other section — else the photo could over-cover before/without a shape
     behind it (incl. the partial-init-failure case). */
  @media (min-width: 721px) {
    /* DESKTOP-layout photo cover — now that WebGL runs on mobile too,
       html.goo-webgl is set there as well, so this desktop margin must be gated
       or it overrides the mobile photo bleed (which has its own Step-B rules). */
    html.goo-webgl .section--about.is-goo-webgl .t-intro__photo {
      margin-block: calc(var(--goo-bleed) * -1);
    }
  }
  /* No-JS: the card FILL must be flush too, or the bled ::before shows a coloured
     frame around the now-flush photo. Mirrors the Kontakt t-cta fallback. When
     WebGL is active the ::before is display:none, so inset:0 is irrelevant there. */
  .section--about .t-intro.tile--goo::before { inset: 0; }

  /* Per-tile drift directions + dock-in ranges. --dx/--dy feed goo-tileDockIn;
     --goo-dock-start/-end drive the CSS animation-range (and the WebGL maths
     later). Stagger: Intro (from left) → Ausbildung (from right) → Today
     (from below-left), one continuous diagonal settle. */
  .section--about .t-intro    { --dx: -90px; --dy:  28px; --goo-dock-start: 28; --goo-dock-end:  70; }
  .section--about .t-training { --dx:  80px; --dy:  44px; --goo-dock-start: 48; --goo-dock-end:  86; }
  .section--about .t-today    { --dx: -64px; --dy:  64px; --goo-dock-start: 68; --goo-dock-end: 100; }
  /* Childhood ("Wie alles begann") is NOT part of the WebGL Ivory merge (it stays
     a calm stand-alone sub-tile, see markup) but should still SLIDE IN — from the
     RIGHT, since it sits top-right (cols 8–13). Translate-only, no --dy (a clean
     right→left glide); its plain-tile bleed-scale grow is suppressed below so the
     card slides rather than just grows. The whole tile translates, so the text
     rides in with it. Consumed by goo-tileDockIn via the selectors extended below. */
  .section--about .t-childhood { --dx: 100px; --dy: 0px; --goo-dock-start: 28; --goo-dock-end: 72; }

  /* Today's box left edge is shoved onto the intro photo's RIGHT edge (via
     margin-inline-start: --about-photo-width…). That left edge is a FREE edge —
     it necks with nothing there (the training neck is on the RIGHT) — so the
     bled ::before fill must NOT bulge --goo-bleed past the photo line. Drop the
     left outset on the fill only (top/right/bottom keep the bleed). Desktop
     only (>1024px): at tablet (721–1024) today resets to a normal left edge
     (see the tablet block above), so the standard -goo-bleed left applies there. */
  @media (min-width: 1025px) {
    .section--about .t-today.tile--goo::before { inset-inline-start: 0; }
  }

  /* ── KONTAKT — Goo merge (text line + photo CTA) ──────────────────────
     Mirrors the audio section: the Pear text tile (t-line) and the square
     photo CTA (t-cta) melt into one Pear shape (the merge is the WebGL layer).
     The photo rides OVER the Pear shape so no Pear frame shows; the neck sits
     on t-cta's LEFT where it meets t-line. Uses the base goo animation. */

  /* CTA keeps its 1/1 square, top-aligned, flush to its cell (no outset) so
     the photo sits size-identical with the card and shrinks 1:1 with it. */
  .section--kontakt .t-cta.tile--goo {
    aspect-ratio: 1 / 1;
    align-self: start;
  }
  /* The no-JS fallback fill stays FLUSH to the box here (inset:0, no
     --goo-bleed outset): the photo rides on top and the fill only needs to back
     the card, so a bled fill would just be a Pear frame the photo can't reach in
     the fallback. (When WebGL runs, the shape IS measured with the --goo-bleed
     outset and the photo grows to cover it — see the img rule below.) */
  .section--kontakt .t-cta.tile--goo::before { inset: 0; }

  /* Photo rides over the Pear card and must GROW WITH the colour area. The
     WebGL shape is measured with the generic --goo-bleed outset on ALL four
     sides (goo-webgl.js measure(): rect ∓ --goo-bleed; t-cta has no bleed
     class, so the outset is symmetric). The photo therefore covers that same
     symmetric box on every edge → no Pear frame anywhere; the neck to t-line
     forms in the gap BETWEEN the tiles, outside the photo, so it stays visible.
     DESKTOP ONLY (≥721px): on mobile the merge is off, so the photo falls back
     to the plain tile--cta-photo behaviour (inset:0, the tile's own overflow
     clip) — otherwise the bled, un-clipped image breaks the stacked card.
     No-JS default: photo sits exactly in the tile box (no overflow, no bleed).
     Only when WebGL is active (html.goo-webgl) does the photo grow to cover the
     shape's outset. */
  @media (min-width: 721px) {
    html.goo-webgl .section--kontakt.is-goo-webgl .t-cta.tile--goo { overflow: visible; }
    html.goo-webgl .section--kontakt.is-goo-webgl .t-cta.tile--goo > picture > img {
      /* Size explicitly with calc — a replaced element resolves width:auto to
         its intrinsic size and would ignore right/bottom insets, so it must be
         sized, not inset-stretched. The WebGL goo shape is the tile box plus a
         --goo-bleed outset on ALL four sides (symmetric — t-cta has no bleed
         class), so the photo covers the same symmetric box: inset -goo-bleed all
         round, width/height + 2·goo-bleed. Centred (not shifted), no Pear frame
         on any edge. Photo tracks the card 1:1 on scroll (same --dx/--dy/dock-
         range), so it stays covering the synchronously-moving WebGL area. */
      inset: calc(var(--goo-bleed) * -1);
      width: calc(100% + var(--goo-bleed) * 2);
      height: calc(100% + var(--goo-bleed) * 2);
      max-width: none;   /* defeat the global img { max-width: 100% } cap */
      border-radius: var(--radius-lg);
    }
  }

  /* Per-tile drift + dock-in ranges: line from the left, photo CTA up from below. */
  .section--kontakt .t-line { --dx: -70px; --dy: 28px;  --goo-dock-start: 30; --goo-dock-end: 72; }
  .section--kontakt .t-cta  { --dx:   0px; --dy: 120px; --goo-dock-start: 50; --goo-dock-end: 92; }

  @media (prefers-reduced-motion: reduce) {
    .section .tile--goo {
      animation: none;
      transform: none;
      --dx: 0; --dy: 0;
    }
    /* …and the fallback fill's grow (the JS path zeros it via the `settled`
       flag / dx=dy=0; this keeps the no-JS / no-WebGL fallback static too). */
    .section .tile--goo::before {
      animation: none;
      transform: none;
    }
  }
  /* Mobile — the stacked full-width tiles drop the merge entirely (no WebGL on
     these breakpoints either, and no bled ::before — that's ≥721px only). The
     box already paints its own solid section colour (base .tile background), so
     it stays flush in its cell; just pin movement off → plain rounded cards. */
  @media (max-width: 720px) {
    .section .tile--goo {
      animation: none;
      transform: none;
    }
  }
  /* Childhood: pin its slide off where the goo tiles also stop moving — reduced
     motion and the mobile single-column stack. Zeroing --dx/--dy makes its
     goo-tileDockIn a no-op (no translate) while leaving the colour morph running
     (colour change isn't vestibular motion, and on mobile it matches the other
     plain tiles which keep their morph). */
  @media (prefers-reduced-motion: reduce) {
    .section--about .t-childhood { --dx: 0; --dy: 0; }
  }
  @media (max-width: 720px) {
    .section--about .t-childhood { --dx: 0; --dy: 0; }
  }
  /* ─────────────────────────────────────────────────────────────────── */

  /* ── Goo-Rand on plain (non-goo) tiles ────────────────────────────────
     Goo cards read ~--goo-bleed larger than their grid cell because their
     fallback ::before (and the WebGL shape) carry a --goo-bleed outset. A plain
     solid tile would sit flush to its cell and read smaller → uneven outer
     spacing across the page. So every plain tile gets a background LAYER
     (::before) that bleeds the same --goo-bleed and scales in on dock-in
     (mirroring the goo cards): the tile content stays put, so only the card
     grows — the text does NOT scale. The layer reads the morphing --tile-color,
     so it still colour-morphs with the section.
     Desktop/tablet only (min-width:721px) — on mobile the goo cards are
     un-bled too, so plain tiles stay flush there as well.
     Excluded: goo tiles (own bled ::before above), ghost/transparent, CTA-photo,
     viewport-bleed tiles, and the Hero intro (its ::before is the logo). */
  @keyframes tileBleedScaleIn { from { transform: scale(0.92); } to { transform: scale(1); } }
  @media (min-width: 721px) {
    .section:not(.section--hero) .tile:not(.tile--goo):not(.tile--ghost):not(.tile--cta-photo):not(.tile--bleed-left):not(.tile--bleed-right) {
      background: transparent;        /* background now painted by the bled ::before */
    }
    .section:not(.section--hero) .tile:not(.tile--goo):not(.tile--ghost):not(.tile--cta-photo):not(.tile--bleed-left):not(.tile--bleed-right)::before {
      content: "";
      position: absolute;
      inset: calc(var(--goo-bleed) * -1);  /* the goo Rand — matches the goo cards' outset */
      background: var(--tile-color, var(--section-color));
      border-radius: var(--radius-lg);
      z-index: -1;                    /* behind the tile content, above the section bg */
    }
    @supports (animation-timeline: view()) {
      /* Each plain island tile GROWS on its OWN view() timeline — NOT the shared
         --section-view. The goo cards share the section timeline so their merge
         order is fixed; but a standalone tile low in a TALL section would, on the
         section timeline, finish its `entry 20–80%` grow long BEFORE it scrolls
         into view (t-closing "Mehr als Saxophon?" sat at scale 1 by the time it
         appeared). view() ties the grow to the tile's OWN entry, so it grows as
         it actually rises into the viewport — at any section height / position.
         Childhood is INCLUDED now (it grows AND slides): the slide rides
         --section-view (goo-tileDockIn), the grow rides its own view() — both run
         during entry and settle together. */
      .section:not(.section--hero) .tile:not(.tile--goo):not(.tile--ghost):not(.tile--cta-photo):not(.tile--bleed-left):not(.tile--bleed-right)::before {
        animation: tileBleedScaleIn cubic-bezier(0.65, 0, 0.35, 1) both;
        animation-timeline: view();
        animation-range: entry 25% entry 100%;
        transform-origin: center;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      .section:not(.section--hero) .tile:not(.tile--goo):not(.tile--ghost):not(.tile--cta-photo):not(.tile--bleed-left):not(.tile--bleed-right)::before {
        animation: none;
        transform: none;
      }
    }
  }

  /* ─────────────────────────────────────────────────────────────────── */
  /* (Leftover outer padding-block removed: it sat on the .tile--content box
     itself AND was duplicated by the .tile__content wrapper's own padding —
     doubling the vertical inset. Padding now lives only on the wrapper, as in
     every other section.) */

  /* Services-Strip — 3 sub-cards stacked vertically inside the middle tile. */
  .section--leistungen .services-grid {
    display: flex;
    flex-direction: column;
    gap: clamp(1rem, 0.6rem + 1vw, 1.75rem);
    max-width: 35ch;
  }
  .section--leistungen .service {
    display: flex;
    flex-direction: column;
    gap: var(--space-2xs);
  }

  /* Closing-Line — personal handwritten sign-off, used at the end of a
     section's lead-tile to break the formal voice of the headline + body
     copy ("Erzählt mir von eurem Tag.", "Schreibt mir gern eine Nachricht."
     etc). Section-agnostic — colour follows the section's --tile-accent. */
  .closing-line {
    font-family: var(--font-accent);
    font-size: clamp(1.5rem, 1.2rem + 1.2vw, 1.875rem);  /* 24 → 30 px — mobile/tablet lifted (tablet 768→28.4) */
    word-spacing: var(--word-spacing-accent);
    font-weight: var(--weight-bold);
    line-height: 1.15;
    letter-spacing: 0.03em;
    color: var(--tile-accent, var(--section-accent));
    margin: clamp(0.4rem, 0.2rem + 0.5vw, 0.8rem) 0 0;
    /* Same gentle counter-clockwise tilt as the hero greeting — keeps the
       handwritten lines feeling like personal sign-offs across sections.
       Rotation lives on the inner element so the parent .tile can still
       fly in via translateY without conflicting transforms. */
    transform: rotate(var(--tilt-accent));
    transform-origin: center;
    align-self: start;
  }

  /* HÖRBEISPIELE — title top-left; below it ONE wide Oxford card holding the
     player controls (left) + photo (RIGHT). The photo is a REAL part of the
     player tile, not part of the goo merge: the tile becomes a SUBGRID over the
     section columns, so the photo spans the player's last two columns
     (section 10–11).

     The dark card behind everything is the goo card fill (fallback ::before /
     WebGL shape), whose outset pushes its edge ~--goo-bleed past the tile box on
     every side. If the photo only filled the tile box it would sit INSIDE that
     dark outset → a blue Oxford frame. So the photo BLEEDS OUT past its cell to
     cover the card fill on the card's free edges and clips itself
     (overflow:hidden) instead of being clipped at the tile — "the image grows
     with the bleed so nothing overhangs". It sits above the fill (z-index: 1),
     so it hides the frame rather than being framed by it. */
  .section--audio .t-title  { grid-column: 2 / 7;  grid-row: 2 / 4; }
  .section--audio .t-player {
    grid-column: 5 / 12;
    grid-row: 4 / 7;
    /* Content-sensitive height (inherits .tile { align-self:start }); the photo
       stretches inside the settled player box, not the grid row span. */
    /* Override .tile's flex column: lay controls + photo side by side on a
       subgrid that adopts the section's columns 5–12 (7 tracks). No overflow
       clip here — clipping at the tile box would re-expose the card fill outset. */
    display: grid;
    grid-template-columns: subgrid;
    grid-template-rows: 1fr;
  }
  .section--audio .t-player__photo {
    grid-column: 6 / 8;               /* subgrid cols 6–7 = section 10–11, right edge = card edge */
    grid-row: 1;
    min-width: 0;                     /* allow the cell to shrink with the grid */
    position: relative;
    z-index: 1;                       /* paint above the goo card fill */
    /* No-JS default: photo sits exactly in the tile box (margin: 0). Only when
       WebGL is active (html.goo-webgl) does the photo bleed to cover the card
       fill's outset on the three free edges. */
    margin: 0;
    /* Round the two RIGHT (free) corners; the left corners stay square where
       they butt against the controls. */
    border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
    overflow: hidden;                 /* clip the image to this rounded, bled box */
  }
  /* Gated: only when WebGL is active does the photo bleed to cover the shape's
     outset on the card's three free edges (top, right, bottom). The left edge
     meets the controls (interior, dark) so it stays flush (0). */
  @media (min-width: 721px) {
    /* DESKTOP-layout photo cover (photo sits on the RIGHT here) — gated so it
       doesn't override the mobile top-photo bleed now that html.goo-webgl is
       set on mobile too. */
    html.goo-webgl .section--audio.is-goo-webgl .t-player__photo {
      margin: calc(var(--goo-bleed) * -1) calc(var(--goo-bleed) * -1) calc(var(--goo-bleed) * -1) 0; /* T R B L */
    }
  }
  /* No-JS: card FILL flush too, else the bled ::before shows an Oxford frame
     around the now-flush photo (mirrors Kontakt t-cta). WebGL active → ::before
     is display:none, so inset:0 is irrelevant there. */
  .section--audio .t-player.tile--goo::before { inset: 0; }
  .section--audio .t-player__photo img {
    width: 100%;
    height: 100%;
    display: block;
    object-fit: cover;
    /* Crop window pushed LEFT onto Fabian + the balloons; keeps the brick-wall
       background out of the right of the frame. Square source, upper-left face. */
    object-position: 10% 14%;
    filter: var(--marian-duotone);
  }
  /* Player controls sit in the first columns (5→10), to the LEFT of the photo
     — never under it. flex:1 from .tile__content is harmless in a grid.
     The card's LEFT edge is the goo card fill (~--goo-bleed past the tile box),
     but the RIGHT edge for the controls is the photo, which sits on the grid line with
     no inward bleed — so the default symmetric padding leaves the controls 10px
     closer to the photo than to the left card edge. Add the 10px back on the
     end side so the controls breathe equally inside the dark card. */
  .section--audio .t-player > .tile__content {
    grid-column: 1 / 6;
    grid-row: 1;
    padding-inline-end: calc(var(--tile-pad) + var(--goo-bleed));
  }
  /* Breathing room around the divider: more gap below the waveform deck, and a
     little space above the first track (below the pear rule). */
  .section--audio .player-mock { gap: var(--space-lg); }
  .section--audio .player-mock__tracks { padding-top: var(--space-sm); }

  /* KONTAKT — line top-left, CTA-on-photo diagonally below-right.
     CTA tile spans 3 rows so the portrait-oriented photo background has
     enough vertical room and doesn't crop the figure too tightly. */
  /* Text tile stays content-sensitive at every width (inherits
     .tile { align-self:start }); the WebGL shape measures that settled box. */
  .section--kontakt .t-line   { grid-column: 3 / 7;  grid-row: 1 / 5; }
  .section--kontakt .t-cta    { grid-column: 7 / 11; grid-row: 4 / 7; }
  /* Free-floating Instagram tile. Base (mobile and fallback): a plain stacked
     card, centered in the text card's column span, content-height, with a
     top-margin gap so it floats clear. The ≥721px rule below positions it
     top-right by the photo (tablet + desktop alike). Colour-morphs with the
     section like any other tile. */
  .section--kontakt .t-social {
    grid-column: 2 / 8;
    grid-row: 5 / 7;
    justify-self: center;
    align-self: start;
    min-height: 0;          /* override the 30svh tile floor → height = content */
    max-width: 28rem;
    margin-block-start: var(--space-xl);
  }
  /* Top-right placement beside the photo's upper edge (col 8/12, row band 2);
     align-self:end seats it at the bottom of that band. Identical from tablet up
     (≥721px): at 8/12 row 2 it sits ABOVE t-cta (rows 4–7), so no collision; on
     mobile the base rule keeps it as a plain stacked card below the photo.
     justify-self/max-width carry over from the base. */
  @media (min-width: 721px) {
    .section--kontakt .t-social {
      grid-column: 8 / 12;
      grid-row: 2;
      align-self: end;
      margin-block-start: 0;
    }
  }
  /* Desktop (≥1201px): the photo CTA narrows to 7/11 (tablet keeps 7/12), so the
     8/12 social tile would jut one column PAST the photo's right edge. Right-align it
     (justify-self:end) onto column line 11 so its right edge sits flush with the
     photo, and top-align it with the text card (t-line, row 1) instead of the bottom
     of row 2. Start at column 8 (NOT 7): the text card ends at line 7, so an 8/11
     cell guarantees a ≥1-column gap to the text card at the narrow end of the desktop
     range (just above 1201px), where a 7/11 cell would otherwise shrink until the
     right-aligned tile's left edge butts against the text card. At wide widths the
     tile floats right regardless, so this only changes the cramped low end. */
  @media (min-width: 1201px) {
    .section--kontakt .t-social {
      grid-column: 8 / 11;
      grid-row: 1;
      justify-self: end;
      align-self: start;
    }
  }
  /* Standalone in its own card now — the card padding owns the spacing, so drop
     the top margin it carried when it followed the closing line in t-line. */
  .section--kontakt .t-social .contact-social { margin-block-start: 0; }
  /* Tablet band (721–1200px → reaches iPad portrait + landscape, not just the old
     ≤1024px). The desktop 4-col photo CTA is too narrow there and the booking
     button wraps. Both tiles grow ONE column OUTWARD from the desktop spans,
     keeping their shared merge neck at col 7:
       • text/headline (t-line) one col LEFT:  3/7 → 2/7
       • photo CTA     (t-cta)  one col RIGHT: 7/11 → 7/12  (5 cols → button room) */
  @media (min-width: 721px) and (max-width: 1200px) {
    .section--kontakt .t-line { grid-column: 2 / 7; }
    .section--kontakt .t-cta  { grid-column: 7 / 12; }
    /* Tablet: tighten the stroke↔text gap a few px. Local --cs-rail-gap drives
       BOTH the flex column-gap and the icon's matching indent, so they stay in
       sync (icon keeps fluchting with the text). */
    .section--kontakt .contact-social {
      --cs-rail-gap: calc(var(--space-xs) - 0.25rem);
      column-gap: var(--cs-rail-gap);
    }
    /* Icon gets its own full row on top (flex:0 0 100% forces the wrap), so the
       red stroke + copy land together on the row below it. Scoped with
       .section--kontakt to outspecify the base `.contact-social__icon-link
       { flex: none }` (which sits LATER in source). padding-inline-start indents
       the SVG by stroke-width + the (tightened) gap so it lines up with the text
       below (not the stroke); flex-start anchors it there. */
    .section--kontakt .contact-social__icon-link {
      flex: 0 0 100%;
      justify-content: flex-start;
      padding-inline-start: calc(var(--spark-marker-width) + var(--cs-rail-gap));
    }
  }
  /* Kontakt: Content der Text-Card als linksbündiger Block horizontal in
     der Karte zentrieren. Da das Padding/Layout jetzt am Wrapper hängt,
     reicht es, ihn auf eine Spaltenbreite zu beschränken und per
     align-self mittig zu setzen — keine Per-Element-Eingriffe nötig. */
  .section--kontakt .t-line > .tile__content {
    max-width: 50ch;
    align-self: center;
  }
  /* Content-agnostic line balancing for the intro copy: balances the wrap into
     even line lengths whatever the text says — survives copy edits, unlike a
     hard-coded break. Does NOT guarantee a break after the colon; it optimises
     line lengths, it doesn't break at a chosen point. */
  .section--kontakt .body-copy p { text-wrap: balance; }
  /* Goo-Bleed-Ausgleich: die geneigte Handschrift-Zeile schiebt ihre untere
     Ecke optisch über die Box — der folgende Social-Callout bekommt dadurch
     Luft zur rotierten Zeile. */
  .section--kontakt .closing-line {
    margin-block-end: var(--goo-bleed);
  }
  .contact-social {
    --contact-social-line-box: clamp(1.0395rem, 1.008rem + 0.168vw, 1.155rem);
    --contact-social-copy-gap: clamp(0.2rem, 0.12rem + 0.25vw, 0.35rem);
    --contact-social-icon-size: calc(var(--contact-social-line-box) + var(--contact-social-line-box) + var(--contact-social-copy-gap));
    --contact-social-rail-overhang: 0.125rem;
    /* Threshold below which the copy can no longer sit beside the icon and wraps
       to its own full-width row (see the Sidebar note below). */
    --contact-social-copy-min: 11rem;
    /* Sidebar (Every Layout): the spark-rail + Instagram icon are the fixed
       sidebar, the copy is the content. The copy keeps --contact-social-copy-min;
       when the card gets too narrow to seat the copy beside the icon, it wraps to
       its own full-width row (icon above copy) instead of cramping the headline to
       three lines — so no per-breakpoint padding/gap/letter-spacing tuning needed. */
    display: flex;
    flex-wrap: wrap;
    align-items: start;
    column-gap: var(--space-xs);
    row-gap: var(--contact-social-copy-gap);
    margin-block-start: var(--space-lg);
    max-width: var(--measure);
    /* The whole tile is now a single <a> (CSS "stretched link", no JS): reset
       link colour/underline so the card looks identical to the old <div>, and
       seat the focus ring on the tile itself. */
    color: inherit;
    text-decoration: none;
  }
  /* Affordance for the whole-tile link: the headline picks up the underline the
     old inline Instagram link used to carry, on pointer hover AND keyboard
     focus. Keyboard users also get a visible focus ring around the card. */
  .contact-social--tile:where(:hover, :focus-visible) .contact-social__headline {
    text-decoration-line: underline;
    text-decoration-thickness: 0.08em;
    text-underline-offset: 0.14em;
  }
  .contact-social--tile:focus-visible {
    outline: 2px solid currentColor;
    outline-offset: 0.35rem;
    border-radius: 2px;
  }
  .contact-social::before {
    content: "";
    display: block;
    flex: none;
    width: var(--spark-marker-width);
    height: calc(var(--contact-social-icon-size) + var(--contact-social-rail-overhang) + var(--contact-social-rail-overhang));
    transform: translateY(calc(0rem - var(--contact-social-rail-overhang)));
    border-radius: 2px;
    background: var(--color-spark);
  }
  .contact-social__icon-link {
    flex: none;
    /* order:-1 pulls the icon ahead of the .contact-social::before red stroke,
       so the row reads Icon → Stroke → Copy (the stroke sits right before the
       text like an eyebrow pin). */
    order: -1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    align-self: start;
    color: inherit;
    text-decoration: none;
  }
  .contact-social__icon {
    inline-size: var(--contact-social-icon-size);
    block-size: auto;
    max-width: none;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.6;
    stroke-linecap: round;
    stroke-linejoin: round;
  }
  .contact-social__icon-dot {
    fill: currentColor;
    stroke: none;
  }
  .contact-social__copy {
    flex: 1 1 var(--contact-social-copy-min);
    min-inline-size: 0;
    display: flex;
    flex-direction: column;
    gap: var(--contact-social-copy-gap);
  }
  .contact-social__headline,
  .contact-social__meta {
    /* Were <p>, now <span> inside the <a>; force block so the two-line stack
       reads exactly as before. */
    display: block;
    margin: 0;
    max-width: none;
    font-family: var(--font-ui);
    font-size: var(--text-button);
    line-height: var(--line-height-tight);
    color: inherit;
  }
  .contact-social__headline {
    font-weight: var(--weight-bold);
    text-transform: uppercase;
    letter-spacing: 0.03em;
  }
  .contact-social__meta {
    font-weight: var(--weight-regular);
    text-transform: none;
    letter-spacing: 0.015em;
  }

  /* CTA-on-photo variant — a B/W portrait fills the tile and the button
     sits centred at the bottom edge. Photo is absolutely positioned so a
     sibling button can stack on top via z-index; the tile's flex layout
     pushes the button to the bottom with `justify-content: flex-end`.
     Focal point sits at ~25% from the top so Fabian's face stays
     comfortably above the button regardless of how the tile crops. */
  .tile.tile--cta-photo {
    position: relative;
    overflow: hidden;
    /* Square portrait box — die Section ist jetzt content-driven, ohne
       explizites Verhältnis fiele das Tile auf seinen 30svh-Boden zurück
       und das Foto würde die Figur zu eng schneiden. 1/1 gibt dem Portrait
       genug Luft, unabhängig von der Viewport-Höhe. */
    aspect-ratio: 1 / 1;
    min-height: 0;
  }
  .tile.tile--cta-photo > picture > img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center;
    filter: var(--marian-duotone);
    z-index: 0;
  }
  /* Kontakt portrait wants deeper shadows (not a flat brightness cut) so the
     dark areas read stronger — bump contrast above the shared 1.15. */
  .section--kontakt .tile--cta-photo > picture > img {
    /* Etwas tiefere Schatten wie zuvor; Duotone-Ton via Token. */
    filter: var(--marian-duotone) contrast(1.1);
  }
  /* Foreground-Wrapper sitzt über dem Foto — kein Padding, Inhalt unten
     mittig, damit der Button am unteren Rand des Fotos schwebt. */
  .tile.tile--cta-photo > .tile__content {
    padding: 0;
    justify-content: flex-end;
    align-items: center;
    position: relative;
    z-index: 1;
  }
  .tile.tile--cta-photo .btn {
    /* Goo-Bleed-Ausgleich: der Button sitzt auf dem Foto, nicht in der Kachel,
       wird also nicht durch den Goo-Gutter nach innen gerückt → +--goo-bleed. */
    margin: calc(var(--tile-pad) + var(--goo-bleed));
  }

  /* UNTERRICHT */
  /* UNTERRICHT — sits between footer banderole and site footer as a
     compact 50/50 block that mirrors the footer's measured rhythm. Reads
     as an aside, not a section: content-driven height, no tile chrome
     (no rounded corners, no shadow, no min-height floor), no tilted
     accent text. The left column holds back-link + eyebrow + headline +
     body + a single ghost-button CTA; the photo on the right bleeds past
     the section's inline padding to the viewport edge and renders desaturated. */
  .section--unterricht {
    /* Override .section defaults — content-height instead of 100svh,
       and a real two-column grid (no 12-col raster). No section-level
       block or inline padding so the photo reaches the section's full
       top/bottom/left edges directly; the content column carries its
       own block padding so the right side stays breathable. Paints its
       own background since there are no .tile children to do it. */
    min-height: 0;
    height: auto;
    padding-block: 0;
    padding-inline: 0;
    /* Every-Layout Grid (RAM technique): two equal 1fr columns side-by-side
       once the section is wide enough for both at ~21rem each, collapsing to a
       single column below — intrinsic, no layout media query. The 1fr/1fr keeps
       the exact 50/50 split the design calls for (a flex Switcher here skews
       the columns to content). min(21rem, 100%) avoids overflow on very narrow
       screens. Only the column-gap is set here; when the grid stacks on
       mobile the vertical gap between photo and content comes from the shared
       `.section { row-gap: var(--tile-stack-gap) }` mobile rule, consistent
       with how every other section spaces its stacked tiles. */
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(21rem, 100%), 1fr));
    grid-template-rows: auto;
    align-items: stretch;
    column-gap: clamp(2rem, 1rem + 3vw, 4rem);
    background: var(--section-color);
    color: var(--section-fg);
  }
  .section--unterricht .unterricht__photo {
    margin: 0;
    min-height: 0;
    block-size: 100%;
    overflow: hidden;
    /* New stacking context so the marian tint below blends only with the
       image, not with whatever sits behind the section. */
    position: relative;
    isolation: isolate;
  }
  /* Marian duotone — full-bleed overlay using `mix-blend-mode: color`,
     which keeps the photo's original lightness/tonality and only
     replaces its hue with Marian. Since the source image is already
     baked to grayscale, the result reads as a clean blue duotone with
     all the original shadows and highlights intact. */
  .section--unterricht .unterricht__photo::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 1;
    background: var(--color-marian);
    mix-blend-mode: color;
    /* Tuned to ~65% so the Marian cast reads as a clear duotone while
       still letting the photo's grayscale tonality breathe through. */
    opacity: 0.65;
    pointer-events: none;
  }
  /* Raster dot texture — same tile the home page lays over its slideshow
     (.stage-bg::before), echoed on the subpage photo. Above the duotone tint
     (z-index) so the dots read on the tinted image; the Ken Burns transform on
     the img leaves this static grid untouched, like the fading slideshow does. */
  .section--unterricht .unterricht__photo::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 2;
    background: url("../images/subtle-dots.d3fddeea00fb.png") repeat;
    pointer-events: none;
  }
  .section--unterricht .unterricht__photo img {
    --unterricht-photo-focal-point: 30% 43%;
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* Focal point just below the pen tip so the sheet music detail stays in view
       on very wide / tall crops instead of sliding out of frame. */
    object-position: var(--unterricht-photo-focal-point);
    display: block;
    /* Grayscale baked into the source asset itself — keeping it out of
       CSS lets the Ken Burns transform composite on the GPU without the
       filter-pipeline stutter that mixing `filter` with `transform`
       triggers in some browsers. */
  }
  .section--unterricht .unterricht__content {
    display: flex;
    flex-direction: column;
    /* Stack gap = the heading-group→lede→button rhythm (H2 page: --space-md,
       narrower than the hero's H1 --space-lg). The absolute back-link is skipped
       by flex gap, so it needs no special handling. */
    gap: var(--space-md);
    /* Default cross-axis stretch — h2 and p take the full content-column
       width, so the body-copy's max-width: 65ch can't push the box wider
       than its container on narrow viewports. The button is opted back
       out below to stay compact. */
    align-items: stretch;
    /* The back-link is lifted out of the flow (absolute, top-left), so the
       content box places itself in the cell on its own. `align-self: safe center`
       centres it when there's room but falls back to the cell's TOP edge on short
       viewports — so the eyebrow can never ride up under the back-link. The
       reserved top padding (2× --space-2xl) keeps that top-edge fallback clear of
       the back-link band with a small gap; the bottom margin biases the centred
       case a touch ABOVE centre so the page doesn't read as "sinking". All reset
       to a plain stack on mobile (cell is content-height there, centring moot). */
    align-self: safe center;
    margin-block-end: var(--space-2xl);
    padding-block-start: calc(var(--space-2xl) + var(--space-2xl));
    padding-block-end: var(--space-2xl);
    /* Cap the text block at 47ch and CENTRE it in the column (margin-inline:auto)
       so the sparse content floats in the middle of its area on wide viewports
       instead of clinging to the left with a big empty gap beside it. When the
       column is narrow the 47ch box fills it, margin-auto collapses to 0, and
       padding-inline keeps the standard left gutter — so the "left padding on the
       narrower/stacked layouts" behaviour is preserved (the ≤720px block restates
       a left hug explicitly). The back-link stays absolutely placed at the
       --section-pad-x gutter (its own rule), independent of this centring. */
    inline-size: 100%;
    max-inline-size: 47ch;
    margin-inline: auto;
    padding-inline: var(--section-pad-x);
  }
  /* Compact button at the end of the content stack — sized in flow, not
     stretched to full column width. */
  .section--unterricht .unterricht__content .btn {
    align-self: flex-start;
    --btn-stroke-width: var(--border-button);
    --btn-weight: var(--weight-medium);
    background: color-mix(in srgb, var(--color-pear) 9%, transparent);
    color: var(--color-pear);
    border-color: transparent;
    box-shadow: inset 0 0 0 var(--btn-stroke-width) currentColor;
  }
  .section--unterricht .unterricht__content .btn:hover {
    background: var(--color-pear);
    color: var(--color-oxford);
    box-shadow: none;
  }
  /* Back-link rides on the page chrome at the top-left at ALL widths (mirroring
     the legal pages' on-photo placement). In the row layout the photo is on the
     right, so it sits over the top of the text column; once stacked it lands on
     the photo banner. Absolute so it's lifted out of the content stack — the
     content is then free to centre itself independently. Keeps the page-specific
     Pear-ghost fill so the control matches the page accent.
     top: --space-2xl alone — the section already starts a header-height down
     (body[data-section="unterricht"] padding-block-start), so this lands at
     header-height + --space-2xl in the viewport, level with the legal back-link.
     z-index 5 inherited from base .back-link; restated here for clarity. */
  .section--unterricht .unterricht__content .back-link {
    position: absolute;
    top: var(--space-2xl);
    left: var(--section-pad-x);
    z-index: 5;
    --back-link-stroke: 1.5px;
    background: color-mix(in srgb, var(--color-pear) 9%, transparent);
    color: var(--color-pear);
    border-color: transparent;
    box-shadow: inset 0 0 0 var(--back-link-stroke) currentColor;
  }
  .section--unterricht .unterricht__content .back-link:hover {
    background: var(--color-pear);
    color: var(--color-oxford);
    box-shadow: none;
  }

  /* PARTNER — slim band between footer-banderole and site-footer. The section
     keeps its Marian ground; each logo sits on its own centered Cool-Gray tile
     (Every-Layout Cluster), and the row of tiles spans 10 of the 12 grid columns
     (cols 2–12). Tiles are separated by a gap (no dividers) and wrap re-centered
     when space runs out. Eyebrow + heading are centered above the row.
     Content-driven height — no 100svh floor. */
  .section--partner {
    min-height: 0;
    height: auto;
    /* Query container: the card's responsive stages below react to the
       section's own inline size, not the viewport — so the layout stays
       correct regardless of where the band is placed. */
    container-type: inline-size;
    container-name: partner;
    /* Re-use the base .section 12-col grid (it was flex-overridden before) so
       the card sits on an exact, centered column span. */
    display: grid;
    grid-template-columns: repeat(12, 1fr);
    /* Three rows — intro + card + closing label. The base .section sets
       `grid-template-rows: repeat(8, auto)`; without this override row-gap
       would also apply between the 6 empty trailing tracks and balloon the
       section height. */
    grid-template-rows: auto auto auto;
    column-gap: 0;
    /* Gap headline→card == the section's bottom padding (--section-pad-x), so
       the card sits visually balanced between heading above and footer below,
       and the band stays slim. */
    row-gap: var(--section-pad-x);
    /* Unten etwas mehr Luft als oben, damit die Kachelreihe nicht zu dicht
       am folgenden Footer/Abschluss klebt. */
    padding-block: var(--section-pad-x) calc(var(--section-pad-x) + clamp(1rem, 2vw, 2.25rem));
    padding-inline: var(--section-pad-x);
    background: var(--section-color);
    color: var(--section-fg);
  }
  /* The generic mobile rule `@media (max-width:720px) .section { grid-template-
     rows: auto; row-gap: var(--tile-stack-gap) }` has equal specificity but a
     later source order, so on phones it would undo the two-row layout and the
     balanced headline→card gap. Re-assert the partner values one notch more
     specific (.section.section--partner) so they hold at every width. */
  .section.section--partner {
    grid-template-rows: auto auto auto;
    row-gap: var(--section-pad-x);
  }
  .partner__intro {
    grid-column: 1 / -1;
    display: flex;
    flex-direction: column;
    gap: var(--space-2xs);
    align-items: center;
    text-align: center;
  }
  /* Partner section title mirrors the legal H2 display treatment
     ("Haftungsausschluss") at --text-xl, but is centred for the band layout. */
  .partner__title {
    font-family: var(--font-display);
    font-feature-settings: var(--feature-display);
    font-size: var(--text-xl);
    font-weight: var(--weight-bold);
    line-height: 0.92;
    letter-spacing: 0.02em;
    text-transform: uppercase;
    color: var(--section-accent);
    margin: 0;
    text-wrap: balance;
    transform: scaleX(1.05);
    transform-origin: center;
  }
  .section--partner .partner__label {
    grid-column: 1 / -1;
    justify-self: center;
    font-family: var(--font-ui);
    font-weight: var(--weight-medium);
    font-size: var(--text-m);
    line-height: var(--line-height-snug);
    max-width: 38ch;
    color: inherit;
    margin: 0;
    text-wrap: balance;
  }
  @media (min-width: 721px) {
    .partner__title {
      transform: scaleX(1.03);
    }
  }
  /* Cluster (Every Layout): each logo lives on its own tile, tiles wrap and
     re-center when the row runs out of room. The gray background + radius moved
     from the container onto the individual tiles, and the Oxford hairlines are
     gone — the gap now does the visual separation. */
  .partner__grid {
    --partner-grid-gap: clamp(0.5rem, 0.35rem + 0.5vw, 0.85rem);
    grid-column: 2 / 12;
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: stretch;
    gap: var(--partner-grid-gap);
  }
  /* Each tile: equal width (flex-basis shared, grow/shrink equal) so the three
     near-square marks come out the same width. Schloss Aufhausen (last tile) is
     widened below. min-width:0 lets them shrink to fit narrow screens before
     wrapping. */
  .partner__grid > li {
    /* Per-item Cluster sizing via custom props (defaults = the square-ish marks).
       A wide item overrides --partner-item-grow/-basis (see .partner__item--wide)
       instead of being targeted by source order. */
    flex: var(--partner-item-grow, 1) 1 var(--partner-item-basis, 12rem);
    min-width: 0;
    display: flex;
    background: var(--color-cool-gray);
    border-radius: var(--radius-lg);
    padding-block: clamp(0.9rem, 0.6rem + 1.1vw, 1.6rem);
    padding-inline: clamp(0.75rem, 0.4rem + 1.4vw, 1.75rem);
  }
  /* Schloss Aufhausen wears a wide wordmark, so its tile is allowed to be wider
     than the square-ish marks — larger grow weight + flex-basis. Explicit class
     (set in the template) instead of li:last-child, so layout is decoupled from
     partner order. */
  .partner__grid > li.partner__item--wide {
    --partner-item-grow: 2.4;
    --partner-item-basis: 20rem;
  }
  .partner__link {
    flex: 1;
    min-width: 0;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    justify-content: center;
    /* Feste Logo-Höhe → alle Kacheln werden gleich hoch (Tile-Höhe = diese
       Höhe + gleiches Padding). Das Logo wird via object-fit:contain in diese
       Box eingepasst, deckungsgleich mit der Duotone-Maske darunter. */
    height: clamp(3.5rem, 2.4rem + 3.2vw, 5.25rem);
    /* Volle Deckkraft im Ruhezustand — erst wenn ein Logo gehovert wird,
       treten die anderen zurück (siehe :hover-Regel unten). */
    opacity: 1;
    transition: transform var(--transition-base), opacity var(--transition-base);
  }
  .partner__link img {
    width: 100%;
    height: 100%;
    object-fit: contain;
    display: block;
    /* Desaturate so the Oxford tint below renders as a clean duotone rather
       than mixing with each brand's hue. (No invert — the card is light Cool-
       Gray, so the Oxford-tinted logos read directly.) */
    filter: grayscale(1);
  }
  /* Oxford-Blue duotone — masked overlay sits only over the logo's non-
     transparent pixels (via `mask: var(--logo-url)`), then color-blends with
     the grayscale img beneath so the original lightness stays intact and just
     the hue swaps to Oxford. Reads as a clean Oxford tint on the Cool-Gray card. */
  .partner__link::after {
    content: "";
    position: absolute;
    inset: 0;
    background-color: var(--color-oxford);
    -webkit-mask: var(--logo-url) no-repeat center / contain;
    mask: var(--logo-url) no-repeat center / contain;
    mix-blend-mode: color;
    pointer-events: none;
  }
  /* Highlight-on-hover: standardmäßig sind alle Logos voll sichtbar. Sobald
     eines gehovert wird, dimmen die übrigen, damit das aktive hervorsticht
     und als Link lesbar wird. */
  .partner__grid:hover .partner__link:not(:hover) {
    opacity: 0.4;
  }
  .partner__link:hover,
  .partner__link:focus-visible {
    /* Anheben + dezent vergrößern, damit der Link-Charakter klar wird. */
    transform: translateY(-4px) scale(1.06);
  }

  /* Narrow partner container (a container query, not the viewport): the wrapping
     Cluster already re-centers tiles at every width, so no per-cell flex juggling
     is needed. We only widen the band to the full section so the stacked tiles
     keep breathing room. */
  @container partner (max-width: 720px) {
    .partner__grid {
      --partner-grid-gap: clamp(0.45rem, 2vw, 0.65rem);
      grid-column: 1 / -1;
    }
    .partner__grid > li {
      flex: 0 1 calc((100% - var(--partner-grid-gap) - var(--partner-grid-gap)) / 3);
      aspect-ratio: 1;
      padding: clamp(0.65rem, 2.6vw, 0.9rem);
    }
    .partner__grid > li.partner__item--wide {
      flex-basis: min(100%, 24rem);
      aspect-ratio: auto;
    }
    .partner__link {
      flex: 0 1 100%;
      align-self: center;
      height: clamp(2.55rem, 15vw, 3.95rem);
    }
    .partner__item--wide .partner__link {
      height: clamp(3rem, 16.5vw, 4.4rem);
    }
  }

  /* ============================================================
     5. HEADER — colored, morphs with current section
     Driven by JS: data-section attr maps to color via CSS var.
     ============================================================ */
  /* ── Header scale — ONE knob drives the whole bar ─────────────────────────
     `--header-fs` is the header's font-size; the logo, burger and vertical
     padding are all sized in `em` off it, so they grow together. The knob is
     flat to ~470px (mobile keeps its already-tuned element sizes), ramps up, and
     plateaus from ~700px (tablet) so the bar holds one height on tablet/desktop.
     `--header-height` is COMPUTED from the same knob (tallest child + 2·pad +
     border), so the fixed-header
     offsets used across the subpages always match the real bar — no px guessing.
     Defined here in landing.css (raw-loaded) rather than tokens.css so it needs
     no bundle rebuild and overrides the token default cleanly. */
  :root {
    --header-fs: clamp(0.73rem, 0.46rem + 0.92vw, 0.865rem);
    --header-inline-pad: clamp(1rem, 3vw, 2.5rem);
    --header-border-width: 2px;          /* Integer-pixel hairline, stays crisp */
    /* Knob-relative (NOT em) so it also resolves correctly in the :root height
       calc below; in the header it equals 2.23em. = 26px icon at the mobile knob. */
    --header-burger-w: calc(2.23 * var(--header-fs));
    /* Full logo height (4.6× the knob) and the ACTUAL mark height, which tapers
       a touch smaller on narrow phones so the wordmark narrows and the gap to
       the pill stays roughly equal to the pill→burger gap; it rejoins the full
       size at ~430px (continuous, no breakpoint), and from there the cluster
       spreads to the right edge on its own (left gap ≈ 2× right by ~405px). */
    --header-logo-height: calc(4.6 * var(--header-fs));
    --header-mark-h: clamp(2.5rem, calc(0.44rem + 10.9vw), var(--header-logo-height));
    /* The bar holds a CONSTANT height (full logo + padding) so the breathing
       room never drops when the mark tapers — padding-block fills whatever the
       tapering mark leaves. --header-height stays exact for the fixed-header
       offsets used across the subpages. */
    --header-height: calc(6.36 * var(--header-fs) + var(--header-border-width));
    /* Pad against the TALLEST real header child so the bar height stays exactly
       --header-height and the fixed-header offsets never undershoot: normally the
       mark, but on very narrow phones (≤~340px) the tapering mark drops below the
       burger hitbox (--header-burger-w · 44/26), so take whichever is taller. */
    --header-pad-block: calc((var(--header-height) - max(var(--header-mark-h), calc(var(--header-burger-w) * 44 / 26)) - var(--header-border-width)) / 2);
  }

  .site-header {
    position: fixed;
    top: 0; left: 0; right: 0;
    z-index: 100;
    font-size: var(--header-fs);   /* the knob; children below size in em */
    padding: var(--header-pad-block) var(--header-inline-pad);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-md);
    background: var(--header-bg, var(--color-pear));
    color: var(--header-fg, var(--color-oxford));
    border-bottom: var(--header-border-width) solid var(--header-edge, var(--color-oxford));
    transition:
      background-color 600ms cubic-bezier(.4,0,.2,1),
      color 600ms cubic-bezier(.4,0,.2,1),
      border-color 600ms cubic-bezier(.4,0,.2,1),
      transform 320ms cubic-bezier(.4,0,.2,1);
  }
  .site-header.is-hidden { transform: translateY(-110%); }
  @media (pointer: coarse) {
    :root {
      /* iOS Safari re-samples the notch/status-bar tint more reliably while a
         live coloured page strip remains visible during downward scroll. Keep a
         tiny header sliver in view on touch devices; desktop keeps the full hide. */
      --header-hidden-strip: calc(5px + var(--header-border-width));
    }
    .site-header.is-hidden {
      transform: translateY(calc(-1 * (var(--header-height) - var(--header-hidden-strip))));
    }
  }

  /* Per-section header palette — driven by scroll-timeline below.
     Each section declares the colors that should be active while it
     is dominant in the viewport. The `@property` initial-value MUST be
     a literal colour per CSS spec (it can't reference a var()), so we
     use the exact palette hex values for the hero state (Pear bg,
     Oxford fg/edge). In practice these defaults are never visible —
     a `body[data-section="…"]` rule below sets the real tokens before
     the header paints. */
  @property --header-bg   { syntax: "<color>"; inherits: true; initial-value: #CDE746; }
  @property --header-fg   { syntax: "<color>"; inherits: true; initial-value: #030027; }
  @property --header-edge { syntax: "<color>"; inherits: true; initial-value: #030027; }

  body[data-section="hero"]       { --header-bg: var(--color-pear);       --header-fg: var(--color-oxford);  --header-edge: var(--color-oxford); }
  body[data-section="about"]      { --header-bg: var(--color-ivory);      --header-fg: var(--color-oxford);  --header-edge: var(--color-oxford); }
  body[data-section="leistungen"] { --header-bg: var(--color-marian);     --header-fg: var(--color-ivory);   --header-edge: var(--color-pear); }
  body[data-section="audio"]      { --header-bg: var(--color-oxford);     --header-fg: var(--color-ivory);   --header-edge: var(--color-pear); }
  body[data-section="kontakt"]    { --header-bg: var(--color-pear);       --header-fg: var(--color-oxford);  --header-edge: var(--color-oxford); }
  body[data-section="unterricht"] { --header-bg: var(--color-marian);     --header-fg: var(--color-ivory);   --header-edge: var(--color-pear); }
  body[data-section="voizless"]   { --header-bg: var(--color-pear);       --header-fg: var(--color-oxford);  --header-edge: var(--color-oxford); }
  body[data-section="partner"]    { --header-bg: var(--color-marian);     --header-fg: var(--color-ivory);   --header-edge: var(--color-pear); }
  body[data-section="impressum"]  { --header-bg: var(--color-oxford);     --header-fg: var(--color-ivory);   --header-edge: var(--color-pear); }
  body[data-section="datenschutz"]{ --header-bg: var(--color-cool-gray);  --header-fg: var(--color-oxford);  --header-edge: var(--color-oxford); }

  /* Standalone Saxophonunterricht page — body is a flex column so the
     fixed header (`position: fixed`, accounted for via `padding-block-start`),
     the Unterricht strip (`flex: 1`), and the site footer all fit in one
     viewport when content allows. No slideshow background and no banderole
     on this page; the back-link in the strip lets the visitor get home. */
  body[data-section="unterricht"] {
    display: flex;
    flex-direction: column;
    height: 100svh;
    min-height: 100svh;
    padding-block-start: var(--header-height);
  }
  body[data-section="unterricht"] main {
    flex: 1;
    display: flex;
    min-height: 0;
  }
  body[data-section="unterricht"] .section--unterricht {
    flex: 1;
    min-height: 0;
    position: relative;
    column-gap: 0;
    grid-template-rows: minmax(0, 1fr);
    overflow: clip;
  }
  /* With the photo now on the RIGHT, the floating Say-Yes-To-Sax CTA (fixed to
     the viewport's right edge) rides over the photo, not the content — so the
     content needs no CTA-clearance reserve in the row layout. The desktop
     content keeps just its plain left gutter (see base rule); the mobile reserve
     still applies once stacked (content full-width), handled in the @media block
     below. */

  /* Voizless placeholder still wants the simpler min-height treatment
     so it visually fills the viewport while the banderole + site footer
     sit below it. */
  body[data-section="voizless"] .voizless-page {
    min-height: calc(100svh - var(--header-height));
    /* Lift the content above the fixed slideshow (.stage-bg, z-index:0), the
       same way .section does — otherwise the photo slides paint over it. */
    position: relative;
    z-index: 1;
    display: grid;
    place-content: center;
    justify-items: center;
    gap: var(--space-sm);
    text-align: center;
    padding: var(--space-xl) var(--space-lg);
  }
  /* Reserved-subpage placeholder. Ivory text + soft Oxford halo so it stays
     legible over any slideshow frame (same lift as .ghost-quote). Remove this
     block together with the placeholder markup once real Voizless content lands. */
  .voizless-placeholder {
    display: contents;
  }
  .voizless-placeholder .section-title,
  .voizless-placeholder__line {
    color: var(--color-ivory);
    text-shadow:
      0 0 24px color-mix(in srgb, var(--color-oxford) 22%, transparent),
      0 0 56px color-mix(in srgb, var(--color-oxford) 14%, transparent),
      0 0 96px color-mix(in srgb, var(--color-oxford)  8%, transparent);
  }
  .voizless-placeholder__line {
    font-family: var(--font-body);
    font-size: var(--text-l);
    margin: 0;
  }

  /* ── 404 — editierbare Fehlerseite ───────────────────────────────────────
     Spiegelt das zentrierte Voizless-Gerüst: zentrierter Stack auf dem dunklen
     Page-Backdrop (body { background: --color-page-bg }), Ivory-Text mit
     Oxford-Halo. Kein Slideshow nötig — der dunkle Grund trägt die Lesbarkeit. */
  body[data-section="notfound"] .notfound-page {
    min-height: calc(100svh - var(--header-height));
    position: relative;
    z-index: 1;
    display: grid;
    place-content: center;
    justify-items: center;
    gap: var(--space-md);
    text-align: center;
    padding: var(--space-2xl) var(--space-lg);
  }
  .notfound { display: contents; }
  .notfound__eyebrow {
    margin: 0;
    font-family: var(--font-ui);
    font-size: var(--text-s);
    text-transform: uppercase;
    letter-spacing: var(--tracking-wide);
    color: var(--color-pear);
  }
  .notfound .section-title,
  .notfound__text {
    color: var(--color-ivory);
    text-shadow:
      0 0 24px color-mix(in srgb, var(--color-oxford) 22%, transparent),
      0 0 56px color-mix(in srgb, var(--color-oxford) 14%, transparent),
      0 0 96px color-mix(in srgb, var(--color-oxford)  8%, transparent);
  }
  .notfound__text {
    font-family: var(--font-body);
    font-size: var(--text-l);
    max-inline-size: 42ch;
    margin: 0;
  }
  /* Cluster (Every-Layout): zentriert, bricht auf schmalen Viewports um. */
  .notfound__actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-sm);
    justify-content: center;
    margin-block-start: var(--space-xs);
  }
  /* Primärer CTA auf dunklem Grund: Pear-Dark-Fläche mit Oxford-Schrift. */
  body[data-section="notfound"] .notfound__actions .btn--primary {
    --btn-bg: var(--color-pear-dark);
    --btn-fg: var(--color-oxford);
  }
  /* Die site-weite Sticky-Booking-CTA ist auf der 404 redundant (es gibt schon
     einen eigenen "Kontakt aufnehmen"-Button) und würde die breite Display-
     Headline überlappen — daher hier ausgeblendet. */
  body[data-section="notfound"] .btn-sticky-cta { display: none; }

  /* Back-to-top in the footer is dead weight on the Saxophonunterricht
     page — the page fits in one viewport, so the top is already in view. */
  body[data-section="unterricht"] .site-footer__to-top {
    display: none;
  }

  /* Subpage photo motion — transform-only pan + Ken Burns, looping with
     alternate playback so the frame breathes forward and then returns instead
     of snapping back. No filter animation: the grayscale/duotone treatment stays
     static and the browser only composites the image layer. */
  @keyframes subpagePhotoKenBurns {
    from {
      transform:
        scale(var(--photo-zoom-from, 1.02))
        translate3d(var(--photo-pan-x-from, 0), var(--photo-pan-y-from, 0), 0);
    }
    to {
      transform:
        scale(var(--photo-zoom-to, 1.075))
        translate3d(var(--photo-pan-x-to, -1.6%), var(--photo-pan-y-to, -0.9%), 0);
    }
  }
  /* Saxophonunterricht photo — visible but calm movement across the sheet/music
     detail, with enough zoom slack for a real left/right drift. */
  .section--unterricht .unterricht__photo img {
    --photo-zoom-from: 1.09;
    --photo-zoom-to: 1.18;
    --photo-pan-x-from: 1.1%;
    --photo-pan-y-from: 0;
    --photo-pan-x-to: -2.6%;
    --photo-pan-y-to: 0;
    animation: subpagePhotoKenBurns 16s ease-in-out infinite alternate;
    transform-origin: var(--unterricht-photo-focal-point);
    will-change: transform;
  }
  /* Base back-link: sits on the photo, horizontally flush with the header
     wordmark (`clamp(1rem, 3vw, 2.5rem)` matches the header's inline padding).
     Vertically lifted off the top edge so it reads as page chrome resting in
     the layout, not stuck to the seam. Ghost style: Marian outline + label on a
     light cool-gray fill so it stays legible on the desaturated/duotone photo
     without going pure white. This on-photo style now serves the LEGAL pages
     (photo on the left; .legal .back-link only bumps the top offset for the
     header). The Unterricht page overrides it to sit in the text flow (see
     .unterricht__content .back-link), since its photo moved to the right. */
  .back-link {
    position: absolute;
    top: clamp(2.5rem, 1.8rem + 1.5vw, 3.75rem);
    /* Align with the header's inline padding (shared :root token) so the
       back-link sits flush under the header content edge. */
    left: var(--header-inline-pad);
    z-index: 5;
    display: inline-flex;
    align-items: center;
    gap: 0.45em;
    --back-link-stroke: 2px;
    padding: calc(0.5em - var(--back-link-stroke)) calc(1em - var(--back-link-stroke));
    border-radius: var(--radius-pill);
    /* Marian outline + label reads cleanly on every Marian-toned photo
       (Unterricht + both legal pages). Solid cool-gray backdrop keeps the
       label crisp against any part of the photo. */
    background: var(--color-cool-gray);
    color: var(--color-marian);
    border: var(--back-link-stroke) solid var(--color-marian);
    font-family: var(--font-ui);
    font-size: var(--text-s);
    font-weight: var(--weight-semibold-base);
    text-transform: uppercase;
    letter-spacing: var(--tracking-wide);
    text-decoration: none;
    transition: background var(--transition-base), color var(--transition-base), transform var(--transition-fast);
  }
  .back-link svg {
    width: 1em;
    height: 1em;
    flex-shrink: 0;
  }
  .back-link:hover {
    background: var(--color-marian);
    color: var(--color-cool-gray);
    transform: translateX(-2px);
  }

  /* ============================================================
     LEGAL PAGES (Impressum / Datenschutz)
     A duotone portrait stays pinned full-height on the left via
     position: sticky; the text column scrolls on the right. No
     slideshow, no banderole — footer-adjacent chrome family
     (cool-gray + marian), mirroring the Unterricht treatment.
     ============================================================ */
  body[data-section="impressum"],
  body[data-section="datenschutz"] {
    /* No top padding: the photo bleeds to the very top edge so an
       overscroll bounce never reveals a gap above it (the fixed header
       floats over the photo's top strip). The text column carries its own
       header clearance instead. overscroll-behavior stops scroll-chaining
       past the page bounds as a belt-and-suspenders against the bounce gap. */
    overscroll-behavior-y: none;
  }
  /* Impressum — Oxford-Blue surface, Pear accent, Marian-tinted photo. */
  body[data-section="impressum"] {
    background: var(--color-oxford);
    color: var(--color-ivory);
    --color-accent: var(--color-pear);
    --legal-tint: var(--color-marian);
  }
  /* Datenschutz — sober surface (cool-gray, no accent highlight, plain title)
     but a Marian-tinted photo, matching the Impressum / Unterricht duotone. */
  body[data-section="datenschutz"] {
    background: var(--color-cool-gray);
    color: var(--color-oxford);
    --color-accent: var(--color-oxford);
    --legal-tint: var(--color-marian);
  }
  .legal {
    /* Every-Layout Sidebar, threshold-driven: ONE variable, --legal-threshold,
       decides where the photo rail drops below the text. Source order is
       photo → text, so the photo rail sits on the LEFT and the text column on
       the RIGHT above the threshold; below it both go full-width (stack, photo
       FIRST → above the text, since the semantic order is photo-first). Both
       items use the Switcher flex-basis (flex-grow 1:2 ≈ rail|text ratio). The
       SAME threshold + container width drive the --legal-over/--legal-under
       switches below, so the photo's sticky→banner switch can never desync from
       the wrap — tune the whole responsive behaviour by changing
       --legal-threshold alone. */
    --legal-threshold: 45rem;
    /* No structural gap between photo rail and text column on the standalone
       legal pages: the text column's padding controls the breathing room and
       keeps the reading block visually centred. */
    --legal-gap: 0;
    /* Positioning context for the absolute back-link that rides on the photo
       (top-left), so it scrolls away with the page rather than the sticky rail. */
    position: relative;
    container-type: inline-size;
    display: flex;
    flex-wrap: wrap;
    align-items: stretch;
    gap: var(--legal-gap);
    /* No inline padding on the wrapper: the text carries its own gutters and the
       photo bleeds to the edges (a full-width banner once stacked). */
  }
  /* Two LENGTH switches derived from the container width vs the threshold —
     huge-positive on one side of the threshold, huge-negative on the other.
     Multiplying a length by a number (and feeding it to min/max/clamp) is
     broadly supported (no typed length/length division, which Firefox lacks),
     so this stays robust while keeping --legal-threshold the single source.
     Set on the flex children so 100cqw resolves against .legal; inherits down
     into the photo.
       --legal-over  > 0 when WIDE  (container ≥ threshold)
       --legal-under > 0 when NARROW (container <  threshold) */
  .legal__text,
  .legal__media {
    /* +1px on --legal-over puts the equality case (container == threshold) on
       the WIDE side, exactly where the Switcher wrap keeps the row, so the
       photo height flips in lockstep with the column collapse (no 1px window
       at exactly the threshold where the row shows a banner-height rail). */
    --legal-over:  calc((100cqw - var(--legal-threshold) + 1px) * 999);
    --legal-under: calc((var(--legal-threshold) - 100cqw) * 999);
  }
  .legal__text {
    /* Sidebar main: shares the row ~2:1 with the rail above the threshold,
       full width below it. */
    flex-grow: 2;
    flex-basis: calc((var(--legal-threshold) - 100%) * 999);
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
    /* Top padding clears the fixed header in the ROW layout (the text top sits
       at the page top there): header height + one standard fluid step
       (--space-2xl) of breathing room below the menu. Once stacked the photo
       banner ABOVE carries the header clearance, so the text only needs the
       --space-2xl air itself — the same breathing room as the row, just without
       the header offset — to separate the headline from the photo (the switch
       drops to --space-2xl when narrow, --legal-over hugely negative). Bottom is
       the normal section air. */
    padding-block: clamp(var(--space-2xl), var(--legal-over), calc(var(--header-height) + var(--space-2xl))) clamp(2.5rem, 1.5rem + 4vw, 5.5rem);
    /* Left gutter only once stacked (full width); in the row the gap to the rail
       already separates them. clamp picks 0 when --legal-under is hugely
       negative (wide) and --section-pad-x when it's hugely positive (narrow).
       The right gutter is the outer (viewport) edge → always the section pad. */
    padding-inline-start: clamp(0px, var(--legal-under), var(--section-pad-x));
    padding-inline-end: var(--section-pad-x);
  }
  /* Both legal pages: let the text column's own gutters define the reading
     block instead of adding a structural gap beside the photo. */
  body[data-section="impressum"] .legal__text,
  body[data-section="datenschutz"] .legal__text {
    /* Reserve at least the sticky-CTA rail width so the reading block never
       slides under the fixed Say-Yes-To-Sax tab on the right edge (it overlapped
       the prose by a few px wide and ~20px once stacked). Padding stays SYMMETRIC,
       so .legal__inner's margin-inline:auto centring is unaffected — the centre
       axis doesn't move, the gutter just widens. --cta-rail-width (40–56px) ≥
       --section-pad-x, so max() only ever grows the gutter, never shrinks it. */
    padding-inline: max(var(--section-pad-x), var(--cta-rail-width));
  }
  /* Legal inner wrapper: caps the whole text block at the reading measure (so
     the Impressum Pear card matches the prose width below it) and centres the
     block (margin-inline:auto) within the column. Text stays left-aligned.
     Carries the vertical stack rhythm that the bare .legal__text held before
     the wrapper was introduced. Both legal templates render it — Datenschutz
     without the Pear card. */
  .legal__inner {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
    inline-size: 100%;
    max-inline-size: var(--measure);
    margin-inline: auto;
  }
  /* Back-link rides on the photo (top-left), mirroring the Unterricht
     treatment. It inherits the base .back-link absolute placement + ghost
     style; only the top offset is bumped by the header height, because the
     legal photo bleeds up behind the fixed header (see .legal__photo) and the
     link would otherwise hide behind the menu. Anchored to .legal (position:
     relative), so it scrolls away with the page rather than the sticky rail.
     Same header clearance as .legal__text (header height + --space-2xl), so the
     link and the text column's first line sit on one level below the menu. */
  .legal .back-link {
    top: calc(var(--header-height) + var(--space-2xl));
  }
  .legal__prose {
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    max-width: 65ch;
  }
  /* Subpage section headings share the home section-title's display treatment
     (Unique, uppercase) one size down at --text-xl — so the subpage H1 + H2 read
     as one display family and only H3 drops to the UI font (base h3 =
     Asap Condensed). */
  .legal__prose h2 {
    font-family: var(--font-display);
    font-feature-settings: var(--feature-display);
    font-size: var(--text-xl);
    font-weight: var(--weight-bold);
    line-height: 0.92;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    color: var(--color-accent);
    margin-block-start: var(--space-lg);
    /* Slight horizontal stretch. Unique has no width axis, so font-stretch/wdth
       would be ignored — use a synthetic 5% scaleX instead. transform-origin
       left keeps the left edge anchored (heading is left-aligned). */
    transform: scaleX(1.05);
    transform-origin: left center;
  }
  /* Tablet + desktop: ease the spacing and the synthetic stretch back a touch.
     Mobile wears the full 0.03em / scaleX(1.05) — it suits the narrow column;
     wider screens read better with a little less. transform-origin is inherited. */
  @media (min-width: 721px) {
    .legal__prose h2 {
      letter-spacing: 0.02em;
      transform: scaleX(1.03);
    }
  }
  .legal__prose h3 {
    font-size: var(--text-l);
    color: inherit;
    margin-block-start: var(--space-xs);
  }
  .legal__prose p {
    font-size: var(--text-base);
    line-height: 1.5;
    max-width: 65ch;
  }
  /* Law of proximity via nested Stacks. The card body (.legal__card .legal__prose)
     is a Stack of GROUPS with a LOOSE gap; each .legal__group is a tight inner
     Stack — related items (intro lines; a term + its value) sit closer than
     unrelated ones. Loose gap = --space-md, tight gap = --space-3xs. */
  .legal__group {
    display: flex;
    flex-direction: column;
    gap: var(--space-3xs);           /* tight: within a group */
  }
  /* Description list (Impressum card): label→value pairs that define a detail,
     not content sections. The list is a Stack of pair-groups with the LOOSE gap
     between pairs; the term is a LABEL the size of an H3 (UI font at --text-m),
     never a display headline; the value is plain body. */
  .legal__defs {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);            /* loose: between definition pairs */
    margin: 0;
  }
  .legal__prose dt {
    font-family: var(--font-ui);
    font-weight: var(--weight-semibold);
    font-size: var(--text-m);
    line-height: var(--line-height-snug);
    color: var(--color-accent);
  }
  .legal__prose dd {
    margin: 0;
    font-size: var(--text-base);
    line-height: 1.5;
  }
  /* <address> wraps the postal address (semantic), but its UA italic default
     doesn't fit the card — keep it upright. */
  .legal__prose address { font-style: normal; }
  .legal__prose strong { font-weight: var(--weight-bold); }
  .legal__prose a {
    color: var(--color-accent);
    font-weight: var(--weight-medium);
  }
  .legal__prose ul {
    display: flex;
    flex-direction: column;
    gap: var(--space-2xs);
    padding-inline-start: 1.2em;
    list-style: disc;
  }
  .legal__prose li { max-width: 65ch; }
  .legal__source {
    margin-block-start: var(--space-md);
    font-size: var(--text-s);
    opacity: 0.7;
  }
  .legal__media {
    position: relative;
    /* Sidebar rail: shares the row ~1 part to the text's 2 (capped at 32rem);
       the cap is lifted once stacked so the photo spans the full width. */
    flex-grow: 1;
    flex-basis: calc((var(--legal-threshold) - 100%) * 999);
    /* 32rem cap in the row; lifted (huge) once stacked so the photo spans full
       width. max picks --legal-under when it's hugely positive (narrow). */
    max-inline-size: max(32rem, var(--legal-under));
    /* Once stacked the photo banner is the FIRST element (source order is
       photo-first), so it must clear the fixed header. The switch adds the
       header height only when narrow (--legal-under hugely positive); 0 in the
       row, where the photo intentionally bleeds up behind the header. */
    margin-block-start: clamp(0px, var(--legal-under), var(--header-height));
  }
  /* Pinned full-height frame while it's the sticky rail; a short static banner
     once stacked. */
  .legal__photo {
    position: sticky;
    /* Wide: bleed to the very top (top: 0) so the photo sits behind the fixed
       header and an overscroll bounce can never reveal a gap above it — the
       earlier `top: var(--header-height)` pushed the photo DOWN, leaving a
       transparent strip behind the header that flashed on the bounce. Narrow:
       the banner is unstuck (container query below) and .legal__media already
       carries the header clearance, so this offset is moot — kept at
       header-height defensively for engines without container queries. */
    top: clamp(0px, var(--legal-under), var(--header-height));
    /* Wide: a 100svh sticky rail (fills the viewport; the top strip hides behind
       the header). Narrow: a short banner. max() picks the huge --legal-over
       when wide so min() lands on 100svh; when narrow --legal-over is hugely
       negative so max() lands on the banner clamp. */
    height: min(
      100svh,
      max(clamp(13rem, 38svh, 20rem), var(--legal-over))
    );
    margin: 0;
    overflow: hidden;
    isolation: isolate;
  }
  .legal__photo img {
    --photo-zoom-from: 1.04;
    --photo-zoom-to: 1.115;
    --photo-pan-x-from: 1.1%;
    --photo-pan-y-from: 0.25%;
    --photo-pan-x-to: -2.6%;
    --photo-pan-y-to: -1.35%;
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* Focal point just left of the bow tie so the subject sits a touch farther
       right in the frame, keeping the back-link clear of the face. */
    object-position: 36% 37%;
    animation: subpagePhotoKenBurns 19s ease-in-out infinite alternate;
    transform-origin: 36% 37%;
    will-change: transform;
    display: block;
  }
  /* Datenschutz desktop rail: anchor the crop top-right (100% 0%) so the
     saxophone bell sits in the upper-right and the hand drops to the left edge —
     the bell, not the hand, is the subject. NOTE: on the tall rail the image
     fills the height exactly (no vertical overflow), so only the X component
     bites here; Y is inert. The mobile banner is reframed separately below. */
  body[data-section="datenschutz"] .legal__photo img {
    --photo-zoom-from: 1.04;
    --photo-zoom-to: 1.105;
    --photo-pan-x-from: 1.1%;
    --photo-pan-y-from: 0;
    --photo-pan-x-to: -2.4%;
    --photo-pan-y-to: -1.15%;
    object-position: 100% 0%;
    transform-origin: 78% 18%;
  }
  /* Below the threshold the photo is a static banner, not a sticky rail: it is
     the FIRST stacked element (above the text) and must scroll away with the
     page. Container query keyed to the SAME width as --legal-threshold (45rem);
     CQ conditions can't read custom properties, so the value is inlined.
     Placed AFTER the base rules so the same-specificity overrides win. */
  @container (max-width: 45rem) {
    .legal__photo { position: static; }
    /* Mobile banner crops, per page (the wide-rail focal points above are tuned
       for the tall rail; the short banner wants a different framing). NOTE: the
       source images are portrait, so on this wide-short banner the image fills
       the box width and the X component is INERT — only Y (and a zoom) reframe.
       Datenschutz: small Y lifts the saxophone body into frame and pushes the
       hand to the bottom edge. */
    /* Impressum: zoom in on the person (scale > 1, clipped by the figure's
       overflow:hidden) for a tighter, more interesting portrait crop — plain
       cover showed too much full-body. transform-origin + object-position share
       the focal point (face, upper third) so the head keeps menu clearance. */
    body[data-section="impressum"] .legal__photo img {
      --photo-zoom-from: 1.35;
      --photo-zoom-to: 1.46;
      --photo-pan-x-from: 1.2%;
      --photo-pan-y-from: 0.2%;
      --photo-pan-x-to: -2.5%;
      --photo-pan-y-to: -1.35%;
      object-position: 38% 18%;
      transform-origin: 38% 18%;
    }
    body[data-section="datenschutz"] .legal__photo img {
      --photo-zoom-from: 1.04;
      --photo-zoom-to: 1.13;
      --photo-pan-x-from: 1.2%;
      --photo-pan-y-from: 0.25%;
      --photo-pan-x-to: -2.7%;
      --photo-pan-y-to: -1.45%;
      object-position: 50% 10%;
      transform-origin: 50% 18%;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .section--unterricht .unterricht__photo img,
    .legal__photo img {
      animation: none;
      transform:
        scale(var(--photo-zoom-from, 1))
        translate3d(var(--photo-pan-x-from, 0), var(--photo-pan-y-from, 0), 0);
      will-change: auto;
    }
  }
  /* Duotone — same treatment as the Unterricht photo. The source asset is
     baked to grayscale, so `mix-blend-mode: color` keeps the tonality and
     swaps only the hue to --legal-tint (Marian on both legal pages). z-index 1
     keeps it above the img but below the dot raster (::before). */
  .legal__photo::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 1;
    background: var(--legal-tint, var(--color-marian));
    mix-blend-mode: color;
    opacity: 0.65;
    pointer-events: none;
  }
  /* Raster dot texture over the photo — the same tile the home page lays over
     its slideshow (.stage-bg::before), so the subpages echo that signature.
     Sits above the duotone tint via z-index so the dots read on the tinted
     image, not under it. */
  .legal__photo::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 2;
    background: url("../images/subtle-dots.d3fddeea00fb.png") repeat;
    pointer-events: none;
  }

  /* Impressum on the dark Oxford surface — light body weight + a touch of
     tracking for legibility. */
  body[data-section="impressum"] .legal__prose {
    font-weight: var(--weight-light);
    letter-spacing: var(--tracking-on-dark);
  }

  /* Impressum intro card — H1 through Postanschrift sit together on a raised
     Pear panel that lifts off the Oxford page. Pear is a light surface, so its
     own foreground/accent (Oxford) overrides the page's Ivory-on-Oxford and
     Pear accent for legibility inside the block. */
  .legal__card {
    background: var(--color-pear);
    color: var(--color-oxford);
    --color-accent: var(--color-oxford);
    /* Light (Pear) island inside the dark Impressum page: regular weight plus a
       dark focus ring that reads on Pear. */
    font-weight: var(--weight-regular);
    --h3-weight: var(--weight-bold);
    --focus-ring-color: var(--color-oxford);
    border-radius: var(--radius-lg);
    padding: clamp(1.5rem, 1rem + 2.5vw, 2.75rem);
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
  }
  /* Tighten the title's bottom air inside the card, and let the first
     sub-heading sit closer to the address lines. */
  .legal__card .legal__title { margin: 0; }
  .legal__card .legal__prose { gap: var(--space-md); }  /* loose: between top-level groups (intro group → definition list) */
  /* Reset the dark-surface prose treatment (light weight + extra tracking is
     for Ivory-on-Oxford) — on the light Pear card, regular weight reads
     cleaner in Oxford. */
  body[data-section="impressum"] .legal__card .legal__prose {
    font-weight: var(--weight-regular);
    letter-spacing: var(--tracking-normal);
  }

  /* Datenschutz stays sober through NEUTRAL colour, not a smaller headline: the
     title now carries the same display treatment + size as Impressum (via
     .section-title → --text-2xl), and its colour resolves to Oxford because the
     body sets --color-accent: Oxford. The eyebrow highlight stays dropped. */
  body[data-section="datenschutz"] .eyebrow { display: none; }
  /* Impressum: drop the eyebrow ~11px so its baseline lands on the same line as
     the back-link's "Zurück" label across the photo — the back-link pill's
     vertical padding offsets its text below the eyebrow otherwise. */
  body[data-section="impressum"] .legal__text .eyebrow { margin-block-start: 0.7rem; }
  /* Both legal back-links ride on the Marian-tinted photo now, so they keep the
     base ghost style (cool-gray fill + Marian outline/label) for legibility on
     the duotone — no per-page accent override (Pear/Oxford labels read poorly on
     the Marian photo). Hover inverts to a solid Marian fill (base .back-link
     rule). */

  /* The legal layout uses the length-switch flag (--legal-over/--legal-under)
     for the Sidebar wrap (photo above text once stacked), the text gutters and
     the photo's sticky-rail→banner sizing, plus ONE container query (45rem) to
     unstick the banner — all keyed to the same --legal-threshold so they flip
     together with nothing to keep in sync. */

  /* Wordmark */
  .wordmark {
    display: inline-flex;
    align-items: center;
    gap: clamp(0.55rem, 1vw, 0.9rem);
    color: inherit;
    text-decoration: none;
  }
  .wordmark svg {
    /* Logo fills the header vertically with only minimal breathing room. Sized
       in em off the header knob (--header-logo-height) so it grows with the bar.
       On small phones it tapers a touch SMALLER via the lower clamp arms so the
       wordmark narrows and the gap to the Voizless pill stays roughly equal to
       the pill→burger gap; the taper meets --header-logo-height again at ~430px
       (no breakpoint, no jump), so from there the pill cluster spreads to the
       right edge on its own — by ~405px the left gap is already ~2× the right. */
    width: auto;
    height: var(--header-mark-h);
    flex-shrink: 0;
  }
  .wordmark__text {
    /* Single source of truth for the name size; the subline derives from it
       so both scale at the SAME rate. A constant factor keeps the subline's
       justify-stretch (letter spacing) proportionally identical at every
       viewport width instead of tightening when small / loosening when wide. */
    /* Tied to the header knob (em) so the wordmark scales as ONE unit with the
       logo; the ≤389px override below still wins on tiny phones. */
    --wordmark-name-size: 2.74em;
    display: flex;
    flex-direction: column;
    gap: 0;
    line-height: 1;
    /* The container width is the name's width; subline stretches to match */
    width: max-content;
  }
  .wordmark__name {
    font-family: var(--font-ui);
    /* Discrete installed cut: Asap Condensed Bold. */
    font-weight: var(--weight-bold);
    font-size: var(--wordmark-name-size);
    line-height: 0.92;
    /* Match the header's 600ms colour morph so the optical weight shift on
       dark sections eases in rather than snapping. */
    transition: font-weight 600ms cubic-bezier(.4, 0, .2, 1);
    text-transform: uppercase;
    letter-spacing: 0.01em;
    /* Never wrap — the name is the wordmark and must stay on one line; its
       width is the reference the subline matches. On tiny phones the fluid
       --wordmark-name-size shrinks so this one line still fits the header. */
    white-space: nowrap;
  }
  .wordmark__sub {
    /* Same styling as .eyebrow (without the red pin) */
    font-family: var(--font-ui);
    /* Constant fraction of the name → scales in lockstep with it. 0.319 is the
       ratio at which this text's natural width equals the name's, so the two
       lines are exactly equal width at EVERY viewport (and stay matched no
       matter how the name size is tuned, since the subline derives from it). */
    font-size: calc(var(--wordmark-name-size) * 0.319);
    /* Discrete installed cut: Asap Condensed Semibold. */
    font-weight: var(--weight-semibold);
    transition: font-weight 600ms cubic-bezier(.4, 0, .2, 1);
    text-transform: uppercase;
    letter-spacing: 0.07em;
    line-height: 1;
    color: inherit;
    /* Equal width with the name comes from the 0.319 ratio (natural width ≈ name
       width) inside the name-width container; width:100% pins the box to that
       container, nowrap keeps it on one line. */
    white-space: nowrap;
    width: 100%;
  }
  .header__right {
    display: flex;
    align-items: center;
    /* Voizless→burger gap equals the burger→viewport-edge gap (the header's
       inline padding), so the pill sits as far from the burger as the burger
       sits from the edge. */
    gap: var(--header-inline-pad);
  }

  /* Voizless pill — Ghost / Prio2: transparenter Hintergrund, Oxford-Outline */
  .voizless-pill {
    /* Stroke weight tracks the background: a dark edge (Oxford/Marian) on the
       light sections reads thinner than a light Pear edge on dark. Default is
       the stronger light-surface stroke; dark-header sections override to 1.5px.
       Padding subtracts the same var so the box size stays stable. */
    --pill-stroke: 2px;
    display: inline-flex;
    align-items: center;
    padding: calc(0.6em - var(--pill-stroke)) calc(1.3em - var(--pill-stroke));
    background: transparent;
    /* Track the header's edge colour, which every section already sets to
       contrast its own --header-bg (Pear on dark Oxford/Marian, Oxford/Marian
       on the light surfaces). This keeps the outline pill visible on EVERY
       page and through the home page's scroll colour-morph — previously it was
       hard-coded Oxford and vanished on the Oxford-header subpages (Impressum),
       with a fragile per-section Pear-flip patching only leistungen/audio. */
    color: var(--header-edge);
    border: var(--pill-stroke) solid var(--header-edge);
    font-family: var(--font-ui);
    /* Sized like the site buttons (same --text-button token as the CTA), so the
       pill scales with the button system. line-height:1 stops the inherited
       body 1.6 from inflating its height. The em-padding above scales with it. */
    font-size: var(--text-button);
    line-height: 1;
    font-weight: var(--weight-semibold-base);
    text-transform: uppercase;
    letter-spacing: var(--tracking-wide);
    border-radius: var(--radius-pill);
    text-decoration: none;
    transition: transform var(--transition-fast), background var(--transition-base), color var(--transition-base), border-color var(--transition-base);
  }
  /* Dark-header sections keep the faint fill so the outline reads clearly, but
     use the slimmer default stroke: light lines on dark read heavier than dark
     lines on light at the same pixel width. */
  body[data-section="leistungen"] .voizless-pill,
  body[data-section="audio"]      .voizless-pill,
  body[data-section="unterricht"] .voizless-pill,
  body[data-section="partner"]    .voizless-pill,
  body[data-section="impressum"]  .voizless-pill {
    --pill-stroke: 1.5px;
    background: color-mix(in srgb, var(--header-edge) 9%, transparent);
    border-color: transparent;
    box-shadow: inset 0 0 0 var(--pill-stroke) var(--header-edge);
    font-weight: var(--weight-medium);
  }
  .voizless-pill:hover {
    transform: translateY(-1px);
    /* Fill with the edge colour, label flips to the header background — a clean
       invert on every section without per-section hover rules. */
    background: var(--header-edge);
    color: var(--header-bg);
    box-shadow: none;
  }
  body[data-section="leistungen"] .voizless-pill:hover,
  body[data-section="audio"]      .voizless-pill:hover,
  body[data-section="unterricht"] .voizless-pill:hover,
  body[data-section="partner"]    .voizless-pill:hover,
  body[data-section="impressum"]  .voizless-pill:hover {
    background: var(--header-edge);
    color: var(--color-oxford);
    box-shadow: none;
  }

  /* Burger */
  /* .burger is now the <summary> of the nav <details>. Strip the native
     disclosure marker (triangle); it lives in the header (z-index 100) so it
     already sits above the overlay (z-index 95) as the close (X) affordance. */
  .burger {
    /* Hitbox keeps the 44/26 ratio to the icon, so it scales with --header-burger-w. */
    inline-size: calc(var(--header-burger-w) * 44 / 26);
    block-size: calc(var(--header-burger-w) * 44 / 26);
    background: transparent; border: 0; padding: 0;
    cursor: pointer; color: inherit;
    display: inline-flex; align-items: center; justify-content: center;
    border-radius: var(--radius-pill);
    list-style: none;          /* removes the ▶ marker (most engines) */
  }
  .burger::-webkit-details-marker { display: none; }   /* Safari/Chrome marker */
  .burger:hover { background: color-mix(in srgb, var(--color-oxford) 8%, transparent); }
  body[data-section="audio"] .burger:hover,
  body[data-section="leistungen"] .burger:hover { background: color-mix(in srgb, var(--color-ivory) 12%, transparent); }
  /* (Page scroll-lock for the open nav lives with the nav-overlay rules above:
     a CSS overflow:hidden baseline plus the real body-pinning lock in landing.js,
     since Safari/iOS ignore overflow:hidden on the viewport.) */
  .burger__lines {
    position: relative;
    /* Geometry derived from --header-burger-w, keeping the original 26×18 / 2.5
       proportions, so the icon scales with the header knob and stays crisp. */
    width: var(--header-burger-w);
    height: calc(var(--header-burger-w) * 18 / 26);
    --burger-stroke: calc(var(--header-burger-w) * 2.5 / 26);
  }
  .burger__lines::before, .burger__lines::after, .burger__lines span {
    content: ""; position: absolute; left: 0; right: 0; height: var(--burger-stroke);
    background: currentColor; border-radius: calc(var(--burger-stroke) / 2);
    transition:
      top 320ms cubic-bezier(.5,1.7,.4,.95) 0ms,
      transform 380ms cubic-bezier(.5,1.7,.4,.95) 100ms,
      opacity 180ms ease;
  }
  .burger__lines::before { top: 0; }
  .burger__lines span    { top: calc(var(--header-burger-w) * 7 / 26); }
  .burger__lines::after  { top: calc(var(--header-burger-w) * 14 / 26); }
  .nav[open] .burger__lines::before {
    top: calc(var(--header-burger-w) * 7 / 26); transform: rotate(45deg); transition-delay: 0ms, 220ms, 0ms;
  }
  .nav[open] .burger__lines span { opacity: 0; transition: opacity 80ms ease; }
  .nav[open] .burger__lines::after {
    top: calc(var(--header-burger-w) * 7 / 26); transform: rotate(-45deg); transition-delay: 0ms, 220ms, 0ms;
  }

  /* ============================================================
     6. NAV OVERLAY — Cool-Gray, centered, Asap Condensed
     ============================================================ */
  .nav-overlay {
    position: fixed; inset: 0; z-index: 95;
    /* The transparent backdrop is stable for the whole open state. A
       solid ::before cover below keeps the reveal clean, then fades out after
       the clip-path has finished opening. */
    background: color-mix(in srgb, var(--color-oxford) 70%, transparent);
    color: var(--color-ivory);
    isolation: isolate;
    /* Cover (Every Layout): the menu centres vertically when there's room and
       the footer stays at the bottom; when the content is taller than the
       viewport the auto margins collapse and the overlay scrolls instead of
       clipping. Top padding clears the fixed header so the top links never hide
       beneath it when scrolled. */
    display: flex; flex-direction: column; align-items: center;
    text-align: center;
    overflow-y: auto; overscroll-behavior: contain;
    padding-block: calc(var(--header-height) + var(--space-md)) var(--space-xl);
    clip-path: circle(0% at calc(100% - 60px) 50px);
    pointer-events: none;
    /* visibility:hidden takes the closed overlay out of the tab order AND the
       accessibility tree — clip-path/pointer-events alone only hide it
       visually, leaving the links keyboard-focusable. The 720ms visibility
       delay keeps it perceivable through the close animation, then removes it. */
    visibility: hidden;
    transition:
      clip-path 720ms cubic-bezier(.5,1.4,.4,1.02),
      visibility 0s linear 720ms;
  }
  .nav-overlay::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    background: var(--color-oxford);
    opacity: 1;
    pointer-events: none;
    transition: opacity 220ms ease;
  }
  body:has(.nav[open]) .nav-overlay {
    clip-path: circle(180% at calc(100% - 60px) 50px);
    pointer-events: auto;
    visibility: visible;
    transition:
      clip-path 720ms cubic-bezier(.5,1.4,.4,1.02),
      visibility 0s;
  }
  body:has(.nav[open]) .nav-overlay::before {
    opacity: 0;
    transition-delay: 720ms;
  }
  /* Stack wrapper around menu + footer. display:contents here means it has NO box
     on tablet/desktop — the menu and footer act as direct overlay children, so
     those layouts are unchanged. The phone breakpoint below turns it into a real
     content-width flex column so menu and footer share one width and align. */
  .nav-overlay__stack { display: contents; }
  /* Stack (Every Layout): vertical rhythm lives entirely on the items via
     margin-block-start, no flex `gap`, no per-item negative-margin tuning. Every
     gap is one modular-scale token; the ONLY exception is the larger break above
     the section divider that splits the primary nav from the secondary block. */
  .nav-overlay__menu {
    position: relative;
    z-index: 1;
    list-style: none;
    --nav-tablet-boost: 0rem;
    --nav-num-gap: clamp(0.6rem, 1vw, 1rem);
    --nav-num-col: 1.15rem;
    --nav-indent: calc(var(--nav-num-col) + var(--nav-num-gap));
    --nav-rhythm: var(--space-2xs);     /* between the primary numbered links */
    --nav-gap-above: var(--space-xs);   /* line → word (above the word) */
    --nav-gap-below: var(--space-2xs);  /* word → line (below the word) */
    --nav-break: var(--space-xl);       /* primary ↔ secondary block break */
    --nav-link-size: clamp(2rem, 1.5rem + 1.4vw, 2.85rem);
    --nav-sub-link-size: clamp(1.3rem, 1rem + 0.9vw, 1.95rem);
    --nav-legal-size: var(--text-s);
    margin-block: auto;
    margin-inline: 0;
    padding: 0;
    display: flex; flex-direction: column; justify-content: flex-start;
    /* Block stays centered in the overlay (align-items:center on .nav-overlay);
       stretch makes every li the width of the widest link, so the links read
       left-aligned and the dividers span the full block width. */
    align-items: stretch; text-align: left;
    /* Content-width block: the menu width is auto, so it shrinks to its widest
       link (plus the symmetric right gutter added below), and the overlay centres
       it (align-items:center on .nav-overlay) — the block sizes to its content at
       every width, centred on the page. The cap keeps an unusually long link from
       overflowing a very narrow phone. (Earlier per-breakpoint fixed widths made
       the block too wide on mobile and too narrow at ~768px.) */
    inline-size: auto;
    max-inline-size: calc(100vw - 2rem);
    /* Symmetric right gutter: the numbers indent the label line by --nav-indent on
       the LEFT, so a matching right padding lets the dividers reach out the same
       amount on the RIGHT (see .nav-overlay__divider). The block (incl. this
       padding) stays centred, so the label column sits centred with equal gutters. */
    padding-inline-end: var(--nav-indent);
  }
  /* Default rhythm (the primary numbered links). Everything else is one of the
     three exceptions below — all pure Stack gaps, no padding/offset on the links.
     The above-word gap is one step larger than the below-word gap so the optical
     spacing reads even despite the all-caps glyphs sitting high in the line box. */
  .nav-overlay__menu > * + * { margin-block-start: var(--nav-rhythm); }
  .nav-overlay__menu > li.is-sub,
  .nav-overlay__menu > li.is-legal { margin-block-start: var(--nav-gap-above); }
  .nav-overlay__menu > li:has(.nav-overlay__divider) { margin-block-start: var(--nav-gap-below); }
  .nav-overlay__menu > li:has(.nav-overlay__divider--section) { margin-block-start: var(--nav-break); }
  .nav-overlay__menu li { overflow: hidden; }
  .nav-overlay__menu a {
    display: inline-flex; align-items: baseline; gap: var(--nav-num-gap);
    font-family: var(--font-ui);
    font-weight: var(--weight-bold);
    font-size: var(--nav-link-size);
    line-height: 1; color: var(--color-ivory);
    text-decoration: none; text-transform: uppercase; letter-spacing: -0.01em;
    transition: color var(--transition-fast), transform 480ms cubic-bezier(.4,0,.2,1);
    transform: translateY(110%);
  }
  body:has(.nav[open]) .nav-overlay__menu a { transform: translateY(0); }
  /* Two-value delay: 0 for color (instant hover), the stagger only on transform
     (the open slide-in). A single value would also delay the hover colour. */
  .nav-overlay__menu li:nth-child(1) a { transition-delay: 0ms, 100ms; }
  .nav-overlay__menu li:nth-child(2) a { transition-delay: 0ms, 160ms; }
  .nav-overlay__menu li:nth-child(3) a { transition-delay: 0ms, 220ms; }
  .nav-overlay__menu li:nth-child(4) a { transition-delay: 0ms, 280ms; }
  /* 5, 7, 9 are aria-hidden divider <li>s (no <a>) — skipped here; the real
     links keep their staggered slide-in delays. */
  .nav-overlay__menu li:nth-child(6) a { transition-delay: 0ms, 400ms; }
  .nav-overlay__menu li:nth-child(8) a { transition-delay: 0ms, 520ms; }
  .nav-overlay__menu li:nth-child(10) a { transition-delay: 0ms, 580ms; }
  .nav-overlay__menu a .num {
    font-family: var(--font-ui);
    font-weight: var(--weight-semibold); font-size: calc(1.05rem + var(--nav-tablet-boost) * 0.18);
    color: var(--color-pear); letter-spacing: 0.06em;
    align-self: flex-start;
    padding-top: 0.5em;
    flex: none;
    width: var(--nav-num-col);
  }
  .nav-overlay__menu a:hover { color: var(--color-pear); }
  .nav-overlay__menu li:has(.nav-overlay__divider) { overflow: visible; }
  .nav-overlay__divider {
    /* Reaches out --nav-indent past the links on the RIGHT to mirror the number
       indent on the LEFT (the menu carries a matching padding-inline-end, and
       li:has(divider) is overflow:visible so the line can extend into it). Result:
       the dividers are symmetric around the centred label column. */
    width: calc(100% + var(--nav-indent)); height: 1px;
    background: color-mix(in srgb, var(--color-ivory) 45%, transparent);
    /* The Stack owns all vertical spacing; the rule itself carries none. */
    margin: 0; border: 0;
  }
  .nav-overlay__menu li.is-sub a {
    padding-left: var(--nav-indent);
    font-size: var(--nav-sub-link-size);
    color: var(--color-ivory); font-weight: var(--weight-semibold);
  }
  /* Legal row: Impressum + Datenschutz side by side, sharing the same indented
     label edge as the links above. */
  .nav-overlay__menu li.is-legal {
    padding-left: var(--nav-indent);
    display: flex; align-items: baseline; gap: 0.7em; flex-wrap: wrap;
    font-size: var(--nav-legal-size);
  }
  .nav-overlay__menu li.is-legal a {
    padding-left: 0;
    font-size: inherit;
    font-weight: var(--weight-semibold);
    letter-spacing: 0.05em;
    color: var(--color-ivory);
  }
  .nav-overlay__menu li.is-legal .is-legal__sep {
    color: color-mix(in srgb, var(--color-ivory) 45%, transparent);
  }
  /* The is-sub / is-legal links set their own ivory color, which out-specifies
     the generic `.nav-overlay__menu a:hover`, so they need explicit hovers. */
  .nav-overlay__menu li.is-sub a:hover,
  .nav-overlay__menu li.is-legal a:hover { color: var(--color-pear); }
  .nav-overlay__footer {
    position: relative;
    z-index: 1;
    padding: clamp(1rem, 2vw, 2rem); color: var(--color-ivory);
    font-family: var(--font-ui);
    font-size: var(--text-s); letter-spacing: 0.05em;
    text-transform: uppercase; opacity: 0.7;
    display: flex; gap: var(--space-lg); flex-wrap: wrap; justify-content: center;
  }
  .nav-overlay__footer a { color: inherit; text-decoration: none; }
  .nav-overlay__footer a:hover { color: var(--color-pear); }
  .nav-overlay__footer-item {
    display: inline-flex; align-items: center; gap: 0.55em;
    white-space: nowrap;
  }
  .nav-overlay__footer-icon {
    inline-size: 1.25em; block-size: auto; flex: none;
    /* lift the icon optically to the cap height of the uppercase label */
    margin-block-start: -0.05em;
  }
  .nav-overlay__footer-icon--line {
    fill: none; stroke: currentColor; stroke-width: 1.7;
    stroke-linecap: round; stroke-linejoin: round;
    /* Equalise apparent line weight across icons with different viewBox sizes
       (Instagram is 20 units, the others 23–24, so a shared stroke-width would
       render the Instagram lines visibly fatter). */
    vector-effect: non-scaling-stroke;
  }
  /* Nav numbers use ONE layout at every width: the inline number column from the
     base .nav-overlay__menu a / .num rules (fixed --nav-num-col + --nav-num-gap,
     label after; sub/legal indented by --nav-indent; divider spans the block width).
     Mobile/touch previously hung the numbers in a left gutter via absolute
     positioning + a clip-path window; that was unified onto the clearer desktop
     inline look, so NO per-breakpoint structural override remains — only the
     --nav-* sizing tokens and the footer layout differ per breakpoint below. */
  @media (min-width: 721px) and (max-width: 1280px),
         (hover: none) and (pointer: coarse) and (min-width: 721px) {
    .nav-overlay {
      --nav-footer-width: max-content;
    }
    .nav-overlay__menu {
      --nav-tablet-boost-by-width: clamp(
        0rem,
        min(calc((100vw - 34rem) * 0.07), calc((80rem - 100vw) * 0.2)),
        1.25rem
      );
      --nav-tablet-boost-by-height: clamp(
        0rem,
        min(calc((100vh - 56rem) * 0.16), calc((89rem - 100vw) * 0.25)),
        1.25rem
      );
      --nav-tablet-boost: clamp(
        0rem,
        max(var(--nav-tablet-boost-by-width), var(--nav-tablet-boost-by-height)),
        1.25rem
      );
      --nav-num-gap: calc(clamp(0.6rem, 1vw, 1rem) + var(--nav-tablet-boost) * 0.12);
      --nav-num-col: calc(1.15rem + var(--nav-tablet-boost) * 0.18);
      --nav-indent: calc(var(--nav-num-col) + var(--nav-num-gap));
      --nav-link-size: calc(clamp(2rem, 1.5rem + 1.4vw, 2.85rem) + var(--nav-tablet-boost));
      --nav-sub-link-size: calc(clamp(1.3rem, 1rem + 0.9vw, 1.95rem) + var(--nav-tablet-boost) * 0.45);
      --nav-legal-size: calc(var(--text-s) + 0.18rem + var(--nav-tablet-boost) * 0.12);
      --nav-primary-block-height: calc(var(--nav-link-size) * 4 + var(--nav-rhythm) * 3);
      margin-block-start: max(0px, calc(var(--sticky-cta-bottom-y, 50svh) - var(--nav-primary-block-height) - var(--header-height) - var(--space-md)));
      margin-block-end: auto;
    }
    .nav-overlay__footer {
      inline-size: var(--nav-footer-width);
      max-inline-size: calc(100vw - 2rem);
      font-size: calc(var(--text-s) + 0.18rem);
      flex-wrap: nowrap;
    }
    .nav-overlay__footer-item {
      padding-block: 0.12rem;
    }
  }
  @media (max-width: 720px) {
    /* Phone: the stack becomes a content-width flex column (flex:1 so it fills the
       height and the footer still sits at the bottom via the menu's
       margin-block-end:auto). Menu and footer both fill it → identical width →
       their left edges line up (numbers ↔ footer icons). */
    .nav-overlay__stack {
      display: flex;
      flex-direction: column;
      flex: 1;
      inline-size: fit-content;
      max-inline-size: calc(100vw - 2rem);
    }
    .nav-overlay__menu,
    .nav-overlay__footer {
      inline-size: 100%;
      max-inline-size: none;
    }
    .nav-overlay__menu {
      --nav-num-gap: clamp(0.6rem, 2vw, 0.8rem);
      --nav-num-col: 1.15rem;
      --nav-indent: calc(var(--nav-num-col) + var(--nav-num-gap));
      --nav-link-size: clamp(2rem, 1.5rem + 1.4vw, 2.85rem);
      --nav-sub-link-size: clamp(1.3rem, 1rem + 0.9vw, 1.95rem);
      --nav-legal-size: calc(var(--text-s) + 0.12rem);
      margin-block-start: max(0px, calc(var(--sticky-cta-bottom-y, 50svh) - (var(--nav-link-size) * 4 + var(--nav-rhythm) * 3) - var(--header-height) - var(--space-md)));
      margin-block-end: auto;
    }
    .nav-overlay__footer {
      flex-direction: column;
      flex-wrap: nowrap;
      align-items: flex-start;
      gap: clamp(0.45rem, 2vw, 0.75rem);
      padding-inline: 0;
      font-size: calc(var(--text-s) + 0.12rem);
      text-align: left;
    }
    .nav-overlay__footer-item {
      justify-content: flex-start;
      padding-block: 0.12rem;
    }
  }

  /* ============================================================
     7. FOOTER BANDEROLE + SLIM FOOTER
     ============================================================ */
  .footer-banderole {
    /* Banderole isn't a section and contributes no padding of its own;
       the top margin doubles up with the preceding section's pad-bottom
       to form the full 2× --section-pad-y inter-section rhythm. Bottom
       margin stays 0 so the partner band reads as docked directly
       underneath the banderole, no visual gap between them. */
    margin-block-start: var(--section-pad-y);
    background: var(--color-pear);
    color: var(--color-oxford);
    overflow: hidden;
    position: relative;
    z-index: 5;
    text-decoration: none;
    display: block;
  }
  .footer-banderole__row {
    padding-block: clamp(0.25rem, 0.5vw, 0.5rem);
    overflow: hidden;
  }
  .footer-banderole__track {
    display: flex; gap: clamp(0.8rem, 1.6vw, 1.6rem);
    width: max-content;
    animation: bandRoll 32s linear infinite;
  }
  .footer-banderole__row--reverse .footer-banderole__track {
    animation-direction: reverse;
  }
  @media (prefers-reduced-motion: reduce) {
    /* Marquee anhalten (WCAG 2.2.2/2.3.3): Dauer-Bewegung für Nutzer:innen mit
       reduzierter-Bewegung-Präferenz stoppen. */
    .footer-banderole__track { animation: none; }
  }
  .footer-banderole__item {
    font-family: var(--font-ui);
    font-weight: var(--weight-bold);
    font-size: clamp(2.2rem, 1.4rem + 2.6vw, 4.2rem);
    text-transform: uppercase; letter-spacing: 0.02em; line-height: 1;
    white-space: nowrap;
    display: inline-flex; align-items: center;
    gap: clamp(0.8rem, 1.6vw, 1.6rem);
  }
  .footer-banderole__item::after {
    content: "";
    width: calc(var(--spark-marker-width) + 1px);
    height: 0.425em;
    border-radius: 2px;
    background: var(--color-spark);
    flex-shrink: 0;
  }
  .footer-banderole:hover { background: var(--color-pear-dark, var(--color-pear)); }
  @keyframes bandRoll { from { transform: translateX(0); } to { transform: translateX(-50%); } }

  .site-footer {
    background: var(--color-oxford);
    color: var(--color-ivory);
    /* Peer line to the header's bottom edge: same 2px solid, full viewport
       width. Caps the footer site-wide and gives a crisp seam between it and
       the Marian partner band above. */
    border-top: 2px solid var(--color-pear);
    padding: clamp(2rem, 4vw, 4rem) var(--section-pad-x);
    /* Full-width chrome (bg + Pear line span the viewport), but the CONTENT is
       inset via .site-footer__inner to the same 10-of-12 column span (cols 2–12)
       as the partner tile row above, so the footer content edges line up with it.
       Query container so that inset can relax on narrow widths. */
    display: grid;
    grid-template-columns: repeat(12, 1fr);
    column-gap: 0;
    align-items: center;
    container-type: inline-size;
    container-name: footer;
    font-family: var(--font-ui);
    font-size: var(--text-s);
    text-transform: uppercase; letter-spacing: 0.05em;
    position: relative; z-index: 5;
    /* Overscroll skirt. The slideshow (.stage-bg) is position:fixed behind
       everything, so when the page rubber-bands at the BOTTOM the footer lifts
       off it and the photo would flash through the gap below the footer. This
       box-shadow paints a solid Oxford slab one viewport tall, flush with and
       below the footer; it bounces WITH the footer (unlike the pinned
       slideshow) and renders at the footer's stacking level (z-index 5 > the
       slideshow's 0), so it masks the image during the stretch. box-shadow
       (not an abs-positioned ::after) is used deliberately: it adds no
       scrollable height, so it never creates real scroll space below the
       footer. offset-y == spread (50vh) keeps the slab flush with the footer's
       bottom edge; sideways spread is clipped by body's overflow-x: clip. */
    box-shadow: 0 50vh 0 50vh var(--color-oxford);
  }
  /* Content band: 10 of 12 columns (cols 2–12), the same span as the partner
     tile row above so their left/right edges align. Inside it, the original
     1fr | auto | 1fr keeps copyright left, back-to-top centered, links right. */
  .site-footer__inner {
    grid-column: 2 / 12;
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: center;
    gap: var(--space-md);
  }
  .site-footer__left {
    grid-column: 1;
    justify-self: start;
    display: flex; align-items: center; gap: var(--space-md);
  }
  .site-footer__links {
    grid-column: 3;
    justify-self: end;
    display: flex; gap: var(--space-md); flex-wrap: wrap;
  }
  /* Narrow footers: 8 columns get cramped, so the content band spans the full
     width again (the chrome was always full-width). */
  @container footer (max-width: 720px) {
    .site-footer__inner { grid-column: 1 / -1; }
  }
  .site-footer svg { width: 32px; height: auto; }
  .site-footer a { color: inherit; text-decoration: none; opacity: 0.85; }
  .site-footer a:hover { color: var(--color-pear); opacity: 1; }
  /* Current legal page — full opacity + persistent underline so the active
     link reads as "you are here". */
  .site-footer__links a[aria-current="page"] {
    opacity: 1;
    color: var(--color-pear);
    text-decoration: underline;
    text-underline-offset: 0.25em;
  }

  /* Back-to-top — centered in the footer's middle column. Round,
     Pear-on-Oxford so it carries the same affordance as the play button
     in the audio section. Selector uses a.… so it beats
     `.site-footer a { color: inherit }` on specificity — otherwise the
     SVG (using currentColor) would render in Ivory instead of Oxford,
     i.e. a white arrow on Pear. */
  a.site-footer__to-top {
    grid-column: 2;
    justify-self: center;
    display: inline-flex; align-items: center; justify-content: center;
    width: clamp(2.5rem, 2.2rem + 1vw, 3rem);
    height: clamp(2.5rem, 2.2rem + 1vw, 3rem);
    border-radius: 50%;
    background: var(--color-pear);
    color: var(--color-oxford);
    opacity: 1;
    transition: transform 140ms ease, background 140ms ease;
  }
  a.site-footer__to-top:hover {
    color: var(--color-oxford);
    background: var(--color-ivory);
    transform: translateY(-2px);
    opacity: 1;
  }
  a.site-footer__to-top svg {
    width: 60%; height: 60%;
  }

  /* ============================================================
     SCROLL HINT
     ============================================================ */
  .scroll-hint {
    /* Viewport-anchored so the hint stays at the bottom of the fold even
       when the hero section grows past 100svh on short viewports. Fade
       out via `body[data-section]` once the user scrolls past hero. */
    position: fixed; bottom: var(--space-md); left: 50%;
    transform: translateX(-50%);
    font-family: var(--font-ui);
    font-weight: var(--weight-semibold);
    font-size: var(--text-xs);
    text-transform: uppercase; letter-spacing: 0.1em;
    /* Ivory bleibt: der Hinweis schwebt über dem meist dunklen, wechselnden
       Bühnen-Bild. Ein Oxford-Schatten (nicht Schwarz) hebt den Kontrast auf
       helleren Bildstellen, ohne auf dunklen Flächen zu stören (WCAG 1.4.3). */
    color: var(--color-ivory); opacity: 0.95; z-index: 5;
    text-shadow: 0 1px 4px rgba(3, 0, 39, 0.75), 0 0 2px rgba(3, 0, 39, 0.6);
    display: flex; align-items: center; gap: 0.6em;
    transition: opacity 240ms cubic-bezier(0.65, 0, 0.35, 1);
  }
  /* Hide once the user has scrolled past the hero — the JS section
     observer flips body[data-section] to whichever section is dominant
     in the viewport, so this `:not` covers leistungen, audio, about,
     kontakt, partner, unterricht, voizless. */
  body:not([data-section="hero"]) .scroll-hint {
    opacity: 0;
    pointer-events: none;
  }
  /* Vertical stroke — same WEIGHT (thickness) as the eyebrow pin (3px),
     Spark red, animated draw-down. */
  .scroll-hint::after {
    content: "";
    width: var(--spark-marker-width);
    height: 28px;
    border-radius: 2px;
    background: var(--color-spark);
    transform-origin: top center;
    animation: strokeDrop 1.6s ease-in-out infinite;
  }
  @keyframes strokeDrop {
    0%, 100% { transform: scaleY(0.25); opacity: 0.4; }
    50%      { transform: scaleY(1);    opacity: 1; }
  }
  @media (prefers-reduced-motion: reduce) {
    /* Dauer-Animation des Scroll-Strichs stoppen (WCAG 2.2.2/2.3.3). */
    .scroll-hint::after { animation: none; }
  }

  /* Audio player — three-track playlist on top, shared WaveSurfer deck
     below. Clicking a list item swaps the source in the single waveform.
     Class names keep the `player-mock` namespace from when this was a
     static mock; the styles now back a real WaveSurfer instance. */
  .player-mock {
    /* Shared geometry tokens so the playlist track-number column lines
       up exactly with the play button — same width, same gap to the
       track name as the play button has to the waveform. */
    --player-control-size: 56px;
    --player-control-gap: var(--space-md);
    display: flex; flex-direction: column;
    gap: var(--space-md);
    width: 100%; min-width: 0;
  }

  /* Tracklist — title row carries num, title+meta, duration.
     The list itself stays at content width (inside tile padding) so the
     dividers between tracks read as inset hairlines. Only the very top
     rule extends edge-to-edge across the tile, drawn as a ::before
     pseudo-element that breaks out past the tile padding. */
  .player-mock__tracks {
    list-style: none; padding: 0; margin: 0;
    display: flex; flex-direction: column;
    position: relative;
  }
  .player-mock__tracks::before {
    content: '';
    position: absolute;
    top: 0;
    /* Break out past the tile padding to the card's edges. On the LEFT the
       visible edge is the goo card fill, ~12px past the tile box (--goo-bleed
       outset + --goo-soft-edge), so extend that far to reach it — same amount
       the photo bleeds. The player doesn't drift sideways (--dx:0), so the card
       fill + stroke translate as one on scroll: this fixed offset stays flush
       through the whole dock-in. (The RIGHT end's offset is set separately
       below — it adds --goo-bleed to balance the content padding.) */
    left: calc(var(--tile-pad) * -1 - var(--goo-bleed) - var(--goo-soft-edge));
    /* The player content carries +--goo-bleed padding-inline-end (see .t-player
       content rule) to balance the goo bleed, which pulls the tracks' right edge
       inward by that amount — so break the rule out an extra --goo-bleed to still
       reach the photo / card edge on the right. */
    right: calc(var(--tile-pad) * -1 - var(--goo-bleed));
    height: 1px;
    background: color-mix(in srgb, var(--color-pear) 75%, transparent);
  }
  .player-mock__item {
    border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
  }
  .player-mock__item:last-child { border-bottom: 0; }
  .player-mock__item-btn {
    width: 100%; display: grid;
    /* minmax(0, 1fr) on the middle column lets the title/meta text wrap
       at very narrow viewports instead of forcing the grid wider than
       its container — `1fr` alone is `minmax(auto, 1fr)`, and the auto
       min-content floor prevented shrinking. */
    grid-template-columns: var(--player-control-size) minmax(0, 1fr) auto;
    column-gap: var(--player-control-gap);
    row-gap: 0;
    align-items: baseline;
    padding: var(--space-2xs) 0;
    background: transparent; border: 0;
    color: inherit; font: inherit; text-align: left;
    cursor: pointer;
    transition: color 140ms ease;
  }
  .player-mock__item-btn:hover {
    color: var(--color-pear);
  }
  .player-mock__item-btn:focus-visible {
    /* Sichtbarer Fokusring statt nur Farbwechsel (WCAG 2.4.7) — wie beim
       Play-Button (Ivory-Ring auf dem dunklen Deck). */
    color: var(--color-pear);
    outline: 2px solid var(--color-ivory);
    outline-offset: 2px;
  }
  .player-mock__item.is-active .player-mock__item-btn { color: var(--color-pear); }
  .player-mock__item-num {
    grid-row: 1 / 3; align-self: center; justify-self: center;
    font-family: var(--font-ui);
    font-weight: var(--weight-bold);
    font-size: var(--s0);
    letter-spacing: var(--tracking-wide);
    opacity: 0.55;
  }
  .player-mock__item.is-active .player-mock__item-num { opacity: 1; }
  .player-mock__item-title {
    grid-column: 2; grid-row: 1;
    font-family: var(--font-ui);
    font-weight: var(--weight-bold);
    font-size: var(--text-lead);
    line-height: var(--line-height-tight);
    text-transform: uppercase;
    letter-spacing: var(--tracking-wide);
  }
  .player-mock__item-meta {
    grid-column: 2; grid-row: 2;
    font-size: var(--text-s);  /* same size as the eyebrows */
    line-height: var(--line-height-snug);
    /* Semibold keeps the dimmed annotation readable at this small size while
       staying on an installed font cut. */
    font-weight: var(--weight-semibold);
    opacity: 0.6;
  }
  .player-mock__item-time {
    grid-row: 1 / 3; grid-column: 3;
    align-self: center;
    font-family: var(--font-ui);
    font-feature-settings: 'tnum' 1;
    font-size: var(--text-s);
    opacity: 0.7;
  }

  /* Deck — play button + shared waveform sit side-by-side. */
  .player-mock__deck {
    display: flex; align-items: center;
    gap: var(--player-control-gap);
  }

  /* Noscript fallback — three native <audio> elements, no styling needed. */
  .player-mock__noscript {
    list-style: none; margin: 0; padding: 0;
    display: flex; flex-direction: column; gap: 0.75rem;
  }
  .player-mock__noscript audio { width: 100%; margin-top: 0.25rem; }

  .player-mock__play {
    width: var(--player-control-size);
    height: var(--player-control-size);
    border-radius: 50%;
    background: var(--color-pear); color: var(--color-oxford);
    display: inline-flex; align-items: center; justify-content: center;
    font-size: var(--text-lead); flex-shrink: 0;
    border: 0; padding: 0; cursor: pointer;
    font-family: inherit;
    transition: transform 120ms ease, filter 120ms ease;
  }
  /* Icons inside the play button — SVG so we control width/height
     independently of any glyph's intrinsic aspect ratio. Sized in em so
     they scale with the button's font-size. The play triangle is taller
     than wide; aria-pressed on the button swaps which icon is visible. */
  .player-mock__play-icon {
    width: 0.95em;
    height: 1.25em;
  }
  .player-mock__play-icon--play {
    /* Optical nudge: a right-pointing triangle's visual mass sits left of
       its geometric center, so push it a hair right to look centered. */
    margin-inline-start: 0.16em;
  }
  .player-mock__play-icon--pause { display: none; }
  .player-mock__play[aria-pressed="true"] .player-mock__play-icon--play  { display: none; }
  .player-mock__play[aria-pressed="true"] .player-mock__play-icon--pause { display: block; margin-inline-start: 0; }
  .player-mock__play:hover { transform: scale(1.06); }
  .player-mock__play:active { transform: scale(0.96); }
  .player-mock__play:focus-visible {
    outline: 2px solid var(--color-ivory);
    outline-offset: 3px;
  }
  .player-mock__waveform {
    height: 110px;
    flex: 1;
    position: relative;
    /* min-width: 0 lets the canvas shrink below its intrinsic width inside
       narrow grid/flex tracks; overflow:hidden is a safety belt so the
       WaveSurfer-rendered canvas can never bleed past the tile edge.
       No mask — the custom bubble renderer has its own soft top/bottom
       shape, a mask would clip the upper/lower halfcircles. */
    min-width: 0;
    overflow: hidden;
  }

  /* Responsive simplifications */
  /* ============================================================
     RESPONSIVE TOPOLOGY
     Spacings are fluid (see tokens). The media query ONLY flips
     layout topology: diagonal-cascade grid → vertical stack.
     ============================================================ */
  /* Tiny phones only: below ~390px the full-size wordmark would push the name
     into the Voizless pill + burger. Instead of wrapping (or hiding the
     subline), scale the whole wordmark down so the name stays on ONE line and
     the width-matched subline stays visible. The fluid value meets the base
     2rem floor exactly at ~390px, so there's no jump at the breakpoint. */
  @media (max-width: 389px) {
    .wordmark__text { --wordmark-name-size: clamp(1.3rem, calc(15vw - 1.65rem), 2rem); }
  }

  /* Very narrow phones (≤340px): the header cluster (wordmark + pill + burger)
     no longer fits on one line. Drop the Voizless pill from the top bar — it
     stays reachable as a dedicated link in the nav overlay (and as the standalone
     /voizless/ page), so nothing is lost. */
  @media (max-width: 340px) {
    .voizless-pill { display: none; }
  }

  /* Hero H1 on very narrow phones. --text-3xl's base clamp (tokens.css) bottoms
     out at 6.5rem/104px around ~360px and then stays STATIC, so below ~360px the
     headline neither shrinks with the viewport nor fits. We override the TOKEN
     itself (global: every h1 benefits) but only ≤360px, leaving the tablet/
     desktop curve exactly as defined. The override is ~proportional (28.9vw ≈ the
     104px/360px ratio), so the headline keeps the SAME share of the width it has
     at 360px all the way down — continuous at 360px (28.9vw caps to 6.5rem there)
     and never collapsing to a token size. The floor stops it shrinking below the
     280px design target. */
  @media (max-width: 360px) {
    :root { --text-3xl: clamp(4.25rem, 28.9vw, 6.5rem); }

    /* The hero CTA label is white-space:nowrap by default (landing.css:636); on
       these narrow phones "Soundcheck planen" + icon is wider than the tile, so
       it was the remaining source of tile overflow. Let it wrap instead — it only
       wraps when the width actually forces it. (Tight line-height + centred text
       are global on .btn in site.css; here we only undo the hero's nowrap.) */
    .hero-cta .btn { white-space: normal; }
  }

  @media (max-width: 720px) {
    .voizless-pill { padding: 0.45em 0.9em; font-size: 0.74rem; }

    /* Sections lose their viewport-lock on phones — auto-height with
       fluid breathing room above & below so the slideshow shows
       through the gaps between sections. */
    .section {
      min-height: auto;
      height: auto;
      grid-template-rows: auto;
      row-gap: var(--tile-stack-gap);
    }
    /* Inter-section spacing is now the .section-stack flex-gap (--section-gap,
       height-scaled), so the old per-pair margin reveal is gone — one source for
       desktop and mobile alike. */

    .tile--lead { min-height: auto; }

    /* HERO on mobile — tile is top-anchored so headline sits in
       the upper half and lede/CTA flow below the fold. The free
       space above the tile = header + a fluid bg-reveal slot,
       so the slideshow has more visible image area. */
    .section--hero {
      min-height: 100svh;
      height: auto;
      align-items: stretch;
      padding-block: calc(var(--header-height) + clamp(50svh, 44svh + 10vw, 64svh)) 0;
      padding-inline: 0;
    }
    .section--hero .t-intro {
      position: relative;
      left: auto;
      right: auto;
      bottom: auto;
      margin-top: 0;
      margin-bottom: 0;
      margin-inline: var(--section-pad-x);
      width: auto;
      min-height: 0;
      max-height: none;
    }

    /* On mobile the intro-tile stacks photo above content (flex column).
       The photo gets a fluid height; content flows beneath. */
    .section--about .t-intro {
      flex-direction: column;
    }
    .section--about .t-intro__photo {
      width: 100%;
      aspect-ratio: 16 / 10;   /* match the player-section photo height */
      /* Reset the desktop photo over-cover: the goo fill ::before is ≥721px
         only, so on mobile the -12px outset cover would just overlap the
         stacked card's content here. */
      margin-block: 0;
      z-index: auto;
      /* The tile doesn't clip its children here, so round the photo's own
         top-right corner to match the tile radius. Top-left stays square to
         meet the left bleed flush. (Photo already has overflow:hidden.) */
      border-start-end-radius: var(--radius-lg);
    }
    /* Wide 16/10 crop of a tall portrait drops the face out the top by default
       (object-position: center). Anchor the crop higher so the musician's face
       stays in frame. Tunable. */
    .section--about .t-intro__photo img {
      object-position: center 20%;
    }
    /* Each non-hero tile spans the full content area, stacked vertically */
    .section--about .t-intro,
    .section--about .t-childhood,
    .section--about .t-training,
    .section--about .t-quote,
    .section--about .t-today,
    .section--leistungen .t-occasions,
    .section--leistungen .t-headline,
    .section--leistungen .t-services,
    .section--leistungen .t-closing,
    .section--audio .t-title,
    .section--audio .t-player,
    .section--kontakt .t-line,
    .section--kontakt .t-cta,
    .section--kontakt .t-social {
      grid-column: 1 / 13;
      grid-row: auto;
      min-height: 0;
      /* Bleed modifiers on tiles (.tile--bleed-left / -right) keep their
         negative inline-margin on mobile — that way the left/right edge
         rhythm stays consistent with desktop. Non-bleed tiles have no
         margin-inline to begin with, so this rule no longer resets it. */
    }

    /* Mobile content padding = tight, symmetric --tile-pad.
       The ~--goo-bleed (10px) gutter is NOT padding on mobile — it belongs on
       the card's bled ::before outset (see the mobile ::before block below),
       so the CARD grows outward while the TEXT keeps a tight, even inset.
       (The old model baked --goo-bleed into the content padding, which pushed
       the text inward and forced early headline wraps.) Special inline
       overrides (bleed-left/-right swallowed-gutter compensation, player) and
       the edge-to-edge cta-photo (padding:0) keep their own values via higher
       specificity. */
    .tile__content,
    .section--about .t-intro__content {
      padding: var(--tile-pad);
    }
    /* „Zwischen Klassik und Pop" (t-training, .tile--bleed-right) erbt aus der
       globalen bleed-right-Regel ein padding-inline-start von --tile-pad +
       --space-md. Auf Mobile sitzt die LINKE (nicht-blutende) Kante aber an
       derselben Spalte wie die übrigen Karten, sodass das Extra den Text nach
       rechts schiebt — sichtbar mehr Einzug als z. B. „Für Momente" (t-occasions).
       Linkes Padding zurück auf --tile-pad → linke Textspalte bündig. Der rechte
       Bleed (Kante/Kompensation) bleibt unberührt. */
    .section--about .t-training > .tile__content { padding-inline-start: var(--tile-pad); }

    /* Dreier-Merge: die LETZTE Karte (occasions / training) fährt sichtbar ein.
       Der reine Grow ist nur 0.92→1.0 (8 %) — praktisch unsichtbar; der CSS-Slide
       (goo-tileDockIn über --section-view) progressed auf den hohen Mobile-Sections
       nicht (Doku-Punkt 2), die Karten standen also still. Diese Distanzen treiben
       einen JS-Slide (geomProgress) in goo-webgl.js: die Karte kommt von UNTEN
       (slide-y) und von RECHTS (slide-x) rein und setzt sich. Wirkt NUR mobil
       (geomProgress läuft ≤720px; Desktop nutzt den CSS-Dock-in). Tuning-Knöpfe. */
    .section--leistungen .t-occasions,
    .section--about .t-training { --goo-slide-x: 40px; --goo-slide-y: 140px; }

    /* Mobile only: native German hyphenation. Drop the global
       `p { text-wrap: pretty }` (Safari's pretty suppresses hyphenation) and
       enable hyphens here. Desktop/tablet keep pretty WITHOUT hyphens, so Chrome
       and Safari render the same there. */
    .body-copy,
    .hero-lede,
    .lead,
    .legal__prose :is(p, li, dd) {
      text-wrap: wrap;
      -webkit-hyphens: auto;
      hyphens: auto;
    }
    /* Hyphenation is COPY-only — the handwritten script lines (--font-accent:
       .closing-line, .greeting-caveat, .ghost-quote) must never hyphenate. They
       aren't in the list above; this guard keeps them safe even if a copy class
       is ever added to one of them. */
    .closing-line,
    .greeting-caveat,
    .ghost-quote {
      -webkit-hyphens: manual;
      hyphens: manual;
    }

    /* Bleed-left tiles run their box to the viewport edge, so their content
       would start ~section-pad-x further LEFT than the non-bleed tiles below.
       Add the swallowed gutter back on the left so the headline text lines up
       on the same vertical with the body text of the tile beneath it. */
    .tile--bleed-left > .tile__content {
      padding-inline-start: calc(var(--tile-pad) + var(--section-pad-x));
    }

    /* Services tile (Sektempfang / Dinner / Party) — three columns narrower
       from the left, staggered against the full-width tiles above/below. */
    .section--leistungen .t-services { grid-column: 2 / 12; }

    /* Headline/intro tile — one column shorter on the right (left edge keeps
       its bleed to the viewport). Right padding comes from the global mobile
       rule (--tile-pad). */
    .section--leistungen .t-headline { grid-column: 1 / 12; }

    /* "Mehr als Saxophon?" is the closing / topic-change tile (not part of the
       goo merge above). Give it generous breathing room above — on top of the
       stack row-gap — so it reads as a deliberate new beat. */
    .section--leistungen .t-closing {
      margin-block-start: var(--space-xl);
      grid-column: 2 / 12;
    }
    /* Override the inset narrowing for this tile — fill the (now 2/12) cell. */
    .section--leistungen .t-closing.tile--inset {
      width: auto;
      justify-self: stretch;
    }
    /* Let "Mehr als Saxophon?" run wider — drop the tile's right inner padding. */
    .section--leistungen .t-closing > .tile__content {
      padding-inline-end: 0;
    }

    /* AUDIO — intro tile ("Drei Tracks, ein Vibe.") bleeds LEFT to the viewport
       edge and runs two columns shorter on the right. The text start line stays
       put: the left content padding gains the swallowed gutter to compensate
       the bleed. */
    .section--audio .t-title {
      grid-column: 1 / 12;
      margin-inline-start: calc(var(--section-pad-x) * -1);
      border-start-start-radius: 0;
      border-end-start-radius: 0;
      /* Mobile-only left bleed without .tile--bleed-left: push the shader's
         uniform-radius left corner off-screen. See t-occasions for the full
         rationale. Desktop never sets this, so the composition is untouched. */
      --goo-edge-left: 56px;
    }
    .section--audio .t-title > .tile__content {
      padding-inline-start: calc(var(--tile-pad) + var(--section-pad-x));
      padding-inline-end: var(--tile-pad);
    }

    /* AUDIO — player tile bleeds RIGHT to the viewport edge (photo runs
       edge-to-edge) and is one column narrower on the left. The controls
       beneath keep their alignment: the right content padding gains the
       swallowed gutter so they don't hug the viewport edge. */
    .section--audio .t-player {
      grid-column: 2 / 13;
      margin-inline-end: calc(var(--section-pad-x) * -1);
      border-start-end-radius: 0;
      border-end-end-radius: 0;
      /* Mobile-only right bleed without .tile--bleed-right — see t-occasions. */
      --goo-edge-right: 56px;
    }
    .section--audio .t-player > .tile__content {
      /* Tighter padding on mobile (drop the goo-bleed extra) so the controls
         and tracklist gain width. Right side stays bleed-compensated. */
      padding: var(--tile-pad);
      padding-inline-end: calc(var(--tile-pad) + var(--section-pad-x));
    }
    /* Tighten the shared control gap (play→waveform AND track-number→title use
       the same token, so they shrink in lockstep and stay aligned) — buys more
       width for the track titles without moving any text baseline. */
    .section--audio .player-mock {
      --player-control-gap: var(--space-2xs);
      /* Play button + track-number column shrink together (stay aligned) so
         the title gains width on the left. */
      --player-control-size: 48px;
    }
    /* Pull the runtime a touch closer to the title — tightens just the
       title↔time gap, leaving the play→waveform gap untouched. */
    .section--audio .player-mock__item-time {
      margin-inline-start: calc(var(--player-control-gap) * -0.4);
    }

    /* ABOUT — intro/headline tile (bleeds left) one column shorter on the right. */
    .section--about .t-intro { grid-column: 1 / 12; }
    /* Smaller right padding so the headline isn't forced into four lines. */
    .section--about .t-intro__content {
      padding-inline-end: var(--tile-pad);
    }
    /* "Wie alles begann" — one column narrower on each side. */
    .section--about .t-childhood { grid-column: 2 / 12; }
    .section--about .t-childhood > .tile__content {
      padding-inline-end: var(--tile-pad);
    }
    /* "Heute auf der Bühne" — same breathing room as the "Mehr als Saxophon"
       closing tile: generous gap above, one column of air each side, inset
       narrowing dropped so it fills the cell. (NB: this tile will be excluded
       from the goo merge later — it docks on its own.) */
    .section--about .t-today {
      margin-block-start: var(--space-xl);
      grid-column: 2 / 12;
    }
    .section--about .t-today.tile--inset {
      width: auto;
      justify-self: stretch;
    }
    /* Match the sibling About tiles: drop the goo-bleed extra on the right so
       the text column isn't narrower here. */
    .section--about .t-today > .tile__content {
      padding-inline-end: var(--tile-pad);
    }

    /* KONTAKT — text tile ("Lasst uns reden"): bleeds left, two columns
       shorter on the right, goo-bleed extra dropped on the right. Text stays
       on its line via the left bleed compensation. */
    .section--kontakt .t-line {
      grid-column: 1 / 12;
      margin-inline-start: calc(var(--section-pad-x) * -1);
      border-start-start-radius: 0;
      border-end-start-radius: 0;
      /* Mobile-only left bleed without .tile--bleed-left — see t-occasions. */
      --goo-edge-left: 56px;
    }
    .section--kontakt .t-line > .tile__content {
      padding-inline-start: calc(var(--tile-pad) + var(--section-pad-x));
      padding-inline-end: var(--tile-pad);
    }

    /* KONTAKT — photo+button CTA tile: bleeds right, one column narrower on
       the left. */
    .section--kontakt .t-cta {
      grid-column: 2 / 13;
      margin-inline-end: calc(var(--section-pad-x) * -1);
      border-start-end-radius: 0;
      border-end-end-radius: 0;
      /* Mobile-only right bleed without .tile--bleed-right — see t-occasions. */
      --goo-edge-right: 56px;
    }

    /* KONTAKT — Instagram tile: drop the goo-bleed extra on the right. */
    .section--kontakt .t-social > .tile__content {
      padding-inline-end: var(--tile-pad);
    }

    /* PARTNER — more breathing room top & bottom on mobile. */
    .section--partner {
      padding-block: var(--space-2xl);
    }


    /* FOOTER — generous breathing room below the content on mobile. */
    .site-footer { padding-bottom: clamp(4rem, 10vw, 6rem); }

    /* FOOTER — first compact step: move the back-to-top control above, but keep
       logo/copyright and legal links beside each other while they still fit. */
    .site-footer__inner {
      display: grid;
      grid-template-columns: auto auto;
      grid-template-areas:
        "top  top"
        "logo links";
      justify-content: center;
      justify-items: center;
      align-items: center;
      gap: var(--space-xs) var(--space-md);
    }
    a.site-footer__to-top { grid-area: top; }
    .site-footer__left {
      grid-area: logo;
      gap: var(--space-2xs);
      justify-content: center;
      justify-self: center;
      white-space: nowrap;
    }
    .site-footer__links {
      grid-area: links;
      gap: var(--space-md);
      justify-content: center;
      justify-self: center;
      flex-wrap: nowrap;
    }
    /* Logo a touch smaller. */
    .site-footer__left svg { width: 26px; }

    /* FOOTER — final compact step: once the two lower bundles no longer fit
       beside each other, give each bundle its own centered row. */
    @container footer (max-width: 352px) {
      .site-footer__inner {
        grid-template-columns: minmax(0, 1fr);
        grid-template-areas:
          "top"
          "logo"
          "links";
        row-gap: var(--space-xs);
      }
    }

    /* "Für Momente, die bleiben" — mirror the left-bleed headline on the
       RIGHT edge (mobile only; desktop keeps its 8/11 grid cell). Box bleeds
       to the viewport edge, right corners flatten, and the content's right
       padding gains the swallowed gutter so the text stays on the same
       vertical as the non-bleed tiles. */
    .section--leistungen .t-occasions {
      margin-inline-end: calc(var(--section-pad-x) * -1);
      border-start-end-radius: 0;
      border-end-end-radius: 0;
      /* Right edge bleeds to the viewport on mobile only (no .tile--bleed-right
         class — that would also bleed it on desktop). The WebGL shader draws a
         uniform corner radius, so without an edge outset its rounded right corner
         lands ON-screen (r.right − radius). --goo-edge-right tells goo-webgl.js to
         push the bleeding right edge (and its corner) the same 56px off-screen as
         a real bleed-right tile, from the start of the dock-in. Desktop never sets
         this var, so the desktop composition is untouched. */
      --goo-edge-right: 56px;
    }
    .section--leistungen .t-occasions > .tile__content {
      padding-inline-end: calc(var(--tile-pad) + var(--section-pad-x));
    }

    /* Overflow guard: edge-bleeding tiles reach the viewport edge exactly,
       but subpixel rounding can otherwise open a sliver of horizontal scroll.
       Clip (not hidden) so the vertical axis stays visible and no scroll
       container is created. */
    .section { overflow-x: clip; }

    /* Mobile-only accent-tilt reduction — the desktop -6° looks busier
       in the narrower single-column stack, so soften to -2°. Same token,
       different value per breakpoint. */
    :root { --tilt-accent: -2deg; }


    /* CTA-on-photo on mobile — gleiches Edge-to-Edge-Treatment, das
       Portrait-Foto braucht aber eine schmalere Aspect-Ratio, damit die
       Figur nicht gequetscht wird. Der Button behält sein inneres Margin
       (`.tile.tile--cta-photo .btn { margin: calc(var(--tile-pad) + var(--goo-bleed)) }` oben). */
    .tile.tile--cta-photo {
      min-height: 0;
      aspect-ratio: 3 / 4;
    }
    .tile.tile--cta-photo > .tile__content {
      padding: 0;
    }

    /* Hörbeispiele player on mobile — the desktop subgrid (photo | controls)
       would squeeze the photo into 2 of 12 section columns, so revert to the
       default flex column: photo on top (full-width, fixed ratio, edge-to-edge),
       controls stacked beneath. */
    .section--audio .t-player {
      display: flex;
      flex-direction: column;
      overflow: hidden;          /* keep the photo clipped to the tile radius */
    }
    .section--audio .t-player__photo {
      width: 100%;
      aspect-ratio: 16 / 10;
      /* No desktop bleed/own-radius on mobile: the goo stage is hidden, the
         tile paints its own solid bg and clips the photo to its top corners. */
      margin: 0;
      border-radius: 0;
      z-index: auto;
    }

    /* Centred-inset variant on mobile — tiles tagged .tile--inset render
       slightly narrower than the viewport and sit centred, creating a
       deliberate visual pause next to full-bleed or full-width tiles.
       Specificity is high enough (.tile.tile--inset) to override any
       desktop-specific margin (e.g. About's t-today photo-edge inset),
       and the rule sits inside the mobile media query so desktop layouts
       are unaffected. `width: 85%` (instead of `max-width`) plus
       `justify-self: center` overrides the grid item's default stretch
       and any tile-internal intrinsic sizing that would otherwise leave
       the item narrower than the inset target. */
    .tile.tile--inset {
      width: 85%;
      max-width: none;
      justify-self: center;
      margin-inline: 0;
    }

    /* Ghost-quote: on mobile, drop the desktop's leftward translate and
       left-anchored rotation pivot so the handwritten line sits centred
       in its tile, rotating around its own centre. align-items wandert
       auf den Wrapper, da dieser jetzt der Flex-Container ist. */
    .section--about .t-quote > .tile__content {
      align-items: center;
      text-align: center;
    }
    .section--about .t-quote { text-align: center; }
    .ghost-quote {
      transform: rotate(var(--tilt-accent));
      transform-origin: center;
    }

    /* ── Mobile goo foundation (Step A): cards carry the bled edge + GROW ──────
       No WebGL / no merge on phones (yet), so each card behaves like a desktop
       plain tile: transparent box + a bled ::before that paints the fill AND
       scales in 0.92→1.0 as the card enters — on its OWN view() timeline (mobile
       sections are far taller than the viewport, so --section-view would finish
       the grow long before the card is on screen). Text stays static (its
       --tile-pad keeps it off the edge); the tile does NOT translate (no
       horizontal dock-in in a single column). border-radius:inherit keeps the
       flattened outer corners on bleed cards. */
    .section:not(.section--hero) .tile {
      background: transparent;
    }
    .section:not(.section--hero) .tile::before {
      content: "";
      position: absolute;
      inset: calc(var(--goo-bleed) * -1);
      background: var(--tile-color, var(--section-color));
      border-radius: inherit;
      z-index: -1;
    }
    @supports (animation-timeline: view()) {
      .section:not(.section--hero) .tile::before {
        animation: tileBleedScaleIn cubic-bezier(0.65, 0, 0.35, 1) both;
        animation-timeline: view();
        animation-range: entry 25% entry 100%;
        transform-origin: center;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      .section:not(.section--hero) .tile::before { animation: none; transform: none; }
    }

    /* Only the ghost-quote and the edge-to-edge CTA photo skip the bled fill:
       the ghost stays transparent, and the CTA photo COVERS its whole card (the
       photo is the fill). The two top-photo cards (About intro, Audio player)
       KEEP the growing ::before fill like every solid card — their photo then
       grows in lockstep on top (see Step B + the JS origin). */
    .section .tile--ghost::before,
    .section .tile--cta-photo::before {
      display: none;
    }
    /* Photo cards bleed EXACTLY like the solid cards: the fill keeps its
       --goo-bleed outset (general rule above), and the PHOTO bleeds the SAME
       --goo-bleed on its outer edges so it covers the fill FLUSH — no colour
       frame — and tracks the fill's growing edge (JS sets the photo origin to the
       card centre). The bottom edge stays flush with the content below. */
    .section--about .t-intro__photo,
    .section--audio .t-player__photo {
      width: calc(100% + var(--goo-bleed) * 2);
      margin-inline-start: calc(var(--goo-bleed) * -1);
      margin-block-start: calc(var(--goo-bleed) * -1);
    }
    /* The player tile clipped its photo to the tile radius via overflow:hidden —
       that would also clip the now-bled photo back to the box. Move the clip onto
       the photo itself (it already has overflow:hidden; just round its top). */
    .section--audio .t-player { overflow: visible; }
    .section--audio .t-player__photo {
      border-start-start-radius: var(--radius-lg);
      border-start-end-radius: var(--radius-lg);
    }
    /* CTA photo IS the card → cover the tile + the same bleed on all four sides
       (object-fit:cover keeps the crop). The tile's own overflow:hidden would
       clip the bled photo back to the box (gap on the bleeding sides) → drop it
       and round the photo itself instead. max-width:none defeats the global
       img{max-width:100%} cap (which was silently capping the width to 100%, so
       only the height grew → the photo sat short on the right). */
    .section--kontakt .tile--cta-photo { overflow: visible; }
    .section--kontakt .t-cta > picture > img {
      inset: calc(var(--goo-bleed) * -1);
      width: calc(100% + var(--goo-bleed) * 2);
      height: calc(100% + var(--goo-bleed) * 2);
      max-width: none;
      border-radius: var(--radius-lg);
    }

    /* Bleed cards reach the viewport edge — push the ::before's bleeding edge
       well PAST it (×4 the bleed) so the grow's inward scale never retracts it
       from the edge (that left a gap to the viewport at <1.0 scale). The inner
       edges keep the normal --goo-bleed. Section overflow-x:clip hides the
       overhang. */
    .section--leistungen .t-headline::before,
    .section--audio .t-title::before,
    .section--kontakt .t-line::before {
      inset-inline-start: calc(var(--goo-bleed) * -4);
    }
    .section--leistungen .t-occasions::before,
    .section--about .t-training::before {
      inset-inline-end: calc(var(--goo-bleed) * -4);
    }

    /* ── Mobile dock-in: cards slide in, scroll-driven & reversible (like
       desktop). Direction by bleed: left-bleed ← from the left, right-bleed ←
       from the right, centred / inset cards ← from below. The translate rides
       the TILE element on its own view() timeline; the GROW (::before scale)
       rides alongside. Ghost-quote keeps its own rotate (excluded). Magnitudes
       (--dx/--dy) are first-pass dials — tune to taste. */
    @supports (animation-timeline: view()) {
      /* Plain (non-goo) tiles dock on their OWN view(). Goo tiles are EXCLUDED
         here so they ride the SECTION timeline (--section-view) like desktop —
         a per-tile view() is degenerate under the overflow-x:clip ancestors, so
         it never progressed and the goo dock-in (which drives the WebGL grow)
         froze. One layer with the goo = --section-view. */
      .section:not(.section--hero) .tile:not(.tile--ghost):not(.tile--goo) {
        animation: goo-tileDockIn linear both;
        animation-timeline: view();
        animation-range: entry 0% entry 70%;
      }
    }
    /* Direction presets — override the desktop --dx/--dy on mobile. Same
       specificity as the default below, listed AFTER it so they win per card. */
    .section:not(.section--hero) .tile { --dx: 0; --dy: 56px; }            /* centred ← from below */
    .section:not(.section--hero) .t-headline,
    .section:not(.section--hero) .t-title,
    .section:not(.section--hero) .t-line,
    .section:not(.section--hero) .t-intro { --dx: -80px; --dy: 0; }         /* bleed-left ← from left */
    .section:not(.section--hero) .t-occasions,
    .section:not(.section--hero) .t-training,
    .section:not(.section--hero) .t-player,
    .section:not(.section--hero) .t-cta { --dx: 80px; --dy: 0; }            /* bleed-right ← from right */

    /* Instagram card grows a touch LATER (it settled before you noticed). */
    @supports (animation-timeline: view()) {
      .section--kontakt .t-social::before { animation-range: entry 45% entry 100%; }
    }

    @media (prefers-reduced-motion: reduce) {
      .section:not(.section--hero) .tile { --dx: 0; --dy: 0; }
    }

    /* When WebGL is active on mobile the photo grow is JS-driven (goo-webgl.js
       scales it in lockstep with the shape, since the CSS scroll-timeline is
       unreliable on the very tall sections) → disable the CSS photo animation so
       it doesn't override the inline transform. The bleed + JS origin stay. */
    html.goo-webgl .section--about.is-goo-webgl .t-intro__photo,
    html.goo-webgl .section--audio.is-goo-webgl .t-player__photo,
    html.goo-webgl .section--kontakt.is-goo-webgl .t-cta > picture > img {
      animation: none;
    }

    /* Reserve a clearance gutter on the right for the sticky CTA — applied
       to TEXT-bearing elements rather than to the tile itself, so a tile
       can still extend (or bleed) to the viewport's right edge while its
       text content stays clear of the CTA rail. Only a small buffer is
       needed here because the tile's own padding-inline-end + the
       section's padding-inline already supply most of the clearance; the
       extra `--space-2xs` is just the visible gap between the rightmost
       text and the CTA's left edge (~6–12px depending on viewport). */
    .section .body-copy,
    .section .section-title,
    .section .tile-subhead,
    .section .tile-h3,
    .section .closing-line,
    .section .contact-social,
    .section .greeting-caveat,
    .section .ghost-quote,
    .section .hero-title,
    .section .hero-lede,
    .section .player-mock {
      padding-inline-end: var(--space-2xs);
    }

    /* Sticky CTA on mobile — see dedicated @media block at end of styles
       (placed after the desktop sticky-CTA rules so it wins the cascade). */

    /* Scroll-Hint auf Mobile ausblenden — auf kleinen Screens redundant
       und nimmt wertvollen Platz weg. */
    .scroll-hint { display: none; }
    /* Force the single-column stack here instead of waiting for the auto-fit
       Grid to collapse intrinsically, so the breakpoint is deterministic — and
       lift the photo above the content (order: -1 on .unterricht__photo below).
       Source order stays content-first for desktop (photo in the right column);
       on mobile the image leads the stack. Relax min-height so the section grows
       with the stack. */
    .section--unterricht {
      min-height: auto;
      grid-template-columns: 1fr;
      grid-template-rows: auto;
      overflow: visible;
    }
    body[data-section="unterricht"] {
      height: auto;
    }
    /* The alternate Unterricht image is portrait. Without a bounded slot, the
       stacked mobile layout would use the image's natural height and lengthen
       the page. Keep it as a cropped banner instead. */
    .section--unterricht .unterricht__photo {
      order: -1;
      /* Same banner height as the legal pages' stacked photo
         (.legal__photo resolves to this clamp when narrow) so all three
         subpages share one mobile image height. */
      block-size: clamp(13rem, 38svh, 20rem);
    }
    /* Content stack on mobile: spans the full section width. Left padding
       is the normal section gutter; right padding reserves the sticky-CTA
       rail width so the text content stays clear of the CTA — the
       Unterricht content isn't wrapped in a .tile, so the body-copy text
       rule's small buffer alone isn't enough here. */
    .section--unterricht .unterricht__content {
      padding-inline-start: var(--section-pad-x);
      padding-inline-end: var(--cta-rail-width);
      /* No above-centre bias when stacked: the cell is content-height here, so
         the desktop margin-block-end would just add stray space before the
         footer. The button→footer gap is controlled by padding-block-end below. */
      margin-block-end: 0;
      /* The base --space-2xl block padding is desktop breathing room for the
         right-hand column; once stacked the photo banner sits above, so halve
         the top gap to the eyebrow (row-gap ~13px + --space-sm ~19px ≈ 32px,
         half of the former ~64px). Bottom gets MORE air than the base --space-2xl
         (~50px) — the button is the last element before the footer, so step it up
         to --space-section (~70px). */
      padding-block-start: var(--space-sm);
      padding-block-end: var(--space-section);
      /* Keep the block flush-LEFT once stacked — the margin-auto centring on the
         base rule is a WIDE-viewport treatment (sparse content floating in a roomy
         column). On the narrow stacked layout the content should hug the left so
         its gutter stays consistent and lines up with the back-link; restate the
         left hug (start 0, end auto) over the base margin-inline:auto. */
      margin-inline: 0 auto;
    }
    /* (The back-link is now absolutely placed at all widths — see the base
       .section--unterricht .unterricht__content .back-link rule — so no
       mobile-specific override is needed here any more.) */

    /* Mobile has no goo merge / no dock-in (cards stack), so the desktop
       "recolour AFTER docking" choreography doesn't apply — mobile keeps the
       simple early colour SNAP at cover 22%. Two ranges, matching the two
       colour animations (text snap + fill blend): text holds then flips at
       22% (step-end, start irrelevant), and the fill blend is collapsed
       to a near-instant flip at 22% too (20%→22%) so mobile reads as a snap, not
       the desktop soft crossfade. cover 0% start just widens the (unused) hold
       window so nothing flips before the section is on screen on phones, where
       sections are far taller than the viewport. */
    @supports (animation-timeline: view()) {
      .section .tile {
        animation-range: cover 0% cover 22%, cover 20% cover 22%;
      }
      /* (Removed the html.goo-js goo-tile/childhood animation:none reset — it
         was written when goo-js was desktop-only; now that WebGL runs on mobile,
         goo-js IS set here and that reset was killing the mobile dock-in, so the
         WebGL shape never grew.) */
    }
  }

  /* Fallback for browsers without scroll-driven animations:
     simply skip the wipe (sections still have correct color) */
  @supports not (animation-timeline: view()) {
    .section::before { display: none; }
  }
