/* ==========================================================================
   BODY TERRAIN HEALING ARTS — v2
   Daylight's structure, Bloom's colour, built around one idea:

   THE PAGE DESCENDS. Depth is not stated, it is lit. The bodywork lives in
   daylight at the top, the page drops into deep forest for the turn, the
   client voices and the work beyond the table, and comes back to daylight to
   book.

   ⚠ THREE GROUNDS, NOT SEVEN, and the two changes between them are carried by
   FULL-BLEED PHOTOGRAPHS, not by gradients. Rewritten Aug 11 2026. This
   header used to describe a descent through dawn, midday, gold and dusk with
   every join melting into the next, and it ended "no section boundary is ever
   a hard edge because the palette is always mid-transition." Izzy: "They're
   reading a little psychedelic to me right now, almost too much on the eyes."
   Four of those seven grounds sat within a few points of each other, so most
   of the melts were a bar of light with no colour change under them to
   explain it. Now: cream for the whole first half, one committed deep forest
   run for the second, cream again to book, and a photograph on each hinge.

   Bands that share a ground meet on a hairline with air around it, which is
   what his design reference (zeonicreations.com) does. ⚠ A band that follows
   a photograph takes NO hairline; the picture already separated them.
   The melt machinery in handoff() still exists and every join currently
   computes to 0px, because there is no longer any join between two different
   flat grounds. It earns its keep again the moment a fourth ground appears.

   Type floors: body 19px, detail 14px, NOTHING below 14px anywhere.
   ========================================================================== */

/* ⚠ Fraunces is OUT. Its f, g and y are deliberately quirky BY DESIGN, not via
   a switchable OpenType feature, so font-variant-ligatures and the WONK axis
   both failed to touch them. Newsreader is the same warm editorial register
   with conventional letterforms. */
@import url('https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,300;6..72,400;6..72,500;6..72,600&family=Hanken+Grotesk:wght@400;500;600&display=swap');

/* ---------- 1 · The descent ------------------------------------------------
   Each band carries data-tone. These are the stops on the journey.          */

:root{
  --ink:#22301C;
  --body:#46523F;
  --muted:#646E5C;   /* darkened Aug 11 2026 for AA, see palettes.py */
  --hair:#E3DAC6;

  --green:#4A9E33;      /* his logo leaf */
  --teal:#12666B;       /* glacial water */
  --rust:#A94A26;       /* canyon */
  --gold:#C8871A;       /* larch */
  --timber:#12291F;     /* deep forest */

  /* Surfaces and on-colour text. Tokenized Aug 7 2026 so palette variants can
     reach every surface; these defaults reproduce the original literals. */
  --card:#fff;              /* raised card / pill surface */
  --on-key:#fff;            /* text sitting on a --key fill */
  --on-dark:#FCFAF3;        /* headline over the hero photograph */
  --on-dark-dim:#EDE9DB;    /* supporting copy over the hero photograph */
  --on-dark-accent:#CDE8BE; /* the second hero line */
  /* ⚠ #E2A93F until Aug 11 2026. The texture lifts the deep green where it
     catches light, and this gold cleared 4.5:1 against the FLAT ground (5.90)
     while falling to 3.93 over the brightest hundredth of the textured one.
     Same hue, a touch brighter and less saturated, and it clears the floor
     across the whole ground now. build.py gates it. */
  --night-key:#F0B64B;      /* accent inside the night band */
  --night-dim:#DCE6D8;      /* body copy inside the night band */
  --night-dim-2:#C9D8C4;    /* quieter body copy inside the night band */
  --sand:#F0E7D6;           /* footer ground, and the page behind it */
  --sand-dim:#6A6754;       /* footer detail text */

  --shell:#FCF8F0;
  --radius:20px;
  --radius-sm:14px;
  /* ⚠ 66ch until Aug 11 2026. Every block on his design reference reads short
     and none of them is short: hers run 40-60 words in a column of roughly 34
     characters with a lot of leading, so the eye never meets a slab. 66ch at
     19px is a 660px line, and his 127-word bands landed as exactly that. 52ch
     is the compromise: the 19px type floor stays, which was his first
     complaint of the whole project, and not one word of his copy is touched.
     Cutting paragraphs is a separate conversation and it is his to have. */
  --measure:52ch;
  --gutter:clamp(22px,5vw,72px);
  /* Bumped Aug 11 2026. Air between sections is what the design reference Izzy
     sent uses instead of a colour change, and now that most joins here are a
     hairline rather than a melt, the space around them is doing the separating. */
  --rhythm:clamp(72px,9.5vw,146px);
  /* Equal space above and below a Book-a-session button, so it sits centred
     between the rule above it and the rule below rather than hugging one. */
  --ctagap:clamp(48px,6vw,92px);
  --fctagap:clamp(40px,4.6vw,66px);
}

/* ⚠ THESE ARE THE LARCH VALUES AND THEY MUST MATCH palettes.py["larch"].
   Larch is the baseline, so a larch build emits no palette.css and nothing
   overrides what is written here. A fix made only in palettes.py would never
   reach the shipped site. build.py gates on the two agreeing.
   The --key on morning/afternoon is a DARKER green than --green on purpose:
   --green stays #4A9E33 because that is his logo leaf, while --key carries
   eyebrows and labels and has to clear 4.5:1 on cream. */
