  /* ── Self-hosted fonts — replaces the Google Fonts CDN (GDPR / LG München I).
     latin + latin-ext subsets, woff2, font-display:swap. Sourced 1:1 from Google's own CSS. ── */
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:400;font-display:swap;src:url('/assets/fonts/instrumentsans-400-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:400;font-display:swap;src:url('/assets/fonts/instrumentsans-400-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:500;font-display:swap;src:url('/assets/fonts/instrumentsans-500-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:500;font-display:swap;src:url('/assets/fonts/instrumentsans-500-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:600;font-display:swap;src:url('/assets/fonts/instrumentsans-600-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:600;font-display:swap;src:url('/assets/fonts/instrumentsans-600-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:700;font-display:swap;src:url('/assets/fonts/instrumentsans-700-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:700;font-display:swap;src:url('/assets/fonts/instrumentsans-700-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face{font-family:'Instrument Serif';font-style:italic;font-weight:400;font-display:swap;src:url('/assets/fonts/instrumentserif-400-italic-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face{font-family:'Instrument Serif';font-style:italic;font-weight:400;font-display:swap;src:url('/assets/fonts/instrumentserif-400-italic-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face{font-family:'Instrument Serif';font-style:normal;font-weight:400;font-display:swap;src:url('/assets/fonts/instrumentserif-400-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face{font-family:'Instrument Serif';font-style:normal;font-weight:400;font-display:swap;src:url('/assets/fonts/instrumentserif-400-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400;font-display:swap;src:url('/assets/fonts/jetbrainsmono-400-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400;font-display:swap;src:url('/assets/fonts/jetbrainsmono-400-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:500;font-display:swap;src:url('/assets/fonts/jetbrainsmono-500-latin-ext.woff2') format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:500;font-display:swap;src:url('/assets/fonts/jetbrainsmono-500-latin.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}

  :root {
    --bg: #F0F2F6; --bg-elev: #FFFFFF; --bg-surface: #E6E8EF;
    --ink: #1A1C24; --ink-mute: #5A5E72; --ink-faint: #6B6F84;
    --line: rgba(26, 28, 36, 0.08); --line-strong: rgba(26, 28, 36, 0.15);
    --teal: #0E9FBE; --teal-glow: rgba(14, 159, 190, 0.20);
    --warm: #C4814A;
    --display: "Instrument Sans", system-ui, sans-serif;
    --mono: "JetBrains Mono", ui-monospace, monospace;
    --gutter-left: 180px;
    --hud-top: 72px; --hud-bot: 72px;
    --col0-left: 56px; --col0-left-shifted: 24px; --col1-left: 78px;
  }
  [data-theme="dark"] {
    --bg: #070709; --bg-elev: #0E0F13; --bg-surface: #151720;
    --ink: #EAEDF3; --ink-mute: #8A8FA8; --ink-faint: #7A8099;
    --line: rgba(234, 237, 243, 0.07); --line-strong: rgba(234, 237, 243, 0.14);
    --teal: #22D3EE; --teal-glow: rgba(34, 211, 238, 0.25);
  }

  /* Palette families — sculpture has two states, each lives inside a narrow color family.
     Cyan (default): cyan body, blue→purple glow at edges.
     Orange (blog):   orange body, yellow→red glow at edges.
     Within each family, a slow keyframe occasionally shifts the hue by ~25° for a few
     seconds, like a bioluminescent organism's skin pulsing through related tones. */
  /* @property registers each gradient stop as <color> so CSS can interpolate them smoothly
     when navigating between rooms. The 6s transition on body is what makes the palette
     fade gradually instead of snapping. */
  @property --grad-0 { syntax: '<color>'; initial-value: #DFF7F9; inherits: true; }
  @property --grad-1 { syntax: '<color>'; initial-value: #67E8F9; inherits: true; }
  @property --grad-2 { syntax: '<color>'; initial-value: #22D3EE; inherits: true; }
  @property --grad-3 { syntax: '<color>'; initial-value: #0E9FBE; inherits: true; }
  @property --grad-4 { syntax: '<color>'; initial-value: rgba(14, 159, 190, 0.35); inherits: true; }
  @property --shadow-soft { syntax: '<color>'; initial-value: rgba(34, 211, 238, 0.40); inherits: true; }
  @property --shadow-mid  { syntax: '<color>'; initial-value: rgba(34, 211, 238, 0.50); inherits: true; }
  @property --shadow-far  { syntax: '<color>'; initial-value: rgba(34, 211, 238, 0.25); inherits: true; }
  /* Nav rail brand color — also room-palette aware so the rail follows the creature's tone */
  @property --teal { syntax: '<color>'; initial-value: #0E9FBE; inherits: true; }
  @property --teal-glow { syntax: '<color>'; initial-value: rgba(14, 159, 190, 0.20); inherits: true; }
  /* Dancing color gradient — center + size drift slowly across the viewport like floating in water */
  @property --color-x { syntax: '<percentage>'; initial-value: 65%; inherits: true; }
  @property --color-y { syntax: '<percentage>'; initial-value: 50%; inherits: true; }
  @property --color-sx { syntax: '<percentage>'; initial-value: 42%; inherits: true; }
  @property --color-sy { syntax: '<percentage>'; initial-value: 55%; inherits: true; }
  @keyframes colorDance {
    0%   { --color-x: 65%; --color-y: 50%; --color-sx: 42%; --color-sy: 55%; }
    18%  { --color-x: 55%; --color-y: 42%; --color-sx: 48%; --color-sy: 50%; }
    36%  { --color-x: 72%; --color-y: 58%; --color-sx: 36%; --color-sy: 62%; }
    54%  { --color-x: 48%; --color-y: 48%; --color-sx: 46%; --color-sy: 52%; }
    72%  { --color-x: 70%; --color-y: 55%; --color-sx: 38%; --color-sy: 58%; }
    88%  { --color-x: 58%; --color-y: 45%; --color-sx: 44%; --color-sy: 56%; }
    100% { --color-x: 65%; --color-y: 50%; --color-sx: 42%; --color-sy: 55%; }
  }

  /* Default cyan palette */
  body {
    --grad-0: #DFF7F9;
    --grad-1: #67E8F9;
    --grad-2: #22D3EE;
    --grad-3: #0E9FBE;
    --grad-4: rgba(14, 159, 190, 0.35);
    --shadow-soft: rgba(34, 211, 238, 0.40);
    --shadow-mid:  rgba(34, 211, 238, 0.50);
    --shadow-far:  rgba(34, 211, 238, 0.25);
  }
  /* Blog: warm sepia-orange family — fades in over 4s when entering room 5.
     The nav rail's --teal also shifts to warm so the whole room speaks one color. */
  body[data-room="5"], body[data-palette="warm"] {
    --grad-0: #F7E8D6;
    --grad-1: #E0B080;
    --grad-2: #C4814A;
    --grad-3: #8B5A2E;
    --grad-4: rgba(70, 40, 15, 0.35);
    --shadow-soft: rgba(196, 129, 74, 0.40);
    --shadow-mid:  rgba(196, 129, 74, 0.50);
    --shadow-far:  rgba(196, 129, 74, 0.25);
    --teal: #C4814A;
    --teal-glow: rgba(196, 129, 74, 0.22);
  }

  /* --hue-base and --hue-breath fed a hue-rotate() on .sculpture that no longer
     exists (see the note on that selector). Their keyframes -- hueGlow and
     hueBreath -- were removed 2026-08-15; the @property registrations stay so
     that any var() reference still lying around resolves to 0deg rather than
     becoming invalid at computed-value time. Do not hang a new animation off
     them: an @property-registered custom property INTERPOLATES, which is what
     made these repaint a near-fullscreen layer 60 times a second. */
  @property --hue-base { syntax: '<angle>'; initial-value: 0deg; inherits: true; }
  @property --hue-breath { syntax: '<angle>'; initial-value: 0deg; inherits: true; }

  /* Anyone who has asked their OS for reduced motion gets none of this -- and it
     doubles as the off switch for measuring what the animations actually cost. */
  @media (prefers-reduced-motion: reduce) {
    .sculpture,
    .sculpture > *,
    .sculpture::after {
      animation: none !important;
    }
  }
  *, *::before, *::after { box-sizing: border-box; }
  html, body {
    margin: 0; padding: 0; height: 100%; width: 100%;
    background: var(--bg); color: var(--ink);
    font-family: var(--display);
    overflow: hidden;
    -webkit-font-smoothing: antialiased;
    /* 4s color transitions — slow enough to feel organic, fast enough to land before the
       next room read. Includes --teal and --teal-glow so the nav rail follows the palette. */
    transition:
      background 0.7s cubic-bezier(0.16, 1, 0.3, 1),
      --grad-0 4s ease, --grad-1 4s ease, --grad-2 4s ease, --grad-3 4s ease, --grad-4 4s ease,
      --shadow-soft 4s ease, --shadow-mid 4s ease, --shadow-far 4s ease,
      --teal 4s ease, --teal-glow 4s ease;
  }

  .stage { position: relative; width: 100vw; height: 100vh; overflow: hidden; }
  .room {
    position: absolute; inset: 0;
    padding: var(--hud-top) 48px var(--hud-bot) var(--gutter-left);
    display: grid; place-items: center start;
    opacity: 0; pointer-events: none;
    transform: translateY(60px) scale(0.995) rotate(var(--room-tilt, 0deg));
    transform-origin: var(--room-origin-x, 50%) 50%;
    transition: opacity 1.25s cubic-bezier(0.22, 1, 0.36, 1), transform 1.35s cubic-bezier(0.22, 1, 0.36, 1);
    background: transparent;
    z-index: 3;
  }
  /* Direction-aware slide. Combined with random per-transition --room-tilt + --room-origin-x
     (set in JS), the room sheets read as asymmetric organic page-lifts rather than uniform slides. */
  body[data-direction="up"] .room {
    transform: translateY(-60px) scale(0.995) rotate(var(--room-tilt, 0deg));
  }
  .room.active { opacity: 1; pointer-events: auto; transform: rotate(0deg); }
  /* When a sub-room is open, hide the parent room underneath so its content doesn't bleed through */
  .room-inner { width: 100%; max-width: 1040px; position: relative; }

  /* ── Edge-scroll sections (router pages only — legacy pt-br canvas keeps its instant snap) ──
     Each section is a real scroll container. Short, centred sections get a symmetric "slack"
     band (empty grid rows above + below the content) so you can scroll a bit freely; the engine
     (assets/js/engine.js, edge-gated wheel/touch) only snaps to the next section once you reach
     the scroll edge. Blog(5)/contact(6) carry real long content + their own scroll, so they're
     excluded from the slack band and start at the top. Tune the give with --room-slack. */
  :root { --room-slack: 18vh; }   /* was 38vh: that put ~250px of empty scroll above AND below
                                     every short section, so scrolling up moved through a quarter
                                     screen of nothing before snapping. 18vh still leaves enough
                                     travel for the edge gate to need a deliberate second gesture. */
  .router-mode .room { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none; }
  .router-mode .room::-webkit-scrollbar { display: none; }
  .router-mode .room:not([data-room="2"]):not([data-room="5"]):not([data-room="6"]) {
    grid-template-rows: var(--room-slack) auto var(--room-slack);
    align-content: start;
  }
  .router-mode .room:not([data-room="2"]):not([data-room="5"]):not([data-room="6"]) > .room-inner { grid-row: 2; }
  /* Room 2 is excluded from the slack band because it already sizes itself to the viewport
     (.room-inner is height: calc(100svh - 200px)). Adding a slack row above and below made the
     section taller than the screen, and placeScroll() then centred that overflow — which rode the
     heading up UNDER the fixed HUD, printing "Three offers." across the wordmark. A section that
     is exactly one screen tall has nothing to scroll and needs no travel to give. */
  .router-mode .room[data-room="2"] { align-content: center; }
  .room::before {
    content: ""; position: absolute; inset: 0; pointer-events: none;
    background-image:
      linear-gradient(to right, var(--line) 1px, transparent 1px),
      linear-gradient(to bottom, var(--line) 1px, transparent 1px);
    background-size: 96px 96px;
    opacity: 0;
    transition: opacity 1s cubic-bezier(0.16, 1, 0.3, 1);
    mask-image: radial-gradient(ellipse 70% 60% at 50% 50%, black 30%, transparent 90%);
    -webkit-mask-image: radial-gradient(ellipse 70% 60% at 50% 50%, black 30%, transparent 90%);
  }
  body[data-room="1"] .room[data-room="1"]::before { opacity: 0.35; }
  body[data-room="2"] .room[data-room="2"]::before { opacity: 0.5; }
  body[data-room="3"] .room[data-room="3"]::before { opacity: 0.7; }
  body[data-room="4"] .room[data-room="4"]::before { opacity: 0.8; }
  body[data-room="5"] .room[data-room="5"]::before { opacity: 0.9; }
  body[data-room="6"] .room[data-room="6"]::before { opacity: 1; }

  /* HUD */
  .hud {
    position: fixed; inset: 0; pointer-events: none; z-index: 30;
    padding: 24px 32px 24px var(--gutter-left);
    display: grid; grid-template-columns: 1fr auto auto; grid-template-rows: auto 1fr auto;
    column-gap: 18px;
    font-family: var(--mono); font-size: 10.5px;
    letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-mute);
  }
  .hud > * { pointer-events: auto; }
  .hud .tl { grid-row: 1; grid-column: 1; }
  .hud .ent { grid-row: 1; grid-column: 2; padding-right: 18px; border-right: 1px solid var(--line-strong); align-self: center; }
  .hud .tr { grid-row: 1; grid-column: 3; text-align: right; align-self: center; }
  .hud .br { grid-row: 3; grid-column: 3; justify-self: end; text-align: right; }
  .hud .brand { color: var(--ink); text-decoration: none; font-family: var(--display); font-weight: 700; font-size: 36px; letter-spacing: -0.015em; text-transform: none; display: inline-flex; align-items: center; gap: 16px; line-height: 1; cursor: pointer; }
  .hud .brand-mark { display: inline-block; width: 124px; height: 66px; background-image: url('/oopuo-logo.svg'); background-repeat: no-repeat; background-size: contain; background-position: center; vertical-align: middle; filter: none; transition: filter 0.7s ease; }
  [data-theme="dark"] .hud .brand-mark { filter: invert(1); }
  .hud .ent a { color: var(--ink-mute); text-decoration: none; transition: color 0.15s ease; }
  .hud .ent a:hover { color: var(--ink); }
  .hud .counter .current { color: var(--ink); }
  .hud .counter .total { color: var(--ink-faint); }
  .hud .next em { font-style: normal; color: var(--ink); }

  /* Locale switcher — injected by engine.js into the HUD, bottom-left (row 3 / col 1).
     Collapsed: shows only the current locale + caret; click to reveal the full menu,
     which pops upward (the switcher lives at the bottom of the viewport). */

  /* Geo-suggest banner — dismissible, shown only on the EN root when the visitor's browser
     language points at another locale. Lives outside .hud so it can sit centered at the top. */

  /* Section title */
  .section-title { position: fixed; top: 28px; left: 50%; transform: translateX(-50%); z-index: 31; display: flex; flex-direction: column; align-items: center; gap: 10px; pointer-events: none; }
  .section-title .tick { width: 28px; height: 1px; background: var(--teal); box-shadow: 0 0 6px var(--teal-glow); }
  .section-title .name { font-family: var(--mono); font-size: 12px; letter-spacing: 0.32em; text-transform: uppercase; color: var(--ink); }
  .section-title .name::before { content: attr(data-num); color: var(--ink-faint); margin-right: 14px; font-weight: 400; }

  /* Nav rail */
  .nav-rail { position: fixed; left: var(--col0-left); top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; align-items: center; gap: 5px; z-index: 25; padding: 12px 0; transition: left 0.6s cubic-bezier(0.16, 1, 0.3, 1); }
  .nav-rail::after { content: ""; position: absolute; left: 50%; top: 50%; width: 240px; height: 240px; transform: translate(-50%, -50%); background: radial-gradient(circle, var(--teal-glow) 0%, transparent 65%); filter: blur(10px); z-index: -2; pointer-events: none; transition: opacity 0.6s ease; }
  .nav-rail::before { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; transform: translateX(-0.5px); background: linear-gradient(to bottom, transparent 0%, var(--teal-glow) 25%, var(--teal) 50%, var(--teal-glow) 75%, transparent 100%); z-index: -1; pointer-events: none; }

  /* The button is the HIT TARGET; ::before is the visible dot. They used to be the same element,
     so transform:scale() shrank the clickable area along with the dot — a distant node ended up
     ~7px across, well under the 24px minimum, and missed clicks read as "the nav is broken".
     Split apart, every node stays a 32px target in every state while the dot still scales. */
  /* An <a href>, not a <button>: these navigate, so they must be real links — a crawler follows
     an anchor and ignores a button, which left the primary nav contributing nothing to the link
     graph. text-decoration is the only rule the swap needs; the reset below was already anchor-safe. */
  .nav-node { position: relative; width: 32px; height: 32px; display: grid; place-items: center; background: none; border: none; padding: 0; cursor: pointer; text-decoration: none; }
  .nav-node::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--ink-faint); transition: transform 0.7s cubic-bezier(0.16, 1, 0.3, 1), filter 0.7s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1), background 0.4s ease, box-shadow 0.5s ease; }
  .nav-node[data-dist="0"]::before { background: var(--teal); transform: scale(2); filter: blur(0); opacity: 1; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.4), 0 0 10px var(--teal), 0 0 28px var(--teal-glow), 0 0 60px var(--teal-glow); }
  .nav-node[data-dist="1"]::before { filter: blur(1.2px); opacity: 0.55; transform: scale(1.15); background: var(--ink-mute); }
  .nav-node[data-dist="2"]::before { filter: blur(2.8px); opacity: 0.3; transform: scale(0.9); }
  .nav-node[data-dist="3"]::before { filter: blur(4.5px); opacity: 0.15; transform: scale(0.75); }
  .nav-node:hover::before { background: var(--ink); opacity: 1; filter: blur(0); }
  .nav-node[data-state="parent"]::before { background: var(--teal); transform: scale(1.5); filter: blur(0); opacity: 0.9; box-shadow: 0 0 6px var(--teal-glow), 0 0 18px var(--teal-glow); }

  .nav-node::after { content: ""; position: absolute; left: 50%; top: 50%; margin-left: 10px; transform: translateY(-50%); height: 1.5px; border-radius: 1px; background: var(--teal); transition: width 0.7s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.5s ease; width: 0; pointer-events: none; }

  /* ASCII sculpture — full-viewport hero background */
  /* z-index 1, not 2: the header shelf is 2 and must paint above the art. Both used to be 2, so
     the shelf only won on DOM order — see the Header shelf block for why that was load-bearing. */
  .sculpture { position: fixed; inset: 0; width: 100vw; height: 100vh; transform: none; pointer-events: none; z-index: 1; opacity: 1; transition: opacity 0.7s ease; overflow: hidden; }
  /* The child is a <canvas> that sculpture.js paints the character grid into.
     KEEP THIS RULE ALMOST EMPTY. It used to carry the whole look — a
     radial-gradient behind background-clip: text, a filter stack, and three
     animations — back when the child was a <table> of ~3,400 glyphs that
     AsciiEffect rewrote via innerHTML every frame. Every one of those properties
     was therefore re-evaluated against a viewport-sized layer ten times a second,
     and the animations ran at 60fps regardless of the render loop, so capping the
     loop could never touch them. Measured 2026-08-15: that combination pinned an
     M4's GPU.
     All of it now happens inside the canvas, where the gradient is one source-in
     fill per frame instead of a clip against every glyph outline:
       - the --grad-* palette and --color-* ellipse are read from these same
         custom properties by createAsciiCanvas(), so CSS is still the source of
         truth for the look and the 4s per-room crossfade still animates;
       - the glow that drop-shadow() used to produce is .sculpture::before below,
         one static element that never repaints.
     Do not put a background, a filter, or an animation back on this selector. */
  .sculpture > * {
    position: absolute !important; top: 50% !important; left: 50% !important;
    transform: translate(-50%, -50%) !important;
    margin: 0 !important; padding: 0 !important;
    pointer-events: none;
  }
  /* The glow, done once instead of 10-30 times a second. A soft radial behind
     the ASCII layer approximates what drop-shadow was producing; nobody can
     tell the halo does not track individual glyph edges, and this element never
     repaints because nothing about it changes. */
  .sculpture::before {
    content: "";
    position: absolute;
    inset: 0;
    background: radial-gradient(ellipse 55% 55% at 50% 50%, var(--shadow-mid, rgba(34,211,238,0.28)) 0%, transparent 70%);
    opacity: 0.5;
    pointer-events: none;
    z-index: -1;
  }
  /* Ambient drift blob — a soft radial of bg color that slowly dances around the viewport.
     Not a static mask; a slow, organic, lava-lamp drift. Animates transforms so the
     interpolation is smooth (CSS can't tween gradient positions/sizes, but it tweens transforms). */
  .sculpture::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 30%;
    width: 55vw;
    height: 65vh;
    transform: translate(-50%, -50%);
    background: radial-gradient(ellipse, var(--bg) 0%, transparent 65%);
    opacity: 0.55;
    pointer-events: none;
    animation: ambientDrift 64s ease-in-out infinite;
    will-change: transform, opacity;
  }
  @keyframes ambientDrift {
    0%   { transform: translate(-50%, -50%) scale(1.0);  opacity: 0.55; }
    20%  { transform: translate(-42%, -58%) scale(1.18); opacity: 0.45; }
    40%  { transform: translate(-62%, -44%) scale(0.88); opacity: 0.60; }
    60%  { transform: translate(-46%, -54%) scale(1.08); opacity: 0.50; }
    80%  { transform: translate(-58%, -42%) scale(1.12); opacity: 0.50; }
    100% { transform: translate(-50%, -50%) scale(1.0);  opacity: 0.55; }
  } /* legacy, hidden */

  /* Typography */
  .label { display: inline-block; font-family: var(--mono); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: 28px; }
  .label::before { content: ""; display: inline-block; width: 28px; height: 1px; background: var(--teal); vertical-align: middle; margin-right: 14px; margin-bottom: 3px; }
  h1 { font-family: var(--display); font-weight: 700; font-size: clamp(2.5rem, 5.6vw, 5rem); line-height: 1.02; letter-spacing: -0.035em; margin: 0 0 28px 0; max-width: 16ch; color: var(--ink); }
  h2 { font-family: var(--display); font-weight: 700; font-size: clamp(2rem, 4vw, 3.25rem); line-height: 1.05; letter-spacing: -0.025em; margin: 0 0 24px 0; max-width: 18ch; color: var(--ink); }

  /* Rooms 3, 5 and 6 used to lead with an <h2>. They now lead with the page's single <h1>,
     because a page with no h1 gives search nothing to rank its topic on and /services/ was one.
     The change is SEMANTIC, not visual, so those h1s keep the h2 type scale exactly. Only the
     home hero (room 1) gets the full h1 scale. */
  .room[data-room="3"] h1,
  .room[data-room="5"] h1,
  .room[data-room="6"] h1 { font-size: clamp(2rem, 4vw, 3.25rem); line-height: 1.05; letter-spacing: -0.025em; margin: 0 0 24px 0; max-width: 18ch; }
  .room:has(.modules-mosaic) h1 { font-size: clamp(1.9rem, 3.4vw, 2.9rem); margin-bottom: 10px; }
  p.lead { font-size: clamp(1rem, 1.3vw, 1.2rem); line-height: 1.6; color: var(--ink-mute); max-width: 56ch; margin: 0 0 28px 0; }
  p.lead strong { color: var(--ink); font-weight: 500; }

  /* Buttons */
  .btn-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; }
  .btn { display: inline-flex; align-items: center; gap: 10px; padding: 13px 22px; border-radius: 4px; font-family: var(--display); font-weight: 500; font-size: 0.95rem; text-decoration: none; transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease; cursor: pointer; border: 1px solid transparent; white-space: nowrap; }
  .btn-primary { background: var(--teal); color: white; }
  .btn-primary:hover { background: color-mix(in srgb, var(--teal) 86%, #000); transform: translateY(-1px); }
  .btn-ghost { color: var(--ink); border-color: var(--line-strong); background: transparent; }
  .btn-ghost:hover { border-color: var(--ink-mute); }
  .btn-warm { background: var(--warm); color: white; box-shadow: 0 8px 24px -10px rgba(196, 129, 74, 0.45); }
  .btn-warm:hover { background: #D4945E; transform: translateY(-1px); }
  .btn .arrow { transition: transform 0.2s ease; }
  .btn:hover .arrow { transform: translateX(3px); }

  /* —— Legibility scrim ————————————————————————————————————————————
     Per-letter halo (stacked text-shadows) so thin text stays readable
     over the animated sculpture. The halo hugs each glyph — no panel
     behind the paragraph. Tint flips automatically with the room theme.
     Applied ONLY to text that sits over the open room background; text
     inside cards with their own background is intentionally excluded
     (module cards, principle cards, tier cards, example cards, stat
     cards, doc-fact/doc-service cards, blog hero gradients). */
  :root { --scrim-glow: rgba(241, 243, 246, 0.98); }
  [data-theme="dark"] { --scrim-glow: rgba(7, 10, 15, 0.98); }
  .label,.lead,.blog-eyebrow,.blog-lead,.process-list h4,.process-list p,.doc-sub,.doc-body,.doc-note {
    text-shadow:
      0 0 2px var(--scrim-glow),
      0 0 4px var(--scrim-glow),
      0 0 9px var(--scrim-glow),
      0 0 16px var(--scrim-glow),
      0 0 26px var(--scrim-glow);
  }
  /* Punch up body-text colour to full ink so the letters themselves
     sit darker against the busy bg. Excludes the teal mono labels
     (.label, .blog-eyebrow, .doc-week) which keep their accent colour.
     body prefix wins specificity vs the lobby's own .lead/etc rules. */
  body .lead,body .blog-lead,body .blog-card-title,body .process-list h4,body .process-list p,body .doc-sub,body .doc-body,body .doc-note {
    color: var(--ink);
  }

  /* Modules */
  .modules { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; margin-top: 16px; max-width: 760px; }
  .module { background: var(--bg-elev); border: 1px solid var(--line-strong); padding: 24px 24px 26px; position: relative; cursor: pointer; transition: background 0.2s ease, border-color 0.2s ease; text-decoration: none; }
  .module:hover { background: var(--bg); border-color: var(--teal); }
  .module-tag { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--teal); margin-bottom: 12px; }
  .module-title { font-family: var(--display); font-weight: 600; font-size: 1.15rem; margin: 0 0 8px 0; color: var(--ink); letter-spacing: -0.01em; }
  .module-desc { font-size: 0.88rem; line-height: 1.55; color: var(--ink-mute); margin: 0; }
  .module .corner { position: absolute; top: 12px; right: 14px; font-family: var(--mono); font-size: 10px; color: var(--ink-faint); letter-spacing: 0.12em; }
  .module .more { position: absolute; bottom: 16px; right: 20px; font-family: var(--mono); font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--teal); opacity: 0; transform: translateX(-6px); transition: opacity 0.2s ease, transform 0.2s ease; }
  .module:hover .more { opacity: 1; transform: translateX(0); }

  /* Mosaic hover — opt in with .modules-mosaic on the grid. The card under the pointer grows
     toward the centre of the grid and the other three ease back, so the four still read as
     giving way to one another. The grid fills the room the way a deck does, so its height comes
     from the flex column rather than a magic number.

     THIS USED TO ANIMATE grid-template-columns / grid-template-rows, and that is the most
     expensive kind of CSS animation there is. An `fr` change is a LAYOUT change: every one of
     the ~33 frames re-laid-out the grid, re-laid-out all 12 text elements inside the four cards,
     and repainted them — none of it compositable, all of it on the main thread. Measured at 4x
     CPU throttle: 0.6ms of layout per frame, 19.8ms per hover, against 0.1ms for a transform.
     The old easing also overshot 56% past the target (cubic-bezier's 1.56), so the tracks sailed
     past 55/45 and settled back with the text reflowing the whole way — which is what read as
     "not smooth". It was not dropped frames; it was a wobble built into the curve.

     Transform is composited: layout is computed once and the GPU moves the finished layer, so
     the cost no longer scales with how much text a card holds. transform-origin is each
     quadrant's OUTER corner, so growth travels inward rather than drifting the cards apart.

     Below 901px none of this applies: the room reverts to its normal auto height and the cards
     stack and scroll, because four cards squeezed into one screen is worse than a scroll. */
  @media (min-width: 901px) {
    /* Fills the room's padded column rather than the 1440px cap a deck uses — the point of the
       mosaic is to own the screen. The reserve scales with viewport height so the grid clears
       the HUD's bottom strip at 1080 without starving itself at 900. */
    .room:has(.modules-mosaic) .room-inner {
      max-width: none;
      display: flex; flex-direction: column;
      height: calc(100svh - clamp(200px, 21svh, 300px));
    }
    /* Same air above the heading as room 2, and for the same reason: measured at y=103 it began
       inside the shelf's band. The room-inner is a fixed-height flex column, so the mosaic
       (flex: 1 1 auto) absorbs the offset and the grid's bottom edge does not move. */
    .room:has(.modules-mosaic) h2 {
      font-size: clamp(1.9rem, 3.4vw, 2.9rem);
      margin-top: clamp(28px, 6svh, 64px);
      margin-bottom: 10px;
    }
    .modules-mosaic {
      max-width: none; width: 100%; transform: none;
      flex: 1 1 auto; min-height: 0;
      grid-template-rows: 1fr 1fr;
    }
  }
  @media (hover: hover) and (min-width: 901px) {
    .modules-mosaic .module {
      overflow: hidden;
      z-index: 1;
      /* 0.7s, and an ease-out that never passes 1. Slower and monotonic reads as a considered
         movement; the old 0.55s bounce read as a twitch. */
      transition: transform 0.7s cubic-bezier(0.22, 0.61, 0.36, 1),
                  background 0.35s ease, border-color 0.35s ease;
    }
    /* Anchor each card at its own outer corner so it expands toward the middle of the grid. */
    .modules-mosaic .module:nth-child(1) { transform-origin: top left; }
    .modules-mosaic .module:nth-child(2) { transform-origin: top right; }
    .modules-mosaic .module:nth-child(3) { transform-origin: bottom left; }
    .modules-mosaic .module:nth-child(4) { transform-origin: bottom right; }
    /* Promote to a compositor layer only while the pointer is actually in the grid. Four
       permanent layers would hold GPU memory for a state that is idle almost all the time, and
       the promotion still lands in the same style recalc that starts the transition. */
    .modules-mosaic:hover .module { will-change: transform; }
    .modules-mosaic .module:hover { transform: scale(1.055); z-index: 2; }
    .modules-mosaic:has(.module:hover) .module:not(:hover) { transform: scale(0.975); }
    /* Slower than the movement it accompanies, so the text settles after the card does rather
       than racing it. */
    .modules-mosaic .module-desc { transition: opacity 0.45s ease; }
    .modules-mosaic:has(.module:hover) .module:not(:hover) .module-desc { opacity: 0; }
  }
  @media (prefers-reduced-motion: reduce) {
    .modules-mosaic .module,
    .modules-mosaic .module-desc { transition: none; }
    .modules-mosaic .module:hover,
    .modules-mosaic:has(.module:hover) .module:not(:hover) { transform: none; }
  }

  /* ══ Header shelf ═══════════════════════════════════════════════════════════════
     One treatment, every page: a glass shelf the header sits on.

     This replaces a gradient scrim that was, measurably, invisible. It tinted with var(--bg)
     over a page whose background is also var(--bg) — 72% of a colour over itself is that colour,
     so across the whole flat background it composited to nothing. Its only visible effect was
     dimming the sculpture's dots. Real glass needs to refract what is behind it, which means
     backdrop-filter; there is no gradient that fakes it.

     The old rule against backdrop-filter here (the sculpture repaints, so the blur recomputes)
     was never applied anywhere else — the contact panel, the deck and the consent bar all blur
     over that same sculpture. The cost is real but bounded: one 120px band, and the blur only
     recomputes over the strip's own area, not the viewport. Measured, not assumed.

     Its own element rather than a pseudo-element of .room, because it spans the viewport edge to
     edge while the room is inset by --gutter-left and 48px. Layering is now DECLARED, not
     inherited from DOM order: sculpture 1, shelf 2, room content 3, HUD 30. Previously both the
     sculpture and the shelf sat at z-index 2 and the shelf only won because it happened to come
     later in the markup — reordering two lines would have hidden it with nothing to show for it.

     The element is built by engine.js at boot, not pasted into page markup. See ensureHeaderShelf(). */
  :root {
    /* --shelf-solid is the band that reads as the header surface: the HUD's type sits at ~45–70px,
       so the glass stays at full strength to 75px. --shelf-h then continues PAST that with nothing
       but the fade tail, dissolving to zero. There is no bottom edge, so unlike the old hard-edged
       version the height can run down through a heading without cutting it — at low alpha the tail
       just helps the type read, which is what a scrim was supposed to do in the first place. */
    --shelf-h: 150px;
    --shelf-solid: 50%;
    --shelf-tint: rgba(255, 255, 255, 0.58);
    --shelf-blur: blur(18px) saturate(135%);
  }
  /* Dark surfaces — the lobby, the contact room, and the dark-themed blog. Glass on dark reads as
     a lift rather than a wash, so the tint goes white-at-low-alpha instead of flipping to black. */
  [data-theme="dark"], body[data-room="1"], body[data-room="6"] {
    --shelf-tint: rgba(255, 255, 255, 0.07);
  }
  .room-frost {
    position: fixed;
    top: 0; left: 0; right: 0;
    height: var(--shelf-h);
    /* z-index 8: above EVERY page-content layer, below the chrome. The layer map is
       sculpture 1 · room 3 · doc-page 7 · SHELF 8 · nav-rail 25 · hud 30 · consent 90.
       It was 2 (between the art and the content) and that is what broke on real pages:
       .router-mode .room is overflow-y:auto (line 165) and .doc-page is z-index 7, so on
       every scrolling layout the page's own text painted OVER the glass — the header read
       as absent on side pages and washed out on contact. Content must pass UNDER the
       header, which is the whole premise of a glass header. */
    z-index: 8;
    pointer-events: none;
    background: var(--shelf-tint);
    -webkit-backdrop-filter: var(--shelf-blur);
    backdrop-filter: var(--shelf-blur);
    /* The mask fades the ENTIRE element — tint and backdrop blur together — so the glass dissolves
       instead of stopping at a line. Fading only the background would leave a hard blur edge at the
       bottom, which is the exact artefact this is avoiding. Intermediate stops rather than a plain
       two-stop linear: a straight alpha ramp makes the start of the fade visible as a faint band. */
    -webkit-mask-image: var(--shelf-fade);
    mask-image: var(--shelf-fade);
    transition: background 0.7s ease;
  }
  /* Declared after the block that uses it is fine — custom properties resolve at use time. Kept
     here so the ramp lives next to the rule it shapes rather than up in the token list. */
  :root {
    --shelf-fade: linear-gradient(to bottom,
      #000 0%,
      #000 var(--shelf-solid),
      rgba(0, 0, 0, 0.86) 64%,
      rgba(0, 0, 0, 0.56) 77%,
      rgba(0, 0, 0, 0.24) 89%,
      transparent 100%);
  }
  /* Without backdrop-filter the tint alone is a barely-there film — exactly the failure this
     replaced. Fall back to an opaque-enough band so the shelf still reads as a surface. */
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .room-frost { background: color-mix(in srgb, var(--bg) 88%, transparent); }
  }
  /* No shelf under 901px, and this one IS a real exception rather than an oversight.
     The mobile HUD wraps to two rows: measured at 390px the wordmark's bottom edge is y=123 while
     the page's first content already starts at y=109 — the HUD and the content overlap, so there
     is no band a bottom edge can sit in without cutting through one of them. Giving mobile a shelf
     means reworking the mobile HUD first. Until then the header carries no treatment here, which
     is the same as before this shelf existed. */
  @media (max-width: 900px) {
    .room-frost { display: none; }
    /* The top padding that reserves the shelf's band goes with it. */
    .doc-page { padding-top: var(--doc-top-mobile, 88px); }
  }

  /* —— Blog (room 5): bg stays identical to the lobby — only the sculpture's hue shifts.
     The ambience change is carried entirely by the sculpture's --hue-offset (+90°). —— */
  /* Blog used to carry its OWN copy of the shelf on .hud::before, with
     `body[data-room="5"] .room-frost { display:none }` suppressing the shared one so the two
     would not double-tint. Both are gone: now that the shared shelf sits at z-index 8 it already
     rides above article text, which is the only thing the duplicate existed to achieve.

     Deleting the display:none matters beyond tidiness. It was the one rule in the file that could
     make the header vanish outright, and it keyed off a body attribute the router rewrites on
     every navigation — precisely the "it disappears when I navigate" failure mode. A z-index can
     be wrong; it cannot make the element disappear. */

  /* Make the blog room scroll internally instead of being centered+cropped */
  .room[data-room="5"] {
    overflow-y: auto;
    place-items: start start;
    padding-top: 130px;
    padding-bottom: 140px;
    scrollbar-width: none;
  }
  .room[data-room="5"]::-webkit-scrollbar { display: none; }
  .room[data-room="5"] .room-inner { max-width: 1200px; }
  .blog-rule { border: 0; height: 1px; background: var(--line-strong); margin: 36px 0 56px 0; }

  /* Featured post: text-only, single column. The sculpture is the visual. */
  .blog-featured {
    display: flex;
    flex-direction: column;
    gap: 20px;
    max-width: 56ch;
    margin-bottom: 64px;
    text-decoration: none;
  }
  .blog-eyebrow {
    font-family: var(--mono);
    font-size: 11px;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    color: var(--teal);
  }
  .blog-featured-title {
    font-family: "Instrument Serif", "Times New Roman", serif;
    font-weight: 400;
    font-size: clamp(2.4rem, 4.6vw, 4rem);
    line-height: 1.0;
    letter-spacing: -0.018em;
    color: var(--ink);
    /* Pinned so the element tag stops mattering. This was an <h2> (max-width 18ch from the bare
       h2 rule) and became the page's <h1> (16ch) when the "Field notes." heading above it was
       removed — without this the promotion would silently narrow the measure and rewrap the line. */
    max-width: 18ch;
    margin: 0;
  }
  .blog-lead {
    font-family: var(--body); font-size: 1.05rem; line-height: 1.6;
    color: var(--ink-mute); max-width: 56ch; margin: 0;
  }

  /* Hero visual blocks — CSS-generated per category for visual identity */
  .blog-hero {
    aspect-ratio: 4 / 3;
    border-radius: 14px;
    position: relative;
    overflow: hidden;
    background: var(--bg-surface);
    /* Applies to whichever layer paints — banner or gradient. The banner SVGs are 1.40 wide;
       the post hero is 4/3 (1.333) and the card 16/11 (1.4545), so `cover` crops under 2.5%
       either way and the banners keep their content inside a 4%..96% safe area. */
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
  }

  /* ── Hero taxonomy — five categories, one per kind of note ──────────────────
     Pick one. Do not invent a sixth; if a note fits nowhere, it is probably two
     notes or it belongs in "guides".

       application     Notes on the app itself — auth, sessions, tenant isolation,
                       IDOR, input handling, secrets in client bundles, the seams
                       AI-built code leaves in a request path.
                       → the public-surface hardening review, and the app half of
                         the production hardening sprint.

       infrastructure  Notes on what the app runs on — network exposure, TLS and
                       headers, storage and bucket policy, backups and restore,
                       CI/CD and supply chain, key and credential handling.
                       → the infrastructure half of the hardening sprint.

       private-ai      Notes on self-hosted models and the machines under them —
                       GPU hosts, inference isolation, data residency, prompt and
                       tool boundaries, what leaves the perimeter and what cannot.
                       → the private AI and GPU architecture offer.

       operations      Notes on keeping it safe after the sprint ends — logging
                       and alerting, patch and dependency cadence, access review,
                       incident and disclosure procedure, runbooks.
                       → the operations offer.

       guides          Everything that is not tied to one offer — method,
                       explainers, how a review is scoped, reference material,
                       and the blog's own editorial blocks. The safe default.
                       (Already used by /blog/'s featured block and empty card.)

     Colour: all five sit inside the warm sepia family the blog room already runs
     (--grad-0..4 under body[data-palette="warm"]), separated by hue position and
     by gradient GEOMETRY, not by borrowing an unrelated hue — a cyan hero would
     fight the room's palette on arrival. Warm arc, coolest to hottest:
     guides (greige) → operations (brass) → application (amber) →
     infrastructure (umber) → private-ai (ember).

     Both themes: these are deliberately dark in light AND dark theme — a hero is
     an image, not a surface, so it does not follow --bg. What IS theme-aware is
     the card treatment below, which drops the gradient for --bg-surface. Every
     end stop is dark enough that the ::after glyph (white at 0.65) clears 4.5:1
     against the bottom-right corner in the worst case; if you retune a stop,
     keep the falloff to the dark stop finishing before that corner.

     Cost: one gradient, composited once, no filter / no animation / no image
     request. Keep it that way — the sculpture already owns this page's frame
     budget. ── */
  /* ── Two layers: the post's own banner, then its category gradient underneath ──────
     Layer 1 is `var(--banner, none)`. Unset — which is every post that has not been given
     one — it resolves to `none` and the gradient below is what paints, so the category
     colour is still the guaranteed floor. A post opts in with one inline custom property
     and nothing else:

       <div class="blog-hero" data-category="application" data-glyph="N.02" aria-hidden="true"
            style="--banner:url(/blog/middleware-authorization-boundary/banner.svg)"></div>

     Same attribute on the index card. Post eight is that one line plus one file in
     its own post folder — no new rule belongs here, and no new rule should be added here.

     The banners are geometry-only SVG (blog/<slug>/banner.svg): the sculpture's glyph field
     held still, a per-post density field sampled on a coarse cell grid and drawn with the
     same ramp the shader uses, dots through blocks. No <text> — an SVG referenced as a
     background gets no webfont, so "JetBrains Mono" would silently become a system font.
     They are dark and mark-free in the bottom-right for the same reason the gradients are:
     ::after prints the counter glyph there. Measured worst case at the glyph box is 8.2:1. */
  .blog-hero[data-category="application"]    { background-image: var(--banner, none), radial-gradient(120% 100% at 72% 24%, #EFC188 0%, #C4814A 32%, #2A170B 100%); }
  .blog-hero[data-category="infrastructure"] { background-image: var(--banner, none), linear-gradient(145deg, #B0723E 0%, #5C3A1E 50%, #150F0A 100%); }
  .blog-hero[data-category="private-ai"]     { background-image: var(--banner, none), radial-gradient(115% 120% at 26% 78%, #E2925A 0%, #93472A 36%, #1C0E08 100%); }
  .blog-hero[data-category="operations"]     { background-image: var(--banner, none), linear-gradient(160deg, #C79A5A 0%, #6E5227 52%, #131109 100%); }
  .blog-hero[data-category="guides"]         { background-image: var(--banner, none), linear-gradient(120deg, #CBB79C 0%, #6A5A48 48%, #17130F 100%); }
  .blog-hero::before {
    content: "";
    position: absolute; inset: 0;
    background-image:
      linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px),
      linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px);
    background-size: 32px 32px;
  }
  /* The 32px overlay is the flat gradient's texture. A banner already carries a grid, at its
     own pitch — keeping both lays two near-pitch grids over each other and moirés. */
  .blog-hero[style*="--banner"]::before { display: none; }
  .blog-hero::after {
    content: attr(data-glyph);
    position: absolute; bottom: 18px; right: 22px;
    font-family: var(--mono);
    font-size: 11px;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    color: rgba(255, 255, 255, 0.65);
  }

  /* Post grid (3-column). Hero blocks are subtle category swatches, not marketing visuals. */
  .blog-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 40px 28px; }
  .blog-card {
    display: flex; flex-direction: column; gap: 16px;
    cursor: pointer; text-decoration: none;
    background: rgba(255, 255, 255, 0.70);
    -webkit-backdrop-filter: blur(22px) saturate(170%);
    backdrop-filter: blur(22px) saturate(170%);
    border: 1px solid rgba(255, 255, 255, 0.75);
    border-radius: 16px;
    padding: 16px 16px 22px;
    box-shadow:
      0 22px 48px -16px rgba(20, 24, 32, 0.22),
      0 4px 12px -4px rgba(20, 24, 32, 0.08),
      inset 0 1px 0 rgba(255, 255, 255, 0.9);
    transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
  }
  .blog-card:hover {
    transform: translateY(-3px);
    box-shadow:
      0 28px 56px -16px rgba(20, 24, 32, 0.26),
      0 6px 14px -4px rgba(20, 24, 32, 0.10),
      inset 0 1px 0 rgba(255, 255, 255, 1);
    border-color: rgba(255, 255, 255, 0.95);
  }
  /* The card drops the post page's gradient for a flat surface — that gradient is the post's,
     not the index's. A banner is the opposite case: it is the post's picture, and the card is
     where it earns its keep, so it survives into the grid.
     This rule used to read `background: var(--bg-surface) !important`, where the !important
     existed only to beat the equally-specific [data-category] gradients above. It does not
     need to: this selector is (0,2,0) too and comes later, so source order already wins.
     Splitting the shorthand into colour + image lets that happen without !important flattening
     the banner layer along with the gradient. */
  .blog-card .blog-hero {
    aspect-ratio: 16 / 11;
    border-radius: 10px;
    background-color: var(--bg-surface);
    background-image: var(--banner, none);
  }
  .blog-card .blog-hero::before { display: none; }
  .blog-card .blog-hero::after { color: var(--ink-mute); font-size: 10px; letter-spacing: 0.22em; }
  /* On the flat surface the glyph is ink. On a banner it sits on the same dark corner the post
     page's does, so it takes the same white — --ink-mute over that corner is only 2.9:1. */
  .blog-card .blog-hero[style*="--banner"]::after { color: rgba(255, 255, 255, 0.72); }
  .blog-card-meta {
    display: flex; align-items: center; gap: 10px;
    font-family: var(--mono);
    font-size: 10.5px;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    color: var(--ink-mute);
  }
  .blog-card-meta .dot { color: var(--ink-faint); }
  .blog-card-meta .cat { color: var(--teal); }
  .blog-card-title {
    font-family: "Instrument Serif", "Times New Roman", serif;
    font-weight: 400;
    font-size: 1.35rem;
    line-height: 1.15;
    letter-spacing: -0.008em;
    color: var(--ink);
    margin: 0;
  }

  @media (max-width: 900px) {
    .blog-featured { grid-template-columns: 1fr; gap: 36px; }
    .blog-grid { grid-template-columns: 1fr; }
  }

  .blog-card { cursor: pointer; }

  /* Blog reader — slide overlay opened from a blog card or the featured Read button.
     Sits above .room (z:3) and .sub-room (z:5) but below HUD (z:30). Internal scroll. */

  /* —— Reading page (service detail, notes, legal) — mirrors the blog-reader chrome ——
     Named doc-* rather than ent-*: these classes were built for an "Enterprise" page that no
     longer exists, and kept the name long after they became the generic reading-page system. */
  .doc-page {
    position: absolute; inset: 0;
    /* Top padding reserves the header shelf's band. At the old 88px the back-link landed at y=93,
       i.e. inside the glass, so it read as fogged before the reader had scrolled anything. The
       article still passes under the shelf on scroll — it just no longer STARTS underneath it. */
    padding: calc(var(--shelf-h) + 12px) 48px 32px var(--gutter-left);
    overflow-y: auto; overscroll-behavior: contain;
    opacity: 0; pointer-events: none;
    transform: translateY(36px);
    transition: opacity 0.55s cubic-bezier(0.22, 1, 0.36, 1),
                transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
    z-index: 7; scrollbar-width: none;
  }
  .doc-page::-webkit-scrollbar { display: none; }
  .doc-page.active { opacity: 1; pointer-events: auto; transform: translateY(0); }

  /* Warp-in for inner pages. The incoming article arrives slightly over-scaled and blurred, then
     settles — a short "coming out of the jump" beat rather than a plain fade.
     Deliberately a one-shot animation with NO fill-mode: when it ends the element falls back to
     its normal filter-free style. A retained blur(0) would otherwise hold a compositing layer
     alive under a full-height scrolling article for as long as the page is open.
     Easing is easeOutCubic, not the expo curve used elsewhere in this file: expo covers ~85% of its
     travel in the first quarter of the duration, which collapsed the blur to ~1px within 150ms and
     read as a flash rather than a settle. */
  @keyframes doc-warp-in {
    from { opacity: 0; filter: blur(14px); transform: translateY(20px) scale(1.025); }
    to   { opacity: 1; filter: blur(0);    transform: translateY(0)    scale(1); }
  }
  body.warping .doc-page.active { animation: doc-warp-in 560ms cubic-bezier(0.33, 1, 0.68, 1); }
  @media (prefers-reduced-motion: reduce) {
    body.warping .doc-page.active { animation: none; }
  }
  /* `doc-open` is only set by openDoc() — i.e. when a reading page is activated from
     inside another page. A side page reached by its own URL (search result, shared link, the
     back-forward cache, any crawler) never gets the class, so the sculpture stayed at full
     opacity behind the text and the body copy became unreadable on the light theme. Key the
     dimming off the article actually being shown instead, which is true on both paths. */
  body:has(.doc-page.active) .sculpture { opacity: 0.35; }

  .doc-inner { max-width: 760px; margin: 0 auto; padding: 0 0 72px 0; }
  .doc-label {
    display: block; font-family: var(--mono);
    font-size: 10.5px; letter-spacing: 0.22em; text-transform: uppercase;
    color: var(--teal); margin-bottom: 20px;
  }
  .doc-h1 {
    font-family: var(--display); font-weight: 600;
    font-size: clamp(2.3rem, 4.6vw, 3.6rem); line-height: 1.06;
    letter-spacing: -0.02em; color: var(--ink); margin: 0 0 26px 0;
  }
  .doc-sub {
    font-family: var(--display); font-weight: 400;
    font-size: 1.2rem; line-height: 1.55; color: var(--ink-mute);
    margin: 0 0 30px 0; max-width: 600px;
  }
  .doc-section { margin-top: 48px; }
  .doc-section h2 {
    font-family: var(--display); font-weight: 600;
    font-size: clamp(1.6rem, 3vw, 2.2rem); line-height: 1.12;
    letter-spacing: -0.015em; color: var(--ink); margin: 0 0 22px 0; max-width: 20ch;
  }
  .doc-body {
    font-family: var(--display); font-size: 1.02rem; line-height: 1.7;
    color: var(--ink-mute); margin: 0 0 1.15em 0; max-width: 620px;
  }
  .doc-body strong { color: var(--ink); font-weight: 600; }

  .doc-facts {
    display: grid; grid-template-columns: repeat(3, 1fr);
    gap: 14px; margin: 26px 0;
  }
  .doc-fact { background: var(--bg-elev); border: 1px solid var(--line-strong); padding: 22px 20px 24px; }
  .doc-fact-val {
    font-family: var(--mono); font-size: 1.45rem; font-weight: 500;
    color: var(--teal); margin-bottom: 10px; letter-spacing: -0.01em;
  }
  .doc-fact-label {
    font-family: var(--display); font-size: 0.82rem; line-height: 1.45; color: var(--ink-mute);
  }

  .doc-services {
    display: grid; grid-template-columns: repeat(2, 1fr);
    gap: 14px; margin-top: 22px;
  }
  .doc-service { background: var(--bg-elev); border: 1px solid var(--line-strong); padding: 28px 26px 30px; }
  .doc-service h3 {
    font-family: var(--display); font-weight: 600; font-size: 1.1rem;
    color: var(--ink); margin: 0 0 12px 0; letter-spacing: -0.005em;
  }
  .doc-service p {
    font-family: var(--display); font-size: 0.92rem; line-height: 1.6;
    color: var(--ink-mute); margin: 0 0 14px 0;
  }
  .doc-deliverable {
    border-top: 1px solid var(--line); padding-top: 14px; margin-bottom: 0 !important;
  }
  .doc-deliverable strong {
    display: block; font-family: var(--mono); font-size: 9.5px;
    letter-spacing: 0.18em; text-transform: uppercase; color: var(--teal);
    margin-bottom: 6px; font-weight: 500;
  }

  .doc-principles { list-style: none; padding: 0; margin: 26px 0 0 0; }
  .doc-principles li {
    padding: 24px 0; border-top: 1px solid var(--line-strong);
    display: grid; grid-template-columns: 46px 1fr; gap: 0 18px;
  }
  .doc-principles li:last-child { border-bottom: 1px solid var(--line-strong); }
  .doc-principle-num {
    font-family: var(--mono); font-size: 11px; color: var(--ink-faint);
    letter-spacing: 0.08em; padding-top: 3px;
  }
  .doc-principles h4 {
    font-family: var(--display); font-weight: 600; font-size: 1.04rem;
    color: var(--ink); margin: 0 0 8px 0;
  }
  .doc-principles p {
    font-family: var(--display); font-size: 0.92rem; line-height: 1.6;
    color: var(--ink-mute); margin: 0; max-width: 560px;
  }
  .doc-note {
    font-family: var(--display); font-style: italic; font-size: 0.88rem;
    color: var(--ink-faint); margin: 0; max-width: 560px;
  }

  /* Source links in body copy. Field notes promise "only when the claim is checkable", and a
     source the reader cannot open is not checkable — so citations are real anchors, not just a
     name in the sentence. Underlined via border-bottom rather than text-decoration so the rule
     sits clear of the descenders, and it inherits the body colour so a paragraph with three
     citations does not turn into a field of blue. --teal resolves to the warm accent under
     body[data-palette="warm"], which is the palette every post runs. */
  .doc-body a, .doc-section p a {
    color: inherit;
    text-decoration: none;
    border-bottom: 1px solid var(--teal);
    transition: color 0.2s ease, border-color 0.2s ease;
  }
  .doc-body a:hover, .doc-section p a:hover,
  .doc-body a:focus-visible, .doc-section p a:focus-visible { color: var(--teal); }

  .doc-cta-block {
    margin-top: 92px; padding-top: 40px; border-top: 1px solid var(--line-strong);
  }
  .doc-cta-block h2 {
    font-family: var(--display); font-weight: 600;
    font-size: clamp(1.7rem, 3.2vw, 2.4rem); line-height: 1.1;
    letter-spacing: -0.015em; color: var(--ink); margin: 0 0 26px 0; max-width: 16ch;
  }

  @media (max-width: 720px) {
    .doc-page { padding-left: 32px; padding-right: 32px; }
    .doc-facts, .doc-services { grid-template-columns: 1fr; }
  }
  .reader-back {
    display: inline-flex; align-items: center; gap: 10px;
    background: transparent; border: 0; cursor: pointer;
    font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.22em; text-transform: uppercase;
    color: var(--ink-mute); padding: 0; margin: 4px 0 64px 0;
    transition: color 0.2s ease, transform 0.2s ease;
  }
  .reader-back:hover { color: var(--ink); transform: translateX(-3px); }

  /* Studio principles */

  /* Sub-page widgets */

  /* Real-work gallery (M.01 Websites · Examples) — screenshot thumbnails of shipped sites.
     Static images in /media/work (headless capture) so the gallery renders even if a site is
     down. No drop-shadows (canon): hover lifts the card + adds a teal glow. Preview cards
     (staging builds, no public link) are plain divs — no lift, signalling they aren't clickable. */
  .process-list { list-style: none; padding: 0; margin: 8px 0 0 0; display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; }
  .process-list li { padding-left: 36px; position: relative; }
  .process-list li::before { content: attr(data-step); position: absolute; left: 0; top: 2px; font-family: var(--mono); font-size: 11px; color: var(--teal); letter-spacing: 0.16em; }
  .process-list h4 { font-family: var(--display); font-weight: 600; font-size: 1.05rem; color: var(--ink); margin: 0 0 6px 0; }
  .process-list p { font-size: 0.9rem; color: var(--ink-mute); line-height: 1.55; margin: 0; }

  /* Stats */
  .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-top: 28px; max-width: 720px; }
  .stat { background: var(--bg-elev); border: 1px solid var(--line-strong); padding: 24px 22px; }
  .stat-val { font-family: var(--mono); font-size: clamp(1.5rem, 2.6vw, 2.2rem); color: var(--ink); letter-spacing: -0.02em; line-height: 1; margin-bottom: 10px; }
  .stat-label { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-mute); }

  .scroll-hint { position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%); font-family: var(--mono); font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink-faint); z-index: 25; animation: pulse 2.4s infinite; transition: opacity 0.3s ease; }
  body[data-room="6"] .scroll-hint { opacity: 0 !important; animation: none !important; }

  /* Sub-page prev/next navigation — visible only when inside a sub-room */
  @keyframes pulse { 0%, 100% { opacity: 0.35; } 50% { opacity: 0.9; } }

  @media (max-width: 900px) {
    :root { --gutter-left: 120px; --col0-left: 28px; --col0-left-shifted: 14px; --col1-left: 56px; }
    .nav-rail { gap: 0px; }
    .modules,.process-list,.stats { grid-template-columns: 1fr; gap: 12px; }
    .post { grid-template-columns: 1fr; gap: 4px; }
  }
  @media (max-width: 600px) {
    :root { --gutter-left: 32px; --hud-top: 60px; --hud-bot: 60px; }
    .nav-rail { display: none; }
    .room { padding-left: 32px; padding-right: 32px; }
    .hud .ent { display: none; }
    /* Mobile: the section-title badge ("06 INVITATION") is fixed top-center; drop the
       brand below it so the wordmark no longer overlays the badge, and slim the mark a
       touch so the top strip breathes. */
    .hud .tl { transform: translateY(46px); }
    .hud .brand { font-size: 26px; gap: 12px; }
    .hud .brand-mark { width: 92px; height: 50px; }
    /* Mobile: the centered scroll/drag hint collides with the locale switcher and the
       NEXT label in the bottom corners; touch scrolling needs no prompt — hide it and
       slim the corners so they clear. */
    .scroll-hint { display: none; }
    .hud .br { font-size: 9.5px; }
  }
  /* Tablet / small-laptop band: the brand sits at --gutter-left (180px, aligned to the
     content column), so its right edge (~453px) overlaps the centered section-title badge
     for every viewport below ~1080px — not just phones. Pull the brand to a corner gutter
     and fluidly slim it so it stays on the top line and always clears the badge. (≤600
     keeps the separate drop treatment for phones.) */
  @media (min-width: 601px) and (max-width: 1080px) {
    .hud { padding-left: 40px; }
    .hud .brand { font-size: clamp(22px, 2.4vw, 32px); gap: 12px; }
    .hud .brand-mark { width: clamp(84px, 9vw, 112px); height: clamp(46px, 4.9vw, 60px); }
  }
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
  }

  /* ── contact room (room 6) form layout — extracted from the pages' inline room-6 <style> (Phase 1).
     The room-6 top-align/scroll override is scoped to [data-contact="form"] so it only applies to
     pages with a rendered HubSpot form (EN today; NL/FR gain the hook when their forms activate in
     Phase 3). Without it, room 6 stays centered — preserving the current NL/FR/pt-br look. ── */
  [data-contact="form"] .room[data-room="6"]{align-items:start;align-content:start;overflow-y:auto;scrollbar-width:none}
  [data-contact="form"] .room[data-room="6"]::-webkit-scrollbar{display:none}
  [data-contact="form"] .room[data-room="6"] .room-inner{padding-top:92px;padding-bottom:72px}
  /* Kept inert: the iframe embed is no longer used (see .oo-form below), but these rules are the
     one-line path back to it if the native form ever has to be reverted. */

  /* —— Native contact form → HubSpot Forms API ————————————————————————————————
     Replaces the cross-origin embed, so it inherits the page's own type and colour instead of
     shipping HubSpot's. Also renders on localhost, which the iframe never did. */
  /* Frosted panel. The sculpture renders behind the whole page, so form copy sat directly on a
     moving bright field — the reason the fields were hard to read. The blur is scoped to this
     560px panel rather than anything full-bleed, which keeps the compositing cost trivial. */
  .oo-form {
    width: 100%; max-width: 560px; margin-top: 34px;
    padding: 34px 34px 30px;
    border-radius: 0;
    /* Dark tint, not a white veil: the text here is light, and the sculpture behind it is a bright
       cyan core. A white scrim blurs the background without darkening it, so contrast stayed poor
       exactly where the sculpture is brightest. Tinting down fixes the legibility the blur alone
       could not. */
    background: rgba(6, 7, 10, 0.46);
    border: 1px solid rgba(255, 255, 255, 0.11);
    box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.07);
    backdrop-filter: blur(22px) saturate(150%);
    -webkit-backdrop-filter: blur(22px) saturate(150%);
  }
  /* Light rooms invert the tint: a white veil over a light background, not a dark one. */
  body:not([data-theme="dark"]) .oo-form {
    background: rgba(255, 255, 255, 0.55);
    border-color: rgba(26, 28, 36, 0.10);
    box-shadow: 0 16px 48px rgba(26, 28, 36, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.65);
  }
  /* Without backdrop-filter the panel would be a nearly invisible tint over a busy field, so
     fall back to an opaque surface instead — legibility matters more than the effect. */
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .oo-form { background: rgba(10, 11, 14, 0.88); }
    body:not([data-theme="dark"]) .oo-form { background: rgba(246, 247, 250, 0.95); }
  }
  /* On the dark room the type goes plain white. The muted greys were tuned for copy sitting on the
     page background; on the frosted panel, over a moving sculpture, they read as washed out.
     Scoped to the dark theme so a light room keeps its own (inverted) contrast. */
  body[data-theme="dark"] .oo-form .oo-field label,
  body[data-theme="dark"] .oo-form .oo-field input,
  body[data-theme="dark"] .oo-form .oo-field textarea,
  body[data-theme="dark"] .oo-form .oo-consent span,
  body[data-theme="dark"] .oo-form .oo-form-done h3,
  body[data-theme="dark"] .oo-form .oo-form-done p { color: #FFFFFF; }
  body[data-theme="dark"] .oo-form .oo-field input::placeholder,
  body[data-theme="dark"] .oo-form .oo-field textarea::placeholder { color: rgba(255, 255, 255, 0.42); }

  /* Field underlines need a touch more contrast against the frosted surface than against the page. */
  .oo-form .oo-field input, .oo-form .oo-field textarea { border-bottom-color: rgba(255, 255, 255, 0.18); }
  body:not([data-theme="dark"]) .oo-form .oo-field input,
  body:not([data-theme="dark"]) .oo-form .oo-field textarea { border-bottom-color: rgba(26, 28, 36, 0.18); }
  .oo-field { margin-bottom: 26px; }
  .oo-field label { display: block; font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: 9px; }
  .oo-field input, .oo-field textarea {
    width: 100%; font-family: var(--display); font-size: 15px; line-height: 1.6; color: var(--ink);
    background: transparent; border: 0; border-bottom: 1px solid var(--line);
    padding: 9px 0; border-radius: 0; -webkit-appearance: none;
    transition: border-color 0.3s ease;
  }
  .oo-field textarea { resize: vertical; min-height: 92px; }
  .oo-field input:focus, .oo-field textarea:focus { outline: none; border-bottom-color: var(--teal); }
  .oo-field input::placeholder, .oo-field textarea::placeholder { color: var(--ink-faint); }
  .oo-field[data-invalid] input, .oo-field[data-invalid] textarea { border-bottom-color: #E5484D; }
  .oo-error { display: none; font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.06em; color: #E5484D; margin-top: 7px; }
  .oo-field[data-invalid] .oo-error { display: block; }

  /* Honeypot: must be present in the DOM for a bot to find and fill, and unreachable for a person.
     Off-canvas rather than display:none — some bots skip hidden fields, which defeats the point. */
  .oo-hp { position: absolute; left: -9999px; top: -9999px; width: 1px; height: 1px; overflow: hidden; }

  .oo-consent { display: flex; gap: 11px; align-items: flex-start; margin: 4px 0 26px; cursor: pointer; }
  .oo-consent input { flex: 0 0 auto; width: 15px; height: 15px; margin-top: 3px; accent-color: var(--teal); }
  .oo-consent span { font-size: 12.5px; line-height: 1.55; color: var(--ink-mute); }
  .oo-consent[data-invalid] span { color: #E5484D; }

  .oo-form-status { margin-top: 20px; font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; line-height: 1.6; min-height: 1em; }
  .oo-form-status[data-state="error"] { color: #E5484D; }
  .oo-form-status[data-state="sending"] { color: var(--ink-mute); }
  .oo-form button[type="submit"][disabled] { opacity: 0.5; pointer-events: none; }
  .oo-form-done { display: none; }
  .oo-form.is-done .oo-form-done { display: block; }
  .oo-form.is-done .oo-form-body { display: none; }
  .oo-form-done h3 { font-family: var(--display); font-weight: 600; font-size: 1.35rem; margin-bottom: 10px; color: var(--ink); }
  .oo-form-done p { font-size: 14.5px; line-height: 1.65; color: var(--ink-mute); }

  /* ── a11y: visible focus ring ── */
  :focus-visible{outline:2px solid var(--teal);outline-offset:3px;border-radius:2px}

  /* ── Phase 2 router: cross-document View Transitions fallback (CSS at-rule only — the <meta> form is dead).
     Chromium 126+/Safari 18.2+ get a native crossfade when JS is off or the fetch/swap router bails;
     Firefox + older browsers just hard-load. The persistent-sculpture router (assets/js/router.js) is the
     primary mechanism — this only fires on real document navigations. ── */
  @view-transition { navigation: auto; }
  @media (prefers-reduced-motion: reduce){
    ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*){ animation: none !important; }
  }

  /* ══ Split hero (Arrival) ═══════════════════════════════════════════════════════
     The sculpture and the text must never share space — body copy over the bright
     cyan core was unreadable, worst on mobile.

     The sculpture element KEEPS its full-viewport box. Three.js sizes its renderer to
     that box, so shrinking the element would distort the render (and the renderer is
     only notified on window resize, not element resize). We move the ARTWORK with a
     transform instead, which the renderer never sees. Scoped to [data-room="1"], so
     the sculpture recentres as you leave Arrival. */
  body[data-room="1"] .sculpture { transform: translateX(24%); }
  .sculpture { transition: opacity 0.7s ease, transform 0.9s cubic-bezier(0.22, 0.61, 0.36, 1); }
  body[data-room="1"] .room[data-room="1"] .room-inner { max-width: min(46vw, 620px); }

  @media (prefers-reduced-motion: reduce) {
    .sculpture { transition: opacity 0.7s ease; }
  }

  /* ══ Deck — the horizontal accordion ════════════════════════════════════════════
     One component, three levels: the Systems deck, the Lab deck, and the workflow
     deck inside a case page. Full-height panels; hovering one expands it and the
     others compress to spines. Clicking activates the panel's real URL. */
  /* The deck fills what is LEFT after the heading and intro, rather than adding a fixed
     height below them — at min(74vh,900px) the panels ran past the fold and took their
     bottom-anchored spine labels with them. svh, not vh, so mobile browser chrome counts. */
  .room[data-room="2"] .room-inner {
    max-width: min(1440px, 92vw);
    display: flex; flex-direction: column;
    height: calc(100svh - 200px);
  }
  /* Air above the heading. Measured at 1440x900 it started at y=112 — 42px below the HUD's type
     and inside the header shelf's fade, where the mask is still ~0.6 opaque, so the top of the
     heading read as veiled. The room-inner is a fixed-height centred flex column, so this margin
     pushes the heading down and the deck (flex:1 1 auto) absorbs it: the section's bottom edge
     does not move. Scaled to svh so a short viewport does not spend the deck's height on padding. */
  .room[data-room="2"] h2 {
    font-size: clamp(1.9rem, 3.4vw, 2.9rem);
    margin-top: clamp(28px, 6svh, 64px);
    margin-bottom: 10px;
  }

  .deck-intro { margin: 0 0 18px; max-width: 620px; flex: 0 0 auto; }
  /* The deck owns the screen. Panels are the primary object in the section, not a
     widget inside it — so it claims the viewport minus the intro and the HUD margins. */
  .deck { display: flex; gap: 10px; flex: 1 1 auto; min-height: 0; }

  .deck-panel {
    flex: 1 1 0; min-width: 0; position: relative; display: block; overflow: hidden;
    background: var(--bg-elev); border: 1px solid var(--line-strong);
    color: inherit; text-decoration: none;
    transition: flex-grow 0.45s cubic-bezier(0.22, 0.61, 0.36, 1),
                background 0.3s ease, border-color 0.3s ease;
  }
  .deck-panel:hover, .deck-panel:focus-visible, .deck-panel.is-open { flex-grow: 3.4; border-color: var(--teal); }
  .deck-panel:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }

  /* Spine: the collapsed state. Vertical label, reads bottom-to-top. */
  .deck-spine {
    position: absolute; inset: 0; display: flex; align-items: flex-end; justify-content: center;
    padding: 0 0 26px; transition: opacity 0.28s ease;
  }
  .deck-spine span {
    writing-mode: vertical-rl; transform: rotate(180deg); white-space: nowrap;
    font-family: var(--mono); font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase;
    color: var(--ink-faint);
  }
  .deck-panel:hover .deck-spine, .deck-panel:focus-visible .deck-spine, .deck-panel.is-open .deck-spine { opacity: 0; }

  /* Body: the expanded state. */
  .deck-body {
    position: absolute; inset: 0; display: flex; flex-direction: column; gap: 10px;
    padding: 28px 30px 30px; opacity: 0; pointer-events: none;
    transition: opacity 0.3s ease 0.12s;
  }
  .deck-panel:hover .deck-body, .deck-panel:focus-visible .deck-body, .deck-panel.is-open .deck-body { opacity: 1; }
  .deck-kicker { font-family: var(--mono); font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--teal); }
  .deck-title { font-family: var(--display); font-weight: 600; font-size: clamp(1.3rem, 2.1vw, 1.9rem); letter-spacing: -0.015em; color: var(--ink); line-height: 1.1; }
  .deck-panel p { margin: 0; max-width: 46ch; }

  /* The gain: the number is the point, so it gets its own type. */
  .deck-gain { margin-top: auto; display: flex; flex-direction: column; gap: 3px; font-family: var(--mono); font-size: 12px; }
  .deck-gain b { font-size: clamp(1.05rem, 1.7vw, 1.45rem); color: var(--teal); font-weight: 600; letter-spacing: -0.01em; }
  .deck-gain s { text-decoration: line-through; color: var(--ink-faint); }

  /* Status tag — no claim on this site appears without one. */
  .deck-status { display: inline-flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-faint); }
  .deck-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--ink-faint); }
  .deck-status[data-state="running"]::before { background: var(--teal); box-shadow: 0 0 10px var(--teal-glow); }
  .deck-status[data-state="designed"]::before { background: transparent; border: 1px solid var(--ink-faint); }

  /* The door panel is the one you can see through — it shows the sculpture rather than sitting
     on white. A tint veils the dot grid enough to stop it fighting the type while keeping the
     transparency that makes it a door. No backdrop-filter, for the same reason as .room-frost:
     the sculpture behind it repaints ~30×/sec and would force a re-blur on every one. */
  .deck-panel.is-door { background: color-mix(in srgb, var(--bg) 34%, transparent); }
  .deck-panel.is-door .deck-title { color: var(--teal); }

  /* Touch + narrow: hover does not exist, so panels stack and tap-expand in place.
     The first tap opens (engine.js intercepts it); a second tap follows the link. */
  @media (max-width: 900px) {
    .room[data-room="2"] .room-inner { max-width: none; height: calc(100svh - 150px); }
    /* Same behaviour, rotated: the deck still fills the screen and the panels share it,
       so a closed panel is a third of the viewport rather than a 66px strip. */
    .deck { flex-direction: column; height: auto; gap: 8px; }
    .deck-panel { flex: 1 1 0; min-height: 0; }
    .deck-panel.is-open { flex-grow: 3.4; }
    .deck-spine { position: static; align-items: center; justify-content: flex-start; padding: 24px 20px; }
    .deck-spine span { writing-mode: horizontal-tb; transform: none; }
    .deck-body { position: absolute; padding: 24px 20px; }
  }

  @media (prefers-reduced-motion: reduce) {
    .deck-panel, .deck-spine, .deck-body { transition: none; }
  }

  /* Content rooms dim the sculpture. The split hero solves room 1 by moving the artwork
     out of the text column, but a deck claims the full width, so there is nowhere to move
     it to — the copy would sit straight on the bright core again. Dimming keeps the
     sculpture present as atmosphere while the content stays legible. */
  body[data-room="2"] .sculpture, body[data-deck] .sculpture { opacity: 0.32; }

  /* The Lab holds a deck of its own, so the layout rules above are shared by class rather
     than duplicated per room number. `.has-deck` is the hook; everything else is identical. */

  /* A deck can also sit inside a reading page (The Lab), where it is one element among
     several rather than the whole screen. `flex: 1 1 auto` is inert outside a flex column,
     so here the deck carries an explicit height. Below 900px it stacks and grows again. */
  .doc-page .deck { height: min(58svh, 560px); margin: 10px 0 6px; }
  @media (max-width: 900px) { .doc-page .deck { height: auto; } }

  /* ══ Case page (a Systems card, activated) ══════════════════════════════════════
     The workflows are a plain list, not a second accordion — nesting the same gesture
     inside itself made the page fight the reader for content they came to read.

     The gain, stated once at the top of the page. It is the reason the page exists,
     so it is not buried in body copy. */



  /* Case pages dim the sculpture for the same reason room 2 does: the content claims the full
     width, so there is nowhere to move the artwork to. Scoped with :has() so the twelve
     existing side pages are untouched. */

  /* An action inside a panel — used where a workflow has somewhere to go. */

  /* Panels are focusable divs where they are not links, so they need the same focus ring. */
  .deck-panel[tabindex]:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }

  /* Workflow list — one row per workflow: number, what it is, what it gained, status.
     Scannable in a single pass, which an accordion is not. */

  /* ══ Centred module grid (services) ═════════════════════════════════════════════
     The CARDS are centred on screen; the heading above them stays on the left gutter
     where the rest of the site's type sits.

     The room is `place-items: center start` inside a 180px left gutter, so `margin:
     auto` would only centre the grid inside that offset column — still left of the
     screen. The offset is cancelled explicitly instead: half the viewport, minus half
     the grid, minus the gutter the column already starts at. max() keeps it from going
     negative on narrow windows before the mobile rules take over. */
  /* transform, not margin: a margin eats into the 1040px column, so at 1920 the grid was
     squeezed to 640px instead of shifting. A transform moves it without touching layout. */
  /* The mosaic variant is exempt: it spans the whole column instead of sitting at 760px, so
     there is no leftover offset to cancel and shifting it would push it off screen. */
  .modules:not(.modules-mosaic) { transform: translateX(max(0px, calc(50vw - 380px - var(--gutter-left)))); }
  @media (max-width: 900px) { .modules { transform: none; } }

  /* ══ Site footer ═══════════════════════════════════════════════════════════════════
     Only rendered where a page actually scrolls: the contact + notes rooms and every
     .doc-page reading page. The snap rooms (hero, deck, hub) are viewport-height and
     gesture-navigated, so a footer inside one would either be unreachable or fight the
     snap. This is why it is markup per page rather than part of the persistent shell. */
  .site-footer {
    margin-top: 88px; padding: 46px 28px 40px;
    border-top: 1px solid var(--line);
    font-family: var(--display);
    position: relative;
  }
  /* The sculpture runs behind the footer and its bright core lands squarely on the middle column,
     which made those links unreadable. Same answer as the form panel: tint the ground down rather
     than only blurring it. The gradient starts transparent so the border-top still reads as a
     hairline rather than the top edge of a box. */
  body[data-theme="dark"] .site-footer {
    background: linear-gradient(to bottom, rgba(6, 7, 10, 0) 0%, rgba(6, 7, 10, 0.72) 14%, rgba(6, 7, 10, 0.82) 100%);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
  body:not([data-theme="dark"]) .site-footer {
    background: linear-gradient(to bottom, rgba(240, 242, 246, 0) 0%, rgba(240, 242, 246, 0.78) 14%, rgba(240, 242, 246, 0.88) 100%);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
  .foot-top { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1fr; gap: 40px; align-items: start; }
  .foot-brand .foot-mark { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.05rem; letter-spacing: -0.01em; color: var(--ink); }
  .foot-brand p { margin-top: 12px; max-width: 30ch; font-size: 13px; line-height: 1.6; color: var(--ink-mute); }
  .foot-col h4 { font-family: var(--mono); font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-faint); margin-bottom: 14px; font-weight: 400; }
  .foot-col ul { list-style: none; padding: 0; margin: 0; }
  .foot-col li { margin-bottom: 9px; }
  .foot-col a { font-size: 13.5px; color: var(--ink-mute); text-decoration: none; transition: color 0.25s ease; }
  .foot-col a:hover { color: var(--ink); }
  .foot-bottom {
    margin-top: 40px; padding-top: 22px; border-top: 1px solid var(--line);
    display: flex; flex-wrap: wrap; gap: 8px 22px; align-items: baseline;
    font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.08em; color: var(--ink-faint);
  }
  .foot-bottom a { color: var(--ink-faint); text-decoration: none; }
  .foot-bottom a:hover { color: var(--ink-mute); }
  .foot-bottom .foot-sep { opacity: 0.5; }
  @media (max-width: 900px) {
    .foot-top { grid-template-columns: 1fr 1fr; gap: 30px; }
    .foot-brand { grid-column: 1 / -1; }
    .site-footer { margin-top: 56px; }
  }

  /* ══ Consent gate ══════════════════════════════════════════════════════════════════
     Analytics must not load before an explicit accept, so the banner is the thing that
     decides whether the GA tag is ever injected — not a cosmetic notice over a tag that
     already fired. Rejecting is exactly as easy as accepting (one click, equal weight),
     which is the part most cookie banners get wrong and the part regulators look at. */
  .consent {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 90;
    display: none; padding: 18px clamp(16px, 4vw, 40px);
    background: rgba(6, 7, 10, 0.82);
    border-top: 1px solid rgba(255, 255, 255, 0.12);
    backdrop-filter: blur(20px) saturate(150%);
    -webkit-backdrop-filter: blur(20px) saturate(150%);
    box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.34);
  }
  .consent[data-open] { display: block; }
  .consent-inner { max-width: 1100px; margin: 0 auto; display: flex; flex-wrap: wrap; gap: 18px 30px; align-items: center; justify-content: space-between; }
  .consent-copy { flex: 1 1 420px; font-family: var(--display); font-size: 13px; line-height: 1.6; color: #FFFFFF; }
  .consent-copy a { color: #FFFFFF; text-decoration: underline; text-underline-offset: 2px; }
  .consent-actions { display: flex; gap: 10px; flex: 0 0 auto; }
  .consent-btn {
    font-family: var(--mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
    padding: 11px 20px; border-radius: 0; cursor: pointer; border: 1px solid rgba(255,255,255,0.28);
    background: transparent; color: #FFFFFF; transition: background 0.25s ease, border-color 0.25s ease;
  }
  .consent-btn:hover { border-color: rgba(255,255,255,0.6); background: rgba(255,255,255,0.07); }
  .consent-btn.is-accept { background: #FFFFFF; color: #0A0B0E; border-color: #FFFFFF; }
  .consent-btn.is-accept:hover { background: rgba(255,255,255,0.86); }
  @media (max-width: 640px) {
    .consent-inner { flex-direction: column; align-items: stretch; }
    .consent-actions { justify-content: stretch; }
    .consent-btn { flex: 1; }
  }

  /* An empty-state card is not a link, so it must not pretend to be one. */
  .blog-card.is-empty { cursor: default; opacity: 0.72; }
  .blog-card.is-empty:hover { transform: none; }