[data-tone="dawn"]      { --bg:#FCF8F0; --fg:var(--ink); --dim:var(--muted); --key:var(--teal);  --line:var(--hair); }
[data-tone="morning"]   { --bg:#FCF8F0; --fg:var(--ink); --dim:var(--muted); --key:#387A24;      --line:var(--hair); }
[data-tone="midday"]    { --bg:#F8F2E5; --fg:var(--ink); --dim:#626C5C;      --key:var(--teal);  --line:#E0D5BC; }
[data-tone="afternoon"] { --bg:#FBF5EA; --fg:var(--ink); --dim:var(--muted); --key:#387A24;      --line:var(--hair); }
[data-tone="goldenhour"]{ --bg:#F1E5CE; --fg:#2A3220;    --dim:#62624B;      --key:var(--rust);  --line:#DCCCA9; }
[data-tone="dusk"]      { --bg:#DFC9A4; --fg:#2A2A1E;    --dim:#56533E;      --key:#8E3D1F;      --line:#C9B18A; }
/* ⚠ --dim was #A8BCA6, which cleared 4.5 against the FLAT ground at 6.15 and
   fell to 4.10 over the textured one. Lightened with the same hue. */
[data-tone="night"]     { --bg:#1B3A2E; --fg:#F2EFE2;    --dim:#B2C5B0;      --key:var(--night-key);      --line:#2E5243; }

section, header.hero, footer{ background:var(--bg); color:var(--fg); }

/* ---------- The ground has a tooth ----------------------------------------
   Chef, Aug 11 2026: "add some more texure to his site, so it's not just plain
   blocks of color... unique to Izzy's vibe."

   The reference was Marla Tseng (manyworldsmanypossibilities.com), where no
   band is ever a flat colour: marbled ink with gold veining under every
   section. Right instinct, wrong material for a manual therapist in Boulder,
   so this is woven fibre rather than stone. texture.py holds the why.

   ⚠ THE FIRST ATTEMPT WAS FINE GRAIN AT 5%, TILED AT 192px, AND IT FAILED.
   Chef: "The texture that you added I can hardly see. It doesn't look like you
   did hardly anything." The error was SCALE, not amplitude: the eye integrates
   high-frequency noise away at reading distance and reads texture as variation
   across hundreds of pixels. The tile is fractal now and 512px, and the depth
   is set in texture.py. Chef picked from a four-depth ladder rendered against
   the real page: "option 3 for the cream, option 4 for the green", so 18% on
   cream and 16% on the green. ⚠ THE TWO GROUNDS ARE NOT ON THE SAME STEP and
   are set independently. Deep forest swallows texture that cream would shout
   about; pairing them looked tidy in the code and wrong on the screen.

   ⚠ THE TILE IS TINTED, NOT NEUTRAL. Neutral grey multiplied into #FCF8F0
   takes the same amount out of every channel and walks the cream to concrete;
   it went grey at all three depths before the tint went in. See INK_TINT.

   ⚠ TWO TILES because a tile that darkens is invisible on #1B3A2E. The night
   ground gets the inverse, screened.

   ⚠ .rate and .offer carry it too, or the price table sits on the ground as a
   patch of flat colour. Their tile phase does not line up with the band behind
   them and it does not matter: there is no landmark in the field for the eye
   to catch a jump in, which is the same property that lets a 512px tile repeat
   down a 6000px page without showing a grid. Measured, not assumed:
   texture.py seam_error().                                                  */
:root{
  --tooth:url(assets/img/grain.webp);
  --tooth-blend:multiply;
  --tooth-size:512px;
  /* The face of a card sitting on the ground. Slightly above the flat --bg so
     it stays clear of the textured ground, which averages darker. */
  --card-face:#FFFCF6;
}
[data-tone="night"]{
  --tooth:url(assets/img/grain-lift.webp);
  --tooth-blend:screen;
  --card-face:#224436;
}
/* ⚠ THE TEXTURE LIVES ON .ground, NOT ON EACH SECTION, and that is the whole
   fix for "the page breaks look a little bit weird". A background-image starts
   over at its own element's top-left, so a tile painted per section restarted
   the pattern at every band edge and drew a hard horizontal line across the
   mottle at each one. build.py wraps consecutive bands that share a ground in
   one .ground, so the field runs unbroken through a whole run and the only
   place it can restart is where the ground actually changes, which on this
   page is only ever underneath a full-bleed photograph.
   Sections inside a run therefore paint NO background of their own. */
.ground{
  background-color:var(--bg);
  background-image:var(--tooth);
  background-size:var(--tooth-size) var(--tooth-size);
  background-blend-mode:var(--tooth-blend);
}
/* ⚠ INCLUDING THE SEAMS. A seam inside a ground must not paint its own
   backing: the whole point is that the photograph dissolves out of the real
   textured ground rather than out of a flat panel that is 12% brighter. */
.ground > section, .ground > .seam{ background:transparent; }

/* ⭐ CHEF 31 Aug 2026, pinned as a note on this band through the markup overlay:
   "this page, no texture please. just a nice white, cream, or off white
   background". The credentials band runs flat.
   ⚠⚠ THE COLOUR IS THE COMPOSITED GROUND, NOT var(--bg), AND THAT DISTINCTION
   IS THE WHOLE JOB. First attempt used var(--bg) on the strength of
   texture.ground() returning #FCF8F1, i.e. "the tooth costs nothing on cream".
   That measured the TILE, not the page. Measured on the actual render, the
   textured ground averages rgb(243,237,229) while flat var(--bg) is
   rgb(252,248,240): a step of 9 to 11 out of 255 across the full page width at
   the about join. That is the white-light-bar failure over again, and it is
   invisible in a build gate because no gate parses a rendered pixel.
   ⚠ So: matched to the composited value. Re-measure it if the tooth, the
   palette or --tooth-blend ever changes. */
#training{ background-color:#F3EDE5; background-image:none; }

/* ⚠ THE PRICE CARDS SIT ON THE GROUND, THEY ARE NOT PART OF IT.
   Chef, Aug 11 2026: "the cards where it shares his pricing should NOT be
   textured, but sit on top of the textured background." He is right and my
   first version had it backwards: a card that carries the same tooth as the
   wall behind it is a hole cut in the wall, not an object on it. Flat stock
   over textured ground is what makes it read as paper laid down.
   --card-face is a shade ABOVE the ground rather than equal to it, because the
   texture darkens the ground on average and a card at the old --bg value would
   now read as a dent. The hairline grid still shows the ground through its 1px
   gaps, which is what holds the table together.                              */
.rate, .offer{
  background-image:none;
  background-color:var(--card-face);
}

/* Bands hand off through a melt rather than an edge: the top of each band
   carries a sliver of the one above it.

   ⚠ THE MELT HEIGHT IS NOW SET PER BAND, by handoff() in build.py, and most
   joins get NONE. Izzy, Aug 11 2026: "They're reading a little psychedelic to
   me right now, almost too much on the eyes." This was a fixed ramp of up to
   150px at every band, and four of the seven grounds are within a few points of
   each other, so most of those ramps were a bar of light on flat cream with no
   colour change underneath to explain it. Six of them down one page is what
   read as psychedelic. Where the ground genuinely changes the melt is still
   here and still does its job, at a little over half the height.

   The fallback is 0px, not the old clamp: a band that somehow reaches this
   without a value should abut cleanly rather than glow.                     */
section[data-tone]{ position:relative; }
section[data-tone]::before{
  /* ⚠ Capped at the band's own top padding so a ramp can NEVER reach the first
     line of copy. The one join that still melts a long way is deep green into
     cream at the visit band, and 84px of dark ramp across a centred heading is
     the "the fade is blurring this" report all over again. --rhythm falls to
     64px on a phone while the melt is a fixed pixel value, so without this the
     ramp crosses the words on small screens only. */
  content:""; position:absolute; inset:0 0 auto 0;
  height:min(var(--melt,0px), var(--rhythm));
  background:linear-gradient(to bottom, var(--from,transparent), transparent);
  pointer-events:none; z-index:0;
}

/* When two bands share a ground there is nothing to melt, so the join is a
   hairline and the vertical rhythm around it, which is how the design
   reference Izzy sent separates every section on one continuous ground.
   Inset to the gutter rather than full-bleed, because bands on this page run
   photographs off the screen edge and a rule crossing one reads as a mistake. */
section[data-tone][data-rule]:not(.seam)::after{
  content:""; position:absolute; top:0; left:var(--gutter); right:var(--gutter);
  height:1px; background:var(--line); pointer-events:none; z-index:1;
}
/* ⚠ The seam must NOT take this overlay. It already carries its own ::after
   running --from at the top and --to at the foot, and doubling the top half is
   why the melt into the lake photograph read wrong. (The interleaved .voice
   blocks were the second exclusion here, for the same stacking reason; they no
   longer exist. That was the "review looks blurred" note, five times over.) */
.seam[data-tone]::before{ content:none; }
/* ⚠ The melt must sit BEHIND the copy. At z-index 1 it painted a fade across
   the first lines of every band, which reads as the text being blurred. */
section > .wrap, header.hero > .wrap{ position:relative; z-index:2; }

/* ---------- 2 · Type -------------------------------------------------------
   His copy went tiny to fit the old layout. It does not fit around the copy
   now. Floors are hard.                                                     */

*{ box-sizing:border-box; }
html{ scroll-behavior:smooth; }
/* ⚠ Sept 1 2026: the nav is position:fixed, so a jump to #sessions put the section's
   own top edge at y=0 and left the eyebrow UNDER the bar. Desktop was already clear
   (heading landed at 137 against a 105px nav); mobile was not, because the band's
   top padding there is 72px against a 116px nav, so every eyebrow sat 44px behind it.
   Measured on all six sections, not assumed. Scoped to the same 1080px breakpoint the
   navlinks collapse at, so the desktop offset that already works is left alone. */
@media (max-width:1080px){ html{ scroll-padding-top:96px; } }
body{
  margin:0; background:var(--shell); color:var(--ink);
  font-family:'Hanken Grotesk',system-ui,-apple-system,sans-serif;
  font-size:19px; line-height:1.65; font-weight:400;
  -webkit-font-smoothing:antialiased;
}
@media (max-width:560px){ body{ font-size:18px; } }

h1,h2,h3,.price{
  font-family:Newsreader,Georgia,serif; font-weight:400; line-height:1.08;
  margin:0; letter-spacing:-.012em;
  /* The odd f/g/y come from Fraunces' quirky alternates. Two belts here
     because Google only declares font-weight on the served face, so the WONK
     axis may never reach the browser: turn the axis down AND switch off the
     ligatures and contextual alternates that carry the shapes. */
  font-optical-sizing:auto;
}
h1{ font-size:clamp(46px,7.4vw,92px); }
h2{ font-size:clamp(32px,4.4vw,54px); margin-bottom:.62em; }
h3{ font-size:clamp(22px,2.4vw,29px); line-height:1.2; }

p{ margin:0 0 1.15em; max-width:var(--measure); }
p:last-child{ margin-bottom:0; }
.lead{ font-size:clamp(21px,2.3vw,26px); line-height:1.5; color:var(--fg); }
.small{ font-size:16px; color:var(--dim); }
.detail{ font-size:14px; color:var(--dim); }   /* the floor. nothing below.  */

.eyebrow{
  font-size:14px; font-weight:600; letter-spacing:.15em; text-transform:uppercase;
  color:var(--key); margin:0 0 18px; display:block;
}

a{ color:inherit; }
/* ⚠ height:auto is LOAD-BEARING, do not drop it. Every <img> now carries real
   width/height attributes so the browser reserves the right box before the
   photo lands (the layout-shift half of a mobile score). But an HTML height
   attribute is a used height, so without this it BEATS the aspect-ratio the
   layout is actually built on: the offering cards went from 698px tall to
   1819px the moment the attributes were added. The two places that genuinely
   want a stretched image, .hero img.bg and .seam img, set height:100%
   themselves further down and still win on specificity. */
img{ max-width:100%; height:auto; display:block; }

/* ---------- 3 · Shell ------------------------------------------------------ */

.wrap{ width:min(1240px,100% - var(--gutter)*2); margin-inline:auto; }
section{ padding-block:var(--rhythm); position:relative; z-index:0; }

/* Asymmetric rhythm. Content leans left, then right, then centres. Balanced
   by eye against the image weight opposite it, not snapped to one rule.     */
.split{ display:grid; gap:clamp(32px,5vw,76px); align-items:center; }
@media (min-width:900px){
  .split{ grid-template-columns:1.05fr .95fr; }
  [data-align="right"] .split{ grid-template-columns:.95fr 1.05fr; }
  [data-align="right"] .split > .copy{ order:2; }
  [data-align="left"]  .copy{ padding-right:clamp(0px,3vw,48px); }
  [data-align="right"] .copy{ padding-left:clamp(0px,3vw,48px); }
}
/* Centred bands centre the HEADING only. Body copy stays ranged left, because
   several centred paragraphs in a row is a readability tax and "confusing" is
   one of his three banned feelings. */
[data-align="centre"] .copy{ margin-inline:auto; }
[data-align="centre"] .eyebrow,
[data-align="centre"] h2{ text-align:center; }
[data-align="centre"] .copy p{ margin-inline:auto; text-align:left; }

/* ---------- 4 · Nav -------------------------------------------------------- */

/* The bar used to be an opaque strip ABOVE the photograph, which put a hard
   horizontal edge across the top of the page before anyone had read a word.
   It now sits ON the photograph: fixed, transparent, with its own soft scrim
   so the links stay legible over a bright sky, and it fades to the solid bar
   only once the hero has scrolled away. The photograph reaches the top of the
   screen, which is the whole point of using a photograph that size. */
nav{
  position:fixed; top:0; left:0; right:0; z-index:60;
  background:transparent; border-bottom:1px solid transparent;
  transition:background .32s ease, border-color .32s ease;
}
nav::before{
  content:""; position:absolute; inset:0; pointer-events:none;
  background:linear-gradient(to bottom, rgba(10,18,12,.58), rgba(10,18,12,0));
  transition:opacity .32s ease;
}
nav .navin{ position:relative; z-index:1; }

/* Over the photograph the bar reads light. The palette already carries the
   on-dark set for exactly this, so this follows whichever colour is chosen. */
nav:not(.solid) .brand b{ color:var(--on-dark); }
nav:not(.solid) .brand span{ color:var(--on-dark-dim); }
nav:not(.solid) .navlinks a{ color:var(--on-dark-dim); }
nav:not(.solid) .navlinks a:hover{ color:var(--on-dark); }
/* ⚠ TWO MARKS IN THE MARKUP, ONE VISIBLE AT A TIME, and it has to work this way.
   The mark is not monochrome any more: the leaves are green in both versions, so
   there is no filter that turns one into the other. Measured on both grounds, the
   ink mark's dark linework vanishes into #1B3A2E and the knockout's white linework
   vanishes into cream, so each ground gets its own file. Everything else about the
   nav already keys off .solid, and this follows it. */
nav .brand .ko{ display:none; }
nav:not(.solid) .brand .ink{ display:none; }
nav:not(.solid) .brand .ko{ display:block;
  filter:drop-shadow(0 1px 6px rgba(0,0,0,.45)); }

nav.solid{
  background:color-mix(in srgb, var(--shell) 88%, transparent);
  backdrop-filter:blur(14px); border-bottom-color:var(--hair);
}
nav.solid::before{ opacity:0; }
.navin{ display:flex; align-items:center; gap:26px; padding:16px 0; }
.brand{ display:flex; align-items:center; gap:12px; text-decoration:none; margin-right:auto; }
/* The colour mark is a full canopy where the trace was a narrow tree, so the same
   width reads smaller. 52px holds the weight the 46px trace had. */
.brand .mk{ width:52px; height:auto; }
.brand b{ font-family:Newsreader,serif; font-weight:400; font-size:21px; line-height:1.05; display:block; }
.brand span{ display:block; font-size:14px; letter-spacing:.06em; color:var(--muted); font-family:'Hanken Grotesk',sans-serif; }
.navlinks{ display:flex; gap:24px; }
.navlinks a{ font-size:16px; text-decoration:none; color:var(--body); }
.navlinks a:hover{ color:var(--green); }
@media (max-width:1080px){ .navlinks{ display:none; } }

.btn{
  display:inline-flex; align-items:center; gap:10px; text-decoration:none;
  font-size:16px; font-weight:600; padding:14px 26px; border-radius:999px;
  background:var(--teal); color:var(--on-key); border:1px solid transparent;
  transition:transform .18s ease, background .18s ease;
  /* ⛔ BOTH OF THESE ARE FOR <button>, AND BOTH WERE MISSING. Chef, Sept 4 2026:
     "hovering over the form, the cursor doesn't change like it does on every
     other button." This class was only ever put on an <a>, which is
     cursor:pointer and inherits the page font for free. A <button> is neither:
     it defaults to cursor:default and to the browser's own UI font. The moment
     the enquiry form introduced the first real <button>, both showed. Fixed on
     .btn itself rather than on the two new buttons, so the next one cannot
     inherit the same bug. */
  cursor:pointer; font-family:inherit;
}
.btn:hover{ transform:translateY(-1px); }
.btn.ghost{ background:transparent; color:var(--fg); border-color:currentColor; }
[data-tone="night"] .btn{ background:var(--key); color:var(--timber); }

/* ---------- 5 · Hero ------------------------------------------------------- */

/* ⚠ THE HERO PAINTS THE REAL GROUND UNDER ITS OWN PHOTOGRAPH, and it has to,
   because the photograph now dissolves at the foot and something has to be
   revealed. It is the same four properties .ground uses, reading the same
   [data-tone] variables, so what appears as the picture fades is the identical
   textured cream the work band is standing on: #FCF8F0 flat and #F1EBE2 once
   the tooth is on it, in both dawn and morning. Checked in palettes.py, not
   assumed. The tile phase does not line up across the join and does not need
   to: there is no landmark in fractal noise for the eye to catch a jump in,
   which is the same property that lets one 512px tile run down 14,000px of
   page. */
/* ⚠ header.hero, NOT .hero, and the extra type selector is load-bearing. Line
   109 sets `section, header.hero, footer{ background:var(--bg) }` and that is
   the SHORTHAND, which resets background-image and background-size to their
   initial values. At plain .hero this rule lost the cascade and the computed
   background-image came back `none`: the hero foot painted flat #FCF8F0 at
   luminance 248 while the band below it painted the textured #F1EBE2 at 236, so
   the dissolve landed on a pale strip and drew a fresh line right where the old
   one had been. Measured, after it had already shipped once. */
header.hero{ position:relative; min-height:min(96vh,920px); display:grid; align-items:end; overflow:hidden;
       background-color:var(--bg); background-image:var(--tooth);
       background-size:var(--tooth-size) var(--tooth-size);
       background-blend-mode:var(--tooth-blend); }

/* ⚠ MEASURED, Aug 18 2026: this join was a 201-luminance step in ONE pixel row,
   35 to 236. The hardest edge on the page, and the first one anyone met, while
   every photograph seam below it had already been treated and ran 30 to 90px.
   The fix is the seam treatment, not a new idea: an irregular warped ramp that
   dissolves the picture instead of laying a wash over it.
   ⚠ TWO MASK LAYERS ON PURPOSE. The gradient covers everything above the fade
   at full opacity and the PNG carries the fade itself at an EXACT pixel height,
   so the dissolve does not stretch when the hero's height resolves differently
   on a phone. A single full-height mask would make the fade a fraction of a box
   that moves with the viewport.
   ⚠ THE VEIL IS MASKED TOO. It is a separate darkening layer over the picture,
   so leaving it unmasked would fade the photograph out from under its own
   shadow and leave a grey rectangle sitting on the cream. */
.hero{ --herofade:clamp(64px,7.2vw,104px); }
.hero img.bg, .hero .veil{
  -webkit-mask-image:url(assets/img/hero-veil.png), linear-gradient(#000,#000);
          mask-image:url(assets/img/hero-veil.png), linear-gradient(#000,#000);
  -webkit-mask-size:100% var(--herofade), 100% calc(100% - var(--herofade) + 1px);
          mask-size:100% var(--herofade), 100% calc(100% - var(--herofade) + 1px);
  -webkit-mask-position:bottom, top;  mask-position:bottom, top;
  -webkit-mask-repeat:no-repeat;      mask-repeat:no-repeat;
}
/* ⚠ object-position IS LOAD-BEARING: IT IS WHAT KEEPS THE MOON.
   The photograph is 4:3 and the hero box is much wider than that, so `cover`
   scales to the width and throws away the top and bottom. At the default 50%
   the crop is centred, and the moon sits high in the sky at 14.3% down, so it
   is the first thing to go on a wide screen. Measured Aug 19 2026 with the
   moon's overflow arithmetic at eight window sizes: fine to 1920, GONE at 2560,
   which is where Chef looks at it and why he reported it missing.
   At 25% the top crop drops from 424px to 212px at 2560 and the moon clears by
   63px, while at 1440 and below the whole overflow is about 20px so the framing
   does not move at all. Do not "tidy" this back to a plain cover. */
.hero img.bg{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
  object-position:center 25%; }
.hero .veil{
  position:absolute; inset:0;
  background:
    radial-gradient(120% 90% at 4% 84%, rgba(14,26,14,.80) 0%, rgba(14,26,14,.34) 46%, transparent 70%),
    linear-gradient(178deg, rgba(16,26,14,.44) 0%, rgba(16,26,14,.04) 34%, rgba(18,30,18,.70) 100%);
}
/* The copy is pushed clear of the dissolve by exactly its height, so the button
   never sits in the part of the picture that is turning into cream. Without
   this the phone case fails first: padding bottoms out at 56px there while the
   fade is still 64. */
.hero .wrap{ position:relative; padding-block:clamp(56px,9vw,120px);
             padding-bottom:calc(clamp(56px,9vw,120px) + var(--herofade)); }
/* Maria's tree in colour over the photograph, from the knockout export.
   ⚠ CORRECTION, Aug 18 2026: this used to say tree-green-ko.png was corrupt
   with black blocks and a near-white trunk. It is not. It is a valid 4602x4267
   RGBA knockout, white linework with green leaves, and it is the source of
   assets/tree-ko.webp. Nothing needs re-exporting from Maria. */
.heromark{
  width:clamp(170px,17vw,260px); height:auto; margin-bottom:14px; margin-left:-14px;
  /* The knockout arrives already white-on-transparent with green leaves, so the
     old brightness(0) invert(1) is gone: it would flatten the leaves back to white
     and undo the whole point of going to colour. The shadow stays, because the
     linework is still fine enough to lose against a bright sky. Wider than the
     trace was, since a spread canopy at the same width reads smaller. */
  filter:drop-shadow(0 4px 26px rgba(0,0,0,.6));
}
.hero h1{ color:var(--on-dark); }
.hero h1 em{ font-style:normal; display:block; color:var(--on-dark-accent); font-size:.52em; letter-spacing:.02em; margin-top:.18em; }
/* Positioning line, sits between the wordmark and the lead. Sized between the
   two so the hero reads as three clear steps rather than two headings and a
   paragraph competing. */
/* Cream, not the accent green. In green it sat directly under "Healing Arts" in
   the same colour and the two fused into one heading block. Serif keeps it
   distinct from the sans lead below, so the hero steps down cleanly:
   wordmark, brand line, place, then who the work is for. */
.hero .herotag{ color:var(--on-dark-dim); font-family:Newsreader,serif; font-size:clamp(21px,2.1vw,27px);
                line-height:1.35; max-width:26ch; margin-top:22px; opacity:.92; }
.hero .lead{ color:var(--on-dark-dim); max-width:52ch; margin-top:22px; }
.hero .ctas{ display:flex; gap:14px; flex-wrap:wrap; margin-top:34px; }
.hero .ghost{ color:var(--on-dark); }

/* ---------- 6 · Figures ---------------------------------------------------- */

figure{ margin:0; }
.fig img{ border-radius:var(--radius); box-shadow:0 34px 70px -46px rgba(30,40,26,.7); }

/* ⚠ A PORTRAIT PHOTO IN A LANDSCAPE RHYTHM HAS TO BE CAPPED BY HEIGHT, NOT BY
   WIDTH, and that is the whole fix for Izzy's Aug 17 note that the About photo
   is much bigger than everything else. Every band figure gets the same column
   width, so a landscape source lands at 766x526 and a portrait one at 766x919:
   1.75x the area off the same rule. Capping the width would just make a small
   photo in a wide gap. Capping the height makes it match the others' presence.

   ⚠ NO PANEL BEHIND IT. Izzy floated one ("maybe even like a smaller photo with
   some kind of background") and it was built and rejected the same day. Chef,
   Aug 17: "ditch the background on the photo you resized. it looks good size
   wise, but we don't need that background behind the photo." The size was the
   note; the panel was my addition. The photo just sits in the column.

   ⚠ The cap is on the SLOT, not the file. He is replacing this photo after his
   shoot and may drop it in himself, so whatever arrives has to land in a frame
   that already holds its shape. */
.fig.portrait{ display:flex; justify-content:center; }
.fig.portrait img{ max-height:clamp(380px,42vw,560px); width:auto; max-width:100%; }


/* The certificate grid. His own 3x3 composite, so the page must not try to
   re-lay it out: one figure, full column width, nothing cropped. It reads as
   proof rather than decoration, which is why it sits UNDER the three columns
   of names rather than beside them. A reader takes the list first and the
   evidence second. */
.fig.certs{ margin-top:clamp(38px,4.6vw,64px); }
.fig.certs img{ width:100%; height:auto; display:block; }

/* Slow, gentle lift on hover. Long easing so it reads as the photo breathing
   rather than a click target snapping. Nothing moves for reduced-motion. */
.fig img, .gallery img{
  transition:transform 1.1s cubic-bezier(.22,.61,.36,1),
             box-shadow 1.1s cubic-bezier(.22,.61,.36,1),
             filter 1.1s ease;
  will-change:transform;
}
.fig:hover img{
  transform:translateY(-8px) scale(1.018);
  box-shadow:0 46px 88px -44px rgba(30,40,26,.78);
  filter:saturate(1.04);
}
.gallery img:hover{
  transform:translateY(-6px) scale(1.025);
  box-shadow:0 30px 56px -34px rgba(30,40,26,.6);
}
[data-tone="night"] .fig:hover img{ box-shadow:0 46px 88px -40px rgba(0,0,0,.85); }

/* ⚠⚠ SEAM PHOTOGRAPHS DO NOT MOVE ON HOVER, AND THIS IS NOT A TASTE CALL.
   Chef, Aug 14 2026: "avoid hover pop for transition photo below creds, it
   messes with the blending" and, on the lake, "better blending for the top part
   of the bottom photo, avoid hover pop please."

   The mechanism, because it is the reason a gentler scale would not have helped
   either: the top-edge dissolve is a MASK ON THE IMAGE ELEMENT, so a transform
   scales the mask along with the picture. At scale(1.03) on an 860px strip the
   masked top edge lifts ~13px above the band and `overflow:hidden` cuts it off,
   so the dissolve the seam is built around is literally clipped away and the
   join snaps to a hard line under the cursor. The blend and the pop cannot both
   exist on the same element. The blend is the point of the band.

   A card photo lifting under the cursor is fine and stays: those are objects on
   a ground, not a join between two grounds.                                   */

@media (prefers-reduced-motion:reduce){
  .fig img, .gallery img{ transition:none; }
  .fig:hover img, .gallery img:hover{ transform:none; }
}
.fig.tall img{ aspect-ratio:4/5; object-fit:cover; width:100%; }
.fig.wide img{ aspect-ratio:3/2; object-fit:cover; width:100%; }
figcaption{ font-size:14px; color:var(--dim); margin-top:12px; }

/* Uniform frames, one aspect ratio, large enough to actually read. Varying
   the sizes looked like a mistake rather than a decision. */
/* ⚠ 34px was not enough air. Chef, Aug 11 2026: more space between the portrait
   and the four photographs under it. At 34px the strip read as part of the
   portrait's own block rather than as a separate row of pictures. */
.gallery{ display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr));
  gap:18px; margin-top:clamp(52px,6vw,92px); }
.gallery img{ aspect-ratio:4/5; object-fit:cover; border-radius:var(--radius-sm); width:100%; }

/* A full-bleed photograph used as a transition between two bands.
   ⚠ Taller than it was. At 490px the seated figure in the foothills shot was a
   detail; the strip has to give a subject room or it may as well be a texture. */
/* ⚠ HEIGHT IS PER SEAM, not one number for both.
   Chef, Aug 11 2026, on the lake at the foot of the page: "could we expand the
   bottom image a little bit more so we can actually see the mountain tops and
   sky? I like that we can see the full reflection... or did you place it like
   it is intentionally?" It was not intentional: this was a single global value
   and the photograph is 4:3, so at 42vw the strip showed 860 of its 1536 rows
   and the centre crop took the summit off the top and the stones off the
   bottom. A taller strip keeps the reflection AND gets the peak, which is what
   makes that photograph worth using at all. Set `height` on a seam band in
   content.json; leave it out and a seam keeps the original proportion.        */
.seam{ padding:0; height:var(--seam-h, clamp(340px,42vw,660px));
       position:relative; overflow:hidden; }
.seam img{ width:100%; height:100%; object-fit:cover; object-position:center 62%; }
/* ⚠ The top fade ran the incoming colour to transparent over 34% of the strip,
   which on a 490px seam laid 167px of sand across the mountains and read as a
   washed-out photograph. Same failure as the review fade: a melt belongs at the
   EDGE, not over the subject. Top is now a 13% edge. The bottom keeps its long
   ramp on purpose, because that one is the actual handoff into the dark band
   and it runs over water and rock, not over anything you need to read. */
/* ⚠ EASED, NOT LINEAR, AND THAT IS THE WHOLE FIX.
   Chef, Aug 11 2026: "I wonder if we could have more of a clean transition from
   the picture below his credentials to his credentials section? almost like it
   cleanly floats down into the photo? right now it's a pretty hard white line
   break." It was a two-stop linear ramp, and a linear gradient ENDS somewhere:
   the eye finds the stop where it reaches transparent and reads that as an
   edge, which is the "hard white line" even though there is a fade under it.
   Multiple stops on an ease-out curve have no terminus to find.

   ⚠ AND IT STILL HAS TO STAY OFF THE SUBJECT. A 34% top fade once laid 167px
   of sand across the mountains and read as a washed-out photograph. This keeps
   the same budget as the 13% version for anything you would call opaque (down
   to 8% of the colour by 14% of the strip) and spends the rest of the ramp on
   a tail so thin it only reads as air. Do not straighten these back into two
   stops, and do not push the mid stops later to make it "smoother". */
/* ⚠ THE TOP IS A MASK ON THE PHOTOGRAPH, NOT A WASH OVER IT.
   Chef, Aug 11 2026: "almost like it cleanly floats down into the photo? right
   now it's a pretty hard white line break." Painting cream over the top of the
   picture cannot fix that, and I tried: this photograph opens on bright hazy
   sky, so a cream overlay on a near-cream sky is invisible and the only thing
   left to see is the exact row where one ends and the other starts.
   So the PICTURE fades in instead. It is masked to transparent at its top edge
   and the strip carries the incoming ground colour behind it, which means the
   join is a dissolve no matter what the photograph happens to open on.

   The foot keeps a painted ramp, because there the picture is running into the
   deep green over water and rock, and a mask there would show the flat green
   through the reflection rather than under it.                               */
.seam{ background:var(--from); --fade:118px; }

/* ⚠⚠ THERE ARE TWO TOP BLENDS AND WHICH ONE IS RIGHT DEPENDS ON WHETHER THE
   BAND ABOVE IS LIGHTER OR DARKER THAN THE PHOTOGRAPH. Set `blend` per seam in
   content.json. This is the Aug 14 2026 answer to Chef on the lake: "better
   blending for the top part of the bottom photo."

   `mask` (default) — the picture itself fades in and the strip's flat --from
   shows through behind it. Right when the band above is LIGHTER than the top of
   the photo, which is the descent: cream running into hazy peaks. A painted
   cream wash there is invisible against a near-cream sky, so the only thing
   left to see is the row where one ends and the other begins.

   `shade` — no mask, the picture runs edge to edge and a ramp of --from is
   painted over its head instead. Right when the band above is DARKER, which is
   the lake coming back out of the deep green. A dissolve there mixes flat green
   INTO a lit summit and the peaks go to fog, which is exactly why the fade was
   capped at 118px and why lengthening it was never the fix. A dark ramp over
   the same rows reads as shadow the light is climbing out of: the summit keeps
   its texture and the join still starts on the band's own colour, so there is
   no line to find. It can therefore run much longer than a dissolve could.  */
/* ⚠⚠ THE HEAD OF THIS SEAM IS A CLEAN EDGE NOW, MATCHING THE LAKE (#1199).
   Chef, Aug 28 2026: "the blend from his credentials to that nice photo of the
   mountains really weakens the photo, and it just looks soft. Let's match the
   other breaks that we've done that look really clean."
   The ramp above dissolved the PHOTOGRAPH itself, so a long head fade did not
   soften a join, it deleted the top of the picture and left it looking hazy.
   It is gone. The photograph now starts at full strength and a hairline of the
   departing cream makes the join read as a decision, exactly as the lake does.
   ⛔ Do not answer a future softness note by putting this gradient back. Both
   seam heads are clean edges and they are meant to match. */
.seam[data-blend="mask"] img{
  -webkit-mask-image:none; mask-image:none;
}
.seam[data-blend="mask"]::before{
  content:""; position:absolute; left:0; right:0; top:0;
  height:2px; background:rgba(240,231,214,.5); z-index:3; pointer-events:none; }
/* ⚠ Overrides the `content:none` reset further up, which is why it carries
   [data-tone] as well: same specificity otherwise, and that rule is EARLIER in
   the file but exists to stop the generic band overlay reaching a seam. */
.seam[data-tone][data-blend="shade"]::before{
  content:""; position:absolute; left:0; right:0; top:0;
  height:var(--fade); z-index:1; pointer-events:none;
  /* ⚠ --from-tex, NOT --from. The band above is textured and the tile lifts
     deep green by about 4%, so a ramp starting on the flat token puts a
     measurable step across the full page width in the strip's first row. The
     mask never had this problem because it shows the real ground through. Same
     failure the foot ramp already fixed with _textured(), mirrored to the top. */
  /* ⚠ THE CURVE IS A DECAY, NOT A RAMP, AND THAT IS THE WHOLE DIFFERENCE.
     Chef, Aug 14 2026, on the first attempt: "your transition got worse
     actually. I want more of the photo, less of the green, and a really smooth
     transition from the photo out into the green texture. like a super gentler
     light but smooth transition."

     An even ramp spends half its length at heavy opacity, so a 200px one laid
     more than 50% green over the first 100px of the picture: smooth, and far
     too much green. This drops fast through the first tenth, where the eye is
     still reading the band above anyway, then runs a very long thin tail that
     never has a stop to find. Full colour only in the first few pixels, under a
     third of the way down by a fifth of the distance, and a whisper after that.
     So the photograph shows through almost all of the distance the blend takes
     up, which is what he is asking for: gentler AND longer, not one or the
     other.

     ⚠ IT MUST STILL REACH FULL --from-tex AT 0. That row sits against the band
     above and anything less is a step. Lighten by moving the SECOND stop, never
     the first.                                                                */
  background:linear-gradient(to bottom,
    var(--from-tex, var(--from)) 0%,
    color-mix(in srgb, var(--from-tex, var(--from)) 74%, transparent) 6%,
    color-mix(in srgb, var(--from-tex, var(--from)) 50%, transparent) 13%,
    color-mix(in srgb, var(--from-tex, var(--from)) 32%, transparent) 22%,
    color-mix(in srgb, var(--from-tex, var(--from)) 20%, transparent) 33%,
    color-mix(in srgb, var(--from-tex, var(--from)) 12%, transparent) 47%,
    color-mix(in srgb, var(--from-tex, var(--from)) 6%, transparent) 63%,
    color-mix(in srgb, var(--from-tex, var(--from)) 2%, transparent) 81%,
    transparent 100%);
}
/* ---- `haze`, the third top blend, and the only one that also does the foot --
   ⚠ FULL REASONING IS IN seamedge.py. Read it before touching any number here.

   `mask` and `shade` both apply ONE ramp across the whole width, and this join
   will not take one: measured on the live preview, the deep green band sits at
   luminance 60 while the top row of the photograph runs 20 at the left treeline
   and 226 at the centre sky. A ramp heavy enough for the middle washes the
   sides; a ramp light enough for the sides cuts the middle. `shade` chose the
   second and Izzy reported it, Aug 17 2026.

   So the correction is a BLEND MODE rather than an opacity, which makes it
   proportional to what is already underneath instead of uniform over it:

     head   MULTIPLY. Moves a 226 sky a long way, moves a 20 conifer almost not
            at all, because dark times dark is dark regardless.
     foot   SCREEN, for the identical reason with the sign flipped. The plinth
            below is SAND, lighter than the water, so the foot needs lifting
            where the reflection is dark and nothing where the summit is lit.

   ⚠ THE `.edge` LAYERS ARE NOT DECORATION AND MUST NOT BE FOLDED INTO `.tint`.
   The first row of a seam has to equal the band above it exactly and the last
   row the band below, and neither multiply nor screen can land on a specific
   value: multiply always lands darker than its own colour, screen always
   lighter. So a short normal-alpha ramp sits on top of each blended one purely
   to own the first and last rows. Delete it and both joins step.

   Both boundaries are noise-displaced per column by seamedge.py, which is what
   removes the straight row. The masks are authored at 720px and stretched;
   the warp is low frequency on purpose so that survives.                     */
.seam[data-blend="haze"] .tint,
.seam[data-blend="haze"] .edge{
  content:""; position:absolute; left:0; right:0; pointer-events:none;
  background-color:currentColor; -webkit-mask-image:var(--m); mask-image:var(--m);
  -webkit-mask-size:100% 100%; mask-size:100% 100%;
  -webkit-mask-repeat:no-repeat; mask-repeat:no-repeat;
}
/* The mask carries the shape AND the falloff, so the layer itself is flat
   colour. currentColor is set per layer below rather than inherited, because
   the head and the foot are two different grounds. */
/* ⚠ THE HEAD DISSOLVES THE PHOTOGRAPH ITSELF. This is the difference between
   "softened" and "faded", and it is the note Chef has now given three times:
   Aug 11 "no white light, just smooth seamless transition from background to
   image", Aug 14 "a really smooth transition from the photo out into the green
   texture", Aug 17 "it doesn't blend at all or fade out into the photo at all."
   Painting the band's colour over the picture leaves the picture starting
   somewhere. Masking the picture means at the top there is only ground, and the
   photograph arrives. Do not answer a future note here by reaching back for a
   painted ramp.
   ⚠ It works because a seam paints NO ground of its own (see the .ground
   override further up), so what shows through the transparent rows is the real
   textured band, not a flat panel. That was half of why the earlier attempt
   fogged: it was dissolving into flat colour. The other half was length. */
.seam[data-blend="haze"] img{
  -webkit-mask-image:var(--veil); mask-image:var(--veil);
  -webkit-mask-size:100% 100%; mask-size:100% 100%;
  -webkit-mask-repeat:no-repeat; mask-repeat:no-repeat;
}
/* ⚠ THE FOOT IS A VEIL NOW, NOT A PAINT, and the two must never both run.
   Chef, Aug 19 2026: "make those two transitions, the one at the top and the
   one at the bottom, matching." Measured at 1440 before the change: the hero
   foot moved 12.7 luminance in its worst single row, this one moved 83.6. The
   difference was technique, not strength. Painting lifts the last rows of the
   picture toward the ground colour and the picture still ENDS somewhere; the
   veil dissolves the picture itself, which is what the hero does.
   The picture now fades to nothing at its foot, so something has to be behind
   it: `.plinth` paints --to across the bottom of the seam, under the image. */
.seam[data-blend="haze"] .tint.foot,
.seam[data-blend="haze"] .edge.foot{ display:none; }
/* ⚠ z-index MUST BE NEGATIVE. The plinth is a later sibling than the image, so
   at any level the image can reach it paints ON TOP of the photograph and hides
   the very dissolve it exists to receive. Measured that way it reports a
   perfect 0.0 step, which is what a covered join always reports. */
/* ⚠ THE SAME CLEAN EDGE AT THE FOOT OF THE SITTING PHOTOGRAPH. Chef, Aug 19,
   after choosing it for the lake: "I'm not crazy about the transition from the
   photo to the green, so I wonder if we do similar to what we did down below
   and have it be kind of like a hard stop before it goes green." The generic
   foot ramp is replaced by a hairline, so the join is a rule rather than a
   fade. The HEAD of this seam keeps its dissolve: it arrives out of cream,
   which is the join that already works. */
#seam-descent::after{
  content:""; inset:auto 0 0 0; height:2px; z-index:3;
  background:rgba(240,231,214,.34); }

/* The clean edge, Chef's pick out of four (#1199). One hairline of the
   destination cream at the top of the strip, so the join reads as an intended
   rule rather than as a photograph that failed to blend. */
.seam[data-blend="haze"]::before{ content:""; position:absolute; left:0; right:0; top:0;
  height:2px; background:rgba(240,231,214,.5); z-index:3; pointer-events:none; }
.seam[data-blend="haze"] .plinth{
  content:""; position:absolute; left:0; right:0; bottom:0; z-index:-1;
  height:var(--foot-h,190px); background:var(--to); pointer-events:none; }
/* ⚠ A haze seam owns BOTH its edges, so the generic foot ramp below must not
   also run: two treatments on one join is what made the lake read wrong the
   first time. */
.seam[data-blend="haze"]::after{ content:none; }
/* ⚠ Safari and any engine without mask-image support would show four flat
   blocks of colour over the photograph, which is far worse than the join this
   fixes. Everything above only applies where masking actually works. */
@supports not ((mask-image:linear-gradient(#000,#000)) or
               (-webkit-mask-image:linear-gradient(#000,#000))){
  .seam[data-blend="haze"] .tint,
  .seam[data-blend="haze"] .edge{ display:none; }
  .seam[data-blend="haze"]::after{ content:""; }
}

/* ⚠ Eased, not a two-stop ramp. A linear gradient ENDS somewhere and the eye
   finds the stop where it reaches transparent, which reads as an edge even
   though there is a fade under it. Do not straighten these back out. */
.seam::after{
  content:""; position:absolute; inset:0;
  background:linear-gradient(to bottom,
    transparent 0%,
    transparent 58%,
    color-mix(in srgb, var(--to) 6%, transparent) 70%,
    color-mix(in srgb, var(--to) 22%, transparent) 80%,
    color-mix(in srgb, var(--to) 52%, transparent) 89%,
    color-mix(in srgb, var(--to) 80%, transparent) 95%,
    var(--to) 100%);
}

/* ---------- 7 · Client voices ---------------------------------------------
   ⚠ ONE dedicated section, Izzy's call Aug 11 2026: "I'd rather not have them
   scattered throughout the page. Can we pull them into one dedicated
   testimonials section instead?" This replaces the interleaved .voice blocks
   that sat between bands, and with them go the per-quote alignment variance
   and the dark-band carve-out, because there is only one placement now.

   Kept from the old treatment, because none of it was what he objected to: the
   quiet size, the accent rule carrying the emphasis instead of the type size,
   and the fact that these take over the display-quote role so Izzy is never
   quoting himself at 54px.                                                  */

.marquee{
  position:relative; margin-top:34px; overflow:hidden;
  /* The edges fade instead of cutting. A card is always half-arrived on both
     sides, which is what makes a slow track read as drifting rather than as a
     row that happens to be clipped by the window. */
  -webkit-mask-image:linear-gradient(90deg,transparent 0,#000 9%,#000 91%,transparent 100%);
          mask-image:linear-gradient(90deg,transparent 0,#000 9%,#000 91%,transparent 100%);
}
.track{
  display:flex; align-items:center; width:max-content;
  gap:var(--vgap,clamp(20px,2.4vw,34px));
  animation:drift var(--dur,54s) linear infinite;
  will-change:transform;
}
/* ⚠ The offset is HALF THE TRACK PLUS HALF A GAP, not a flat -50%. The two
   passes are separated by one gap like every other pair of cards, so a flat
   -50% lands one half-gap short and the loop visibly stutters once per cycle. */
@keyframes drift{
  from{ transform:translate3d(0,0,0); }
  to{ transform:translate3d(calc(-50% - var(--vgap,clamp(20px,2.4vw,34px)) / 2),0,0); }
}
/* Stop it while someone is actually reading a quote, by mouse or by keyboard. */
.marquee:hover .track,
.marquee:focus-within .track{ animation-play-state:paused; }

.marquee blockquote{
  flex:0 0 clamp(292px,30vw,460px); margin:0;
  background:var(--card); border-radius:var(--radius-sm);
  border:1px solid color-mix(in srgb, var(--line) 70%, transparent);
  padding:clamp(24px,2.2vw,32px);
  box-shadow:0 22px 44px -34px rgba(34,48,28,.5);
  /* ⚠ Cards size to their own quote and centre on the track. Equal height was
     the first build and it was wrong here: these are Google reviews and they run
     40 to 100 words, so the tallest one set the height for all six and a
     four-line review sat in 400px of empty white. Ragged heights on a drifting
     track read as a row of real notes; a grid of equal boxes with holes in it
     reads as a mistake. Do not "tidy" this back to stretch. */
  display:flex; flex-direction:column;
}
.marquee p{
  font-family:Newsreader,Georgia,'Apple Color Emoji','Segoe UI Emoji',
              'Noto Color Emoji',serif;
  font-size:clamp(16.5px,1.3vw,18px); line-height:1.58;
  color:var(--body); max-width:none; margin:0;
}
.marquee .who{
  display:block; margin-top:16px; font-family:'Hanken Grotesk',sans-serif;
  font-size:14px; letter-spacing:.1em; text-transform:uppercase;
  color:var(--key); font-weight:600; font-style:normal;
}
.marquee .stars{
  display:flex; font-size:14px; letter-spacing:.18em;
  color:var(--key); opacity:.6; margin-bottom:14px;
}

/* The way out to the rest of the reviews. One centred line, sized to the .who
   caps under each quote so it reads as part of the same furniture rather than
   as an ad for Google. ⚠ It inherits --key, which on the night ground is
   #F0B64B, the value that was raised specifically to clear AA over the
   TEXTURED green; do not hardcode a colour here or it will pass the flat
   check and fail the real one. */
/* ⚠ max-width:none is load-bearing, same trap the CTA row hit above: the global
   `p` rule caps every paragraph at --measure, so text-align:center centred the
   link inside a narrow left-hung box and it rendered a third of the way across
   the page. Verified in a render, not assumed. */
.morevoices{ max-width:none; text-align:center; margin-top:clamp(30px,3.4vw,48px); }
.morevoices a{
  font-family:'Hanken Grotesk',sans-serif; font-size:15px; letter-spacing:.09em;
  text-transform:uppercase; font-weight:600; color:var(--key);
  text-decoration:none; padding-bottom:3px;
  border-bottom:1px solid color-mix(in srgb, var(--key) 42%, transparent);
  transition:border-color .25s ease;
}
.morevoices a:hover, .morevoices a:focus-visible{ border-bottom-color:var(--key); }
@media (prefers-reduced-motion:reduce){ .morevoices a{ transition:none; } }

/* ⚠ On the deep green the cards are NOT white. A row of white slabs on a
   saturated ground is the loudest thing that could happen on this page, and it
   would undo the whole point of quieting the joins. A barely-raised surface
   plus a hairline reads as a card without shouting. */
/* ⚠ Opaque, not the translucent white it was. Same rule as the price cards:
   a card sits ON the ground, it is not a window into it. Translucent meant the
   texture ran straight through the review, so the card had no face of its own
   and the quote read as printed on the wall. */
[data-tone="night"] .marquee blockquote{
  background:var(--card-face);
  border-color:rgba(255,255,255,.11);
  box-shadow:none;
}
[data-tone="night"] .marquee p{ color:var(--night-dim); }
[data-tone="night"] .marquee .stars{ opacity:.85; }

/* ⚠ Reduced motion gets a real carousel, not a frozen one. Without this the
   track simply stops and five of the six reviews are unreachable. The clone is
   hidden here because it is only there to make the loop seamless, and with the
   animation off it is six duplicate cards to scroll past. */
@media (prefers-reduced-motion:reduce){
  .marquee{
    overflow-x:auto; scroll-snap-type:x mandatory;
    -webkit-mask-image:none; mask-image:none;
    padding-inline:var(--gutter); scrollbar-width:thin;
  }
  .track{ animation:none; }
  .track [data-clone]{ display:none; }
  .marquee blockquote{ scroll-snap-align:center; }
}

/* ---------- 8 · Rates ------------------------------------------------------ */

/* ⚠ THE PRICES AND THE OFFERS UNDER THEM ARE ONE SYSTEM, not cards followed by
   a grid. Chef, Aug 11 2026: "Massage session pricing could use some structural
   work." Four white rounded cards floating on cream, then an even ruled grid
   directly beneath them, read as two unrelated components stacked. Both are now
   cells in the same ruled table: a 1px gap over a --line ground draws the rules,
   which means every divider is exactly one hairline and never two abutting
   borders. ⚠ Do not put the card chrome back on one half of this band. */
.rates{
  display:grid; gap:1px; margin-top:48px;
  grid-template-columns:1fr;
  background:var(--line);
  border-top:1px solid var(--line);
}
@media (min-width:560px){ .rates{ grid-template-columns:repeat(2,1fr); } }
@media (min-width:980px){ .rates{ grid-template-columns:repeat(4,1fr); } }
/* Cells are equal height, so the home-call line is pinned to the bottom rather
   than floating up when a blurb runs short. */
.rate{ background-color:var(--bg); padding:30px clamp(20px,2.2vw,30px) 32px;
       display:flex; flex-direction:column; }
.rate .home{ margin-top:auto; }
.rate .dur{ font-size:16px; font-weight:600; letter-spacing:.09em; text-transform:uppercase; color:var(--key); }
.rate .price{ font-family:Newsreader,serif; font-size:52px; line-height:1; margin:12px 0 16px; }
.rate p{ font-size:18px; color:var(--body); }
/* ⚠ margin-top stays AUTO here. It was being overridden by margin-top:20px in
   this rule, so the home-call line simply followed the blurb and sat higher on
   any card whose blurb ran short. The 120-minute card was 30px out of line. The
   gap now comes from the blurb's own margin-bottom. */
.rate p{ margin-bottom:20px; }
.rate .home{ font-size:16px; color:var(--dim); border-top:1px solid var(--line); margin-top:auto; padding-top:16px; }
/* The home-call line is optional and currently absent on every card: Izzy prices
   home calls as the session rate plus a travel fee that changes after the first
   visit, so there is no single second number to print. Without it the blurb is
   the last child and its 20px bottom margin would sit against the card padding. */
.rate p:last-child{ margin-bottom:0; }

/* Slow hover pop, matching the photographs rather than the buttons. */
.rate{ transition:transform .5s cubic-bezier(.22,.61,.36,1), box-shadow .5s ease; }
.rate:hover{ transform:translateY(-7px); box-shadow:0 26px 44px -30px rgba(30,40,26,.42); }
@media (prefers-reduced-motion:reduce){ .rate, .rate:hover{ transition:none; transform:none; } }

/* CTA paragraphs are capped by the global p max-width, so "centred" text was
   centring inside a 726px box that itself sat left. Let them span the wrap. */
.cta-row{ max-width:none; text-align:center; }
/* ⚠ CENTRED BETWEEN THE TWO RULES, not just centred left-to-right. Chef,
   Aug 11 2026. The button sat 44px under the offers grid's bottom rule and
   137px above the next band's hairline, because the gap below it was the
   section's own padding and the gap above it was a hand-set margin. One value
   drives both now, so it stays centred at every width. */
#sessions{ padding-bottom:var(--ctagap); }
#sessions .cta-row{ margin-top:var(--ctagap); }
/* Same defect in the footer: 28px above the button, 78px below it. */
footer .fcols{ margin-top:var(--fctagap); }

/* ⚠ WAS SIX PILLS IN A FLEX-WRAP ROW PLUS A PARAGRAPH OF SMALL PRINT. Chef,
   Aug 11 2026: "the paragraph and pills at the bottom are not very appealing or
   well organized." Six pills of unequal width landed 4 + 2, so the last row was
   short and centred and read as an accident rather than a layout, and the
   paragraph underneath repeated what the pills said before reaching the two
   facts that mattered.

   Now: an EVEN grid, three across and two down, each offer carrying its own
   explanation. Equal cells, no wrapping, nothing ragged. The three offers that
   need explaining are ordered first so the two rows sit level. */
.offers{
  display:grid; gap:1px; margin-top:1px;
  grid-template-columns:1fr;
  background:var(--line);          /* the gap IS the rule between cells */
  border-bottom:1px solid var(--line);
}
/* ⚠ No top border and no top margin: the offers grid butts straight onto the
   prices above it, so the 1px gap between the two blocks IS their shared rule.
   A border here plus the rates' bottom edge would draw two hairlines a pixel
   apart, which reads as a printing error at any zoom. */
.rates + .offers{ margin-top:1px; }
/* ⚠ TWO COLUMNS, NOT THREE, and that is about alignment rather than taste. The
   prices above run four across, so a three-column grid underneath puts its
   vertical rules at thirds against the prices' quarters and none of them line
   up. Two columns share the centre rule with four, and six offers fill three
   even rows. */
@media (min-width:640px){ .offers{ grid-template-columns:repeat(2,1fr); } }
.offer{
  background-color:var(--bg); padding:24px clamp(20px,2.2vw,30px) 26px;
  display:flex; flex-direction:column; gap:5px;
}
.offer b{
  color:var(--key); font-size:15px; font-weight:600;
  letter-spacing:.09em; text-transform:uppercase;
}
.offer .odet{ font-size:18px; color:var(--fg); }
.offer .onote{ font-size:16px; color:var(--dim); margin-top:5px; }
/* One line per pack, under "Three sessions:". A sentence listing all three
   prices ran long and read as a paragraph; three lines read as a menu. */
.offer .obul{ list-style:none; margin:8px 0 0; padding:0; }
.offer .obul li{ font-size:16px; color:var(--dim); padding:3px 0 3px 15px; position:relative; }
.offer .obul li::before{
  content:""; position:absolute; left:0; top:.72em;
  width:5px; height:5px; border-radius:50%; background:var(--key); opacity:.62;
}
/* A cell that is a real link (gift cards) looks identical to the ones that are
   not, until you touch it. Anything else turns an even grid into one odd cell. */
a.offer{ text-decoration:none; color:inherit; transition:background .25s ease; }
a.offer:hover{ background-color:color-mix(in srgb, var(--key) 7%, var(--bg)); }
@media (prefers-reduced-motion:reduce){ a.offer{ transition:none; } }

/* ---------- 9 · Training --------------------------------------------------- */

.cols{ display:grid; gap:36px; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); margin-top:48px; }
.cols h3{ font-size:22px; margin-bottom:16px; padding-bottom:12px; border-bottom:2px solid var(--key); display:inline-block; }
.cols ul{ margin:0; padding:0; list-style:none; }
.cols li{ font-size:17px; padding:9px 0; border-bottom:1px solid var(--line); color:var(--body); }

/* ---------- 10 · The deeper work ------------------------------------------ */

.gate{
  border-left:3px solid var(--key); padding:18px 0 18px 26px; margin:34px 0 0;
  max-width:var(--measure);
}
/* Four offerings as a 2x2 of cards. Alternating full-width rows left a tall
   photo beside three lines of text and a column of dead space; the flow
   rhythm lives in the bands above, this band wants density. */
.deephead h2{ max-width:18ch; }
.deepintro{ margin-top:26px; max-width:62ch; }
.deepintro p{ max-width:none; }

.offerings{
  display:grid; gap:clamp(26px,3.4vw,44px); margin-top:52px;
  grid-template-columns:1fr;
}
/* Exactly two columns above the breakpoint. auto-fit gave three at desktop,
   which leaves the fourth offering orphaned on its own row. */
@media (min-width:820px){ .offerings{ grid-template-columns:repeat(2,1fr); } }
.offering{ display:flex; flex-direction:column; }
.offering .fig{ margin-bottom:24px; }
.offering .fig img{ aspect-ratio:5/4; object-fit:cover; width:100%; border-radius:var(--radius); }
.offering h3{ color:var(--fg); margin-bottom:12px; }
.offering p{ color:var(--dim); font-size:18px; max-width:46ch; }

/* The bridge offering. Full grid width, photo beside the copy rather than above
   it, so the first thing past the hinge still looks like a bodywork session and
   the four that follow read as the step further out. Also keeps the grid even:
   five offerings in a two-column grid would otherwise orphan one. */
.offering.wide{ grid-column:1 / -1; }
@media (min-width:820px){
  .offering.wide{ display:grid; grid-template-columns:1.22fr .78fr;
                  gap:clamp(30px,3.6vw,52px); align-items:center; }
  .offering.wide .fig{ margin-bottom:0; }
}
/* 4/3 rather than 16/9, and a wider photo column, so the lead offering's image
   is TALLER than the four in the grid below it. At 16/9 it measured 355px
   against their 478px, which made the featured item look like an afterthought
   and repeated the mismatched-photo-sizes note. */
.offering.wide .fig img{ aspect-ratio:4/3; }
.offering.wide h3{ font-size:clamp(27px,2.4vw,34px); }
.offering.wide p{ max-width:52ch; }
[data-tone="night"] .offering p{ color:var(--night-dim-2); }
[data-tone="night"] .fig img{ box-shadow:0 34px 70px -40px rgba(0,0,0,.8); }

/* Below the grid, not in it. Izzy asked for the hybrid session to sit in an
   inquire-about-this spot rather than among the offerings, so this deliberately
   has no photograph and no button of its own: a picture and a CTA are exactly
   what made it read as a set, purchasable service. A hairline and centred copy
   keep it in the band's rhythm without giving it a card. */
.other{
  max-width:64ch; margin:clamp(38px,4.4vw,58px) auto 0; text-align:center;
  padding-top:clamp(26px,3vw,34px); border-top:1px solid var(--line);
}
.other h3{ font-size:20px; color:var(--fg); margin-bottom:12px; }
.other p{ font-size:18px; color:var(--dim); max-width:none; }
[data-tone="night"] .other p{ color:var(--night-dim-2); }

/* ---------- 11 · Visit ----------------------------------------------------- */

.visit{ display:grid; gap:clamp(30px,4vw,64px); grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); margin-top:44px; }
.visit h3{ font-size:20px; margin-bottom:16px; }
.visit p, .visit li{ font-size:17px; color:var(--body); }
.visit ul{ margin:0; padding:0; list-style:none; }
.visit li{ display:flex; justify-content:space-between; gap:20px; padding:8px 0; border-bottom:1px solid var(--line); }

.slot{
  border:1px dashed var(--line); border-radius:var(--radius-sm);
  background:color-mix(in srgb, var(--key) 5%, transparent);
  padding:20px 22px; margin-top:18px;
}
.slot .who{ display:block; font-size:14px; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--key); margin-bottom:8px; }
.slot p{ font-size:17px; color:var(--dim); }

/* Real directions, in Izzy's words, replacing the dashed awaiting-your-words
   slot. It is a subordinate note inside the studio column, so it takes a small
   labelled heading and a rule rather than another h3 competing with "The
   studio" above it. */
.dirs{ margin-top:24px; padding-top:22px; border-top:1px solid var(--line); }
.dirs h4{
  font-size:15px; font-weight:600; letter-spacing:.1em; text-transform:uppercase;
  color:var(--key); margin-bottom:10px;
}
.dirs p{ font-size:17px; color:var(--body); }
.dirs p + p{ margin-top:12px; }

.signup{ display:flex; gap:12px; flex-wrap:wrap; margin-top:20px; }
.signup input{
  flex:1 1 220px; font:inherit; font-size:17px; padding:14px 18px;
  border:1px solid var(--line); border-radius:999px; background:var(--card); color:var(--ink);
}

/* ---------- 11b · ALIGNMENT ------------------------------------------------
   ⚠ ONE GRID. Every band shares the same wrap, the same left edge, the same
   rhythm. An earlier pass gave each band its own width and offset, staggered
   the grid children, and alternated the quote sides. It read as broken
   alignment, not as flow.

   Movement now comes only from what a band CONTAINS: photo left or photo
   right, a full-bleed seam, a change of colour. Never from things failing to
   line up. "Confusing" is one of his three banned feelings and misalignment
   is the fastest route to it.                                               */

@media (min-width:1000px){

  /* ── Composition, not offset ───────────────────────────────────────────
     Each band is a DIFFERENT SHAPE. Inside every band, everything aligns
     exactly. Variety comes from the archetype a section uses, never from
     elements failing to meet an edge.                                     */

  /* 02 · The work — copy held in the column, photograph runs off the RIGHT
     edge of the screen. Asymmetric and deliberate; nothing misaligned.    */
  /* ⚠ The bleed distance MUST be computed from the wrap width, not with the
     usual calc(-1 * (50vw - 50%)) trick. A percentage margin resolves against
     the containing block, and for a grid item that is its GRID AREA, not the
     centred wrapper. Measured overflow was 287px past the viewport on each
     side, which sliced a quarter off both photographs. --edge is exact. */
  [data-align] .split{ --edge:calc((100vw - min(1240px, 100vw - var(--gutter)*2)) / 2); }

  [data-align="left"] .split{ grid-template-columns:.86fr 1.14fr; }
  [data-align="left"] .split > .fig{ margin-right:calc(-1 * var(--edge)); }
  [data-align="left"] .split > .fig img{ border-radius:var(--radius) 0 0 var(--radius); }
  [data-align="left"] .split > .fig.tall img{ aspect-ratio:16/11; }

  /* 04 · About — mirrored. Photograph runs off the LEFT edge, copy sits
     right. The two bands answer each other across the page.              */
  [data-align="right"] .split{ grid-template-columns:1.14fr .86fr; }
  [data-align="right"] .split > .fig{ margin-left:calc(-1 * var(--edge)); }
  [data-align="right"] .split > .fig img{ border-radius:0 var(--radius) var(--radius) 0; }
  [data-align="right"] .split > .fig.tall img{ aspect-ratio:16/11; }

  /* ── A CAPPED PHOTO CANNOT USE THE BLEED ARCHETYPE ──────────────────────
     ⚠ Chef, Aug 18 2026: "you made the pic smaller like I asked, but the copy
     now sits with a big gap between the photo and the copy."

     The two rules above are one composition, not three settings: the figure
     column is OVERSIZED and the photograph is meant to fill it and run off the
     screen edge, which is why it gets a one-sided radius and a 16/11 crop.
     Capping a portrait by height breaks every part of that at once. Measured on
     About at 1440: the column stayed 766px wide while the image inside fell to
     467px and centred, leaving 150px of dead ground against the screen edge and
     222px between the photograph and the first line of copy. The photo looked
     right and the band around it did not.

     So a portrait figure opts OUT of the archetype rather than half-living in
     it: no bleed, no one-sided radius, and the column shrinks to the picture so
     the only space left between them is the grid gutter.

     ⚠ Do NOT answer this by shrinking the gap. The gap is the page's rhythm and
     it is correct everywhere else; what was wrong was a column sized for an
     image that is no longer in it.                                          */
  [data-align="left"]  .split:has(> .fig.portrait){ grid-template-columns:minmax(0,1fr) auto; }
  [data-align="right"] .split:has(> .fig.portrait){ grid-template-columns:auto minmax(0,1fr); }
  [data-align] .split > .fig.portrait{ margin-left:0; margin-right:0; }
  [data-align] .split > .fig.portrait img{ border-radius:var(--radius); aspect-ratio:auto; }

  /* ⚠ EXCEPTION, and the rule behind it: a PORTRAIT source cannot go in a
     landscape frame. The only photograph of Izzy's face on this band is
     1536x2340, and 16/11 cover-cropped it into a band across his chest. His
     head was outside the frame on the section that introduces him. The bleed
     stays; the ratio turns portrait to suit the subject. */
  #about .split > .fig.tall img{ aspect-ratio:5/6; }

  /* 03 · Sessions — the one band that must read as plain fact. Symmetrical,
     contained, level. Pricing is not the place for composition.          */
  /* Four tiers since the 75-minute session was added. auto-fit wraps the fourth
     onto its own row at 1080px, which is exactly the ragged pricing Chef called
     out, so the row is pinned to four and the wrap widened to hold them. */
  #sessions .wrap{ text-align:center; width:min(1300px,100% - var(--gutter)*2); }
  #sessions .rates{ grid-template-columns:repeat(4,1fr); }
  #sessions .rate{ padding:30px 24px; }
  #sessions .rate .price{ font-size:46px; }
  #sessions .rates, #sessions .offers{ text-align:left; }
  #sessions .offers{ justify-content:center; }
  #sessions .small{ margin-inline:auto; }

  /* 06 · The hinge — no photograph, narrow measure, a lot of air. The
     quietest band on the page, which is what makes the turn land.
     ⚠ The 1.5x air is right ABOVE the copy and wrong below it: the seam
     photograph follows immediately, so the extra 65px read as a gap between
     the words and the picture rather than as breathing room. Top keeps the
     pause, bottom drops to the page rhythm every other band uses.          */
  #hinge{ padding-top:calc(var(--rhythm) * 1.5); padding-bottom:var(--rhythm); }
  #hinge .copy{ max-width:56ch; }

  /* 05 · Training — the widest, densest band. Three columns, tops level.  */
  #training .wrap{ width:min(1320px,100% - var(--gutter)*2); }

}

/* Bleeds push past the wrap, so kill any horizontal scroll they cause. */
html, body{ overflow-x:hidden; }
/* The page rests on the footer's sand, so any overscroll or sub-pixel gap below
   the plinth shows sand rather than a stripe of the cream from further up. */
html{ background:var(--sand); }

}

/* ---------- 12 · Motif ----------------------------------------------------
   A small family of marks drawn from his own world: the tree, a river
   opening into an ocean, a peak, a feather. They sit IN the scroll flow as
   band openers, never fixed beside it, and they are solid rather than
   hairline. Both of those were why the last three attempts got killed.     */

.mark{ display:block; margin:0 auto 30px; width:46px; height:46px; color:var(--key); opacity:.92; }
.mark svg{ width:100%; height:100%; fill:currentColor; }

/* ---------- 13 · Footer ----------------------------------------------------
   ⚠ THE FOOTER IS THE LAST SECTION, not a strip under it. Until Aug 11 2026 a
   band called "Finding me" carried the address, hours, directions and the
   mailing list, and then a plinth underneath repeated the address and the
   phone number, so the page ended twice. Chef collapsed them.

   SYMMETRICAL, which was the brief: a centred identity block, three equal
   columns, a centred bar of links. Nothing hangs off one side.

   The page still ends on a PLINTH rather than another melt. Every band above
   hands off with a photograph or a hairline, so a fade here read as one more
   transition; a settled sand tone the page comes to rest on is a different
   mechanism, which is what makes it read as an ending. */
footer{
  padding-block:clamp(64px,7vw,104px) clamp(40px,4.5vw,64px);
  border-top:0;
  --bg:var(--sand);
  background:var(--sand);
  color:var(--ink);
}

.fhead{ text-align:center; }
/* ⚠ Maria's mark is fine detail and dissolves below roughly 140px. At 40px it
   was a smudge. 104px is the smallest it reads as a tree. */
.fhead img{ width:104px; height:auto; display:block; margin:0 auto 18px; }
.fbrand{ font-family:Newsreader,Georgia,serif; font-size:clamp(24px,2.4vw,31px);
  font-weight:400; display:block; }
.ftag{ max-width:none; margin:8px auto 0; color:var(--sand-dim); font-size:17px; }
.fcontact{ max-width:none; margin:20px auto 0; font-size:17px; line-height:1.75; }
.fcontact a{ color:inherit; }
.fcta{ max-width:none; margin:var(--fctagap) auto 0; text-align:center; }

.fcols{
  display:grid; gap:clamp(30px,4vw,64px);
  grid-template-columns:1fr;
  margin-top:clamp(48px,5.5vw,78px);
  padding-top:clamp(44px,5vw,70px);
  border-top:1px solid color-mix(in srgb, var(--sand-dim) 26%, transparent);
}
@media (min-width:860px){ .fcols{ grid-template-columns:repeat(3,1fr); } }
/* ⚠ 600 is served explicitly in the @import at the top of this file. Google
   only ships the weights you ask for, so setting font-weight:600 without adding
   it to the URL gets a browser-synthesised fake bold, which on a serif smears
   the thins. Chef asked for the footer section titles in bold, Aug 11 2026. */
.fcols h3{ font-size:20px; margin-bottom:16px; font-weight:600; }
.fcols p{ font-size:17px; color:var(--body); max-width:none; }
.fcols p + p{ margin-top:12px; }

.fhours{ margin:0; padding:0; list-style:none; }
.fhours li{
  display:flex; justify-content:space-between; gap:20px; font-size:17px;
  color:var(--body); padding:8px 0;
  border-bottom:1px solid color-mix(in srgb, var(--sand-dim) 22%, transparent);
}
.fhours li:last-child{ border-bottom:0; }

.fbar{
  margin-top:clamp(44px,5vw,70px); padding-top:clamp(26px,3vw,38px);
  border-top:1px solid color-mix(in srgb, var(--sand-dim) 26%, transparent);
}
.fnav{ display:flex; gap:26px; flex-wrap:wrap; justify-content:center; }
.fnav a{ font-size:16px; text-decoration:none; color:var(--sand-dim); }
.fnav a:hover{ color:var(--ink); }

/* ---------- 14 · Reveal ---------------------------------------------------
   A settle, not a gate. Nothing waits on the scroll to become readable.    */

.rv{ opacity:0; transform:translateY(16px); transition:opacity .8s ease, transform .8s cubic-bezier(.2,.7,.3,1); }
.rv.in{ opacity:1; transform:none; }
@media (prefers-reduced-motion:reduce){ .rv{ opacity:1; transform:none; transition:none; } }

/* ---------- 15 · Concept ribbon (preview only, strip before launch) ------- */

.ribbon{
  position:fixed; left:0; right:0; bottom:0; z-index:90;
  background:var(--timber); color:#EFEDE1; font-size:14px;  /* preview chrome:
     the ground follows the palette so the ribbon stays dark whatever is
     chosen, but the label is fixed. It is not brand colour. */ padding:9px 18px;
  display:flex; gap:18px; align-items:center; justify-content:center; flex-wrap:wrap;
}
.ribbon b{ color:var(--night-key); }
body{ padding-bottom:44px; }

/* ---------- 9 · The connective motif --------------------------------------
   ⚠ THE CLASS IS `.btm`, NEVER `.mk`. `.brand .mk` above is the header logo,
   and declaring `.mk{position:absolute}` here once dropped the tree mark on
   top of the Body Terrain wordmark for a whole review round.
   ⚠ Masks, not images: the SVG supplies the shape and `background` supplies the
   colour, so one file reads on cream and on the night ground.
   ⚠ Decoration on a booking page, so pointer-events off and aria-hidden set. */
.btm{ position:absolute; pointer-events:none; z-index:0; display:block;
  -webkit-mask-repeat:no-repeat; mask-repeat:no-repeat;
  -webkit-mask-size:contain; mask-size:contain;
  -webkit-mask-position:center; mask-position:center;
  background:var(--btm-ink); }
section, .seam, footer{ position:relative; }
section > .wrap, footer > .wrap{ position:relative; z-index:2; }
:root{ --btm-ink:rgba(74,96,64,.20); }
[data-tone="night"] .btm{ --btm-ink:rgba(226,240,220,.19); }
/* ⚠⚠ A MARK TAKES ITS INK FROM THE GROUND IT IS ACTUALLY ON, WHICH MEANS CHILD
   COMBINATORS, NOT DESCENDANT ONES.
   The .ground wrappers NEST rather than sit as siblings. The real tree is
   morning > night > morning(#visit), so the descendant rule above reaches into
   the nested morning ground and paints a footer mark in the night ink: a
   near-white at 19% on sand, invisible, and no gate reads a rendered pixel.
   Latent until 31 Aug 2026, when Chef put a k'intu in #visit, the first mark
   ever placed there. Contrast 3/255 before, 70/255 after.
   ⛔ THE OBVIOUS FIX IS WRONG. Re-asserting the cream ink as a plain descendant
   rule ALSO matched the canopy in #deeper, because #deeper sits inside the night
   ground which itself sits inside the outer morning one. It silently swapped a
   night mark to the cream ink. Verified by reading every mark's computed
   background, not by looking.
   A mark has exactly ONE immediate ground, so `> * >` makes these two mutually
   exclusive and order stops mattering. `#voices .btm` is an id and still wins. */
.ground[data-tone="night"]   > * > .btm{ --btm-ink:rgba(226,240,220,.19); }
.ground[data-tone="morning"] > * > .btm{ --btm-ink:rgba(74,96,64,.20); }
/* ⭐ Sept 2 2026, decision #1414: the phone gets marks of its own.
   The old rule here was `@media (max-width:900px){ .btm{ display:none; } }`,
   written because the columns stack and there is no open margin left to hold a
   mark. That reasoning held for a mark placed against a desktop column; it does
   not hold for one composed for a 390px page, which bleeds off the side instead
   of sitting in a margin. Two thirds of Izzy's 2025 traffic was a phone.
   ⚠ `data-m` is set by motifs.script() on exactly the marks that carry a row in
   motifs.MOBILE, so anything not yet placed for the phone stays hidden and this
   change is invisible until a mark opts in.
   ⚠ 900 HERE MUST STAY EQUAL TO THE 900 IN motifs.script(). It reads that same
   number off clientWidth to pick which rule to paint; if they part, a mark is
   painted with the wrong composition or hidden while holding the right one. */
@media (max-width:900px){ .btm:not([data-m]){ display:none; } }

/* ⭐ Chef, Sept 3 2026: "the motifs of the Flower and the k'intus that cross a
   border to sit on top of the border, not underneath", across mobile AND web.
   ⚠ THE BORDER HE MEANS IS THE 1px BAND RULE, and it is `section::after` at
   `top:0` carrying `z-index:1`. A mark is `z-index:0` inside that SAME section
   stacking context, so the rule paints over the mark and slices it in half.
   ⚠ The band BACKGROUND was never the problem, which is why the earlier reading
   of this was wrong. Sections do not overlap and they paint in DOM order, so
   the part of a mark hanging above its own section already draws over the band
   above. Measured, not assumed: the bloom petals are visible on #about ground.
   ⛔ THE TWO OBVIOUS FIXES ARE BOTH WRONG.
     · Raising the mark to `z-index:2` ties it with `section > .wrap` and lets a
       motif paint over copy. That is the one thing the wrap rule exists to stop.
     · Lifting the whole section (`#about,#training,#visit{z-index:1}`, which is
       what the DEAD CSS string in motifs.py still claims shipped on Sept 1) also
       lifts #training over #seam-descent, and the rootfan there is a deliberate
       TWO-TONE TWIN: the same shape at identical coordinates in two inks, .26
       green above the edge and .34 cream below, so the band edge splits it.
       Lifting the section erases that split and flattens the twin to one tone.
   So: drop the RULE under the mark, and only on the two sections that own one.
   Copy still sits above everything and every other band edge is untouched.

   ⭐ Sept 4 2026 (#1572), Chef again after his phone round: "on the flower motif
   and any kintus that are crossing a border to pull those to the front so that
   the border line is behind them. Only those two motifs, please."
   ⛔ SO THE RULE IS KEYED TO THE MOTIF, NOT TO TWO NAMED SECTIONS. The first
   version of this hardcoded #about and #training because those are where the
   DESKTOP crossings happen. A mark he moves to another band takes its crossing
   with it and leaves a section-keyed rule behind, which is how the same note
   came back twice. `:has()` asks the question he actually asked: does this
   section own a bloom or a k'intu.
   ⚠ Where :has() is unsupported the two explicit selectors below still fire, so
   the worst case is exactly the Sept 3 behaviour rather than a regression.
   ⛔ bloom AND kintu ONLY. "Only those two motifs" is a ceiling. The rootfan
   twin in particular MUST keep its band edge: it is the same shape twice in two
   inks, .26 green above the join and .34 cream below, and the edge is what
   splits it. Lifting it flattens the twin to one tone. */
section:has(> .btm[data-motif="bloom"])::after,
section:has(> .btm[data-motif="kintu"])::after,
#about::after, #training::after{ z-index:0; }
.btm[data-motif="bloom"],
.btm[data-motif="kintu"]{ z-index:1; }

/* ⭐⭐ Sept 4 2026, and this is the SECOND half of that note, because the first
   half did not finish the job. Chef, after the deploy: "the line break still
   comes through the kintu and the flower motifs on web version."
   ⛔⛔ Z-INDEX CANNOT HIDE A LINE BEHIND A TRANSLUCENT LINE DRAWING, AND THAT IS
   WHY THE RULE ABOVE LOOKED CORRECT AND CHANGED ALMOST NOTHING. It is real and
   it works: painted the band rule red and the marks blue on the live page and
   the blue wins on every inked column. But a k'intu is three drawn leaf
   outlines and the bloom is a wire flower, so upward of ninety percent of each
   mark's box is HOLE. The rule paints through the holes no matter what order it
   paints in. Raising the mark buys back only the few pixels of actual stroke.
   ⚠ The only thing that removes a line from inside a drawing is removing the
   line. So the rule is INTERRUPTED across the mark that crosses it, with a long
   soft ramp at each end so it reads as the rule dissolving into the mark rather
   than as a border that broke.
   ⚠ `--cut-l` and `--cut-r` are px offsets from the SECTION'S left edge, set by
   motifs.script() from the marks' measured boxes on every place() run, so they
   follow the phone composition and any resize for free. The `- var(--gutter)`
   is what re-bases them onto this pseudo-element, which is inset by exactly
   that on both sides.
   ⛔ KEYED TO `data-cut`, WHICH ONLY APPEARS ON A SECTION WHOSE RULE IS ACTUALLY
   CROSSED. Never on a named section: the same mistake as #1572, one round later,
   and a mark Chef moves has to take its gap with it. */
section[data-cut]::after{
  --cut-fade:clamp(48px, 7vw, 130px);
  -webkit-mask-image:linear-gradient(to right,
    #000 calc(var(--cut-l) - var(--gutter) - var(--cut-fade)),
    transparent calc(var(--cut-l) - var(--gutter)),
    transparent calc(var(--cut-r) - var(--gutter)),
    #000 calc(var(--cut-r) - var(--gutter) + var(--cut-fade)));
  mask-image:linear-gradient(to right,
    #000 calc(var(--cut-l) - var(--gutter) - var(--cut-fade)),
    transparent calc(var(--cut-l) - var(--gutter)),
    transparent calc(var(--cut-r) - var(--gutter)),
    #000 calc(var(--cut-r) - var(--gutter) + var(--cut-fade)));
}

/* ⭐ Chef, same pass: "the k'intu's are the only motifs I'd like to have their
   color bumped up to make them more visible." Only the k'intu, named, so this is
   keyed off `data-motif` instead of the shared `--btm-ink` every other mark on
   the ground reads. Precedent for a per-shape value is already in this file: the
   cactus carries .40 because fine line work disappears at the value that suits a
   solid mass, and the k'intu is three drawn coca leaves, finer than the cactus.
   ⚠ SPECIFICITY, not source order. `.btm[data-motif="kintu"]` is (0,2,0) and
   LOSES to `.ground[data-tone] > * > .btm` at (0,3,0) no matter where it sits in
   the file. Matching the ground structure exactly is what makes these bite, and
   it keeps a k'intu ever placed on the night ground reading in cream.
   ⭐ Sept 4 2026 (#1578): .34 -> .54, Chef's pick from a four-value sheet
   rendered on BOTH marks at 390 (.34 / .44 / .54 / .64). The night value
   trails by .01 for the same reason it always has: cream on a dark ground
   reads heavier than green ink on cream at the same alpha. */
.ground[data-tone="morning"] > * > .btm[data-motif="kintu"]{ --btm-ink:rgba(74,96,64,.54); }
.ground[data-tone="night"]   > * > .btm[data-motif="kintu"]{ --btm-ink:rgba(226,240,220,.53); }

/* The reviews band gets its own green and keeps the tooth.
   ⚠ background-COLOR, not background: the shorthand resets background-image,
   which is where the tooth lives, and that is how the texture went missing.
   ⚠ Contrast checked, not guessed: --fg reads 8.36 here and --dim 5.28. */
#voices{ background-color:#264C3B; background-image:var(--tooth);
  background-size:var(--tooth-size) var(--tooth-size);
  background-blend-mode:var(--tooth-blend); }
#voices .btm{ --btm-ink:rgba(226,240,220,.21); }
/* This band has no heading, so its eyebrow is doing a heading's job. */
#voices .eyebrow{ font-size:22px; letter-spacing:.13em; margin-bottom:26px; }


/* ---------- 17 · The phone ------------------------------------------------- */
/* ⭐ ADDED Sept 1 2026, from Chef's own pass on a real phone frame. Four things
   were wrong and they are all in this one block so the whole pass can be read,
   argued with, or reversed in one place.

     1. "Book a Session" in the bar broke onto two lines at 390px.
     2. There was no way to reach a section. The page is 17,129px tall on a
        phone, twenty screens, and .navlinks is display:none below 1080px.
     3. The pricing band ran 2,196px, most of it four stacked price cards.
     4. The about-band gallery stacked four photographs to 1,784px, two solid
        screens of pictures with no copy between them.

   ⚠ NOTHING HERE TOUCHES THE DESKTOP RENDER. Every rule sits inside a
   max-width query, and the two new elements (.navtoggle, .navdrawer) are
   display:none until one opens. The desktop page is byte-identical.          */

/* --- 17.1 the hamburger --------------------------------------------------- */
.navtoggle{
  display:none; width:42px; height:42px; padding:0; margin-left:2px;
  background:none; border:0; cursor:pointer; border-radius:10px;
  flex-direction:column; align-items:center; justify-content:center; gap:5px;
}
/* ⚠⚠ --ink AND --hair, NEVER --fg OR --line, ANYWHERE UNDER <nav>. The tone
   variables (--fg, --dim, --key, --bg, --line) are declared only on
   [data-tone="..."] elements, and <nav> is a sibling of every one of them. A
   var() that resolves to nothing makes the whole declaration invalid at
   computed-value time, so `background:var(--fg)` on these bars did not fall
   back to anything: it computed to transparent and the open-state X was
   INVISIBLE while every measurement said it was there. Green build gates do
   not read CSS. Only the render showed it. */
.navtoggle span{
  display:block; width:22px; height:2px; border-radius:2px; background:var(--ink);
  transition:transform .28s cubic-bezier(.4,0,.2,1), opacity .18s ease;
}
/* Over the hero photograph the bar is transparent, so the bars take the same
   knockout colour the wordmark already does. Once the drawer is open the panel
   has painted its own ground behind them and they go back to ink. */
nav:not(.solid):not(.open) .navtoggle span{ background:var(--on-dark); }
nav.open .navtoggle span:nth-child(1){ transform:translateY(7px) rotate(45deg); }
nav.open .navtoggle span:nth-child(2){ opacity:0; }
nav.open .navtoggle span:nth-child(3){ transform:translateY(-7px) rotate(-45deg); }
@media (prefers-reduced-motion:reduce){ .navtoggle span{ transition:none; } }

/* --- 17.2 the drawer ------------------------------------------------------ */
.navdrawer{ display:none; }

@media (max-width:1080px){
  .navtoggle{ display:flex; flex:none; }
  .navbook{ flex:none; white-space:nowrap; }
  .brand{ min-width:0; }
  /* ⚠ nowrap regardless of width. Without it the wordmark broke to
     "Body / Terrain" at 360px and made the bar 85px tall. That was there before
     this pass and nobody had reported it. */
  .brand b{ white-space:nowrap; }

  .navdrawer{
    display:block; visibility:hidden; overflow:hidden; max-height:0;
    background:color-mix(in srgb, var(--shell) 97%, transparent);
    backdrop-filter:blur(16px);
    transition:max-height .34s cubic-bezier(.4,0,.2,1), visibility 0s linear .34s;
  }
  nav.open .navdrawer{
    visibility:visible; max-height:min(70vh, 520px);
    border-top:1px solid var(--hair);
    transition:max-height .34s cubic-bezier(.4,0,.2,1), visibility 0s;
  }
  /* The bar is transparent over the hero. An open drawer needs a ground under
     it wherever it opens, so opening also solidifies the bar. */
  nav.open{ background:color-mix(in srgb, var(--shell) 97%, transparent); backdrop-filter:blur(16px); }
  nav.open::before{ opacity:0; }
  nav.open .brand b, nav.open .brand span{ color:var(--ink); }
  nav.open .brand span{ color:var(--muted); }
  nav.open .brand .ko{ display:none; }
  nav.open .brand .ink{ display:block; }

  .navdrawer .wrap{ display:flex; flex-direction:column; padding-block:4px 20px; }
  .navdrawer a.dl{
    font-family:Newsreader,serif; font-size:21px; line-height:1; color:var(--ink);
    text-decoration:none; padding:15px 2px; border-bottom:1px solid var(--hair);
  }
  .navdrawer a.dl:last-of-type{ border-bottom:0; }
  @media (prefers-reduced-motion:reduce){ .navdrawer{ transition:none; } }
}
/* ⚠ THE BAR IS A WIDTH BUDGET, AND THE SHRINK ONLY STARTS WHERE THE BUDGET
   FAILS. At full size the row measures brand 179 + gap 26 + pill 164 + gap 26 +
   toggle 42 = 437, which still fits the 468px of .navin a 520px viewport gives.
   The drawer therefore appears at 1080 (where the link row goes) while the type
   stays at desktop size down to 520, so a tablet does not get phone-sized
   chrome floating in six hundred pixels of empty bar.
   ⚠ Anything added to this bar must be re-measured at 375px before it ships. */
@media (max-width:519px){
  /* Measured, not estimated: mark 42 + gap 9 + "Body Terrain" nowrap at 18px,
     95 = 146 brand, + 8 + pill 123 + 8 + toggle 42 = 327, against the 331 that
     a 375px phone gives. Four pixels spare, and 375 is the narrowest width that
     still keeps the wordmark. */
  .navin{ gap:8px; padding:12px 0; }
  .brand{ gap:9px; }
  .brand .mk{ width:42px; }
  .brand b{ font-size:18px; }
  /* ⚠ THE FIX FOR THE TWO-LINE BUTTON IS nowrap, NOT a shorter label. Chef's
     note was that it wrapped, not that it said too much, and "Book" alone
     loses the verb-plus-object that makes it read as an invitation. */
  .navbook{ font-size:14.5px; padding:11px 14px; }
}
/* Under 560px the subline is the first thing to go: it is the only piece of the
   bar that repeats what the hero says two hundred pixels below it. */
@media (max-width:560px){ .brand span{ display:none; } }
/* ⚠ AND UNDER 375px THE WORDMARK GOES TOO, because the numbers above stop
   working: 316px of bar cannot hold 154 of brand, 123 of pill and 42 of toggle.
   The mark alone is the right thing to lose it to. It is distinctive, it is the
   same tree the hero paints two hundred pixels below, and the alternative was
   dropping the booking button, which is the one thing a phone visitor is here
   to press. Tested at 320px, the narrowest phone still in use. */
@media (max-width:374px){ .brand b{ display:none; } }

/* --- 17.3 prices, one card per row --------------------------------------- */
/* A price card on a phone was 257px of column: duration, then a 52px numeral on
   its own line, then the blurb. Duration and price are one fact, so they go on
   one line and the card comes down to about 150px. The blurbs stay: they are
   how someone picks between 60 and 90 minutes, and dropping them would answer
   the scroll complaint by removing the reason to read.
   ⚠ display:grid overrides the flex column from §8. Anything that lands in this
   card later must be given `grid-column:1/-1` or it will try to sit in the
   price's lane.
   559 and not 519, because this breakpoint belongs to the rates grid rather
   than to the phone: .rates goes two across at 560, and the stacked single
   column that makes a 52px numeral cost its own line only exists below that. */
@media (max-width:559px){
  .rates{ margin-top:32px; }
  .rate{
    display:grid; grid-template-columns:1fr auto;
    align-items:baseline; column-gap:16px;
    padding:20px 20px 22px;
  }
  .rate .dur{ grid-column:1; grid-row:1; font-size:15px; }
  .rate .price{ grid-column:2; grid-row:1; font-size:36px; margin:0; }
  .rate p{ grid-column:1/-1; grid-row:2; font-size:16px; margin:9px 0 0; }
  .rate .home{ grid-column:1/-1; margin-top:14px; padding-top:12px; }

  /* The offers below are text, not cards, so they only lose their padding. */
  .offer{ padding:17px 20px 19px; gap:4px; }
  .offer .odet{ font-size:17px; }
  .offer .onote{ font-size:15px; margin-top:3px; }
  .offer .obul li{ font-size:15px; padding:2px 0 2px 15px; }
}

/* --- 17.3b prices, turned sideways ---------------------------------------- */
/* Chef, Sept 2 2026: "I thought we were going to resize the mobile view pricing
   area? it's too much scrolling to get through it all." 17.3 above already cut
   each card from 257px to about 150 by putting duration and price on one line,
   and four of those is still 616px of column. The card cannot get smaller
   without losing the blurb, and ⛔ THE BLURBS STAY (#1480): they are how someone
   picks between 60 and 90 minutes.
   So the same answer the gallery got, for the same reason: the grid is TURNED,
   not narrowed. Four cards side by side in a snap strip is one screen instead of
   two, at full size, and the 84% basis leaves a sliver of the next card showing
   so a thumb knows it is there. Arrows and dots come from build.py.
   ⚠ Prices are a COMPARISON, which a strip makes harder than a stack does. That
   is the trade, and it is why the strip keeps the peek, the dots and the arrows
   rather than relying on a swipe nobody is told about.
   ⚠ 559 not 519, the same number 17.3 uses and for its reason: .rates goes two
   across at 560, so the single stacked column only exists below that. */
@media (max-width:559px){
  .rates{
    display:flex; grid-template-columns:none; gap:12px;
    overflow-x:auto; overscroll-behavior-x:contain;
    scroll-snap-type:x mandatory; -webkit-overflow-scrolling:touch;
    scrollbar-width:none; padding-bottom:2px;
  }
  .rates::-webkit-scrollbar{ display:none; }
  .rate{ flex:0 0 84%; scroll-snap-align:start; }
  /* The offers below butt onto the rates through a 1px shared rule (§8). A
     strip has no bottom edge to share, so the rule is drawn here instead. */
  .rates + .offers{ margin-top:20px; border-top:1px solid var(--line); }
}

/* --- 17.3c the short offers stay stacked, and here is why ----------------- */
/* Tried and rejected on the render, Sept 2 2026: label left, value right, on the
   four offers whose value is short. It overflowed the page. The budget is about
   296px inside the card, and an uppercase label with .09em tracking runs near
   10px a character while the value runs near 8, so only "Your first massage /
   $20 off." actually fits. "Teachers, veterans and health care workers" needs
   420px for the label alone. One offer in six is not a layout.
   ⛔ Do not re-attempt this by shortening the labels. They are Izzy's own words
   and the whole band is verbatim (#747).
   What is left for this block is a CONTENT question, not a CSS one: six offers
   at 586px on a phone is honest, and cutting it further means showing less. That
   is Chef's call. */
/* --- 17.3d the strip furniture, shared by prices and reviews -------------- */
/* One row under a strip: back, dots, forward. It carries the position and the
   two taps in the same 44px band, so nothing else has to explain the strip.
   ⚠ 44px is the touch target, not the drawn circle. Anything smaller is a miss
   on a thumb, and this sits under the one part of the page that is asking to be
   tapped repeatedly. */
.snav{ display:none; }
@media (max-width:559px){
  .snav{
    display:flex; align-items:center; justify-content:center;
    gap:14px; margin-top:18px;
  }
  .snav button{
    width:44px; height:44px; padding:0; border-radius:50%; cursor:pointer;
    background:transparent; border:1px solid var(--line); color:var(--key);
    font:400 19px/1 ui-sans-serif,system-ui,sans-serif;
    display:flex; align-items:center; justify-content:center;
    transition:border-color .2s ease, opacity .2s ease;
  }
  .snav button:disabled{ opacity:.32; cursor:default; }
  .snav button:not(:disabled):active{ border-color:var(--key); }
  .snav .sdots{ display:flex; gap:8px; }
  .snav .sdots i{
    width:7px; height:7px; border-radius:50%; background:var(--line);
    transition:background .2s ease, transform .2s ease;
  }
  .snav .sdots i.on{ background:var(--key); transform:scale(1.25); }
  /* On the night ground the hairline is invisible against the green. */
  [data-tone="night"] .snav button{ border-color:rgba(255,255,255,.22); }
  [data-tone="night"] .snav .sdots i{ background:rgba(255,255,255,.26); }
  [data-tone="night"] .snav .sdots i.on{ background:var(--key); }
  @media (prefers-reduced-motion:reduce){
    .snav button, .snav .sdots i{ transition:none; }
  }
}

/* --- 17.5 the reviews, tapped rather than drifting ------------------------ */
/* Chef, Sept 2 2026: "I wonder if we should do a click left/right for the
   reviews also on the mobile view. the auto-scroll might be funky on a phone."
   It is. The drift is paused by :hover and :focus-within, and a phone has
   neither, so a quote slides out from under the eye with no way to hold it. The
   marquee already knows how to be a carousel: the reduced-motion block below §7
   turns the same track into a snap strip, and this is that state made the phone
   default rather than an accessibility fallback.
   ⚠ THE CLONES GO. The track carries a duplicate pass so the loop is seamless;
   with no animation they are six more cards to scroll past for nothing. */
@media (max-width:559px){
  .marquee{
    overflow-x:auto; overscroll-behavior-x:contain;
    scroll-snap-type:x mandatory; -webkit-overflow-scrolling:touch;
    -webkit-mask-image:none; mask-image:none;
    scrollbar-width:none;
  }
  .marquee::-webkit-scrollbar{ display:none; }
  /* ⚠ TOPS ALIGNED, NOT CENTRES. §7 centres the cards on the track and says why:
     these run 40 to 100 words and ragged heights read as a row of real notes
     while drifting past. One at a time under a thumb it reads as breakage
     instead, because each card starts at a different height and the one peeking
     at the edge is offset from the one being read. flex-start rather than
     stretch, so a short review still keeps its own box instead of sitting in a
     tall empty one sized by the longest quote. */
  .track{ animation:none; width:auto; gap:12px; align-items:flex-start; }
  .track [data-clone]{ display:none; }
  .marquee blockquote{ flex:0 0 86%; scroll-snap-align:center; }
}

/* --- 17.4 the gallery, a swipe strip -------------------------------------- */
/* ⚠ THE GRID IS NOT NARROWED, IT IS TURNED. Four 4:5 photographs at full column
   width is 1,784px of vertical scroll on a phone and no amount of shrinking
   fixes that without making the pictures too small to be worth showing. Turned
   sideways the same four occupy one screen and stay full size.
   The 84% flex-basis is load-bearing: the sliver of the next photograph is the
   only thing telling a thumb there is more to the right.
   ⚠ 519px, NOT 900px, and the number is the grid's own. .gallery is
   auto-fit/minmax(230px), so it only collapses to ONE column below about 490px
   of wrap. Above that a tablet already gets two or three across and there is
   nothing to fix; turning it sideways there just took a working grid away.
   Same breakpoint the nav shrink uses, so the phone layout starts in one place. */
@media (max-width:519px){
  .gallery{
    display:flex; grid-template-columns:none; gap:12px;
    overflow-x:auto; overscroll-behavior-x:contain;
    scroll-snap-type:x mandatory; -webkit-overflow-scrolling:touch;
    padding-bottom:2px; scrollbar-width:none;
  }
  .gallery::-webkit-scrollbar{ display:none; }
  .gallery img{ flex:0 0 84%; scroll-snap-align:start; }
  /* Hover lift is a mouse idea and it fights a snap strip under a thumb. */
  .gallery img:hover{ transform:none; box-shadow:none; }
  .gdots{ display:flex; justify-content:center; gap:8px; margin-top:16px; }
  .gdots i{
    width:7px; height:7px; border-radius:50%; background:var(--line);
    transition:background .2s ease, transform .2s ease;
  }
  .gdots i.on{ background:var(--key); transform:scale(1.25); }
  @media (prefers-reduced-motion:reduce){ .gdots i{ transition:none; } }
}
@media (min-width:520px){ .gdots{ display:none; } }

/* ─── 18. THE ENQUIRY FORM ───────────────────────────────────────────────────
   Sept 4 2026. Sits in #deeper behind Book a Free Call (#1517/#1534/#1582).

   ⚠ TONE-AGNOSTIC ON PURPOSE. Every colour here is one of the band's own tone
   variables (--fg, --dim, --line, --key, --card-face), never a literal. #deeper
   is the NIGHT band today, so a form styled for cream would have been invisible
   the moment it landed; written this way it also survives the band being
   re-toned or the form being moved, which is the likelier future.
   ⛔ The supplied embed shipped its own palette (--bt-stone, --bt-moss, --bt-clay)
   and none of those colours are on this site. They are deliberately not carried
   over: that is a client's OWN kill-list problem in miniature, and the method
   transfers while the palette does not. */
.btpanel{ margin-top:26px; }
.bt-intake{ max-width:620px; }
.bt-intake .bt-q{ margin-bottom:22px; }
.bt-intake label,
.bt-intake .bt-label{
  display:block; font-size:15px; font-weight:500; color:var(--fg);
  margin-bottom:7px; letter-spacing:.01em;
}
.bt-intake .bt-hint{ font-size:13.5px; color:var(--dim); margin:-2px 0 9px; }
.bt-intake input[type="text"],
.bt-intake input[type="email"],
.bt-intake textarea{
  width:100%; box-sizing:border-box; font:inherit; font-size:16px;
  /* ⚠ 16px, not 15. iOS Safari ZOOMS the whole page on focus for anything
     smaller, which on a phone throws the visitor out of the panel they just
     opened. This is the one place a font size is a layout decision. */
  color:var(--fg); background:var(--card-face, rgba(255,255,255,.06));
  border:1px solid var(--line); border-radius:4px; padding:12px 13px;
}
.bt-intake textarea{ min-height:118px; resize:vertical; }
.bt-intake input:focus, .bt-intake textarea:focus{
  outline:2px solid var(--key); outline-offset:1px;
}
.bt-intake .bt-choices{ display:flex; flex-direction:column; gap:9px; }
.bt-intake .bt-choice{
  display:flex; align-items:flex-start; gap:11px; padding:12px 14px;
  background:var(--card-face, rgba(255,255,255,.06)); border:1px solid var(--line);
  border-radius:4px; cursor:pointer; font-weight:400; margin:0;
}
.bt-intake .bt-choice input{ margin-top:3px; accent-color:var(--key); flex:none; }
.bt-intake .bt-choice span{ color:var(--fg); font-size:15px; }
.bt-intake .bt-choice:has(input:checked){ border-color:var(--key); }
.bt-intake .bt-actions{ display:flex; align-items:center; gap:16px; flex-wrap:wrap; }
.bt-intake .bt-msg{ margin:0; font-size:14px; color:var(--dim); }
.bt-intake .bt-msg.bt-err{ color:var(--key); }
.bt-intake .bt-done p{ margin:0; font-size:19px; color:var(--fg); }

/* ⛔ THE HONEYPOT IS MOVED OFF-SCREEN, NOT display:none. A bot that skips
   hidden fields walks past the trap; one that fills everything it can reach
   falls in. It keeps aria-hidden and tabindex="-1" so nobody real ever meets it. */
.bt-intake .bt-hp{
  position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden;
}

@media (max-width:559px){
  .bt-intake .bt-actions{ gap:12px; }
  .bt-intake .bt-actions .btn{ width:100%; }
}

/* ─── 19. THE THANK-YOU POP-UP ───────────────────────────────────────────────
   Sept 4 2026 (#1589). Chef, testing the live form: "when you complete the form,
   it stays still and I can't tell if anything happened. I scrolled up to see:
   Thank you..." Replacing the panel in place put the confirmation ABOVE where he
   was looking, which is no confirmation at all.

   ⚠ A NATIVE <dialog>, NOT A DIV. showModal() gives the Escape key, focus
   trapping and an inert background for free. Hand-rolling those is how a modal
   ends up letting you tab into the page behind it.
   ⚠ The dialog is appended to <body> by the script, never nested in the panel:
   the panel is collapsed on success, and a dialog inside a hidden container
   cannot be shown. */
.bt-modal{
  border:none; border-radius:10px; padding:0; max-width:440px; width:calc(100% - 40px);
  background:var(--shell); color:var(--ink);
  box-shadow:0 40px 90px -30px rgba(0,0,0,.55);
}
.bt-modal::backdrop{ background:rgba(18,41,31,.55); }
.bt-modal .bt-modal-in{ padding:34px 30px 28px; text-align:center; position:relative; }
.bt-modal p{ margin:0 0 24px; font-size:18px; line-height:1.5; color:var(--ink); }
.bt-modal .btn{ cursor:pointer; }
.bt-modal .bt-x{
  position:absolute; top:10px; right:14px; background:none; border:none;
  font-size:26px; line-height:1; color:var(--muted); cursor:pointer; padding:6px 10px;
}
.bt-modal .bt-x:hover{ color:var(--ink); }
