/* =====================================================================
   1. FONT — DECIDED (Phase 0)
   ---------------------------------------------------------------------
        display   Fraunces   weights 300-500, variable opsz 9..144
        body      DM Sans    weights 400/500/600

   Both faces were ALREADY in use across the existing block files, so
   this decision is subtractive: nothing new to add, six families to
   delete from Jevelin's theme options — Montserrat, Raleway, Poppins,
   Open Sans, and the two system fallbacks that leak in behind them.

   Fraunces is variable with an optical-size axis. Requesting the axis
   rather than a fixed cut lets an 84px headline use fine, high-contrast
   letterforms while a 20px card title uses sturdier ones, from one
   file. `font-optical-sizing: auto` in section 6a does that for free.

   NEVER set a display heading above weight 300. That single rule is
   most of the perceived-quality gap with Symmetry — see section 3.

   LOADING
   In WordPress: enqueued by the child theme (functions.php section 3),
   only on pages carrying the `hth-ds` body class.
   Standalone (reference-page.html): a <link> in that file's <head>.
   Deliberately NOT @import-ed here — @import blocks the CSSOM and
   cannot be preconnected.
   ===================================================================== */


:root {

  /* ===================================================================
     2. COLOUR — 9 values. That is the whole palette.
     -------------------------------------------------------------------
     Structure copied from Symmetry, hues chosen for HTH:
       ONE warm neutral field   (flatters cedar/hemlock photography)
       ONE deep ink             (blue-shifted, ties to the HTH logo)
       ONE accent, used rarely  (the existing azure)
     Symmetry's actual values, for reference:
       #E5DAC9 field · #EAE9E1 light tan · #D9D8D0 dark tan · #393F49 ink
     =================================================================== */
  --hth-sand:        #EDE7DD;   /* primary page field                   */
  --hth-sand-deep:   #DDD5C7;   /* alternate band, hairlines, hovers    */
  --hth-shell:       #F7F4EF;   /* near-white panels ON sand            */
  --hth-white:       #FFFFFF;   /* overlay cards only                   */

  --hth-ink:         #23303C;   /* headings, dark sections              */
  --hth-ink-2:       #3B4C5B;   /* body copy                            */
  --hth-ink-3:       #556372;   /* labels, captions — AA on sand (4.9:1)*/

  --hth-azure:       #2E86B8;   /* LARGE text, icons, rules only (3.3:1)*/
  --hth-azure-ink:   #1F6C97;   /* small text + links on light  (4.6:1) */
  --hth-azure-lift:  #6FB9E0;   /* accent on ink backgrounds            */

  --hth-line:        rgba(35,48,60,.14);
  --hth-line-dark:   rgba(255,255,255,.16);

  /* Semantic aliases — write these in blocks, never the raw names.
     Switching a section light→dark then means flipping one class. */
  --hth-bg:      var(--hth-sand);
  --hth-surface: var(--hth-shell);
  --hth-head:    var(--hth-ink);
  --hth-body:    var(--hth-ink-2);
  --hth-label:   var(--hth-ink-3);
  --hth-accent:  var(--hth-azure-ink);
  --hth-rule:    var(--hth-line);


  /* ===================================================================
     3. TYPE SCALE — 8 steps. Display weight is ALWAYS 300.
     -------------------------------------------------------------------
     This is the single highest-leverage change in this file. Symmetry's
     h1 is 72px / weight 300 / letter-spacing -2px. HTH's current h1 is
     78px weight 400, sitting next to h2s at 36px/600, 24px/400 and
     52px/800. Heavy weights at large sizes read as "sale flyer".
     Light weights at large sizes read as "expensive".
     =================================================================== */
  --hth-f-display: "Fraunces", Georgia, "Times New Roman", serif;
  --hth-f-body:    "DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif;

  --hth-t-xl:  clamp(44px, 6.0vw, 84px);   /* page title, one per page  */
  --hth-t-lg:  clamp(34px, 4.2vw, 60px);   /* section titles            */
  --hth-t-md:  clamp(26px, 2.6vw, 40px);   /* sub-sections              */
  --hth-t-sm:  clamp(20px, 1.6vw, 24px);   /* card titles               */
  --hth-t-bl:  18px;                       /* lead paragraph            */
  --hth-t-b:   16px;                       /* body                      */
  --hth-t-s:   14px;                       /* captions, spec values     */
  --hth-t-xs:  12px;                       /* eyebrow / label           */

  --hth-tr-xl: -.028em;   /* negative tracking scales with size —       */
  --hth-tr-lg: -.024em;   /* this is what makes large light type look   */
  --hth-tr-md: -.018em;   /* deliberate rather than stretched           */
  --hth-tr-sm: -.010em;
  --hth-tr-lab: .14em;    /* the ONE place letter-spacing goes positive */


  /* ===================================================================
     4. SPACING — one scale. Symmetry ships three values: 40/80/120.
     =================================================================== */
  --hth-1: 4px;   --hth-2: 8px;   --hth-3: 12px;  --hth-4: 16px;
  --hth-5: 24px;  --hth-6: 32px;  --hth-7: 48px;  --hth-8: 64px;
  --hth-9: 96px;  --hth-10: 128px; --hth-11: 160px;

  --hth-sec:    clamp(64px, 9vw, 128px);    /* standard section pad-y   */
  --hth-sec-lg: clamp(96px, 13vw, 180px);   /* hero / statement pad-y   */
  --hth-gut:    clamp(20px, 4vw, 64px);     /* page gutter              */
  --hth-maxw:   1400px;                     /* content max width        */
  --hth-measure: 62ch;                      /* paragraph max width      */


  /* ===================================================================
     5. RADIUS — 3 values. HTH currently renders 12 different radii.
     =================================================================== */
  --hth-r:      10px;    /* buttons, chips, small cards (Symmetry: 10px)*/
  --hth-r-lg:   20px;    /* the big overlapping panels                 */
  --hth-r-pill: 999px;


  /* ===================================================================
     6. MOTION — 1 easing curve, 3 durations.
     -------------------------------------------------------------------
     --hth-expo is Symmetry's actual --expo, verified in their CSS.
     Everything on their site moves on this one curve. That consistency
     is most of what reads as "subtle and expensive".
     =================================================================== */
  --hth-expo:  cubic-bezier(.165, .84, .44, 1);
  --hth-fast:  .3s;
  --hth-mid:   .9s;
  --hth-slow:  1.4s;

  --hth-shadow:    0 1px 2px rgba(35,48,60,.04), 0 8px 24px rgba(35,48,60,.06);
  --hth-shadow-lg: 0 2px 4px rgba(35,48,60,.05), 0 24px 64px rgba(35,48,60,.10);
}


/* =====================================================================
   6a. SCOPED ADOPTION — how the fonts reach the page without touching
       Jevelin's theme options, and without affecting production pages.
   ---------------------------------------------------------------------
   THE PROBLEM THIS SOLVES
   We want one MVP page in Fraunces + DM Sans while ~200 live pages stay
   in Raleway. Changing Jevelin's global font option would restyle the
   entire site in one click, which is exactly the disruption we're
   avoiding. So the adoption is scoped to a body class that only
   design-system pages carry (added by functions.php §5).

   WHY NO !important IS NEEDED — measured on the live site, not assumed.
   Every selector currently setting a TEXT face on hightechhealth.com:

       body                                     (0,0,1)
       h1, h2, h3, h4                           (0,0,1)
       .h3, h1, h2, h3, h4                      (0,0,1)
       html body  /  body h1..h4                (0,0,2)
       html .menu-item a                        (0,0,2)
       .sh-heading span.sh-heading-content      (0,2,1)  ← the only awkward one

   All 14 `!important` font declarations on the site are icon fonts
   (Jevelin-Icons, simple-line-icons, FontAwesome). NOT ONE text face
   uses !important. So ordinary specificity wins, and we keep the
   escape hatch of !important in reserve rather than spending it now.

   `body.hth-ds` is (0,1,0), which already beats everything except the
   .sh-heading rule — that one gets its own line below.

   GOING SITE-WIDE LATER
   When the whole site moves over, delete the `body.hth-ds` prefix from
   the five rules in this section. Nothing else changes.
   ===================================================================== */

/* Base — inheritance does most of the work from here. */
body.hth-ds {                                    /* (0,1,0) > body (0,0,1) */
  font-family: var(--hth-f-body);
  color: var(--hth-ink-2);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;

  /* ------------------------------------------------------------------
     THIS TWO-LINE RULE IS WHAT MAKES .hth-pin WORK AT ALL. Do not
     remove it without reading this.

     Jevelin sets `overflow-x: hidden` on <body> — standard practice to
     stop full-width rows producing a horizontal scrollbar. But per
     spec, when one axis is `hidden` the other computes from `visible`
     to `auto`. So the real computed value is:

         body { overflow-x: hidden; overflow-y: auto; }

     ...which makes <body> a SCROLL CONTAINER. `position: sticky` sticks
     relative to its nearest scrolling ancestor, so every .hth-pin__stage
     was sticking to <body> instead of the viewport. Since <body> never
     scrolls internally (the viewport does), the sticky element had
     nothing to respond to and simply scrolled away — while .hth-pin
     still reserved its full 2x height, leaving a screen-sized void
     underneath. Measured before the fix: stage top went 77px -> -523px
     over 600px of scroll. It was not pinning at all.

     `overflow: clip` clips identically but does NOT create a scroll
     container, so sticky keeps working and there is still no horizontal
     scrollbar. Verified live on /21697-2/: stage holds at top 0 through
     the pin travel, releases correctly at the end, no h-scrollbar.

     Scoped to body.hth-ds — (0,1,0) beats Jevelin's `body` at (0,0,1) —
     so production pages keep the original behaviour untouched.

     If `overflow: clip` is ever unsupported the declaration is simply
     ignored and Jevelin's `hidden` stands: the pin stops pinning but
     nothing breaks. That is the correct failure mode, so no @supports
     wrapper. (Chrome 90+, Firefox 81+, Safari 16+.)
     ------------------------------------------------------------------ */
  overflow-x: clip;
  overflow-y: visible;
}

/* Display face. Weight 300 is the entire premium effect — see §3. */
body.hth-ds h1, body.hth-ds h2,
body.hth-ds h3, body.hth-ds h4,
body.hth-ds h5, body.hth-ds h6 {                 /* (0,1,2) > body h1 (0,0,2) */
  font-family: var(--hth-f-display);
  font-optical-sizing: auto;   /* Fraunces opsz axis: fine at 84px,
                                  sturdier at 20px, from one file      */
  color: var(--hth-head);
}

/* font-weight is set SEPARATELY and only on headings that are NOT using
   the type scale. Reason: `body.hth-ds h3` is (0,1,2) and would beat
   `.hth-sm` at (0,1,0) — so a card title marked
   `<h3 class="hth-display hth-sm">`, which is meant to be weight 500,
   would silently render at 300. The :not() hands ownership of weight
   back to the scale classes wherever they are present, and applies the
   Light default only to legacy/theme headings on the same page. */
body.hth-ds :is(h1,h2,h3,h4,h5,h6):not([class*="hth-"]) { font-weight: 300; }

/* The one selector on the site with real specificity. */
body.hth-ds .sh-heading span.sh-heading-content { /* (0,3,1) > (0,2,1)     */
  font-family: var(--hth-f-display);
  font-weight: 300;
}

/* Navigation and buttons follow the body face. */
body.hth-ds .menu-item a,
body.hth-ds button,
body.hth-ds input,
body.hth-ds select,
body.hth-ds textarea {                            /* (0,1,1)/(0,2,1)       */
  font-family: var(--hth-f-body);
}

/* Jevelin ships h1–h4 with theme-option sizes and margins that fight the
   type scale. Neutralise INSIDE our sections only — the header, footer
   and any legacy row on the same page keep Jevelin's spacing. */
.hth-section :where(h1, h2, h3, h4, h5, h6) { margin: 0; line-height: inherit; }


/* =====================================================================
   6c. THE ONE PLACE !important IS EARNED
   ---------------------------------------------------------------------
   Appearance -> Customize -> Additional CSS (the #wp-custom-css block,
   post 9231) contains this:

       a, img, h2 { transition: none !important; }

   Site-wide, and !important. Almost certainly added to stop Jevelin's
   own `h1..h6 { transition: 0.3s ease-in-out }` producing a jarring
   colour flick somewhere. But `a` is every button, `img` is every
   photograph, and `h2` is every section heading — so it silently
   disables a large part of this design system. Measured on the live
   page before this rule existed:

       .hth-reveal on an h2      transition: none   <- appeared abruptly
       .hth-btn (an <a>)         transition: none   <- no hover at all
       gallery / card <img>      transition: none   <- no settle, no zoom
       .hth-reveal on a span     0.9s               <- fine
       .hth-reveal on a div      0.9s               <- fine

   That mix is why it read as "the h2 has no animation" rather than as
   a systemic problem: only the elements that happen to be a, img or h2
   were affected, and everything else animated normally.

   You cannot out-specify !important — only match it. These selectors
   are all at least (0,2,0) against their (0,0,1), so they win cleanly
   and they only reach inside our own namespace.

   THE REAL FIX IS UPSTREAM. Narrow that Customizer rule to whatever it
   was actually written for. When it is gone, this whole section can be
   deleted — nothing else in the file depends on it.
   ===================================================================== */
.hth-reveal-ready .hth-reveal {
  transition: opacity var(--hth-mid) var(--hth-expo),
              transform var(--hth-mid) var(--hth-expo) !important;
}
.hth-reveal-ready .hth-settle > img {
  transition: transform var(--hth-slow) var(--hth-expo) !important;
}
.hth-card__media > img {
  transition: transform var(--hth-slow) var(--hth-expo) !important;
}
.hth-gallery__slide > img { transition: none !important; }  /* genuinely wants none */

/* THE MOSAIC, added late, and the clearest demonstration of why this section
   is dangerous to leave in place. Section 26 declared its zoom correctly, at
   (0,1,0), from the day the block shipped — and the photograph scaled in
   exactly one frame the whole time, because a bare `img` at (0,0,1) WITH
   !important beats any specificity without it. It read as "the block has no
   animation" rather than as a cascade problem, which is precisely the
   misdiagnosis the note at the top of this section records for the h2.

   THE DURATION AND EASING ARRIVE THROUGH CUSTOM PROPERTIES, so this stays ONE
   declaration while section 26 still gets a different speed per effect and a
   slower return than approach. A custom property resolves at the element, and
   an element handed a new --hth-mos-d mid-transition simply uses it — which is
   how the return is slower than the arrival without a second !important rule
   here to keep in sync with that one.

   Three properties, because the four effects do not animate the same thing:
   transform (zoom, spotlight, push), filter (bloom, and the dimming half of
   spotlight), and `scale`, listed for the day this block moves to the
   individual property the expand rail already uses. Opacity is deliberately
   absent — the clip layer is a <video>, which the Customizer rule never
   touched, so it keeps its own transition in section 24. */
.hth-mosaic__media > img {
  transition: transform var(--hth-mos-d, .8s) var(--hth-mos-e, ease),
              scale     var(--hth-mos-d, .8s) var(--hth-mos-e, ease),
              filter    var(--hth-mos-d, .8s) var(--hth-mos-e, ease) !important;
}

.hth-btn {
  transition: background   var(--hth-fast) var(--hth-expo),
              color        var(--hth-fast) var(--hth-expo),
              border-color var(--hth-fast) var(--hth-expo) !important;
}
.hth-rich a {
  transition: text-decoration-color var(--hth-fast) var(--hth-expo) !important;
}


/* =====================================================================
   6b. BOX MODEL — do not remove.
   ---------------------------------------------------------------------
   Every primitive below that combines a percentage width with padding
   (.hth-split__info, .hth-card__body, .hth-btn, .hth-pin__stage) assumes
   border-box. Without it, padding is ADDED to the declared width and any
   two-column row overflows the viewport by exactly its padding.

   This is scoped to our namespace and its descendants, and wrapped in
   :where() so it carries ZERO specificity — it can never win a fight
   with a theme or plugin rule that deliberately sets content-box.

   On the live site Jevelin's Bootstrap already sets border-box globally,
   so this is belt-and-braces there. It is load-bearing for any
   standalone use (reference-page.html, a staging page, an email preview).
   ===================================================================== */
:where([class*="hth-"]),
:where([class*="hth-"]) *,
:where([class*="hth-"])::before,
:where([class*="hth-"])::after,
:where([class*="hth-"]) *::before,
:where([class*="hth-"]) *::after { box-sizing: border-box; }


/* =====================================================================
   7. THEME SWITCHES
   ---------------------------------------------------------------------
   Put .hth--dark (or .hth--shell) on any .hth-section and every child
   block re-colours itself. No block needs its own light/dark variant.
   ===================================================================== */
.hth--sand  { --hth-bg: var(--hth-sand);  --hth-surface: var(--hth-shell); }
.hth--shell { --hth-bg: var(--hth-shell); --hth-surface: var(--hth-white); }
.hth--white { --hth-bg: var(--hth-white); --hth-surface: var(--hth-shell); }

.hth--dark {
  --hth-bg:      var(--hth-ink);
  --hth-surface: #2C3B49;
  --hth-head:    #FFFFFF;
  --hth-body:    rgba(255,255,255,.82);
  --hth-label:   rgba(255,255,255,.60);
  --hth-accent:  var(--hth-azure-lift);
  --hth-rule:    var(--hth-line-dark);
}

/* Over a photograph. Text sits on a scrim, not on the raw image.

   --hth-eyebrow is the eyebrow's own ink, set ONLY here. At the shared
   68% label ink the eyebrow washed out on light photographs, worst on a
   phone, where wrapping lifts it above the dark end of the scrim. 92%
   keeps it one step under the white title. Captions and badge labels
   stay on --hth-label.

   --hth-eyebrow-shadow is a halo, not a drop shadow: a wide soft blur
   that darkens the photo just around the letters, plus a tight 1px edge
   for the brightest highlights. Small uppercase type is the first thing
   a light photograph swallows, so only the eyebrow gets it.

   A context that hands the section's ink back (the expand rail's
   copy-below) resets both with `initial`, which falls .hth-eyebrow back
   to --hth-label and no shadow. */
.hth--photo {
  --hth-head:    #FFFFFF;
  --hth-body:    rgba(255,255,255,.88);
  --hth-label:   rgba(255,255,255,.68);
  --hth-eyebrow: rgba(255,255,255,.92);
  --hth-eyebrow-shadow: 0 0 12px rgba(13,18,24,.55), 0 1px 2px rgba(13,18,24,.45);
  --hth-accent:  var(--hth-azure-lift);
  --hth-rule:    var(--hth-line-dark);
}


/* =====================================================================
   8. SECTION + LAYOUT PRIMITIVES
   ---------------------------------------------------------------------
   Use these INSTEAD of setting padding and background per-row in the
   WPBakery Design Options panel. Row-level design options are the main
   source of the 343 KB of inline CSS currently on /infrared-saunas/,
   because WPBakery writes a unique .vc_custom_1234567890 rule for each
   one and they are never garbage-collected.
   ===================================================================== */
.hth-section {
  background: var(--hth-bg);
  color: var(--hth-body);
  padding-block: var(--hth-sec);
  padding-inline: var(--hth-gut);
  position: relative;
  isolation: isolate;
}
.hth-section--lg   { padding-block: var(--hth-sec-lg); }
.hth-section--flush{ padding-block: 0; }
.hth-section--tight{ padding-block: var(--hth-6); }

/* Bleed a section edge-to-edge out of a boxed WPBakery container.
   ---------------------------------------------------------------------
   The naive version of this is `width: 100vw; margin-inline: calc(50% -
   50vw)`, which overflows by the width of the vertical scrollbar,
   because 100vw includes it. The usual patch — `overflow-x: hidden` on
   html/body — MUST NOT be used here: it silently kills `position:
   sticky` on descendants, which would break every .hth-pin on the site.

   Instead hth-ds.js measures the scrollbar once and exposes --hth-sbw.
   Falls back to the naive behaviour if the script hasn't run. */
.hth-bleed {
  --_vw: calc(100vw - var(--hth-sbw, 0px));
  width: var(--_vw);
  margin-inline: calc(50% - var(--_vw) / 2);
}

.hth-wrap { max-width: var(--hth-maxw); margin-inline: auto; }

/* Any section carrying an Anchor ID is a jump target, so it needs to
   clear the fixed header when something links to it. 112px matches the
   two blocks that already did this by hand (.hth-faq and the model
   matrix headings) — kept identical so a page with both does not scroll
   to two different offsets depending on which link was clicked.

   Attribute selector rather than a class: the id IS the condition, so
   there is nothing to remember to add and nothing to get out of sync. */
.hth-section[id] { scroll-margin-top: 112px; }

/* Symmetry's signature move: a rounded panel that rises OVER the
   full-bleed image above it. Two lines of CSS, no JS. */
.hth-lift {
  border-top-left-radius:  var(--hth-r-lg);   /* physical fallback first */
  border-top-right-radius: var(--hth-r-lg);
  border-start-start-radius: var(--hth-r-lg);
  border-start-end-radius:   var(--hth-r-lg);
  margin-block-start: calc(-1 * var(--hth-7));
  z-index: 2;
}

.hth-grid { display: grid; gap: var(--hth-7); align-items: start; }
.hth-grid--2   { grid-template-columns: repeat(2, minmax(0,1fr)); }
.hth-grid--3   { grid-template-columns: repeat(3, minmax(0,1fr)); }
.hth-grid--4   { grid-template-columns: repeat(4, minmax(0,1fr)); }
/* Asymmetric splits do more for perceived quality than 50/50. */
.hth-grid--7-5 { grid-template-columns: 7fr 5fr; }
.hth-grid--5-7 { grid-template-columns: 5fr 7fr; }

/* ---------------------------------------------------------------------
   THE PRODUCT SPLIT — 60 / 40, measured off Symmetry's Luik page.
   Their markup is `xl:w-6/10` and `xl:w-4/10` in a flex row with gap 0;
   the breathing room comes from 120px of left padding on the INFO
   column, not from a grid gap. Worth copying exactly: a gap pushes both
   columns inward, whereas one-sided padding lets the image sit hard
   against the container edge and stay visually dominant.

   The info column is `justify-between`, which is why their CTA lands
   flush with the bottom edge of the image. That alignment is doing
   quiet work — keep it.
   --------------------------------------------------------------------- */
.hth-split { display: flex; align-items: stretch; gap: 0; }

/* The inset is fluid, not a hard 120px. At 1280px a fixed 128px would
   leave a ~320px text column — technically fine, visually cramped. */
.hth-split { --hth-split-inset: clamp(40px, 6vw, 120px); }

.hth-split__media { flex: 0 0 60%; min-width: 0; }
.hth-split__info  {
  flex: 1 1 40%; min-width: 0;          /* 1 1 so it absorbs rounding  */
  padding-left: var(--hth-split-inset);
  display: flex; flex-direction: column; justify-content: space-between;
  gap: var(--hth-6);
}
/* `justify-between` only flatters a FULL column. With short copy it
   opens a conspicuous hole between the specs and the CTA — visible on
   Symmetry's own Luik page, where the gap runs ~150px. Two options:
   write enough copy to fill the column, or opt out with --top and let
   the CTA sit directly under the content. */
.hth-split--top .hth-split__info { justify-content: flex-start; }

/* ---------------------------------------------------------------------
   STICKY GALLERY
   ---------------------------------------------------------------------
   THE PROBLEM, MEASURED ON /tr2-new/ AT 1760px
     .hth-split__media   1262px   (stretched to match the info column)
     .hth-gallery         672px   (5:4 inside a 60% column)
   So 590px of the media column is empty. The image ends beside the middle
   of the copy and the bottom half of the buy box scrolls past a blank
   field. The taller the right column gets — and the configurator made it
   much taller — the worse it reads.

   Nothing in the markup changes. align-items:stretch already gives the
   media column the full height of the split, and the gallery is a short
   box at the top of it, so a sticky gallery has exactly the difference to
   travel and releases on its own at the bottom of the column. No JS, no
   scroll listener, no measurement.

   THREE THINGS THAT WOULD BREAK THIS, none of which are true here —
   checked on the live page before writing it, and worth re-checking if it
   ever stops working:
     1. overflow other than visible on ANY ancestor. A single
        overflow:hidden up the chain silently kills sticky and there is no
        error. .hth-section, .hth-wrap and .hth-split are all visible.
     2. a height on the sticky element's parent. .hth-split__media takes
        its height from the flex stretch, which is what we want.
     3. the element being the only child with no room to move — which is
        the short-copy case, where sticky is a no-op rather than a bug.

   THE OFFSET IS NOT A CONSTANT — THE HEADER IS FIXED
   Jevelin's .sh-header is position:fixed, ~101px tall, z-index 5000, and
   it sits below the 32px WP admin bar when one is present. Pin the
   gallery at a plain 32px and its top ~100px lives UNDER that bar. It is
   an easy mistake to ship, because the top of a product photo is usually
   empty background, so a screenshot looks fine while the cabin is
   quietly cropped.

   So the top is the header's bottom edge plus breathing room, and the
   header's bottom edge is measured by hth-ds.js into --hth-hdr-bottom
   (see the HEADER BOTTOM module there). Falls back to 0px, which is
   correct for any page with no fixed header.
   --------------------------------------------------------------------- */
.hth-split { --hth-split-sticky-gap: var(--hth-6); }

@media (min-width: 1280px) and (min-height: 700px) {
  .hth-split:not(.hth-split--nopin) > .hth-split__media > .hth-gallery {
    position: sticky;
    top: calc(var(--hth-hdr-bottom, 0px) + var(--hth-split-sticky-gap));
    /* MAKE IT FIT — the same move as .hth-figure--sticky below. The
       gallery is 5:4 (width / height), so 5/4 x the room under the header
       is the width that makes it exactly that tall. min() leaves a tall
       window with the full column; a short one gets a scaled, uncropped
       gallery instead of losing the hold. */
    width: min(100%, calc(5 / 4 * (100dvh - var(--hth-hdr-bottom, 0px)
                                          - 2 * var(--hth-split-sticky-gap))));
  }
  /* Flipped, keep a narrowed gallery against the outer page edge. */
  .hth-split--flip:not(.hth-split--nopin) > .hth-split__media > .hth-gallery {
    margin-inline-start: auto;
  }
}

/* ---------------------------------------------------------------------
   THE SAME MOVE FOR HTH SPLIT FEATURE  —  .hth-figure--sticky
   ---------------------------------------------------------------------
   Opt-in, because .hth-split above is only ever a product layout with a
   long buy column, whereas the split feature is the workhorse block and
   plenty of its sections are short enough that a held image would be a
   held image for no reason. The block's "When the copy is taller"
   dropdown adds this class; nothing already built changes.

   WHY IT WORKS AT ALL, given `.hth-grid { align-items: start }`.
   That was the thing worth checking rather than assuming. `align-items:
   start` sizes the FIGURE to its content, but its grid AREA still spans
   the whole row — and a sticky item is positioned against its containing
   block, which is that area. So the travel available is exactly the
   height the copy column has over the image, and the figure releases on
   the area's bottom edge. Verified live on /home-new/: a 704px figure
   beside 890px of copy held at the offset for 186px of scroll and
   released at rowTop + 890 - 704, to the pixel.

   The corollary is the honest limit of the effect: THE HOLD IS ONLY AS
   LONG AS THE HEIGHT DIFFERENCE. Where the image is the taller column
   there is no travel and this does nothing at all, which is the right
   failure mode — it needs no guard, it simply stops applying.

   THE GALLERY'S TEST, NOT THE GALLERY'S NUMBERS — and that distinction
   is worth one extra media query. The test both blocks want is the same:
   figure + header + gap must fit the viewport, or the bottom of the
   image sits under the fold for the whole scroll. The two layouts reach
   it differently. .hth-split stacks at 1280px, so above that its gallery
   is always at or near its 672px ceiling and one constant covers it.
   .hth-grid--5-7 does not stack until 900px, and below the wrap's
   ceiling the 4:5 figure SHRINKS with the window while the copy grows —
   704px at 1528px wide, 588px at 1280, 502px at 1100, 454px at 1000 — so
   the hold both fits more easily and lasts longer as the window narrows.
   Borrowing 1280 from the gallery therefore switched the effect off
   across 900–1279px, where it works best, for a reason belonging to the
   other block's breakpoint rather than to this one's geometry.

   AND THE FIT TEST ITSELF IS THE WRONG QUESTION. "Does the figure fit
   under the header" has a third answer besides yes and no: MAKE IT FIT.
   The width line below sizes the held figure to the room actually
   available, and aspect-ratio takes the height down with it — the image
   is scaled, never cropped, because .hth-figure is a ratio box and its
   img is width/height 100% inside it.

   The arithmetic, measured on a real 1700 x 845 laptop window: the
   figure wants 704px and the viewport has 845 - 132 header - 64 (a gap
   above and below) = 649. It missed the old 900px gate by 23px and lost
   the hold entirely. Sized to fit it becomes 519 x 649 — 8% narrower
   than the column — holds at the header for 180px of scroll and
   releases on the row's bottom edge. The copy is 891px there, so the
   shrink BUYS travel: 242px of hold against 187px at full size.

   var(--hth-ar) is set by the ratio classes in §13 as `4 / 5`, `16 / 10`
   and so on — width over height — so `var(--hth-ar) * fit` is the width
   that makes the figure exactly `fit` tall, for whichever shape the
   block is set to. min() means a window with room to spare gets the full
   column and nothing changes.

   WHAT IS LEFT OF THE GATE, then, is only: is the layout still two
   columns (900px, the grid's own collapse point — not 1280px, which was
   .hth-split's breakpoint and never had anything to do with this block),
   and is there enough height that scaling the image down is still a
   better trade than letting it scroll. 700px is that floor: below it the
   figure is under about three-quarters size and the hold is no longer
   worth what it costs the photograph.

   The gap variable is reused rather than renamed, and set on the element
   because .hth-grid is not .hth-split and would not inherit it.
   --------------------------------------------------------------------- */
.hth-figure--sticky { --hth-split-sticky-gap: var(--hth-6); }

@media (min-width: 900px) and (min-height: 700px) {
  .hth-grid > .hth-figure--sticky {
    position: sticky;
    top: calc(var(--hth-hdr-bottom, 0px) + var(--hth-split-sticky-gap));
    width: min(100%, calc(var(--hth-ar, 4 / 5)
          * (100dvh - var(--hth-hdr-bottom, 0px)
                    - 2 * var(--hth-split-sticky-gap))));
  }

  /* Flipped, the figure is the RIGHT column, so a narrowed one must keep
     its outer edge against the page rather than drifting in toward the
     copy. Only when it has actually narrowed — at full width the auto
     margin is zero. */
  .hth-grid--7-5 > .hth-figure--sticky { margin-inline-start: auto; }
}

/* STICKY BEATS THE SCROLL-DRIVEN ZOOM, and they must not both run.
   .hth-zoom's playhead is `animation-timeline: view()` — the element's
   own progress through the scrollport. Pin the element and that progress
   stalls, so the image zooms partway, freezes at whatever scale it had
   reached, then lurches on when the figure releases. Not broken, but
   fussy in exactly the way §13a's own "restraint" note argues against.

   Sticky wins because it is a layout decision and the zoom is
   decoration. Specificity (0,2,1) beats §13a's (0,1,1) from anywhere in
   the file, so this does not need to sit inside that @supports block or
   after it. `.hth-settle` is unaffected: it fires once on entry and is
   finished long before the hold begins. */
.hth-figure--sticky.hth-zoom > img { animation: none; }

/* WHY min-height, AND WHY 900
   The gallery is 5:4 in a 60% column, so it is roughly a full screen
   tall: 595px at a 1280px window, and 672px at its ceiling — the column
   stops growing at --hth-maxw, so 672px is the tallest it ever gets.
   Pin that below a fixed header on a short viewport and the bottom of
   the cabin sits under the fold for the whole scroll — the visitor never
   sees the product whole, which is a worse trade than an empty column.

   The threshold WAS the worst case added up:
     672 gallery + 133 header and admin bar + 32 gap  =  837
   with a 900px gate. That gate switched the hold off on most laptops —
   a 1080p Windows screen at 125% scaling is ~1536 x 750 — which is
   where it was reported "broken" on /trs2-new/ (2026-10-07). The fix is
   the one .hth-figure--sticky already uses: size the held gallery to
   the room under the header (the width line above) so it never sits
   under the fold, and keep only a 700px floor, below which the scaled
   gallery is too small to be worth holding. Editors can switch the
   hold off per block with "Pin the image" (adds .hth-split--nopin). */

/* Image on the right instead. Alternate down a long page. */
.hth-split--flip { flex-direction: row-reverse; }
.hth-split--flip .hth-split__info { padding-left: 0; padding-right: var(--hth-split-inset); }

/* Stack below Symmetry's own breakpoint for this layout (Tailwind xl,
   1280px). Above it you get 60/40; below it a 40% column is too narrow
   to set type in, so it becomes one column. */
@media (max-width: 1279px) {
  .hth-split, .hth-split--flip { flex-direction: column; gap: var(--hth-7); }
  .hth-split__media, .hth-split__info { flex: 1 1 auto; }
  .hth-split__info,
  .hth-split--flip .hth-split__info { padding-left: 0; padding-right: 0; }
  /* Stacked, the gallery no longer needs to fill a 60% column. */
  .hth-split__media .hth-gallery { aspect-ratio: 3 / 2; }
}
@media (max-width: 780px) {
  .hth-split__media .hth-gallery { aspect-ratio: 1 / 1; }
}

/* Collapse order matters. A 4-up goes to 2×2 on tablet, not straight to
   one column — a single column of nine palette swatches is a scroll. */
@media (max-width: 900px) {
  .hth-grid--2, .hth-grid--3,
  .hth-grid--7-5, .hth-grid--5-7 { grid-template-columns: 1fr; }
  .hth-grid--4 { grid-template-columns: repeat(2, minmax(0,1fr)); }
  .hth-grid { gap: var(--hth-6); }
}
@media (max-width: 560px) {
  .hth-grid--4 { grid-template-columns: 1fr; }
}


/* =====================================================================
   9. TYPOGRAPHY PRIMITIVES
   ===================================================================== */
:where(.hth-section) :where(h1,h2,h3,h4,p,li,figcaption) { margin: 0; }

.hth-display,
:where(.hth-section) > :where(h1,h2) {
  font-family: var(--hth-f-display);
  font-weight: 300;              /* never bold. this is the whole trick */
  color: var(--hth-head);
  text-wrap: balance;
}
.hth-xl { font-size: var(--hth-t-xl); letter-spacing: var(--hth-tr-xl); line-height: 1.0;  }
.hth-lg { font-size: var(--hth-t-lg); letter-spacing: var(--hth-tr-lg); line-height: 1.05; }
.hth-md { font-size: var(--hth-t-md); letter-spacing: var(--hth-tr-md); line-height: 1.12; }
.hth-sm {
  font-size: var(--hth-t-sm); letter-spacing: var(--hth-tr-sm);
  line-height: 1.25; font-weight: 500; color: var(--hth-head);
}

.hth-lead, .hth-p {
  font-family: var(--hth-f-body);
  color: var(--hth-body);
  max-width: var(--hth-measure);
  text-wrap: pretty;
}
.hth-lead { font-size: var(--hth-t-bl); line-height: 1.62; font-weight: 400; }
.hth-p    { font-size: var(--hth-t-b);  line-height: 1.70; font-weight: 400; }
.hth-p + .hth-p { margin-top: var(--hth-4); }
.hth-caption { font-size: var(--hth-t-s); line-height: 1.55; color: var(--hth-label); }

/* The eyebrow. Only element in the system with uppercase + tracking. */
.hth-eyebrow {
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs);
  font-weight: 500;
  letter-spacing: var(--hth-tr-lab);
  text-transform: uppercase;
  color: var(--hth-eyebrow, var(--hth-label));   /* --hth-eyebrow: §7 .hth--photo only */
  text-shadow: var(--hth-eyebrow-shadow, none);  /* likewise */
  margin-bottom: var(--hth-4);
  display: block;
}

/* Hairline rule that reads as craft rather than decoration. */
.hth-rule { border: 0; border-top: 1px solid var(--hth-rule); margin-block: var(--hth-6); }


/* =====================================================================
   10. BUTTONS — two, and only two.
   ===================================================================== */
.hth-btn {
  --_bg: var(--hth-ink); --_fg: #fff; --_bd: var(--hth-ink);
  display: inline-flex; align-items: center; gap: var(--hth-3);
  font-family: var(--hth-f-body); font-size: var(--hth-t-s); font-weight: 500;
  line-height: 1; text-decoration: none; cursor: pointer;
  padding: 17px 26px; border-radius: var(--hth-r);
  background: var(--_bg); color: var(--_fg); border: 1px solid var(--_bd);
  transition: background var(--hth-fast) var(--hth-expo),
              color      var(--hth-fast) var(--hth-expo),
              border-color var(--hth-fast) var(--hth-expo);
}
.hth-btn svg { flex: none; transition: transform .6s var(--hth-expo); }
.hth-btn:hover svg { transform: translateX(4px); }
.hth-btn:hover { --_bg: #2E3E4D; --_bd: #2E3E4D; }

.hth-btn--ghost { --_bg: transparent; --_fg: var(--hth-head); --_bd: var(--hth-rule); }
.hth-btn--ghost:hover { --_bg: var(--hth-sand-deep); --_bd: var(--hth-sand-deep); --_fg: var(--hth-ink); }

.hth--dark  .hth-btn, .hth--photo .hth-btn { --_bg: #fff; --_fg: var(--hth-ink); --_bd: #fff; }
.hth--dark  .hth-btn:hover, .hth--photo .hth-btn:hover { --_bg: rgba(255,255,255,.86); --_bd: rgba(255,255,255,.86); }
.hth--dark  .hth-btn--ghost, .hth--photo .hth-btn--ghost { --_bg: transparent; --_fg: #fff; --_bd: rgba(255,255,255,.42); }

.hth-btn:focus-visible {
  outline: 2px solid var(--hth-azure); outline-offset: 3px;
}


/* =====================================================================
   11. MOTION PRIMITIVE A — SCROLL REVEAL
   ---------------------------------------------------------------------
   Symmetry's entire reveal vocabulary is `opacity 1s ease-in-out` on a
   class they call .aos. That is it. We add a 14px rise, because it
   costs nothing and reads slightly more intentional.

   Needs hth-ds.js (16 lines). If JS fails, .hth-reveal-ready is never
   added and everything stays visible — no invisible-content failure.
   ===================================================================== */
.hth-reveal-ready .hth-reveal {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity var(--hth-mid) var(--hth-expo),
              transform var(--hth-mid) var(--hth-expo);
  transition-delay: calc(var(--i, 0) * 90ms);   /* stagger siblings */
}
.hth-reveal-ready .hth-reveal.is-in { opacity: 1; transform: none; }


/* =====================================================================
   12. MOTION PRIMITIVE B — PINNED PARALLAX  (zero JavaScript)
   ---------------------------------------------------------------------
   This is exactly how Symmetry does it. Verified on their Ethos page:
   a sticky child of height 100svh inside a parent of height 200svh,
   with overflow hidden. The image holds still while the page moves.
   No GSAP, no Lenis, no scroll listener, no jank.

     <div class="hth-pin">
       <div class="hth-pin__stage">
         <img class="hth-pin__img" src="..." alt="">
         <div class="hth-pin__scrim"></div>
         <div class="hth-pin__copy"> ...text... </div>
       </div>
     </div>

   Tune the travel with --hth-pin-h (2 = one full extra screen).

   TWO KNOBS, AND THEY MULTIPLY. Do not separate them.
   ---------------------------------------------------------------------
     --hth-pin-stage   how TALL the held frame is, as a fraction of the
                       viewport. 1 = full screen. .68 = an interior page
                       whose first paragraph should be visible without
                       scrolling.
     --hth-pin-h       how LONG it is held, as a multiple of the stage.

   The distance a sticky child stays put is `parent height - child
   height`. So the parent must be measured in STAGE heights, not in
   viewport heights:

       parent = travel x stage x 100svh

   Get this wrong and a SHORTER hero pins LONGER. With the stage at .68
   and the old `parent = travel x 100svh`, a 1.35 travel gives 0.67
   screens of hold instead of 0.35 — nearly double, on the page that
   asked for less. Multiplying keeps "hold for 35% of a stage" true at
   every stage size.

   hth-ds.js measures the real stage rather than assuming the viewport,
   for the same reason. If you change one of these, change both.
   ===================================================================== */
.hth-pin { height: calc(var(--hth-pin-h, 2) * var(--hth-pin-stage, 1) * 100vh);  position: relative; }
.hth-pin { height: calc(var(--hth-pin-h, 2) * var(--hth-pin-stage, 1) * 100svh); }   /* svh where supported */
.hth-pin__stage {
  position: sticky; top: 0;
  height: calc(var(--hth-pin-stage, 1) * 100vh); overflow: hidden;
  display: grid; place-items: end start;
  padding: var(--hth-gut);
  /* --------------------------------------------------------------
     HEADER OFFSET. The stage is a full viewport tall, but the FIRST
     hero on a page starts below the site header — measured on the
     live site at 146px logged out, 178px with the admin bar. So its
     bottom edge sits that far past the fold, and because content is
     bottom-aligned, the buttons are the first thing pushed out of
     view. That is not a design decision, it is arithmetic.

     hth-ds.js measures the real distance and sets --hth-pin-offset
     on the first pin only. Adding it as bottom padding lifts the
     copy back above the fold.

     Chosen over shortening the stage deliberately: a shorter stage
     fits perfectly at scroll 0, but once the header scrolls away and
     the stage sticks at top:0 it leaves a strip of bare background
     at the bottom of the viewport. Padding never produces a gap. The
     cost is that the copy sits a little higher once you scroll —
     which reads as a lower-third placement, not as a bug.

     The real fix is a transparent header on design-system pages, at
     which point the offset measures 0 and this does nothing. See the
     note in STAGE-4-NOTES.md.

     ONLY APPLIES TO A FULL-SCREEN STAGE. A short hero (--hth-pin-stage
     below 1) is not full-bleed under a transparent header; it sits
     BELOW a solid one, so its bottom edge is already on screen and
     there is nothing to correct. Adding the padding anyway lifts the
     copy into the middle of the frame, which reads as a centring
     mistake. hth-ds.js writes 0px in that case — see its measure().
     -------------------------------------------------------------- */
  /* TWO reasons the copy must sit higher than the gutter alone, and the
     answer is the LARGER of them, not their sum.

       --hth-pin-offset  the stage starts below the header, so its bottom
                         edge is that far past the fold at scroll 0.
       --hth-pin-rise    the next section is a .hth-lift and has pulled
                         itself up over the bottom of the frame; that
                         band is painted over, whatever is under it.

     Both are measured by hth-ds.js. max(), because they are two claims
     on the same strip of space rather than two strips: clearing the
     larger clears the smaller. Adding them would lift the copy by the
     header's height AND the panel's rise on a hero where one of the two
     already covers both. Where the offset is the larger — a hero under a
     solid header — nothing about the composition changes at all. */
  padding-bottom: calc( var(--hth-gut) +
                        max( var(--hth-pin-offset, 0px),
                             var(--hth-pin-rise,   0px) ) );
}
@supports (height: 100svh) { .hth-pin__stage { height: calc(var(--hth-pin-stage, 1) * 100svh); } }
.hth-pin__img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover; object-position: center;
  will-change: transform;
}
.hth-pin__scrim {
  position: absolute; inset: 0;
  background: linear-gradient(to top,
              rgba(20,28,36,.72) 0%,
              rgba(20,28,36,.30) 38%,
              rgba(20,28,36,0)   68%);
}
.hth-pin__copy { position: relative; max-width: 660px; }

/* Shorter variant for mid-page statement images. */
.hth-pin--half { --hth-pin-h: 1.5; }


/* ---------------------------------------------------------------------
   12b. MAKING THE PIN EARN ITS SCROLL  (.hth-pin--reveal)
   ---------------------------------------------------------------------
   A bare pin at the TOP of a page is the weakest possible use of the
   technique. The visitor's very first scroll gesture produces no visible
   change, which does not read as "cinematic hold" — it reads as a broken
   scrollbar. The pin's real value is a PAUSE, and a pause only works once
   there is something to pause from.

   Two things follow:

   1. Mid-page, a long pin is right. You arrive already scrolling, the
      image holds for a beat, the next section climbs over it. That is
      the Symmetry pattern and it needs no help.

   2. As a hero, the pin needs the copy to move during the hold, so the
      first gesture visibly does something. The headline lifts and fades
      while the photograph stays; the image pushes in very slightly. The
      frame is held, but the composition is not frozen.

   Driven by a named view-timeline on .hth-pin, using the `contain` range
   — for an element taller than the viewport that range is exactly the
   window during which it is pinned. So the animation is mapped to the
   hold itself rather than guessed at with pixel offsets.

   Native CSS. No JS, no scroll listener, off the main thread.
   Firefox (no scroll-driven animation in stable yet) gets a plain pin,
   which is why the travel default is also shorter — see the note on
   --hth-pin-h in the block.
   --------------------------------------------------------------------- */
/* ---------------------------------------------------------------------
   WHY THIS ONE IS NOT A SCROLL-DRIVEN CSS ANIMATION
   ---------------------------------------------------------------------
   `animation-timeline: view()` would be the elegant way to do this, and
   it is how .hth-zoom (§13a) works. It is the wrong choice HERE for one
   reason: Firefox still has scroll-driven animations behind a flag in
   stable. For a decorative image zoom, a Firefox visitor getting a
   static image is a fine degradation. For this, it is not — without the
   motion the pin is exactly what it was described as: a scrollbar that
   stops working for a second. The fallback would be worse than removing
   the feature.

   So progress is published by hth-ds.js as --hth-pin-p (0 -> 1 across
   the pinned window) and CSS interpolates from it. One rAF-throttled
   scroll handler, works in every browser, and the properties animated
   (opacity, translate, scale) are all compositor-friendly.

   With JS off or reduced-motion on, --hth-pin-p stays 0 and everything
   sits at its start value — a normal static hero.

   TUNING — all five are set per block from the editor, so a designer can
   adjust the feel without touching CSS, and the defaults keep every hero
   on the site consistent unless someone deliberately changes one.

     --hth-pin-hold   0-1    when the copy STARTS fading (default .45)
     --hth-pin-fade   number 1/(1-hold), precomputed in PHP so the CSS
                             never has to divide by a variable
     --hth-pin-lift   px     how far the copy rises   (default 64px)
     --hth-pin-push   number how far the image scales (default .06)
     --hth-pin-drift  px     drift travel, drift mode (default 48px)
   --------------------------------------------------------------------- */
.hth-pin--reveal .hth-pin__copy {
  /* Full opacity until --hth-pin-hold, then linear to 0 at the END of the
     hold. The dead zone matters: if the headline starts dissolving on the
     first wheel notch it reads as a glitch rather than a transition. The
     translate has NO dead zone — a few pixels of lift is the immediate
     feedback that the gesture registered. */
  opacity: calc(1 - max(0, var(--hth-pin-p, 0) - var(--hth-pin-hold, .45)) * var(--hth-pin-fade, 1.82));
  translate: 0 calc(var(--hth-pin-p, 0) * var(--hth-pin-lift, 64px) * -1);
}

.hth-pin--reveal .hth-pin__img {
  /* Enough to feel alive, not enough to read as an effect — which is the
     whole brief for motion on this site. */
  scale: calc(1 + var(--hth-pin-p, 0) * var(--hth-pin-push, .06));
}

/* ---------------------------------------------------------------------
   DRIFT — the alternative treatment.
   The image slides vertically across the hold while the copy stays fully
   legible the whole time. Use it when the words are the point and you
   just want the frame alive behind them; use --reveal when the image is
   the point and the copy should get out of its way.

   The pre-scale is not decoration: translating a cover image by +/-Npx
   exposes an edge without it. It is set from PHP to match the chosen
   drift distance rather than fixed, because a single value is either too
   small for the strong setting (edge shows) or needlessly soft for the
   subtle one. Pairs, at a 1021px viewport:

       drift  24px  ->  scale 1.08   (41px overhang each side)
       drift  48px  ->  scale 1.14   (71px)
       drift  80px  ->  scale 1.22   (112px)
   --------------------------------------------------------------------- */
.hth-pin--drift .hth-pin__img {
  scale: var(--hth-pin-driftscale, 1.14);
  translate: 0 calc((var(--hth-pin-p, .5) - .5) * var(--hth-pin-drift, 48px) * 2);
}

/* --drift and --reveal both animate .hth-pin__img. Use one or the other. */

@media (max-width: 780px), (max-height: 640px) {
  /* Sticky over-scroll on a phone eats the fold. Collapse to a plain
     tall image. Symmetry does the same thing.

     max-height too: a window under 640px tall (1920x1080 at 150%
     Windows scaling, a laptop with the window not maximised) has the
     same problem as a phone — a FULL-SCREEN stage is a fixed box with
     no room for its copy under the header. Collapsing it lets the copy
     size the frame. Sub-100% heroes are handled for every height in
     §12c below; this line is for the 100% home/product heroes.

     min(), not a flat 76svh: a hero the editor deliberately set SHORT
     must not get taller on the device where vertical space is scarcest.
     A 100% stage still collapses to 76svh; a 55% one stays 55%. */
  .hth-pin { height: auto; }

  /* min-height, NOT height. A fixed height plus overflow:hidden clips
     the copy the moment it does not fit, and on a phone it very nearly
     does not: a real device's visible viewport is ~150px shorter than
     the screen size Chrome's emulator reports, because the emulator has
     no address bar. So the frame that fit in testing is the one that
     crops the buttons on the actual handset. Growing is the safe
     failure — the image is inset:0 / object-fit:cover and simply covers
     the taller box. The min() still holds: a hero the editor set SHORT
     never gets taller than it asked for unless its own words demand it. */
  .hth-pin__stage { position: static;
                    height: auto;
                    min-height: min(76svh, calc(var(--hth-pin-stage, 1) * 100svh));
                    /* Clear the transparent header lying over the top of a
                       hero-first page. When the copy outgrows the frame on a
                       narrow phone the stage grows UP to the gutter, and the
                       eyebrow lands under the logo. --hth-pin-head is the
                       measured overlap (hth-ds.js), 0 when there is none. */
                    padding-top: calc(var(--hth-gut) + var(--hth-pin-head, 0px)); }
}


/* =====================================================================
   12c. SHORT SCREENS — LAPTOPS, SCALED DISPLAYS, SHORT WINDOWS
   ---------------------------------------------------------------------
   THE BUG. A sub-100% hero had a FIXED height (68svh) with
   overflow:hidden, and the only rule that let it grow was the 780px
   WIDTH breakpoint. A 13" laptop, a 1920x1080 screen at 125-150%
   Windows scaling, or any window dragged short is still wide, so it
   kept the fixed box while the copy inside it did not shrink at all —
   display type is sized off the viewport WIDTH (4.2vw caps at 60px from
   1430px wide, whatever the height).

   When the copy outgrew the box the grid row started at the top padding
   and spilled out of the bottom, so BOTH symptoms came from one cause:
     - top:    the eyebrow sat at gutter height, under the transparent
               header and the logo (nothing reserved the header on desktop)
     - bottom: the buttons ran past the stage, clipped by overflow:hidden
               and painted over by the .hth-lift panel (z-index 2)
   Measured on /calorimetry/ before this fix: eyebrow at 128-138px
   against a logo ending at 142px; buttons ending 24-73px below the
   stage at 1280x600 and 1366x650, and under the panel at every one of
   1280x600, 1366x650, 1440x790 and 1536x730.

   THE FIX, in three parts.
   --------------------------------------------------------------------- */

/* 1. A SHORT HERO GROWS INSTEAD OF CLIPPING.
   The 68% / 80% / 55% heights become a MINIMUM. A sub-100% hero never
   pins (pinned-hero.php forces travel to 1), so there is no sticky
   geometry to protect — the stage can be as tall as its words need.
   On a normal screen nothing changes: the copy is shorter than the
   frame, it sits bottom-left exactly as before.

   .hth-pin--short is added by pinned-hero.php for any stage below 1.
   (Not an attribute match on the inline --hth-pin-stage: hth-ds.js
   writes other properties into the same style attribute, and the
   browser re-serialises it as "--hth-pin-stage: .68" with a space.)

   Not sticky also switches on the header clearance: hth-ds.js measures
   --hth-pin-head (how far the transparent header lies over the hero)
   for any stage that is not position:sticky, and the padding-top below
   reserves it, so a hero that grows can never grow up under the logo.

   align-content:end keeps the copy on the bottom edge of a frame that
   is taller than it; when the copy is the taller one the frame grows to
   fit and nothing is aligned off either end. */
@media (min-width: 781px) {
  .hth-pin--short { height: auto; }
  .hth-pin--short .hth-pin__stage {
    position: relative;
    height: auto;
    min-height: calc(var(--hth-pin-stage, 1) * 100vh);
    min-height: calc(var(--hth-pin-stage, 1) * 100svh);
    align-content: end;
    padding-top: max(var(--hth-gut), calc(var(--hth-pin-head, 0px) + var(--hth-5)));
  }
}

/* 2. DISPLAY TYPE FOLLOWS THE SCREEN'S HEIGHT TOO.
   min(vw, svh) inside the existing clamps: on a normal or tall screen
   the width term is the smaller one and nothing changes; on a short,
   wide one the height term takes over and the headline steps down.
   At 1440x790 a 68% hero's H1 goes 60 -> 55px, at 1280x600 54 -> 42px.
   The full-screen .hth-xl uses a looser 10.5svh so the home hero is
   untouched on a 13" laptop (84 -> 83px at 790 tall) and only steps
   down where it actually collided with the header (82 -> 68px at
   1366x650, where the eyebrow sat under the logo).
   Same floors as the --hth-t-* tokens, so phones are unchanged.
   Scoped to the hero copy so section titles elsewhere are untouched.

   Line-height: Jevelin's style.css sets h1-h6 { line-height: normal
   !important }, which beats .hth-lg's 1.05 and quietly adds ~18% to
   every display heading — a three-line hero H1 was 222px instead of
   189px. Restored here for the hero only. (The same override is live
   on every .hth-display heading on the site; see the note in
   SHORT-HERO-FIX.md before widening this.) */
.hth-pin__copy .hth-xl { font-size: clamp(44px, min(6.0vw, 10.5vh), 84px); }
.hth-pin__copy .hth-lg { font-size: clamp(34px, min(4.2vw, 7vh), 60px); }
.hth-pin__copy .hth-md { font-size: clamp(26px, min(2.6vw, 5vh), 40px); }
@supports (height: 100svh) {
  .hth-pin__copy .hth-xl { font-size: clamp(44px, min(6.0vw, 10.5svh), 84px); }
  .hth-pin__copy .hth-lg { font-size: clamp(34px, min(4.2vw, 7svh), 60px); }
  .hth-pin__copy .hth-md { font-size: clamp(26px, min(2.6vw, 5svh), 40px); }
}
.hth-pin__copy .hth-display.hth-xl { line-height: 1.0  !important; }
.hth-pin__copy .hth-display.hth-lg { line-height: 1.05 !important; }
.hth-pin__copy .hth-display.hth-md { line-height: 1.12 !important; }

/* 3. TIGHTER RHYTHM WHEN THE SCREEN IS SHORT.
   The 24px above the lead and 48px above the buttons are inline styles
   in pinned-hero.php, hence !important. Only below 820px of height —
   a 13" MacBook in Chrome is ~790, 1920x1080 at 125% scaling ~730, at
   150% ~600 — so desktop monitors keep the full spacing. */
@media (min-width: 781px) and (max-height: 820px) {
  .hth-pin__copy > .hth-lead    { margin-top: var(--hth-4) !important; }
  .hth-pin__copy > div          { margin-top: var(--hth-6) !important; }
  .hth-pin__copy > .hth-caption { margin-top: var(--hth-3) !important; }
}


/* =====================================================================
   13. MOTION PRIMITIVE C — SLOW IMAGE SETTLE
   ---------------------------------------------------------------------
   Image starts 6% oversized and settles to 1.0 over 1.4s as it enters.
   Use on hero and statement images only — two or three per page. Used
   everywhere it becomes noise.
   ===================================================================== */
/* THE LOADING FILL IS ON THE IMAGE, NOT THE FRAME.
   It used to be `background: var(--hth-sand-deep)` on .hth-figure itself.
   That painted a light hairline round every photograph: the frame is
   rarely a whole number of pixels (558.19 × 313.97 on /home-new/), so its
   rounded, clipped edge is anti-aliased TWICE, once for the frame's own
   fill and once for the image over it, and the edge pixels come out part
   sand. Invisible on sand, a clear light ring on ink. With the fill on the
   <img> there is one layer at the edge and it blends straight into the
   section. The placeholder while loading looks exactly the same as before.
   On ink it uses --hth-surface so a slow image is a dark tile, not a pale
   one. Cut-outs drop it (§13f): they are transparent by design. */
.hth-figure { overflow: hidden; border-radius: var(--hth-r-lg); background: none; }
.hth-figure > img,
.hth-figure .hth-scar__pane > img { background: var(--hth-sand-deep); }
:where(.hth--dark) .hth-figure > img,
:where(.hth--dark) .hth-figure .hth-scar__pane > img { background: var(--hth-surface); }
.hth-figure--cutout img { background: none; }
.hth-figure > img { display: block; width: 100%; height: 100%; object-fit: cover; }

.hth-reveal-ready .hth-settle > img {
  transform: scale(1.06);
  transition: transform var(--hth-slow) var(--hth-expo);
}
.hth-reveal-ready .hth-settle.is-in > img { transform: scale(1); }

/* ---------------------------------------------------------------------
   13f. CUT-OUT FIGURE, RING AND BADGE  —  HTH Split Feature
   ---------------------------------------------------------------------
   Everything above is right for a PHOTOGRAPH: a rounded frame, a sand
   fill so the box is never empty while the image loads, and a clip so
   the settle's 6% oversize never shows. It is wrong for an image that is
   already transparent outside its subject (the circle of book covers on
   /home-new/): the fill and corners draw a taupe rounded square behind
   the circle. --cutout takes all three away and shows the image whole.

     <div class="hth-figure hth-settle hth-ar-11
                 hth-figure--cutout hth-figure--ring hth-figure--badged">
       <img src="..." alt="...">
       <div class="hth-figure__badge">
         <span class="hth-figure__badge-k">Featured in</span>
         <span class="hth-figure__badge-v">40+</span>
         <span class="hth-figure__badge-k">Books</span>
       </div>
     </div>

   All of it is opt-in from split-feature.php, so no existing figure
   changes. position: relative here is a FLOOR, not a decision: the
   sticky rule (§ .hth-figure--sticky, 0,2,0) outranks it and a sticky
   element is a containing block too, so the badge still pins to it.
   --------------------------------------------------------------------- */
.hth-figure--cutout {
  position: relative;
  background: none;
  border-radius: 0;
  overflow: visible;
}
.hth-figure--cutout > img { object-fit: contain; }

/* SETTLE, REVERSED. With no clip, starting at 1.06 would draw the covers
   6% past their own circle and through the ring, then shrink back. So a
   cut-out grows in from .96 instead — same duration, same easing, and it
   ends on exactly the same frame. (0,3,1) and (0,4,1) beat the photo
   rules above whatever order they load in. Reduced motion's
   `transform: none !important` in §17 still wins over both. */
.hth-reveal-ready .hth-settle.hth-figure--cutout > img       { transform: scale(.96); }
.hth-reveal-ready .hth-settle.hth-figure--cutout.is-in > img { transform: scale(1); }

/* Zoom has nothing to clip it either. The PHP drops the class; this is
   the same belt and braces as .hth-figure--sticky.hth-zoom below. */
.hth-figure--cutout.hth-zoom > img { animation: none; }

/* RING. A hairline circle the size of the figure, with the image inset
   3.4% inside it (18px of 526 in the mockup) so the ring sits clear of
   the covers rather than on them. Meant for Image shape: Square — on any
   other ratio the "circle" is an ellipse, which is the editor's choice to
   make. Uses --hth-rule, not --hth-line, so it flips on .hth--dark. */
.hth-figure--ring::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 1px solid var(--hth-rule);
  pointer-events: none;
}
.hth-figure--ring > img {
  position: absolute;
  top: 3.4%; left: 3.4%;
  width: 93.2%; height: 93.2%;   /* a replaced element ignores right/bottom with width:auto */
}

/* BADGE. Live text, so it is set in Fraunces and DM Sans and reads aloud,
   instead of being baked into the raster in some third face. Its 8px
   border is the SECTION's own ground (--hth-bg), so the gap between badge
   and covers matches whichever theme the section is set to.

   Type scales with the badge, not the viewport: the badge is its own
   inline-size container and the figures are in cqi (of the box INSIDE
   the border). 54px in a 164px badge and 38px in a 116px one both come
   to 36.5% — the mockup's desktop and phone sizes from one rule. The px line before each cqi one is the
   fallback for a browser without container units. */
.hth-figure--badged { position: relative; }

.hth-figure__badge {
  position: absolute;
  right: 0;
  bottom: 4%;
  width: clamp(116px, 31%, 164px);
  aspect-ratio: 1;
  box-sizing: border-box;
  border-radius: 50%;
  border: 8px solid var(--hth-bg);
  background: var(--hth-ink);
  color: var(--hth-white);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  font-family: var(--hth-f-body);
  container-type: inline-size;
  z-index: 1;
}
.hth-figure__badge-v {
  font-family: var(--hth-f-display);
  font-weight: 300;
  font-size: 38px;
  font-size: 36.5cqi;
  line-height: 1;
  letter-spacing: var(--hth-tr-xl);
  margin: 2px 0 4px;
}
.hth-figure__badge-k {
  font-size: 8px;
  font-size: clamp(8px, 6.8cqi, 10px);   /* under --hth-t-xs on purpose: it has to fit a circle */
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: var(--hth-tr-lab);
  text-transform: uppercase;
  opacity: .8;
}

/* On a dark section an ink badge on an ink ground disappears, ring and
   all. Invert it rather than give the block a colour control. */
.hth--dark .hth-figure__badge { background: var(--hth-white); color: var(--hth-ink); }

/* Phone: the mockup's 6px separator and slight overhang. */
@media (max-width: 460px) {
  .hth-figure__badge { border-width: 6px; right: -6px; bottom: 2%; }
}

/* ---------------------------------------------------------------------
   13g. SPLIT CAROUSEL  —  HTH Split Carousel (inc/blocks/split-carousel.php)
   ---------------------------------------------------------------------
   A Split Feature whose copy and image are a set. Everything below is
   additive: the block reuses .hth-grid, .hth-figure (+ --cutout, --ring,
   __badge), .hth-eyebrow, .hth-display, .hth-rich, .hth-stat and
   .hth-btn exactly as Split Feature renders them.

     <div class="hth-grid hth-grid--7-5 hth-scar hth-scar--img-right"
          data-hth-scar data-hth-autoplay="0" role="region"
          aria-roledescription="carousel" aria-label="...">
       <div class="hth-scar__copy">
         <div class="hth-scar__slide is-active" ...>
           <span class="hth-eyebrow hth-scar__line" style="--l:0">...</span>
           <h2 class="hth-display hth-lg hth-scar__line" style="--l:1">...</h2>
           ...
         </div>
         ... one per slide ...
       </div>
       <div class="hth-scar__media">
         <div class="hth-figure hth-settle hth-ar-169 hth-scar__figure">
           <div class="hth-scar__pane is-active"><img ...></div> ...
         </div>
       </div>
       <div class="hth-scar__nav"> bars · 01/04 · [pause] ‹ › </div>
     </div>

   THREE STATES, IN THE ORDER THEY HAPPEN
     no .is-ready   JS has not run (or never will). Slide one and pane one
                    only, controls hidden: it is a Split Feature.
     .is-waiting    JS is up, section not yet in view. Slide one's lines
                    are held down, the way .hth-reveal holds copy.
     .is-ready      Every slide's copy shares ONE grid cell, so the column
                    is as tall as the longest slide and the controls never
                    move. Panes are stacked in the figure.

   THE FOCUS PULL. The incoming pane starts soft (blur 14px), 3% off to
   the side it is coming from and 4% large, and sharpens, settles and
   fades up over ~1.2s on --hth-expo. The outgoing pane stays OPAQUE
   underneath while it softens and drifts the other way, and only fades
   once the new one has covered it — so the frame never dips to its sand
   fill mid-change. Copy lines leave fast (0.3s, no stagger) and arrive
   slow, lifting 16px through a 5px blur with an 80ms stagger: the same
   vocabulary as §11's reveal, so a slide change reads like the page.

   WHY THE IDLE STATE HAS `transition: none`. The direction a pane enters
   from (--d, set per pane by the script) has to be in place BEFORE the
   pane turns active, or it would glide across from wherever it last
   was. With no transition on idle, the script sets --d, forces a style
   flush, then adds .is-active — and the active rule's transition is the
   one the browser uses, because a transition always takes the
   destination state's transition list.

   Individual `translate` / `scale` properties, not `transform`, so the
   pane's motion, the drag offset and §13's settle on the <img> inside
   can never overwrite each other.
   --------------------------------------------------------------------- */
.hth-scar {
  grid-template-rows: auto 1fr;
  row-gap: var(--hth-6);
}
.hth-scar--img-right { grid-template-areas: "copy media" "nav media"; }
.hth-scar--img-left  { grid-template-areas: "media copy" "media nav"; }
.hth-scar__copy  { grid-area: copy;  min-width: 0; }
/* Centred against copy + controls, not top-aligned as Split Feature is:
   the controls pin to the bottom of the column, so a top-aligned image
   leaves the whole composition leaning upward. */
.hth-scar__media { grid-area: media; min-width: 0; align-self: center; }
.hth-scar__nav   { grid-area: nav; }
.hth-scar:not(.is-ready) { row-gap: 0; }

/* Spacing Split Feature sets inline, set here instead. */
.hth-scar__body { margin-top: var(--hth-5); }
.hth-scar__cta  { margin-top: var(--hth-6); }

/* ---- before / without JS: slide one only ---- */
.hth-scar:not(.is-ready) .hth-scar__slide:not(.is-active),
.hth-scar:not(.is-ready) .hth-scar__pane:not(.is-active),
.hth-scar:not(.is-ready) .hth-scar__nav { display: none; }

/* ---- the figure and its panes ---- */
.hth-scar__figure { position: relative; isolation: isolate; }
.hth-scar__pane   { position: absolute; inset: 0; }
.hth-scar__pane > img {
  display: block; width: 100%; height: 100%;
  object-fit: cover;
  -webkit-user-drag: none;
}
.hth-figure--cutout .hth-scar__pane > img { object-fit: contain; }
.hth-figure--ring   .hth-scar__pane > img {
  position: absolute; top: 3.4%; left: 3.4%; width: 93.2%; height: 93.2%;
}

/* §13's settle, re-aimed one level down (the img is inside a pane, not a
   direct child of the figure). Photograph shrinks from 1.06; a cut-out
   grows from .96, for §13f's reason. */
.hth-reveal-ready .hth-scar__figure.hth-settle .hth-scar__pane > img {
  scale: 1.06;
  transition: scale var(--hth-slow) var(--hth-expo);
}
.hth-reveal-ready .hth-scar__figure.hth-settle.hth-figure--cutout .hth-scar__pane > img { scale: .96; }
.hth-reveal-ready .hth-scar__figure.hth-settle.is-in .hth-scar__pane > img,
.hth-reveal-ready .hth-scar__figure.hth-settle.hth-figure--cutout.is-in .hth-scar__pane > img { scale: 1; }

/* ---- pane states ---- */
.hth-scar.is-ready .hth-scar__pane {
  opacity: 0;
  visibility: hidden;
  filter: blur(14px);
  translate: calc(var(--d, 1) * 3%) 0;
  scale: 1.04;
  z-index: 0;
  transition: none;
}
.hth-scar.is-ready .hth-scar__pane.is-active {
  opacity: 1;
  visibility: visible;
  filter: none;
  translate: var(--hth-scar-drag, 0px) 0;
  scale: 1;
  z-index: 2;
  transition: opacity .8s cubic-bezier(.33, 0, .2, 1),
              filter 1.1s var(--hth-expo),
              translate 1.3s var(--hth-expo),
              scale 1.6s var(--hth-expo);
}
.hth-scar.is-ready .hth-scar__pane.is-leaving {
  opacity: 0;
  visibility: visible;
  filter: blur(8px);
  translate: calc(var(--d, 1) * -1.5%) 0;
  scale: 1.015;
  z-index: 1;
  transition: opacity .4s linear .5s,          /* held until covered */
              filter .9s cubic-bezier(.4, 0, .2, 1),
              translate 1.1s var(--hth-expo),
              scale 1.1s var(--hth-expo);
}

/* Cross-fade option: opacity only, same hand-over. */
.hth-scar--fade.is-ready .hth-scar__pane,
.hth-scar--fade.is-ready .hth-scar__pane.is-active,
.hth-scar--fade.is-ready .hth-scar__pane.is-leaving { filter: none; scale: none; }
.hth-scar--fade.is-ready .hth-scar__pane            { translate: none; }
.hth-scar--fade.is-ready .hth-scar__pane.is-active  { transition: opacity .7s cubic-bezier(.33, 0, .2, 1), translate 1.3s var(--hth-expo); }
.hth-scar--fade.is-ready .hth-scar__pane.is-leaving { translate: none; transition: opacity .01s linear .7s; }

/* ---- copy states ---- */
.hth-scar.is-ready .hth-scar__copy  { display: grid; }
.hth-scar.is-ready .hth-scar__slide { grid-area: 1 / 1; min-width: 0; visibility: hidden; pointer-events: none; }
.hth-scar.is-ready .hth-scar__slide.is-active  { visibility: visible; pointer-events: auto; }
.hth-scar.is-ready .hth-scar__slide.is-leaving { visibility: visible; }

.hth-scar.is-ready .hth-scar__line {
  opacity: 0;
  translate: 0 16px;
  filter: blur(5px);
  transition: none;
}
.hth-scar.is-ready .is-active > .hth-scar__line {
  opacity: 1;
  translate: none;
  filter: none;
  transition: opacity .9s var(--hth-expo),
              translate 1.1s var(--hth-expo),
              filter .9s var(--hth-expo);
  transition-delay: calc(.22s + var(--l, 0) * 80ms);
}
.hth-scar.is-ready .is-leaving > .hth-scar__line {
  opacity: 0;
  translate: 0 -8px;
  filter: blur(3px);
  transition: opacity .3s cubic-bezier(.4, 0, 1, 1),
              translate .4s cubic-bezier(.4, 0, 1, 1),
              filter .3s linear;
  transition-delay: 0s;
}
/* First entry: held down until the section scrolls in. Later in the file
   than the active rule at the same specificity, so it wins while it is on. */
.hth-scar.is-waiting .is-active > .hth-scar__line {
  opacity: 0; translate: 0 16px; filter: blur(5px); transition: none;
}

/* ---- swipe ---- */
.hth-scar.is-ready .hth-scar__copy,
.hth-scar.is-ready .hth-scar__media { touch-action: pan-y pinch-zoom; }
.hth-scar.is-ready .hth-scar__media { user-select: none; -webkit-user-select: none; }
@media (hover: hover) and (pointer: fine) {
  .hth-scar.is-ready .hth-scar__media { cursor: grab; }
  .hth-scar.is-dragging .hth-scar__media { cursor: grabbing; }
}
.hth-scar.is-dragging .hth-scar__pane.is-active { transition: none; }

/* ---- controls ---- */
.hth-scar__nav {
  align-self: end;
  display: flex;
  align-items: center;
  gap: var(--hth-4);
  width: 100%;
  max-width: var(--hth-measure);   /* arrows end where the paragraph does */
}
.hth-scar__bars { display: flex; gap: var(--hth-2); }

/* Each line is a real button with a 24px hit area; the 2px line is drawn
   inside it. The track is --hth-rule, so it flips on .hth--dark. */
.hth-scar__bar {
  -webkit-appearance: none; appearance: none;
  position: relative;
  width: 32px; margin: 0; padding: 11px 0;
  background: none; border: 0; border-radius: 0;
  color: inherit; cursor: pointer;
}
.hth-scar__bar::before {
  content: ""; display: block; height: 2px; border-radius: 1px;
  background: var(--hth-rule);
  transition: background var(--hth-fast) var(--hth-expo);
}
.hth-scar__bar:hover::before { background: var(--hth-label); }
.hth-scar__fill {
  position: absolute; left: 0; right: 0; top: 11px;
  height: 2px; border-radius: 1px;
  background: var(--hth-head);
  transform: scaleX(0);
  transform-origin: 0 50%;
  pointer-events: none;
}
.hth-scar__bar.is-active .hth-scar__fill {
  transform: scaleX(1);
  transition: transform .9s var(--hth-expo);
}
/* Autoplay: the active line IS the timer. The script advances on this
   animation's end, so pausing it (hover, focus, off screen, tab hidden)
   pauses the carousel at exactly the point the visitor can see. */
.hth-scar.is-playing .hth-scar__bar.is-active .hth-scar__fill {
  transition: none;
  animation: hth-scar-fill var(--hth-scar-dur, 8s) linear forwards;
}
.hth-scar.is-playing.is-held .hth-scar__bar.is-active .hth-scar__fill { animation-play-state: paused; }
@keyframes hth-scar-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }

.hth-scar__count {
  display: flex; gap: 6px; margin: 0;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs); font-weight: 500; line-height: 1;
  letter-spacing: var(--hth-tr-lab);
  color: var(--hth-label);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.hth-scar__count [data-hth-scar-cur] { color: var(--hth-head); }

.hth-scar__btns { display: flex; gap: var(--hth-2); margin-left: auto; }
.hth-scar__btn {
  -webkit-appearance: none; appearance: none;
  width: 40px; height: 40px; padding: 0; margin: 0;
  display: grid; place-items: center;
  border-radius: 50%;
  border: 1px solid var(--hth-rule);
  background: transparent;
  color: var(--hth-head);
  cursor: pointer;
  transition: background var(--hth-fast) var(--hth-expo),
              border-color var(--hth-fast) var(--hth-expo);
}
.hth-scar__btn svg { display: block; transition: translate .5s var(--hth-expo); }
.hth-scar__btn:hover { background: var(--hth-surface); border-color: var(--hth-surface); }
.hth-scar__btn[data-hth-scar-prev]:hover svg { translate: -2px 0; }
.hth-scar__btn[data-hth-scar-next]:hover svg { translate:  2px 0; }
.hth-scar__btn:focus-visible,
.hth-scar__bar:focus-visible { outline: 2px solid var(--hth-azure); outline-offset: 2px; }
.hth--dark .hth-scar__btn:focus-visible,
.hth--dark .hth-scar__bar:focus-visible { outline-color: var(--hth-azure-lift); }

/* Pause / play: quieter than the arrows — no ring until hovered. */
.hth-scar__btn--toggle { border-color: transparent; color: var(--hth-label); }
.hth-scar__btn--toggle:hover { color: var(--hth-head); }
.hth-scar__i-play { display: none !important; }
.hth-scar:not(.is-playing) .hth-scar__i-pause { display: none !important; }
.hth-scar:not(.is-playing) .hth-scar__i-play  { display: block !important; }

/* ---- stacked (phone / tablet) ----
   Image, controls, copy — in BOTH orientations, unlike Split Feature,
   whose flip puts the copy first on a phone. Two reasons:
     1. Under a photograph the controls read as "more photographs", the
        gesture a phone already knows.
     2. The copy cell is as tall as the longest slide. Copy first would
        open that difference as a gap ABOVE the image on every shorter
        slide; copy last lets it fall into the section's bottom padding. */
@media (max-width: 900px) {
  .hth-scar { grid-template-rows: none; row-gap: var(--hth-6); }
  .hth-scar--img-right,
  .hth-scar--img-left  { grid-template-areas: "media" "nav" "copy"; }
  .hth-scar__media { align-self: start; }
  .hth-scar.is-ready .hth-scar__nav { margin-top: calc(var(--hth-4) - var(--hth-6)); max-width: none; }
}
@media (max-width: 460px) {
  .hth-scar__bar { width: 24px; }
  .hth-scar__btns { gap: var(--hth-1); }
}

/* ---- reduced motion: cross-fade only, nothing moves or blurs ---- */
@media (prefers-reduced-motion: reduce) {
  .hth-scar.is-ready .hth-scar__pane,
  .hth-scar.is-ready .hth-scar__line { filter: none !important; translate: none !important; scale: none !important; }
  .hth-scar.is-ready .hth-scar__pane.is-active,
  .hth-scar.is-ready .is-active > .hth-scar__line { transition: opacity .3s linear !important; }
  .hth-scar.is-ready .hth-scar__pane.is-leaving { transition: opacity .01s linear .3s !important; }
  .hth-scar.is-ready .is-leaving > .hth-scar__line { transition: opacity .2s linear !important; }
  .hth-scar__pane > img { scale: none !important; transition: none !important; }
  .hth-scar__btn svg { transition: none; }
}
@media print {
  .hth-scar__nav { display: none !important; }
}

/* =====================================================================
   13a. SCROLL-DRIVEN ZOOM — motion tied to scroll position, no JS
   ---------------------------------------------------------------------
   This is the "scroll-controlled zoom" effect. Native CSS scroll-driven
   animation: the browser maps the element's progress through the
   viewport onto the animation timeline. It runs off the main thread,
   costs no JavaScript, and cannot jank.

     <div class="hth-figure hth-zoom hth-ar-169">
       <img src="..." alt="">
     </div>

   HOW IT DIFFERS FROM .hth-settle
   .hth-settle fires ONCE on entry and finishes (IntersectionObserver).
   .hth-zoom is continuous and reversible — scroll up and it unwinds,
   because the scroll position IS the playhead.
   Do not put both on the same element; they fight over `scale`.

   BROWSER SUPPORT (August 2026)
   Chrome/Edge 115+, Safari 26+ (threaded since 26.4), Opera 101+.
   Firefox still has it behind layout.css.scroll-driven-animations
   in stable as of 152, though it is an Interop 2026 priority.
   Roughly 84% of users globally.
   The @supports wrapper means the other ~16% get a clean static image —
   correct degradation for decorative motion. Nothing is lost, nothing
   is broken, no polyfill, no JavaScript fallback to maintain.

   RESTRAINT
   Two or three per page. The effect works because it is scarce; on
   every image it becomes an aquarium.
   ===================================================================== */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {

    @keyframes hth-zoom-scale { from { scale: 1; } to { scale: 1.10; } }

    .hth-zoom > img,
    .hth-zoom > .ph {
      animation: hth-zoom-scale linear both;
      animation-timeline: view();
      /* `cover` = the entire time the element intersects the scrollport,
         from first pixel entering to last pixel leaving. The zoom is
         therefore spread across the whole pass rather than rushed at
         the edges. */
      animation-range: cover 0% cover 100%;
      transform-origin: center;
    }

    /* NOTE: .hth-pin--drift used to live here, as a scroll-driven animation
       over the `cover` range. It has moved to §12b and is now driven by
       --hth-pin-p instead. Two reasons it was invisible here:

       1. `cover` spans the element's entire pass through the viewport,
          which for a 1.35x-tall pin is far longer than the pinned window.
          A +/-2.5% translate spread over that distance is imperceptible
          during the hold — which is exactly the part you are looking at.
       2. It only ran where scroll-driven animations run, so Firefox got
          nothing at all, making "drift" and "static" identical there.

       That is why drift and static looked the same. */
  }
}

/* Ratio helpers so images never reflow the page while loading.

   Each one also publishes its shape as --hth-ar. `aspect-ratio` can only
   ever CAP a box's height; anything that needs the shape as a floor has to
   hand it to a spacer instead, and needs the ratio as a value to do that.
   See the copy-bearing image band in §13c. Declared on the same line as the
   aspect-ratio itself so the two cannot drift apart. */
.hth-ar-219 { aspect-ratio: 21 / 9;  --hth-ar: 21 / 9;  }   /* widest band — pure atmosphere */
.hth-ar-54  { aspect-ratio: 5 / 4;   --hth-ar: 5 / 4;   }   /* Symmetry's gallery proportion */
.hth-ar-2   { aspect-ratio: 2 / 1;   --hth-ar: 2 / 1;   }
.hth-ar-32  { aspect-ratio: 3 / 2;   --hth-ar: 3 / 2;   }
.hth-ar-43  { aspect-ratio: 4 / 3;   --hth-ar: 4 / 3;   }
.hth-ar-11  { aspect-ratio: 1 / 1;   --hth-ar: 1 / 1;   }
.hth-ar-45  { aspect-ratio: 4 / 5;   --hth-ar: 4 / 5;   }
.hth-ar-169 { aspect-ratio: 16 / 9;  --hth-ar: 16 / 9;  }
.hth-ar-1610{ aspect-ratio: 16 / 10; --hth-ar: 16 / 10; }   /* the TRS 10.1" panel, exactly */


/* =====================================================================
   13d. MEDIA SPLIT — the click-to-play film
   ---------------------------------------------------------------------
   A poster in an .hth-figure-shaped frame with a play control over it,
   and an iframe that does not exist until someone asks for one.

   WHAT THIS SECTION IS ACTUALLY STYLING

   Not a video player. A photograph with a button on it. Everything here
   is about making that button legible over an unknown image without
   putting a black box on the page:

     * a scrim, weak and even. Strong enough that a white mark survives a
       bright poster, weak enough that the poster is still the subject.
       It brightens on hover rather than darkening — the frame opens up
       when you approach it instead of closing down.
     * a ring, not a filled disc. A filled red-and-white lozenge is
       YouTube's, and the whole point of the facade is that this is our
       page until the visitor decides otherwise.
     * the triangle is optically centred, not geometrically: a right-
       pointing triangle in a circle looks left-of-centre when its
       bounding box is centred, which is why the mark carries a 3px
       nudge. Two pixels of correction nobody consciously notices and
       everybody feels.

   The button is a real <button> and gets a real focus ring. It is 72px
   at its smallest, well past the 44px target minimum, because it is the
   one control in the section.
   ===================================================================== */
.hth-media {
  position: relative;
  overflow: hidden;
  border-radius: var(--hth-r-lg);
  background: var(--hth-sand-deep);
  isolation: isolate;
}
.hth-media > .hth-media__poster,
.hth-media > iframe,
.hth-media > video {
  display: block;
  width: 100%; height: 100%;
  object-fit: cover;
  border: 0;
}

/* THE SCRIM IS THE BUTTON'S OWN BACKGROUND, not a separate layer over
   the poster. That is deliberate: when the embed swaps in, the button is
   removed and the scrim goes with it. A scrim held in its own element is
   the classic facade bug — a grey veil left sitting over a playing film,
   which nobody notices until a customer mentions the video looks dim. */
.hth-media__play {
  position: absolute; inset: 0;
  /* FLEX, NOT GRID. `display:grid; place-items:center` centres each item
     inside its own implicit ROW, and two auto rows split the frame in
     half — so the mark sits in the middle of the top half and the
     duration in the middle of the bottom, which looks like a mistake and
     is one. place-content would also fix it; flex says what is meant. */
  display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: var(--hth-3);
  width: 100%; height: 100%;
  padding: 0; margin: 0;
  border: 0; cursor: pointer;
  background: linear-gradient(to bottom, rgba(20,28,36,.18), rgba(20,28,36,.34));
  color: #fff;
  transition: background var(--hth-med) var(--hth-expo);
}
.hth-media__play:hover {
  background: linear-gradient(to bottom, rgba(20,28,36,.10), rgba(20,28,36,.26));
}
.hth-media__play:focus-visible {
  outline: 2px solid #fff;
  outline-offset: -6px;
}

.hth-media__play-mark {
  display: grid; place-items: center;
  width: clamp(72px, 7vw, 96px);
  aspect-ratio: 1;
  border-radius: 50%;
  border: 1.5px solid rgba(255,255,255,.85);
  background: rgba(20,28,36,.28);
  backdrop-filter: blur(2px);
  transition: transform var(--hth-med) var(--hth-expo),
              background var(--hth-med) var(--hth-expo);
}
/* Optical centring — see the note above. */
.hth-media__play-mark > svg { margin-left: 3px; }

.hth-media__play:hover .hth-media__play-mark {
  transform: scale(1.06);
  background: rgba(20,28,36,.42);
}

.hth-media__dur {
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs); font-weight: 500;
  letter-spacing: var(--hth-tr-lab);
  color: rgba(255,255,255,.92);
  font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 2px rgba(20,28,36,.5);
}

/* The caption is evidence, not a title. Same size and colour as fine
   print, hard left under the frame, never centred — a centred caption
   under a left-aligned column reads as a slide. */
.hth-media__caption {
  margin: var(--hth-4) 0 0;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs);
  line-height: 1.55;
  color: var(--hth-label);
  max-width: 60ch;
}

/* On a dark field the caption's --hth-label is already light; nothing to
   override. Stated so nobody adds a rule here later "to be safe" and
   breaks the sand variant. */

@media (max-width: 780px) {
  .hth-media__play-mark { width: 64px; }
}

/* Reduced motion: the button still works and still shows its state
   through the scrim, it simply does not grow. */
@media (prefers-reduced-motion: reduce) {
  .hth-media__play,
  .hth-media__play-mark { transition: none; }
  .hth-media__play:hover .hth-media__play-mark { transform: none; }
}


/* =====================================================================
   13e. FIGURE — a drawing, not a photograph
   ---------------------------------------------------------------------
   Everything else in section 13 crops. This does not, and that is the
   entire reason it exists.

   A photograph has a subject you can crop toward. A chart has axes: cut
   15% off one edge and you have removed the thing that made it evidence.
   So the frame here LETTERBOXES — the drawing keeps its own proportion
   and any spare room in the frame stays empty. `object-fit: contain` for
   a raster fallback, and the SVG's own preserveAspectRatio for the real
   case.

   THE COLOUR STORY, which is the point of inlining the SVG at all:

     currentColor  -> .hth-section sets `color: var(--hth-body)`, so an
                      ink stroke drawn in currentColor is the section's
                      body colour and follows sand -> shell -> ink with
                      no second export.
     --hth-accent  -> the highlighted series. Already redefined per
                      theme (azure on light, azure-lift on ink), so the
                      one series that must stay legible on a dark field
                      does so automatically.

   Nothing below overrides anything for .hth--dark, deliberately. Every
   value here is a token that has already been shifted by the theme.
   Adding a dark override would be adding a second source of truth.
   ===================================================================== */
.hth-fig { margin: 0; display: block; }

/* Label type, above the frame. What is plotted — not a headline. */
.hth-fig__title {
  margin: 0 0 var(--hth-4);
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs); font-weight: 500;
  letter-spacing: var(--hth-tr-lab); text-transform: uppercase;
  color: var(--hth-label);
}

/* PADDING LIVES ON THE FRAME, and with border-box that means a shape
   class sizes the OUTER box. The inner plot area is therefore slightly
   wider than the nominal ratio. That is fine and it is why this note is
   here rather than a fix: the drawing itself never distorts — it is
   centred and letterboxed by preserveAspectRatio — so the only effect is
   a few pixels of extra breathing room, which is what padding is for. */
.hth-fig__frame {
  display: block;
  border-radius: var(--hth-r-lg);
  overflow: hidden;
}
.hth-fig--panel .hth-fig__frame {
  background: var(--hth-surface);
  padding: clamp(16px, 2.2vw, 32px);
}
.hth-fig--plain .hth-fig__frame { padding: clamp(16px, 2.2vw, 32px); }

/* Bare has no panel and no radius, so it has nothing to clip — and
   clipping it would be actively wrong. A chart's end-labels are usually
   drawn just outside the plot area, and with no padding to spill into
   `overflow: hidden` shaves them off at the frame edge. */
.hth-fig--bare .hth-fig__frame { padding: 0; border-radius: 0; overflow: visible; }

/* ---------------------------------------------------------------------
   THE MINIMUM LEGIBLE WIDTH — the one thing about SVG that bites here
   ---------------------------------------------------------------------
   SVG TYPE SCALES WITH THE BOX. A chart drawn with 11px axis labels in a
   640px viewBox renders those labels at 6px on a 390px phone. There is
   no CSS that holds them still: font-size inside an SVG is in the user
   coordinate system, so it scales with everything else.

   Measured on this block before the fix: the same figure rendered its
   axis type at ~6px on a 390px viewport and ~23px at 1325px wide. One
   drawing, a 4x range of type sizes, neither end matching the page.

   Both ends are handled here, and only here:

     LOWER  below --hth-fig-min the figure keeps its size and the region
            scrolls sideways. A chart you have to nudge is worth more
            than a chart you cannot read.
     UPPER  .hth-fig--wide is capped and centred rather than filling the
            container. 1040px is about 1.4x the beside-copy width, which
            keeps drawn type within a step of where it was authored.

   The scroll region carries tabindex and a label from the block, but
   only when a minimum is actually set — a non-scrolling region with a
   tab stop is a dead stop in the keyboard order.
   --------------------------------------------------------------------- */
.hth-fig__scroll { display: block; position: relative; }

.hth-fig__track { display: block; }
.hth-fig__track.is-scrollable {
  overflow-x: auto;
  overscroll-behavior-x: contain;      /* don't trigger browser back-swipe */
  scrollbar-width: thin;
  /* The panel's own radius would be clipped square by the scroll box, so
     the box carries it too. Harmless when nothing overflows. */
  border-radius: var(--hth-r-lg);
}
.hth-fig__track.is-scrollable > .hth-fig__frame { min-width: var(--hth-fig-min, 560px); }
.hth-fig--bare .hth-fig__track.is-scrollable { border-radius: 0; }

.hth-fig__track:focus-visible { outline: 2px solid var(--hth-accent); outline-offset: 3px; }

/* THE EDGE FADE, and it is not decoration.
   A chart cut off at the frame edge with no cue reads as a chart that
   ENDS there — a reader who never scrolls concludes the two curves
   separate and stops, which on this page is the opposite of the point.
   So the fade appears only while there is more to the right, and it is
   removed the moment the region is scrolled to the end.

   --hth-bg rather than a fixed colour, so it matches whichever field the
   section is on with no dark override. */
.hth-fig__scroll.has-overflow::after {
  content: '';
  position: absolute;
  top: 0; bottom: 0; right: 0; width: clamp(32px, 7%, 72px);
  pointer-events: none;
  border-start-end-radius: var(--hth-r-lg);
  border-end-end-radius:   var(--hth-r-lg);
  background: linear-gradient(to left, var(--hth-bg), rgba(0,0,0,0));
  transition: opacity var(--hth-fast) var(--hth-expo);
}
.hth-fig__scroll.is-at-end::after { opacity: 0; }

.hth-fig--wide .hth-fig__scroll,
.hth-fig--wide .hth-fig__frame { max-width: 1040px; }
.hth-fig--wide .hth-fig__title,
.hth-fig--wide .hth-fig__scroll,
.hth-fig--wide .hth-fig__cap { margin-inline: auto; max-width: 1040px; }

/* Natural height: the viewBox decides, which is what you want almost
   always. hth_svg() strips the root width/height so this can work — with
   them still on the element the drawing would be locked to its export
   size and would not scale at all. */
.hth-fig__frame > .hth-svg {
  display: block;
  width: 100%; height: auto;
  overflow: visible;
}

/* Wide-only text inside a figure. At <= 560px the figure stops shrinking
   and scrolls, so a long subtitle line is cut off at the frame. Anything a
   build script marks hth-svg--wide-only (e.g. the CO2 chart's "Lines:
   averages. Bands: …" key) drops out there, and the block's caption, which
   carries the same sentence at every width, takes over. */
@media (max-width: 560px) {
  .hth-svg .hth-svg--wide-only { display: none; }
}
.hth-fig__frame > .hth-fig__img {
  display: block;
  width: 100%; height: auto;
  object-fit: contain;                     /* NEVER cover — see the note above */
}

/* When a shape IS forced, fill the box and letterbox inside it. Stated
   explicitly rather than relying on height:100% resolving against an
   auto grid row, which browsers treat as auto and would silently give
   you the natural height back. */
.hth-fig__frame[class*="hth-ar-"] > .hth-svg,
.hth-fig__frame[class*="hth-ar-"] > .hth-fig__img {
  width: 100%; height: 100%;
}

/* The caption is the accessible content — the drawing is aria-hidden,
   because a screen reader gets nothing usable out of a <path>. Hard left
   under the frame, never centred: a centred caption under a left-aligned
   column reads as a slide. Same rule as .hth-media__caption. */
.hth-fig__cap {
  display: block;
  margin-top: var(--hth-4);
  max-width: 68ch;
}
.hth-fig__caption {
  display: block;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s); line-height: 1.55;
  color: var(--hth-body);
}

/* One step down again. The method is not fine print because it is
   unimportant — it is fine print because it is FOR the reader who has
   already decided to check, and putting it at caption weight would make
   every figure look like it was apologising for itself. */
.hth-fig__method {
  display: block;
  margin-top: var(--hth-2);
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs); line-height: 1.5;
  color: var(--hth-label);
  font-variant-numeric: tabular-nums;
}
.hth-fig__caption + .hth-fig__method { margin-top: var(--hth-3); }

/* Beside copy. The grid already aligns to start; the copy gets a small
   optical nudge so its eyebrow sits level with the figure title rather
   than with the top of the frame. */
.hth-fig-split .hth-fig__copy { align-self: center; }

/* MIN-WIDTH ZERO, OR THE SCROLL REGION BLOWS OUT THE PAGE.
   A grid item's default min-width is `auto`, which means "at least as
   wide as my content". The scroll region's content is deliberately held
   at --hth-fig-min (560px), so on a 390px phone the COLUMN grew to 560
   and the whole document got a horizontal scrollbar — the figure never
   scrolled, the page did. Measured before this line: documentElement
   scrollWidth 560 against clientWidth 390.

   .hth-grid--7-5 / --5-7 use bare `7fr 5fr`, so they carry the same
   implicit min. Fixed here rather than in §8 so no other block's
   collapse behaviour changes. */
.hth-fig-split > * { min-width: 0; }

@media (max-width: 900px) {
  /* Stacked, the figure comes first in source order for the default
     layout and second when flipped — which is correct either way: on a
     phone the drawing should lead, because the copy explains it. */
  .hth-fig-split .hth-fig__copy { align-self: start; }
}

/* Full width: the copy above is a lede, not a column. Held to a
   measure, because a 1400px line of body copy is unreadable however
   good the type is. */
.hth-fig__lede {
  max-width: 68ch;
  margin-bottom: var(--hth-7);
}


/* =====================================================================
   13b. GALLERY — the carousel that sits beside the spec column
   ---------------------------------------------------------------------
   Measured off Symmetry's Luik page:
     container   aspect-ratio 900/720 (5:4) desktop · 1:1 mobile
                 border-radius 8px · overflow hidden · touch-pan-y
     slides      flex-[1_0_100%], inline translateX(-N00%)
                 transition: all 2s cubic-bezier(.4,0,0,1)
     arrows      48px wide × FULL height, transparent, pinned to the
                 left and right edges — the chevron is just an icon
                 inside a tall invisible hit area
     progress    9 bars, 24×2px, white, gap 8px, top-right 16px,
                 active opacity 1 / inactive 0.5, 200ms
     counter     bottom-right, "01 / 09"

   ONE DELIBERATE DEPARTURE. Symmetry drives this with JS-applied
   translateX. We use CSS scroll-snap instead, which gives us native
   touch swipe, native keyboard support, correct focus behaviour and
   screen-reader traversal for free — none of which their version has.
   The trade: the browser owns the scroll timing, so you don't get their
   languid 2s custom curve. If you decide that curve is worth the
   accessibility cost, see the note at the end of hth-ds.js.

     <div class="hth-gallery" data-hth-gallery>
       <div class="hth-gallery__track">
         <figure class="hth-gallery__slide"><img src="" alt=""></figure>
         ... one figure per image ...
       </div>
       <button class="hth-gallery__nav hth-gallery__nav--prev"
               aria-label="Previous image"> [chevron svg] </button>
       <button class="hth-gallery__nav hth-gallery__nav--next"
               aria-label="Next image"> [chevron svg] </button>
       <div class="hth-gallery__bars" aria-hidden="true"></div>
       <p class="hth-gallery__count" aria-hidden="true">
         <span data-hth-current>01</span> / <span data-hth-total>01</span></p>
     </div>

   The bars and the counter are populated by hth-ds.js from the actual
   slide count — you never hand-maintain them.
   ===================================================================== */
.hth-gallery {
  position: relative;
  aspect-ratio: 5 / 4;                /* Symmetry: 900/720 = 5:4 exactly */
  border-radius: var(--hth-r);
  overflow: hidden;
  background: var(--hth-sand-deep);   /* shows through while loading    */
  user-select: none;
}
.hth-gallery__track {
  display: flex;
  height: 100%;
  overflow-x: auto;
  overscroll-behavior-x: contain;     /* don't trigger browser back-swipe */
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;              /* Firefox                        */
  -ms-overflow-style: none;

  /* ------------------------------------------------------------------
     THIS LINE IS WHY SWIPE WORKS. Do not "match Symmetry" here.

     It used to read `touch-action: pan-y pinch-zoom`, copied from the
     touch-pan-y class on their carousel. That is correct for THEM and
     wrong for us, and the difference is the whole architecture:

       Symmetry  slides move by JS-applied translateX. The element is
                 not a scroll container, so they set pan-y to let
                 vertical page scroll pass through while their own
                 pointer handlers read the horizontal drag themselves.

       Us        the track IS a scroll container. `pan-y` permits
                 vertical panning ONLY, so it forbade the exact gesture
                 the gallery runs on. Verified: overflow-x auto,
                 scroll-snap x mandatory, scrollWidth 5880 vs client
                 840 — genuinely scrollable, and a finger could not
                 move it.

     `manipulation` is shorthand for pan-x + pan-y + pinch-zoom: both
     axes allowed, and it also drops the ~300ms double-tap-to-zoom delay
     so the arrows and buttons feel immediate. Browsers axis-lock on the
     first movement direction, so a mostly-vertical swipe still scrolls
     the page rather than nudging the gallery.
     ------------------------------------------------------------------ */
  touch-action: manipulation;
}
.hth-gallery__track::-webkit-scrollbar { display: none; }

.hth-gallery__slide {
  flex: 0 0 100%;
  height: 100%;
  margin: 0;
  /* `start`, not `center` — it lines up exactly with the
     `index × clientWidth` scroll target that hth-ds.js uses. */
  scroll-snap-align: start;
  scroll-snap-stop: always;           /* one image per swipe, not three */
}
.hth-gallery__slide > img {
  display: block; width: 100%; height: 100%;
  object-fit: cover; object-position: center;
}

/* Arrows. Full-height hit area, invisible until hovered. */
.hth-gallery__nav {
  position: absolute; top: 0; bottom: 0;
  width: 56px; padding: 0; border: 0;
  display: grid; place-items: center;
  color: #fff; cursor: pointer; z-index: 2;
  background: linear-gradient(to right, rgba(20,28,36,.28), rgba(20,28,36,0));
  opacity: 0;
  transition: opacity var(--hth-fast) var(--hth-expo);
}
.hth-gallery__nav--prev { left: 0; }
.hth-gallery__nav--next {
  right: 0;
  background: linear-gradient(to left, rgba(20,28,36,.28), rgba(20,28,36,0));
}
.hth-gallery:hover  .hth-gallery__nav,
.hth-gallery:focus-within .hth-gallery__nav { opacity: 1; }
.hth-gallery__nav:focus-visible { opacity: 1; outline: 2px solid #fff; outline-offset: -4px; }
.hth-gallery__nav[disabled] { opacity: 0 !important; cursor: default; }
.hth-gallery__nav svg { filter: drop-shadow(0 1px 3px rgba(0,0,0,.45)); }

/* On touch, swiping IS the control. Symmetry hides the arrows too. */
@media (hover: none) { .hth-gallery__nav { display: none; } }

/* Progress bars — top right, 24×2px, 8px apart. */
.hth-gallery__bars {
  position: absolute; top: var(--hth-4); right: var(--hth-4);
  display: flex; gap: var(--hth-2); z-index: 2; pointer-events: none;
}
.hth-gallery__bars > span {
  display: block; width: 24px; height: 2px;
  background: #fff; opacity: .5;
  transition: opacity .2s linear;
}
.hth-gallery__bars > span.is-active { opacity: 1; }

/* Counter — bottom right. tabular-nums stops it jittering on change. */
.hth-gallery__count {
  position: absolute; bottom: var(--hth-4); right: var(--hth-4);
  margin: 0; z-index: 2; pointer-events: none;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs); font-weight: 500;
  letter-spacing: var(--hth-tr-lab); color: #fff;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 1px 3px rgba(0,0,0,.45);
}

/* Above ~9 images the bars stop being readable. Hide them, keep the
   counter — which is the more useful indicator at that length anyway. */
.hth-gallery--long .hth-gallery__bars { display: none; }

@media (max-width: 780px) {
  .hth-gallery { aspect-ratio: 1 / 1; }   /* Symmetry: aspect-square    */
}


/* =====================================================================
   13c. IMAGE BAND — the breathing space
   ---------------------------------------------------------------------
   A single photograph across the page, with optional parallax and
   optional copy over it. On Symmetry's Luik page this is what stops the
   product argument becoming a wall: an inset, rounded, wide frame with
   generous sand around it, and nothing to read.

   Two jobs, and the second one matters more than it looks:
     * atmosphere — one image, no words, purely a pause
     * a statement — a short claim over a dark scrim

   INSET vs BLEED. Inset (rounded, margin either side) reads calmer and
   more expensive; full-bleed reads more cinematic but leaves nowhere for
   the eye to rest. Symmetry uses inset for the mid-page pauses and bleed
   only for the hero. Follow that.

   PARALLAX. Driven by --hth-band-p (0 -> 1 as the band crosses the
   viewport), published by hth-ds.js. Not `animation-timeline: view()`,
   for the same reason as the pin: Firefox has scroll-driven animations
   behind a flag in stable, and here the fallback would be a visibly
   mis-scaled image rather than merely a static one.

   Travel is a PERCENTAGE of image height, not px, so it behaves the same
   on a 400px band and a 900px one. The pre-scale must exceed twice the
   travel or the image edge shows at the extremes:

       travel  4%  ->  scale 1.10   (5% overhang each side)
       travel  8%  ->  scale 1.20   (10%)
       travel 12%  ->  scale 1.28   (14%)

   Defaults to --hth-band-p: .5 so that with JS off or reduced motion the
   image sits dead centre, translated by zero — a normal static photo.
   ===================================================================== */
.hth-band {
  position: relative;
  /* Rendered as a <figure> by both the image band and the CTA band, and the
     UA default is `margin: 1em 40px`. Without this the inset frame sits 40px
     short of the container on each side for no stated reason. */
  margin: 0;
  overflow: hidden;
  background: var(--hth-sand-deep);   /* shows while the image decodes */
  isolation: isolate;
}
.hth-band--inset { border-radius: var(--hth-r-lg); }

.hth-band__img {
  display: block; width: 100%; height: 100%;
  object-fit: cover; object-position: center;
}

/* The clip drifts with the photograph, from the same custom property on
   the same figure. It is the same transform on the same box — the only
   thing that does not carry over is srcset, which a single video file
   does not have, hence the explicit encode widths in the block's field
   description. See section 24. */
.hth-band--parallax .hth-band__img,
.hth-band--parallax .hth-band__clip {
  scale: var(--hth-band-scale, 1.20);
  translate: 0 calc((var(--hth-band-p, .5) - .5) * var(--hth-band-travel, 8%) * 2);
  will-change: translate;
}

.hth-band__scrim {
  position: absolute; inset: 0; z-index: 1;
  background: linear-gradient(to top,
              rgba(20,28,36,.70) 0%,
              rgba(20,28,36,.28) 40%,
              rgba(20,28,36,0)   72%);
}
/* Centred copy needs an even wash, not a bottom-weighted one. */
.hth-band--center .hth-band__scrim {
  background: linear-gradient(to top, rgba(20,28,36,.55), rgba(20,28,36,.45));
}

.hth-band__copy {
  position: absolute; z-index: 2;
  inset: auto 0 0 0;
  padding: clamp(24px, 5vw, 72px);
  max-width: 760px;
}
.hth-band--center .hth-band__copy {
  inset: 0; max-width: none;
  display: grid; place-content: center; justify-items: center;
  text-align: center;
}
.hth-band--center .hth-band__copy > * { max-width: 800px; }

/* §15i opens with --hth-8 of top margin, which is right when the row is the
   only thing in a column and far too much when it is the last item in an
   overlay stack — the button above it uses --hth-6. Specificity, not source
   order, wins this one, so it can live here with the rest of the band. */
.hth-band__copy .hth-badges { margin-top: var(--hth-6); }

.hth-band__caption {
  margin: var(--hth-4) 0 0;
  font-family: var(--hth-f-body); font-size: var(--hth-t-s);
  color: var(--hth-label);
}
/* A flush band has no bottom padding — correct for the photograph, wrong for
   a caption, which would otherwise sit hard against the next section. */
.hth-section--flush .hth-band__caption { margin-bottom: var(--hth-6); }

@media (max-width: 780px) {
  /* A wide band becomes a letterbox slot on a phone. Give it height back
     so the subject is still legible.

     ALL THREE WIDE SHAPES, not two. 2:1 was missed here, which meant the
     *middle* shape stayed a 195px letterbox on a 390px phone while both
     the wider 21:9 and the narrower 16:9 on either side of it were
     corrected — and 2:1 is the default.

     These carry --hth-ar with them, or a copy-bearing band would keep the
     desktop shape as its floor while the frame around it used the phone
     one. */
  .hth-band.hth-ar-219 { aspect-ratio: 16 / 10; --hth-ar: 16 / 10; }
  .hth-band.hth-ar-2   { aspect-ratio: 3 / 2;   --hth-ar: 3 / 2;   }
  .hth-band.hth-ar-169 { aspect-ratio: 4 / 3;   --hth-ar: 4 / 3;   }
  .hth-band__copy { padding: var(--hth-5); }
}

/* NB: everything below is deliberately AFTER the phone media query above.
   `.hth-band.hth--photo` and `.hth-band.hth-ar-219` have identical
   specificity, so the one that comes later in the file wins — and the
   `aspect-ratio: auto` below has to beat the phone ratio, or the figure
   keeps a hard 16/10 cap on exactly the screens this fixes. Same reason
   §15h's CTA overrides sit below §13c. Do not reorder.
   ------------------------------------------------------------------- */

/* ---- A BAND THAT CARRIES COPY IS SIZED BY THE COPY -------------------
   THE BUG THIS FIXES. The copy is absolutely positioned, so it adds
   nothing to the figure's height: the figure is exactly aspect-ratio tall
   and overflow:hidden — it has to be, the parallax pre-scale overhangs it
   by 10%. The moment the copy is taller than the shape, the surplus goes
   off the TOP of the frame and is cut, headline first. That is not a
   graceful degradation, it is a decapitated sentence.

   On a 390px phone the 21:9 band drops to 16:10 — 244px of frame — and
   the statement band on the TRS-2 page needs 330px for eyebrow +
   two-line headline + four-line paragraph + button. It was never a
   phone-only fault, only a phone-first one: a longer headline, a larger
   default font size, or a phone's system text scale does the same thing
   at any width. Desktop mobile emulation is not a test for it, because
   it emulates the viewport and not the device's text scale.

   THE FIX. Make the shape a FLOOR rather than a cap. The layers stack in
   one grid cell: the photograph and the scrim are absolute and size
   nothing, an empty spacer carries the aspect ratio, and the copy is the
   only child left in flow. The row is the taller of the two, so the band
   keeps its declared shape whenever the copy fits and grows when it does
   not — which is the behaviour every one of these overlays assumed it
   already had.

   Atmosphere bands have no copy and therefore no .hth--photo, so they do
   not enter here at all and stay exactly aspect-ratio tall. */
.hth-band.hth--photo {
  display: grid;
  grid-template-columns: 1fr;
  aspect-ratio: auto;            /* the shape moves to the spacer below */
}
.hth-band.hth--photo::before {
  content: "";
  grid-area: 1 / 1;
  aspect-ratio: var(--hth-ar, 2 / 1);
  /* Stretched — the default — its height would come FROM the row, and a
     box sized in both axes ignores its aspect-ratio. Then the spacer
     contributes nothing and the band collapses onto the copy. */
  align-self: start;
  min-width: 0;
  pointer-events: none;
}
/* Out of flow, so a photograph pre-scaled to 1.2x cannot inflate the row
   it is supposed to be cropped by. */
.hth-band.hth--photo > .hth-band__img { position: absolute; inset: 0; }

.hth-band.hth--photo > .hth-band__copy {
  grid-area: 1 / 1;
  position: relative;            /* in flow now — still above the scrim */
  inset: auto;
  align-self: end;
  width: 100%;
  min-width: 0;
}
.hth-band--center.hth--photo > .hth-band__copy { align-self: center; }



/* =====================================================================
   14. SPEC TABLE — replaces the current chip/pill grids
   ---------------------------------------------------------------------
   Symmetry lists specs as label-above-value in a plain 4-up grid with a
   hairline above each. No pills, no boxes, no icons. It reads as a data
   sheet, which is what signals "engineered" rather than "marketed".
   ===================================================================== */
.hth-specs { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: var(--hth-5) var(--hth-6); }
.hth-spec  { border-top: 1px solid var(--hth-rule); padding-top: var(--hth-3); }
.hth-spec__k { font-size: var(--hth-t-xs); letter-spacing: var(--hth-tr-lab);
               text-transform: uppercase; color: var(--hth-label); display: block; }
.hth-spec__v { font-size: var(--hth-t-b); color: var(--hth-head); margin-top: 6px; display: block; }
@media (max-width: 700px) { .hth-specs { grid-template-columns: repeat(2, minmax(0,1fr)); } }

/* ---------------------------------------------------------------------
   FLAT VARIANT — the one on Symmetry's Luik page. A single
   "SPECIFICATIONS" label, then a wrapped list of bare attributes inside
   a rule above and below. Use when the values are self-describing
   ("Outdoor sauna", "4–5 Person") and don't need field names.

     <div class="hth-specs-flat">
       <span class="hth-eyebrow">Specifications</span>
       <ul><li>Outdoor sauna</li><li>4–5 Person</li>...</ul>
     </div>

   NOTE — their live version has a real bug here: at 1371px the values
   run together as "Weather durabilityIntegrated lightingSimple
   assembly", because the columns are auto-width with no gap. This
   version uses a wrapping flex row with an explicit column gap, so it
   cannot happen. Don't copy the bug along with the layout.
   --------------------------------------------------------------------- */
.hth-specs-flat {
  border-top: 1px solid var(--hth-rule);
  border-bottom: 1px solid var(--hth-rule);
  padding-block: var(--hth-5) var(--hth-4);
}
.hth-specs-flat > ul {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap;
  column-gap: var(--hth-6); row-gap: var(--hth-3);
}
.hth-specs-flat > ul > li {
  font-family: var(--hth-f-body); font-size: var(--hth-t-s);
  color: var(--hth-head); white-space: nowrap;
}


/* =====================================================================
   15. CARD
   ===================================================================== */
.hth-card {
  background: var(--hth-surface);
  border-radius: var(--hth-r-lg);
  overflow: hidden;
  display: flex; flex-direction: column;
  transition: transform var(--hth-fast) var(--hth-expo),
              box-shadow var(--hth-fast) var(--hth-expo);
}
/* The ratio lives on the MEDIA BOX, not the image, so a .hth-ar-* class
   from the block can set it. The fallback is wrapped in :where() — zero
   specificity — so any ratio class beats it regardless of source order.
   Without the fallback, older saved content with no ratio class would
   collapse the box to zero height. */
:where(.hth-card__media) { aspect-ratio: 4 / 3; }
.hth-card__media { overflow: hidden; }
.hth-card__media > img { display: block; width: 100%; height: 100%; object-fit: cover;
  transition: transform var(--hth-slow) var(--hth-expo); }
.hth-card:hover { box-shadow: var(--hth-shadow-lg); }
.hth-card:hover .hth-card__media > img { transform: scale(1.04); }
.hth-card__body { padding: var(--hth-5); display: flex; flex-direction: column; gap: var(--hth-3);
  flex: 1 1 auto; }
.hth-card__body .hth-btn { margin-top: auto; align-self: flex-start; }

/* ---------------------------------------------------------------------
   CONTENT-LED CARDS  (.hth-card--rich)
   ---------------------------------------------------------------------
   The default card is a label: an image, a name, one line of dimensions.
   Copy sits at caption size in muted ink because it is a caption.

   But a card row is sometimes carrying an actual argument — poplar vs
   hemlock is a decision the buyer makes, and "lowest-VOC hardwood,
   chosen by toxicologists" is not a caption. This modifier promotes the
   body: full body size, body ink, more padding, and a wider gap so two
   sentences do not read as one block.

   Deliberately a MODIFIER rather than the default. Promoting every card
   would make the TR-1/2/3 row shout its dimensions at the same volume
   as its names.
   --------------------------------------------------------------------- */
.hth-card--rich .hth-card__body { padding: var(--hth-6); gap: var(--hth-4); }
.hth-card--rich .hth-card__body .hth-rich { font-size: var(--hth-t-b); color: var(--hth-body); }
.hth-card--rich .hth-card__body .hth-rich > * + * { margin-top: var(--hth-4); }

/* Caption mode — the default. Same markup, quieter. */
.hth-card__body .hth-rich { font-size: var(--hth-t-s); line-height: 1.6; color: var(--hth-label); }

/* Key points: the hairline-marker list from .hth-rich, tightened for a
   card and separated from the prose above it by a rule rather than
   just space, so it reads as a different KIND of information. */
.hth-card__points {
  list-style: none; margin: var(--hth-2) 0 0; padding: var(--hth-4) 0 0;
  border-top: 1px solid var(--hth-rule);
}
.hth-card__points li {
  position: relative; padding-left: 20px; margin-top: var(--hth-3);
  font-family: var(--hth-f-body); font-size: var(--hth-t-s); line-height: 1.5;
  color: var(--hth-body);
}
.hth-card__points li:first-child { margin-top: 0; }
.hth-card__points li::before {
  content: ""; position: absolute; left: 0; top: .6em;
  width: 10px; height: 1px; background: var(--hth-accent);
}

/* ---------------------------------------------------------------------
   EQUAL-HEIGHT CARDS
   ---------------------------------------------------------------------
   The card interior was already built for this and nothing noticed:
   .hth-card is a flex column, .hth-card__body is flex:1 1 auto, and the
   button carries margin-top:auto. The ONLY thing holding cards at their
   natural height is `align-items: start` on .hth-grid (§8), which is the
   right default for a grid of arbitrary content and the wrong one for a
   row of cards.

   So this is one declaration. The payoff is not the outer rectangle — a
   card row on a sand field has a visible panel edge, and ragged bottoms
   read as a mistake rather than as looseness — it is that the buttons
   bottom-align for free, which is what actually makes the row scan.

   Later in the file than §8 and the same specificity, so it wins on
   source order. Do not move §15 above §8.
   --------------------------------------------------------------------- */
.hth-grid--stretch { align-items: stretch; }

/* The ratio box must not absorb the stretch — it is the one part of the
   card whose height is a design decision rather than a consequence. */
.hth-grid--stretch .hth-card__media { flex: 0 0 auto; }

/* ---------------------------------------------------------------------
   ...AND ALIGN THE KEY POINTS TOO
   ---------------------------------------------------------------------
   Equal height fixes the outer box but leaves the hairline above each
   key-points list wherever its prose happened to end — so in a two-up
   comparison the two lists start at different heights and the eye can no
   longer read ACROSS them, which is the whole reason the wood cards use
   parallel points in a parallel order (see COPY-wood-choice.md).

   Pushing the list down with margin-top:auto lands every rule on the same
   line, provided the lists have the same number of items. They should:
   if one card has three points and another has five, this will look
   deliberate and be wrong, and the fix is the copy, not the CSS.

   The button's own margin-top:auto has to be cancelled, or the two auto
   margins split the free space between them and the button floats to the
   middle of the card. Three classes beats the two-class rule above.
   --------------------------------------------------------------------- */
.hth-grid--align-points .hth-card__points { margin-top: auto; }
.hth-grid--align-points .hth-card__body .hth-btn { margin-top: 0; }


/* =====================================================================
   15b. PRICE + WOOD SELECTOR
   ---------------------------------------------------------------------
   The TR-2 has two woods at two prices. Showing both, both with a
   struck-through MSRP, is four numbers competing in the most valuable
   space on the page — the single strongest "retail flyer" signal there
   is. Instead: one entry price, and a small toggle that changes it.
   ===================================================================== */
.hth-price { display: flex; align-items: baseline; gap: var(--hth-3); flex-wrap: wrap; }
.hth-price__now {
  font-family: var(--hth-f-display); font-weight: 300;
  font-size: var(--hth-t-md); letter-spacing: var(--hth-tr-md);
  color: var(--hth-head); line-height: 1;
  font-variant-numeric: tabular-nums;   /* no width jump when it changes */
}
.hth-price__was {
  font-size: var(--hth-t-s); color: var(--hth-label);
  text-decoration: line-through; font-variant-numeric: tabular-nums;
}
.hth-price__note { font-size: var(--hth-t-s); color: var(--hth-label); }

.hth-woods { display: flex; gap: var(--hth-2); flex-wrap: wrap; margin-top: var(--hth-4); }
.hth-woods button {
  font-family: var(--hth-f-body); font-size: var(--hth-t-s); font-weight: 500;
  padding: 10px 16px; border-radius: var(--hth-r); cursor: pointer;
  background: transparent; color: var(--hth-body);
  border: 1px solid var(--hth-rule);
  transition: border-color var(--hth-fast) var(--hth-expo),
              background   var(--hth-fast) var(--hth-expo),
              color        var(--hth-fast) var(--hth-expo);
}
.hth-woods button:hover { border-color: var(--hth-head); }
.hth-woods button[aria-pressed="true"] {
  background: var(--hth-ink); border-color: var(--hth-ink); color: #fff;
}
.hth--dark .hth-woods button[aria-pressed="true"],
.hth--photo .hth-woods button[aria-pressed="true"] {
  background: #fff; border-color: #fff; color: var(--hth-ink);
}


/* =====================================================================
   15g. STATS STRIP — the measured-numbers row
   ---------------------------------------------------------------------
   Label above, big light numeral, small accent unit, hairline between.
   This is the block that carries the entire argument of the redesign:
   your competitors write "low EMF" and you write 0.089 V/m.

   THREE DETAILS THAT DO THE WORK

   1. font-variant-numeric: tabular-nums. Without it the numerals have
      different widths and the whole row twitches while counting. This
      is the single most common reason a count-up looks cheap.

   2. --hth-stat-w reserves the final string's width in ch, set inline
      per stat from PHP. Even with tabular figures, "0" is narrower than
      "0.089", so the cell would grow as it counts and shove its
      neighbours. Reserving the end width means nothing moves.

   3. The unit is small, weight 500, and in the accent colour — never
      the same size as the number. "0.089 V/m" with both at the same
      size reads as a part number; the demoted unit reads as a
      measurement.

   The live homepage version renders 0 in the HTML and counts up to the
   real figure. Ours renders the real figure and counts up TO it, so a
   visitor with JS off or a failed observer sees 0.089 V/m rather than
   0.000 V/m — a claim that is merely unanimated instead of false.
   ===================================================================== */
.hth-stats {
  display: grid;
  gap: var(--hth-6) 0;
  grid-template-columns: repeat(var(--hth-stats-cols, 4), minmax(0, 1fr));
  align-items: start;
}
.hth-stats__item {
  padding-inline: clamp(12px, 2.5vw, 32px);
  border-left: 1px solid var(--hth-rule);
  text-align: var(--hth-stats-align, center);
}
.hth-stats__item:first-child { border-left: 0; }

.hth-stats__k {
  display: block;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs); font-weight: 500;
  letter-spacing: var(--hth-tr-lab); text-transform: uppercase;
  color: var(--hth-label);
  margin-bottom: var(--hth-3);
}

.hth-stats__v {
  display: flex; align-items: baseline; gap: 3px;
  justify-content: var(--hth-stats-justify, center);
}
.hth-stats__n {
  font-family: var(--hth-f-display); font-weight: 300;
  font-size: clamp(30px, 3.4vw, 46px);
  letter-spacing: var(--hth-tr-md); line-height: 1;
  color: var(--hth-head);
  font-variant-numeric: tabular-nums;   /* see note 1 */
  min-width: var(--hth-stat-w, auto);   /* see note 2 */
  display: inline-block;
  text-align: inherit;
}
.hth-stats__u {
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s); font-weight: 500;
  color: var(--hth-accent);
  white-space: nowrap;
}

/* ---------------------------------------------------------------------
   THE REFERENCE FIGURE  —  .hth-stats__vs
   ---------------------------------------------------------------------
   "0.089 V/m" is a fact. "0.089 V/m against 22 V/m" is the reason anyone
   is reading the page. So the comparison sits directly under our figure,
   not down in the fine print where a claim goes to be ignored.

   FOUR DELIBERATE DEMOTIONS, and they are the whole design:

   1. Body face, not the display face. Our number is set in the display
      family; the reference is not. That alone separates them before any
      size or colour difference is read.
   2. Roughly half the size. It is a datum, not a second headline.
   3. --hth-label, not --hth-head. Muted, and never a warning colour. A
      competitor's figure set in red stops being a measurement and starts
      being an attack ad — on a page whose entire claim is that we are
      the ones who publish methods, that trade is a bad one.
   4. A hairline above, so the eye reads number -> rule -> reference and
      cannot mistake the second figure for part of the first.

   tabular-nums for the same reason as the main numeral: two figures
   stacked in one column with different digit widths look misaligned even
   when they are not.
   --------------------------------------------------------------------- */
.hth-stats__vs {
  margin-top: var(--hth-3);
  padding-top: var(--hth-3);
  border-top: 1px solid var(--hth-rule);
  display: flex; flex-wrap: wrap; align-items: baseline;
  gap: 0 6px;
  justify-content: var(--hth-stats-justify, center);
  text-align: inherit;
}
.hth-stats__vs-n {
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s); font-weight: 500;
  color: var(--hth-label);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.hth-stats__vs-l {
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs); font-weight: 400;
  color: var(--hth-label);
}

/* In a narrow column the reference wraps to its own line rather than
   squeezing the figure. Nothing else changes — same type, same colour. */
@media (max-width: 560px) {
  .hth-stats__vs { gap: 2px 6px; }
}

.hth-stats__note {
  margin: var(--hth-7) 0 0;
  font-family: var(--hth-f-body); font-size: var(--hth-t-s);
  color: var(--hth-label);
  max-width: 70ch;
}

/* Dividers have to follow the column count, so the first item of every
   ROW loses its left border rather than only the first item overall. */
.hth-stats[style*="--hth-stats-cols: 4"] .hth-stats__item:nth-child(4n+1),
.hth-stats[style*="--hth-stats-cols: 3"] .hth-stats__item:nth-child(3n+1),
.hth-stats[style*="--hth-stats-cols: 2"] .hth-stats__item:nth-child(2n+1) {
  border-left: 0;
}

@media (max-width: 900px) {
  .hth-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .hth-stats__item:nth-child(odd)  { border-left: 0; }
  .hth-stats__item:nth-child(even) { border-left: 1px solid var(--hth-rule); }
}
@media (max-width: 460px) {
  .hth-stats { grid-template-columns: 1fr; gap: var(--hth-5); }
  .hth-stats__item { border-left: 0 !important; padding-inline: 0;
                     border-top: 1px solid var(--hth-rule); padding-top: var(--hth-4); }
  .hth-stats__item:first-child { border-top: 0; padding-top: 0; }
}

/* ---------------------------------------------------------------------
   NO DIVIDERS — .hth-stats--nodiv
   ---------------------------------------------------------------------
   The rule between figures is right when the numbers are a set being
   read across: +34%, 0.36 mG, 0.089 V/m are one measurement session and
   the dividers say so. It is wrong when each figure now carries its own
   fine print, because then every stat is already a self-contained block
   and the vertical rules add a second, competing structure.

   Covers three cases in one declaration, which is why it is written
   this way rather than as three: the vertical rule at full width, the
   `:nth-child(even)` rule that survives the 900px reflow, and the
   horizontal rule the 460px stack and the --inline variant both switch
   to. !important because the 460px rule already uses it.
   --------------------------------------------------------------------- */
.hth-stats--nodiv .hth-stats__item,
.hth-stats--nodiv .hth-stats__item:nth-child(even) {
  border-left: 0 !important;
  border-top: 0 !important;
}

/* ---------------------------------------------------------------------
   OVER A PHOTOGRAPH — what the live homepage strip does.
   The scrim is not optional: white numerals on an unscrimmed room photo
   fail contrast the moment the image changes.
   --------------------------------------------------------------------- */
.hth-stats-photo { position: relative; overflow: hidden; border-radius: var(--hth-r-lg); isolation: isolate; }
.hth-stats-photo > img {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; z-index: 0;
}
.hth-stats-photo__scrim {
  position: absolute; inset: 0; z-index: 1;
  background: linear-gradient(to bottom, rgba(20,28,36,.62), rgba(20,28,36,.74));
}
.hth-stats-photo > .hth-stats,
.hth-stats-photo > .hth-stats__note {
  position: relative; z-index: 2;
}
.hth-stats-photo > .hth-stats { padding: clamp(28px, 5vw, 56px) clamp(16px, 3vw, 40px); }

/* ---------------------------------------------------------------------
   INLINE VARIANT — two or three figures inside a column of body copy.
   ---------------------------------------------------------------------
   Used by the split-feature block so numbers can sit WITH an argument
   rather than in a band of their own. Same vocabulary as the full strip,
   three deliberate differences:

     * hairline ABOVE each cell, not vertical dividers between. In a 40%
       column the verticals land too close together and read as a table.
     * smaller numerals — a 46px figure in a narrow column out-shouts the
       headline above it.
     * always left aligned, because body copy is.
   --------------------------------------------------------------------- */
.hth-stats--inline {
  gap: var(--hth-5) var(--hth-6);
  margin-top: var(--hth-6);
  --hth-stats-align: left;
  --hth-stats-justify: flex-start;
}
.hth-stats--inline .hth-stats__item {
  border-left: 0 !important;
  padding-inline: 0;
  border-top: 1px solid var(--hth-rule);
  padding-top: var(--hth-4);
}
.hth-stats--inline .hth-stats__n { font-size: clamp(24px, 2.2vw, 32px); }
.hth-stats--inline .hth-stats__k { margin-bottom: var(--hth-2); }


/* ---------------------------------------------------------------------
   FINE PRINT UNDER A STAT — and the divider above it
   ---------------------------------------------------------------------
   One class serving all three places a figure appears: the stats strip,
   the split-feature callout, and the inline stats repeater. A method
   that renders three slightly different ways is three chances to look
   unserious about the number it is defending.

   12px, not 14px. The caption size (--hth-t-s) is already in use for
   .hth-stat__l and .hth-stats__note, both of which are meant to be
   read. Fine print is deliberately a step quieter: it exists so the one
   reader who checks can check, not so that everyone reads it. --hth-ink-3
   is documented AA on sand at 4.9:1, which holds at this size.

   The divider is a real <hr class="hth-rule hth-rule--tight"> rather
   than a border on the fine print, so "under the figure" still draws
   when there is no fine print to sit above.
   --------------------------------------------------------------------- */
.hth-rule--tight { margin-block: var(--hth-4); }

.hth-fine {
  margin-top: var(--hth-4);
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs);
  line-height: 1.55;
  color: var(--hth-label);
  text-align: inherit;
}
/* hth_inline() runs wpautop, so the text arrives wrapped in <p>. Same
   reset as .hth-rich and .hth-cmp — see §16g. */
.hth-fine :where(p) { margin: 0; }
.hth-fine :where(p + p) { margin-top: var(--hth-2); }

/* `font: inherit` is the point of the shorthand here: the link is the
   same face, size, weight and line-height as the fine print above it,
   whatever those become later. The doubled class keeps it above a theme
   rule like `.content a` without reaching for !important. */
.hth-fine .hth-fine__link {
  display: inline-block;
  margin-top: 6px;
  font: inherit;
  color: var(--hth-head);
  text-decoration: underline;
  text-decoration-color: var(--hth-rule);
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: text-decoration-color var(--hth-fast) var(--hth-expo);
}
.hth-fine .hth-fine__link:hover { text-decoration-color: var(--hth-accent); }

/* The callout sits in a text column, so its fine print needs a measure.
   Inside a stats strip the column is already the measure. */
.hth-stat .hth-fine { max-width: 46ch; }

/* Same reason, for the reference figure. --hth-stats-justify is set by the
   stats strip and does not exist in a copy column, where the fallback would
   centre a line under left-aligned body text. */
.hth-stat .hth-stats__vs { justify-content: flex-start; max-width: 46ch; }

/* "Under the whole stat" — closes this one off from the next. */
.hth-stats__item--ruled,
.hth-stat--ruled {
  padding-bottom: var(--hth-5);
  border-bottom: 1px solid var(--hth-rule);
}


/* =====================================================================
   15c. NUMBERED PROCESS STEPS
   ---------------------------------------------------------------------
   The number is a light display glyph, not a filled circle badge. A
   circle badge is the single most common way a "how it works" row ends
   up looking like a bank website.
   ===================================================================== */
.hth-step { border-top: 1px solid var(--hth-rule); padding-top: var(--hth-4); }
.hth-step__n {
  font-family: var(--hth-f-display); font-weight: 300;
  font-size: var(--hth-t-md); line-height: 1;
  color: var(--hth-accent); display: block; margin-bottom: var(--hth-4);
  font-variant-numeric: tabular-nums;
}
.hth-step__t {
  font-family: var(--hth-f-display); font-weight: 300;
  font-size: var(--hth-t-sm); letter-spacing: var(--hth-tr-sm);
  color: var(--hth-head); margin: 0 0 var(--hth-3);
}


/* =====================================================================
   15d. PULL QUOTE
   ---------------------------------------------------------------------
   No quotation-mark graphic, no avatar circle, no star row. Just the
   sentence at lead size with a hairline above the attribution. Restraint
   is what makes a testimonial read as true rather than as marketing.
   ===================================================================== */
.hth-quote { display: flex; flex-direction: column; height: 100%; }
.hth-quote__t {
  font-family: var(--hth-f-body); font-size: var(--hth-t-bl);
  line-height: 1.62; color: var(--hth-head); margin: 0 0 var(--hth-5);
  text-wrap: pretty; flex: 1 1 auto;
}
.hth-quote__by {
  border-top: 1px solid var(--hth-rule); padding-top: var(--hth-3);
  font-size: var(--hth-t-s); color: var(--hth-label);
}

/* A single big quote, centred, used as a full band. */
.hth-quote--lead .hth-quote__t {
  font-family: var(--hth-f-display); font-weight: 300;
  font-size: var(--hth-t-md); letter-spacing: var(--hth-tr-md); line-height: 1.3;
}


/* =====================================================================
   15e. STAT CALLOUT — a single number pulled out of body copy
   ===================================================================== */
.hth-stat { border-top: 1px solid var(--hth-rule); padding-top: var(--hth-4); margin-top: var(--hth-6); }
.hth-stat__v {
  font-family: var(--hth-f-display); font-weight: 300;
  font-size: var(--hth-t-lg); letter-spacing: var(--hth-tr-lg);
  line-height: 1; color: var(--hth-head); display: block;
}
/* Declares its own family. It had none, so it inherited — which happened
   to be the body face on the live site and the display serif anywhere the
   inherited font differed. A citation set in the display serif reads as a
   pull quote rather than a source. */
.hth-stat__l { font-family: var(--hth-f-body); font-size: var(--hth-t-s);
               color: var(--hth-label); margin-top: var(--hth-3);
               display: block; max-width: 46ch; }


/* =====================================================================
   15f. RICH TEXT — for body copy that carries inline links
   ---------------------------------------------------------------------
   The current TR-2 copy is dense with links. They must not look like
   default blue underlines, and they must not be invisible either.
   ===================================================================== */
.hth-rich { font-family: var(--hth-f-body); font-size: var(--hth-t-b); line-height: 1.70;
            color: var(--hth-body); max-width: var(--hth-measure); }
/* Flow spacing between blocks of rich text.
   ---------------------------------------------------------------------
   ORDER AND SPECIFICITY BOTH MATTER HERE, and getting it wrong is silent.
   The element resets below used to be written as `.hth-rich p { margin: 0 }`
   and `.hth-rich ul { margin: 0 }` — specificity (0,1,1) — which quietly
   beat the `> * + *` flow rule at (0,1,0). Result: every paragraph after
   the first in every rich field on the site rendered with no space above
   it, so multi-paragraph body copy read as one undifferentiated slab.
   Nothing errored and nothing looked obviously broken; it just looked
   cheap.

   :where() drops the resets to zero specificity, so the flow rule — now
   placed after them — wins on source order. Keep it that way. */
.hth-rich :where(p, ul, ol) { margin: 0; }
.hth-rich > * + * { margin-top: var(--hth-4); }
.hth-rich strong { font-weight: 500; color: var(--hth-head); }
.hth-rich a {
  color: var(--hth-head); text-decoration: underline;
  text-decoration-color: var(--hth-rule);
  text-underline-offset: 3px; text-decoration-thickness: 1px;
  transition: text-decoration-color var(--hth-fast) var(--hth-expo);
}
.hth-rich a:hover { text-decoration-color: var(--hth-accent); }
.hth-rich ul { list-style: none; padding: 0; }
.hth-rich ul li { position: relative; padding-left: 22px; margin-top: var(--hth-3); }
.hth-rich ul li::before {
  content: ""; position: absolute; left: 0; top: .65em;
  width: 10px; height: 1px; background: var(--hth-accent);
}
/* Numbered lists are one toolbar click away in any WYSIWYG field, so they
   need a treatment rather than the browser default. Tabular numerals and a
   fixed indent keep the text edge straight past nine items. */
.hth-rich ol { list-style: none; padding: 0; counter-reset: hth-ol; }
.hth-rich ol li { position: relative; padding-left: 28px; margin-top: var(--hth-3); }
.hth-rich ol li::before {
  counter-increment: hth-ol;
  content: counter(hth-ol) ".";
  position: absolute; left: 0; top: 0;
  color: var(--hth-accent); font-variant-numeric: tabular-nums;
}


/* =====================================================================
   15h. CLOSING CTA — the optional photograph
   ---------------------------------------------------------------------
   The band is dark and text-only by default, and that is the right
   default: it is the one place on the page where nothing should compete
   with the phone number. But a full-width dark rectangle with copy in
   the left third reads as unfinished rather than restrained, because the
   right two-thirds are empty rather than deliberately quiet.

   Two ways to fix that, in increasing volume:

     BESIDE   a rounded photograph beside the copy, 7/5. The copy keeps
              its measure and its ranking; the photograph fills the void
              and gives the eye somewhere to land. Safe on any page.

     BEHIND   the photograph fills the section and the copy sits over a
              scrim. Much louder. Only use it when the CTA is the LAST
              thing on the page — a photographic band with a footer under
              it reads as a second hero.

   Both reuse .hth-band from §13c, so parallax comes free: the same
   IntersectionObserver in hth-ds.js publishes --hth-band-p, and the same
   travel/pre-scale pairing applies.
   ===================================================================== */
.hth-cta        { display: grid; gap: clamp(40px, 6vw, 120px); align-items: center; }
.hth-cta__copy  { max-width: 760px; }
.hth-cta--split { grid-template-columns: 7fr 5fr; }

/* Image on the LEFT swaps the grid tracks rather than the markup, so the
   source order stays copy-first — which is what a screen reader reads
   and what the stacked layout uses. */
.hth-cta--split.hth-cta--flip  { grid-template-columns: 5fr 7fr; }
.hth-cta--flip .hth-cta__copy  { grid-column: 2; }
.hth-cta--flip .hth-cta__media { grid-column: 1; grid-row: 1; }

@media (max-width: 900px) {
  .hth-cta--split,
  .hth-cta--split.hth-cta--flip  { grid-template-columns: 1fr; }
  .hth-cta--flip .hth-cta__copy  { grid-column: 1; }
  .hth-cta--flip .hth-cta__media { grid-column: 1; grid-row: auto; }
  /* A 700px-tall photograph standing between a phone reader and the
     phone number is a cost, not a flourish. Opt out per block. */
  .hth-cta--hide-media .hth-cta__media { display: none; }
}

/* ---- BEHIND ---------------------------------------------------------
   The media has to be a sibling of .hth-wrap, not a descendant, or it
   inherits the wrap's max-width and stops at 1400px on a wide screen. */
.hth-section.hth-cta-bg { position: relative; isolation: isolate; overflow: clip; }
.hth-section.hth-cta-bg > .hth-wrap { position: relative; z-index: 2; }

/* Overrides .hth-band's own position/background — later in the file, so
   no !important is needed. Do not move this section above §13c. */
.hth-cta-bg__media { position: absolute; inset: 0; z-index: 0; background: var(--hth-ink); }
.hth-cta-bg__media .hth-band__img { width: 100%; height: 100%; }

/* Horizontal wash, not the band's bottom-weighted one: here the copy is
   on one SIDE, and a bottom gradient would leave the headline sitting on
   bare photograph. Heavy enough for AA on the copy side at any exposure;
   do not lighten it to show more of the picture. */
.hth-cta-bg__scrim {
  position: absolute; inset: 0; z-index: 1;
  background: linear-gradient(to right,
              rgba(20,28,36,.90) 0%,
              rgba(20,28,36,.78) 42%,
              rgba(20,28,36,.45) 100%);
}
/* Stacked, the copy covers the full width — a side gradient would put
   half the text on a light part of the photograph. */
@media (max-width: 900px) {
  .hth-cta-bg__scrim { background: rgba(20,28,36,.84); }
}


/* =====================================================================
   15i. BADGE / LOGO ROW
   ---------------------------------------------------------------------
   UL, ETL, money-back, free shipping, patents, carbon-negative. Sits
   under a statement band, and gets three things right that a hand-built
   logo row almost never does:

   1. NORMALISE ON HEIGHT, NOT WIDTH. Supplied logos have wildly different
      proportions — a UL mark is nearly square, "FREE SHIPPING" is a wide
      wordmark. Equal WIDTH makes the square one enormous. Equal HEIGHT is
      the least-bad rule and the one every well-made trust row uses.
      max-width plus object-fit:contain then letterboxes anything
      pathologically wide instead of stretching it, because with a fixed
      height and an auto width, max-width alone WOULD distort it.

   2. MONOCHROME BY DEFAULT. Full-colour third-party badge PNGs on a dark
      band is the single fastest way to make a premium page look like a
      checkout footer. `brightness(0) invert(1)` forces any opaque logo to
      solid white regardless of its source colours, and plain
      `brightness(0)` forces solid black on the light themes — so a row of
      badges collected from six different suppliers reads as one set.
      Opacity below 1 keeps them subordinate to the claim above.

   3. THEY ARE EVIDENCE, NOT DECORATION. Hence the label option and the
      real alt text: "UL listed" is a claim a screen reader needs, and a
      40px mark is frequently not legible on its own anyway.
   ===================================================================== */
/* The block sets --hth-badge-base inline and NOTHING else. --hth-badge-h
   is derived here so the phone override below can still reach it — an
   inline --hth-badge-h would beat any stylesheet rule, media query or
   not, and the mobile step-down would silently do nothing. */
.hth-badges {
  --hth-badge-base: 40px;
  --hth-badge-h: var(--hth-badge-base);
  --hth-badge-w: calc(var(--hth-badge-h) * 4);
  display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
  gap: var(--hth-5) var(--hth-7);
  margin-top: var(--hth-8);
}
.hth-badges--left { justify-content: flex-start; }
/* Safety net: an empty badge row (no items) would still add its 64px
   margin-top. hth_badges() now returns '' in that case; this covers any
   other markup path. Requires no whitespace inside the wrapper. */
.hth-badges:empty { display: none; }

.hth-badges__item {
  display: flex; flex-direction: column; align-items: center;
  gap: var(--hth-2); text-align: center; text-decoration: none;
}
.hth-badges__item img {
  display: block;
  height: var(--hth-badge-h); width: auto;
  max-width: var(--hth-badge-w);
  object-fit: contain;                     /* letterbox, never stretch */
}
.hth-badges__label {
  font-family: var(--hth-f-body); font-size: var(--hth-t-xs);
  letter-spacing: var(--hth-tr-lab); text-transform: uppercase;
  color: var(--hth-label);
}

.hth-badges--mono .hth-badges__item img {
  filter: brightness(0); opacity: .55;
  transition: opacity var(--hth-fast) var(--hth-expo);
}
.hth--dark .hth-badges--mono .hth-badges__item img { filter: brightness(0) invert(1); opacity: .62; }

/* AND over a photograph, which is the other place the ink has to be white.
   Same ancestor pair §11 uses for .hth-btn, and for the same reason: the
   scrim is always dark whatever the SECTION's Background is set to, so a row
   inside a .hth--photo figure on the default sand band would otherwise be
   forced to solid black and laid on a dark image. Higher opacity than the
   dark band because the marks are competing with photographic texture rather
   than with a flat ink field — at .62 the picture reads straight through
   them. Labels need no rule: §7's .hth--photo block already puts
   --hth-label at white 68%, and .hth-badges__label follows it. */
.hth--photo .hth-badges--mono .hth-badges__item img { filter: brightness(0) invert(1); opacity: .78; }
.hth--photo a.hth-badges__item:hover img { opacity: 1; }

/* Only the linked ones respond — a hover state on a static image is a
   promise of interaction the element cannot keep. */
a.hth-badges__item:hover img { opacity: 1; }

/* Shrink rather than let them wrap one-per-line: four badges stacked
   vertically stops being a trust row and becomes a list of demands. */
@media (max-width: 700px) {
  .hth-badges {
    gap: var(--hth-5) var(--hth-6);
    --hth-badge-h: calc(var(--hth-badge-base) * .8);
  }
}


/* =====================================================================
   16. WPBAKERY NEUTRALISERS
   ---------------------------------------------------------------------
   Lets a Raw HTML block control its own box instead of fighting the
   row wrapper. Scoped to rows you opt in by adding the class
   `hth-row` in the row's Extra Class Name field.
   ===================================================================== */
.vc_row.hth-row { margin-left: 0; margin-right: 0; }
.vc_row.hth-row > .vc_column_container { padding-left: 0; padding-right: 0; }
.vc_row.hth-row .vc_column-inner { padding-top: 0 !important; }
.vc_row.hth-row .wpb_text_column:last-child,
.vc_row.hth-row .wpb_content_element { margin-bottom: 0; }
/* Jevelin injects its own h2 margins and font stack into rows. */
.hth-section :where(h1,h2,h3,h4) { font-family: var(--hth-f-display); }

/* -----------------------------------------------------------------------
   JEVELIN'S BLOCKQUOTE DECORATION. Found September 2, 2026 while building
   the reel rail, and it was ALREADY WRONG on .hth-quote__t — which is the
   block both home-page decks name as the customer-reviews fallback.

   The parent theme styles bare `blockquote` with:

       padding: 0 25px · border-left: 2px solid #ebebeb
       margin-left: 45px · font-style: italic · line-height: 180%
       ::before  a themify icon-font glyph

   Every one of those is inherited by any <blockquote> we emit that does
   not explicitly cancel it. .hth-quote__t set family, size, line-height,
   colour and margin — so it cancelled the line-height and the margin and
   silently kept the indent, the rule, the italics and a stray "66"
   floating beside the text. It looked deliberate enough that nobody had
   questioned it.

   Cancelled once, here, for every blockquote the design system draws
   rather than per block, so a future one cannot reintroduce it. The
   ::before needs `content: none` AND a zeroed size — Jevelin sets the
   glyph in an icon font, so `content` alone still leaves its box.
   ----------------------------------------------------------------------- */
.hth-section blockquote {
  padding: 0;
  border: 0;
  margin-left: 0;
  margin-right: 0;
  font-style: normal;
  quotes: none;
}
.hth-section blockquote::before,
.hth-section blockquote::after {
  content: none;
  display: none;
}


/* =====================================================================
   16c. FEATURE INDEX — the capability sheet
   ---------------------------------------------------------------------
   Deliberately NOT the .hth-step treatment, and registered as its own
   element so the two can never be confused in the editor.

   A process step is narrative: three or four of them, a light serif
   numeral, generous air, read in order. An index is a data sheet: nine
   or more items, no numerals, no order, sans-serif titles, and the same
   hairline-above grammar as .hth-spec — because that is what it is. A
   capability list set like a story reads as marketing; set like a spec
   sheet it reads as a machine that happens to do nine things.

   Built for the TRS software chapter, where it sits on .hth--dark.
   Works on any field — the rule and the accent both come from tokens.
   ===================================================================== */
.hth-features {
  display: grid;
  grid-template-columns: repeat(var(--hth-fi-cols, 3), minmax(0, 1fr));
  column-gap: var(--hth-6);
  row-gap: var(--hth-7);
}

.hth-feature {
  position: relative;
  padding-top: var(--hth-4);
}

/* The signature: a 24px accent segment leading into the hairline. Draw
   both colours as ONE gradient rather than placing a blue pseudo-element
   over a grey border. Two overlapping 1px layers can land on different
   device-pixel boundaries and create the dark seam visible at the join. */
.hth-feature::before {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  width: 100%;
  height: 1px;
  background: linear-gradient(to right,
              var(--hth-accent) 0,
              var(--hth-accent) 24px,
              var(--hth-rule) 24px,
              var(--hth-rule) 100%);
}

.hth-feature__t {
  font-family: var(--hth-f-body);     /* sans, NOT the display serif —   */
  font-weight: 500;                   /* this is the whole difference    */
  font-size: var(--hth-t-b);
  line-height: 1.35;
  letter-spacing: 0;
  color: var(--hth-head);
  margin: 0 0 var(--hth-2);
}

.hth-feature__c {
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s);
  line-height: 1.55;
  color: var(--hth-label);
  margin: 0;
}
.hth-feature__c a { color: inherit; text-decoration: underline;
                    text-underline-offset: 2px; }

/* Nine items want 3×3, not 4+4+1. The block sets --hth-fi-cols; these
   two rules are the floor. */
@media (max-width: 900px) { .hth-features { grid-template-columns: repeat(2, minmax(0,1fr)); } }
@media (max-width: 560px) { .hth-features { grid-template-columns: 1fr; row-gap: var(--hth-6); } }


/* =====================================================================
   16d. SCREEN CAPTURES
   ---------------------------------------------------------------------
   A raw UI capture dropped onto the ink field looks like a screenshot
   in a bug report. One treatment, applied everywhere a screen appears,
   is what makes eight of them read as one product.

   The inset hairline is the only "bezel" — a drawn device frame dates
   faster than anything else on a page, and the physical sauna door is
   already the frame in the photography.

   Pair with .hth-ar-1610 on the media box (see §11). The ratio lives on
   the box, never on the <img>.
   ===================================================================== */
.hth-screen {
  border-radius: var(--hth-r);         /* the same 10px as everything else */
  overflow: hidden;
  background: #0E1620;                 /* darker than --hth-ink, so a      */
  box-shadow:                          /* capture with black edges still   */
    0 1px 0 rgba(255,255,255,.10) inset,  /* reads as a lit panel          */
    0 24px 64px rgba(0,0,0,.35);
}
.hth-screen > img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* On a light field the deep drop shadow is wrong — it reads as a sticker. */
.hth--sand  .hth-screen,
.hth--shell .hth-screen,
.hth--white .hth-screen { box-shadow: var(--hth-shadow); }


/* =====================================================================
   16f. CARD ROW WITH A LEFT RAIL
   ---------------------------------------------------------------------
   A layout wrapper for HTH Card Grid, not a new component. The rail
   holds an eyebrow, a heading, an explanatory paragraph and an optional
   link; the right column holds an ordinary .hth-grid of ordinary
   .hth-cards. Nothing about a card changes — same shapes, same aspect
   ratio control, same shadow, same hover.

   This is what replaces the model-matrix block. That block hard-coded
   three occupancies × two series and could describe nothing else.
   Stacked rail rows do the same job — "1 person" and its two models,
   then "2 person", then "3 person" — and also every other job of that
   shape: a wood group, an accessory group, any category on a
   collection page.

   The rail is one grid column, so its width is one number. Twelfths
   keep it in the same mental units as the rest of the system.
   ===================================================================== */
.hth-cardrail {
  display: grid;
  grid-template-columns: var(--hth-rail-w, 3fr) minmax(0, 9fr);
  gap: clamp(32px, 4vw, 72px);
  align-items: start;
}

/* The cards column MUST be able to shrink. Without minmax(0, ·) a grid
   track floors at its content's min-content width, and one long unbroken
   card title then pushes the whole row past the wrap — the same class of
   overflow bug documented at .hth-split in §7. */
.hth-cardrail__cards { min-width: 0; }

.hth-cardrail__rail { min-width: 0; position: sticky; top: 112px; }
.hth-cardrail__rail .hth-md { margin-top: var(--hth-2); }
.hth-cardrail__copy { margin-top: var(--hth-4); color: var(--hth-body); }
.hth-cardrail__cta  { margin-top: var(--hth-5); }

/* Stacked rows read as one table when a hairline separates them. It goes
   on the SECTION so it spans the full content width, not just the rail. */
.hth-cardrow--ruled > .hth-wrap {
  border-top: 1px solid var(--hth-rule);
  padding-top: var(--hth-7);
}

@media (max-width: 900px) {
  /* Below the grid's own breakpoint the rail has nowhere useful to sit,
     so it becomes an ordinary heading above the cards. */
  .hth-cardrail { grid-template-columns: 1fr; gap: var(--hth-6); }
  .hth-cardrail__rail { position: static; }
}
@media (prefers-reduced-motion: reduce) {
  .hth-cardrail__rail { position: static; }
}


/* =====================================================================
   16g. COMPARISON TABLE
   ---------------------------------------------------------------------
   A real <table>, styled to the same hairline grammar as .hth-spec.
   border-spacing rather than border-collapse: collapsed borders and
   position:sticky do not co-operate — the header's border disappears
   the moment it detaches, because a collapsed border belongs to the
   table, not the cell.

   One decoration, not three. The live tables use a filled header bar,
   alternating row fills AND coloured pills; this uses a rule above each
   row and a single tint on the column the page is arguing for. Zebra
   striping exists to help the eye track across wide rows — at four
   columns with a 62ch measure there is nothing to track.
   ===================================================================== */
.hth-cmp-scroll { overflow-x: auto; }

/* hth_inline() runs wpautop, so every field that allows a link — row
   labels, cell values, cell notes, column headings — arrives wrapped in
   <p>. Those fields render inside plain spans here rather than inside a
   .hth-rich container, so nothing else neutralises the browser's default
   1em paragraph margin: without this, every row label in the table sits
   in 16px of dead space above and below, and the whole table breathes
   like a document instead of a data sheet.

   :where() keeps it at zero specificity so a cell can still override. */
.hth-cmp :where(p) { margin: 0; }
.hth-cmp :where(p + p) { margin-top: var(--hth-3); }

/* Links inside cells inherit the .hth-rich treatment rather than the
   theme's, so a linked spec value looks like the links in body copy. */
.hth-cmp a:not(.hth-btn) {
  color: inherit; text-decoration: underline;
  text-decoration-color: var(--hth-rule);
  text-underline-offset: 3px; text-decoration-thickness: 1px;
  transition: text-decoration-color var(--hth-fast) var(--hth-expo);
}
.hth-cmp a:not(.hth-btn):hover { text-decoration-color: var(--hth-accent); }

.hth-cmp {
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;
  table-layout: fixed;
  font-family: var(--hth-f-body);
  text-align: left;
}
/* The label column carries sentences; the value columns carry ticks or a
   short claim. Equal widths would waste the table on its least important
   column. */
.hth-cmp__corner, .hth-cmp__label { width: 34%; }
.hth-cmp--4 .hth-cmp__corner, .hth-cmp--4 .hth-cmp__label { width: 28%; }

/* ---- head ----------------------------------------------------------
   NOT STICKY, DELIBERATELY. A sticky column header is the obvious thing
   to want on a twenty-row table and it cannot work here: an ancestor
   with overflow in EITHER axis becomes the sticky containing block, and
   .hth-cmp-scroll above sets overflow-x: auto, which computes overflow-y
   to auto as well. The header would then stick to a box that never
   scrolls vertically — it looks stuck at first and then scrolls away,
   which is worse than not doing it.

   Section heading rows are the answer instead: they re-state the context
   every six or eight rows, they work on a phone, and they cost nothing.
   ------------------------------------------------------------------- */
.hth-cmp thead th {
  background: var(--hth-bg);
  vertical-align: bottom;
  padding: var(--hth-3) var(--hth-5) var(--hth-4);
  border-bottom: 1px solid var(--hth-head);
}
.hth-cmp thead th:first-child { padding-left: 0; }
.hth-cmp__colname {
  display: block;
  font-family: var(--hth-f-display); font-weight: 300;
  font-size: var(--hth-t-sm); letter-spacing: var(--hth-tr-sm);
  line-height: 1.2; color: var(--hth-head);
}
.hth-cmp__colsub {
  display: block; margin-top: 4px;
  font-size: var(--hth-t-s); color: var(--hth-label);
}

/* ---- body ---------------------------------------------------------- */
.hth-cmp tbody th,
.hth-cmp tbody td {
  padding: var(--hth-4) var(--hth-5);
  border-top: 1px solid var(--hth-rule);
  vertical-align: top;
}
.hth-cmp tbody th:first-child,
.hth-cmp tfoot td:first-child { padding-left: 0; }

.hth-cmp__label { font-weight: 400; }
.hth-cmp__labeltext {
  display: block; font-size: var(--hth-t-b); line-height: 1.5;
  color: var(--hth-head);
}
.hth-cmp__rownote,
.hth-cmp__note {
  display: block; margin-top: 4px;
  font-size: var(--hth-t-s); line-height: 1.5; color: var(--hth-label);
}
.hth-cmp__value {
  display: block; font-size: var(--hth-t-b); line-height: 1.5;
  color: var(--hth-head);
}
/* A mark that sits above a value needs air under it; a mark alone in a
   cell does not. :only-child does that with no extra class. */
.hth-cmp__mark { display: block; }
.hth-cmp__mark:not(:only-child) { margin-bottom: var(--hth-2); }
.hth-cmp__mark--yes { color: var(--hth-accent); }
.hth-cmp__mark--no,
.hth-cmp__mark--na  { color: var(--hth-label); }

/* ---- section heading row ------------------------------------------- */
.hth-cmp__group th {
  padding: var(--hth-7) 0 var(--hth-3);
  border-top: 1px solid var(--hth-rule);
  font-weight: 400;
}
.hth-cmp__groupnote {
  display: block; margin-top: var(--hth-2);
  max-width: var(--hth-measure);
  font-size: var(--hth-t-s); line-height: 1.55; color: var(--hth-label);
}

/* ---- divider below a row -------------------------------------------
   Every row already carries a hairline above it, so a divider has to be
   a different thing rather than a second one of the same thing — it is
   the ink rule the table head uses, plus space either side. That makes
   it read as "this run of rows has ended", which a second --hth-rule
   hairline never would.
   ------------------------------------------------------------------- */
.hth-cmp tbody tr.has-divider > th,
.hth-cmp tbody tr.has-divider > td {
  border-bottom: 1px solid var(--hth-head);
  padding-bottom: var(--hth-6);
}
.hth-cmp tbody tr.has-divider + tr > th,
.hth-cmp tbody tr.has-divider + tr > td {
  border-top-color: transparent;   /* the divider IS the rule; don't double it */
  padding-top: var(--hth-6);
}
.hth-cmp__group + tr th,
.hth-cmp__group + tr td { border-top-color: transparent; }
.hth-cmp tbody tr:first-child th,
.hth-cmp tbody tr:first-child td { border-top: 0; }

/* ---- the highlighted column ---------------------------------------- */
.hth-cmp .is-hl { background: var(--hth-surface); }
.hth-cmp thead th.is-hl {
  border-radius: var(--hth-r) var(--hth-r) 0 0;
  /* The sticky header sits on --hth-bg; the tint has to win over it. */
  background: var(--hth-surface);
}
.hth-cmp tfoot td.is-hl { border-radius: 0 0 var(--hth-r) var(--hth-r); }

/* ---- footer link row ------------------------------------------------ */
.hth-cmp tfoot td {
  padding: var(--hth-5) var(--hth-5) var(--hth-6);
  border-top: 1px solid var(--hth-rule);
}

/* =====================================================================
   Stacked layout.

   Below 760px a three-column table is either a horizontal scrollbar or
   9px type. Neither is acceptable on the page that has to convince
   somebody to spend $8,000, so each row becomes a small block: the row
   label as its heading, then one line per column with the column name
   supplied by data-col.

   display:block on table elements removes their table semantics, which
   is a real cost — but an unreadable table has no semantics worth
   keeping either, and the ARIA roles below put them back.
   ===================================================================== */
@media (max-width: 760px) {
  .hth-cmp, .hth-cmp thead, .hth-cmp tbody, .hth-cmp tfoot,
  .hth-cmp tr, .hth-cmp th, .hth-cmp td { display: block; width: auto; }

  .hth-cmp thead { display: none; }

  .hth-cmp tbody tr {
    border-top: 1px solid var(--hth-rule);
    padding-block: var(--hth-5);
  }
  .hth-cmp tbody tr:first-child { border-top: 0; }
  .hth-cmp tbody th,
  .hth-cmp tbody td { border-top: 0; padding: 0; }

  .hth-cmp__labeltext { font-size: var(--hth-t-bl); }

  /* Two flex items: the column name, and ONE wrapper holding whatever the
     cell contains. Both alternatives were tried and both are wrong.

     Flex with the cell's children as direct items makes the mark, the
     value and the note three side-by-side columns, so "Patented Ideal
     Spectrum" wraps to one word per line in a 90px track.

     Grid with the ::before spanning every row is subtler and worse: a
     column name that wraps to two lines is taller than the row holding an
     18px tick, and a spanning item distributes its height across the
     tracks it spans. Measured result — a 5px gap under the mark in the
     "High Tech Health" column and 24px under it in "Standard infrared
     saunas", from nothing but the length of the heading. The wrapper
     makes the cell's height its own business. */
  .hth-cmp tbody td {
    display: flex;
    align-items: flex-start;
    gap: var(--hth-3);
    margin-top: var(--hth-3);
    padding-top: var(--hth-3);
    border-top: 1px solid var(--hth-rule) !important;
  }
  .hth-cmp tbody td::before {
    content: attr(data-col);
    flex: 0 0 38%;
    font-size: var(--hth-t-xs); letter-spacing: var(--hth-tr-lab);
    text-transform: uppercase; color: var(--hth-label);
    line-height: 1.6;
  }
  .hth-cmp__stack { flex: 1 1 auto; min-width: 0; }
  .hth-cmp__stack > * + * { margin-top: 4px; }
  .hth-cmp__mark { margin-bottom: 0 !important; }

  /* A tint on a stacked cell reads as a highlighted line of text rather
     than a column, so it is dropped and the rounding with it. */
  .hth-cmp .is-hl { background: none; border-radius: 0; }

  .hth-cmp__group th { padding: var(--hth-6) 0 0; border-top: 0; }
  .hth-cmp tfoot td { padding: var(--hth-4) 0 0; }
  .hth-cmp tfoot td::before { content: none; }

  /* Stacked, the divider belongs to the row block rather than to each
     cell — the cells are already separated by their own hairlines. */
  .hth-cmp tbody tr.has-divider > th,
  .hth-cmp tbody tr.has-divider > td { border-bottom: 0; padding-bottom: 0; }
  .hth-cmp tbody tr.has-divider {
    border-bottom: 1px solid var(--hth-head);
    padding-bottom: var(--hth-5);
  }
  .hth-cmp tbody tr.has-divider + tr { border-top: 0; }
  .hth-cmp tbody tr.has-divider + tr > th,
  .hth-cmp tbody tr.has-divider + tr > td { padding-top: 0; }
  .hth-cmp__group.has-divider { padding-bottom: var(--hth-4); }
}


/* =====================================================================
   16e. MODEL MATRIX — paired by occupancy
   ---------------------------------------------------------------------
   Not a six-card catalogue. The left rail makes occupancy the first
   decision, and the two fixed product columns make Foundation versus
   Smart the second. Every model remains visible: the jump links scroll,
   they never filter.

   At desktop each product is a compact horizontal object so all three
   pairs fit without turning the collection page into six near-full-screen
   cards. At tablet the occupancy label moves above a two-up pair. At
   phone width the pair stacks, retaining Foundation then Smart order.
   ===================================================================== */
.hth-model-matrix,
.hth-model-group,
.hth-model-matrix__head > span { scroll-margin-top: 112px; }

.hth-model-matrix__intro { max-width: 760px; margin-bottom: var(--hth-6); }
.hth-model-matrix__copy { margin-top: var(--hth-4); }
.hth-model-matrix__copy > * { margin: 0; }
.hth-model-matrix__copy > * + * { margin-top: var(--hth-3); }
.hth-model-matrix__offer { margin: var(--hth-4) 0 0; }

.hth-model-jumps {
  display: flex; flex-wrap: wrap; gap: var(--hth-2);
  margin-bottom: var(--hth-7);
}
.hth-model-jumps a {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: 42px; padding: 9px 15px;
  border: 1px solid var(--hth-rule); border-radius: var(--hth-r);
  font-family: var(--hth-f-body); font-size: var(--hth-t-s); font-weight: 500;
  color: var(--hth-head); text-decoration: none;
  transition: background var(--hth-fast) var(--hth-expo),
              border-color var(--hth-fast) var(--hth-expo);
}
.hth-model-jumps a:hover { background: var(--hth-surface); border-color: var(--hth-head); }
.hth-model-jumps a:focus-visible {
  outline: 2px solid var(--hth-accent); outline-offset: 3px;
}

.hth-model-matrix__head,
.hth-model-group {
  display: grid;
  grid-template-columns: minmax(140px, .32fr) repeat(2, minmax(0, 1fr));
  column-gap: var(--hth-5);
}
.hth-model-matrix__head {
  align-items: end;
  padding-bottom: var(--hth-4);
  border-bottom: 1px solid var(--hth-rule);
}
.hth-model-matrix__head > span:not(:first-child) {
  font-family: var(--hth-f-body); font-size: var(--hth-t-xs); font-weight: 500;
  letter-spacing: var(--hth-tr-lab); text-transform: uppercase;
  color: var(--hth-label);
}
.hth-model-matrix__head > span:nth-child(2) { grid-column: 2; }
.hth-model-matrix__head > span:nth-child(3) { grid-column: 3; }

.hth-model-group {
  align-items: stretch;
  padding-block: var(--hth-7);
  border-bottom: 1px solid var(--hth-rule);
}
.hth-model-group__label { grid-column: 1; align-self: start; padding-top: var(--hth-2); }
.hth-model-group__label .hth-eyebrow { margin-bottom: var(--hth-2); }
.hth-model-group__label .hth-md { font-size: clamp(24px, 2.2vw, 34px); }

.hth-model {
  display: grid; grid-template-columns: minmax(140px, 42%) minmax(0, 1fr);
  min-width: 0; overflow: hidden;
  background: var(--hth-surface); border-radius: var(--hth-r-lg);
  box-shadow: var(--hth-shadow);
}
.hth-model--foundation { grid-column: 2; }
.hth-model--smart      { grid-column: 3; }

.hth-model__media {
  min-width: 0; min-height: 100%; overflow: hidden;
  background: var(--hth-sand-deep);
}
.hth-model__media > img {
  display: block; width: 100%; height: 100%;
  padding: var(--hth-4); object-fit: contain;
  transition: transform var(--hth-slow) var(--hth-expo);
}
.hth-model:hover .hth-model__media > img { transform: scale(1.025); }

.hth-model__body {
  display: flex; flex-direction: column; min-width: 0;
  padding: var(--hth-5);
}
.hth-model__series { margin-bottom: var(--hth-2); }

.hth-model__specs { margin: var(--hth-4) 0 0; }
.hth-model__specs > div {
  display: grid; gap: 4px;
  padding-block: var(--hth-3);
  border-top: 1px solid var(--hth-rule);
}
.hth-model__specs dt {
  font-family: var(--hth-f-body); font-size: var(--hth-t-xs);
  letter-spacing: var(--hth-tr-lab); text-transform: uppercase;
  color: var(--hth-label);
}
.hth-model__specs dd {
  margin: 0; font-family: var(--hth-f-body); font-size: var(--hth-t-s);
  line-height: 1.45; color: var(--hth-head);
}

.hth-model__price { margin-top: auto; padding-top: var(--hth-5); }
.hth-model__price > span {
  display: block; margin-bottom: var(--hth-2);
  font-family: var(--hth-f-body); font-size: var(--hth-t-xs);
  letter-spacing: var(--hth-tr-lab); text-transform: uppercase;
  color: var(--hth-label);
}
.hth-model__price > strong {
  display: block;
  font-family: var(--hth-f-display); font-size: var(--hth-t-md);
  font-weight: 300; line-height: 1; letter-spacing: var(--hth-tr-md);
  color: var(--hth-head);
}
.hth-model__variant {
  margin: var(--hth-2) 0 0;
  font-family: var(--hth-f-body); font-size: var(--hth-t-s);
  line-height: 1.45; color: var(--hth-label);
}
.hth-model__body .hth-btn { align-self: flex-start; margin-top: var(--hth-5); }

.hth-model-advisor {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--hth-6); padding-top: var(--hth-7); margin-top: var(--hth-3);
}
.hth-model-advisor > p { margin: 0; }

@media (max-width: 1099px) {
  .hth-model-matrix__head,
  .hth-model-group { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .hth-model-matrix__head > span:first-child { display: none; }
  .hth-model-matrix__head > span:nth-child(2) { grid-column: 1; }
  .hth-model-matrix__head > span:nth-child(3) { grid-column: 2; }

  .hth-model-group__label { grid-column: 1 / -1; padding: 0 0 var(--hth-5); }
  .hth-model--foundation { grid-column: 1; }
  .hth-model--smart      { grid-column: 2; }
  .hth-model { grid-template-columns: 1fr; }
  .hth-model__media { min-height: 0; }
}

@media (max-width: 700px) {
  .hth-model-matrix__head { display: none; }
  .hth-model-group { grid-template-columns: 1fr; row-gap: var(--hth-5); }
  .hth-model-group__label,
  .hth-model--foundation,
  .hth-model--smart { grid-column: 1; }
  .hth-model { grid-template-columns: minmax(150px, 42%) minmax(0, 1fr); }
  .hth-model-advisor { align-items: flex-start; flex-direction: column; }
}

@media (max-width: 560px) {
  .hth-model { grid-template-columns: 1fr; }
  .hth-model__body .hth-btn,
  .hth-model-advisor .hth-btn { width: 100%; justify-content: center; }
}


/* =====================================================================
   16f. FAQ — native disclosure, typographic rather than boxed
   ---------------------------------------------------------------------
   <details>/<summary> supplies state, keyboard interaction and a usable
   no-JavaScript fallback. The only visual state is the plus/minus mark;
   there are no cards, alternating fills or animation delays between a
   question and its answer.
   ===================================================================== */
.hth-faq { max-width: 900px; scroll-margin-top: 112px; }
.hth-faq__header { max-width: 760px; margin-bottom: var(--hth-8); }
.hth-faq__intro { margin-top: var(--hth-4); }
.hth-faq__intro > * { margin: 0; }
.hth-faq__intro > * + * { margin-top: var(--hth-3); }

.hth-faq__items { border-top: 1px solid var(--hth-rule); }
.hth-faq__item { border-bottom: 1px solid var(--hth-rule); }
.hth-faq__item summary {
  position: relative; display: flex; align-items: center;
  min-height: 72px; padding: var(--hth-5) 56px var(--hth-5) 0;
  list-style: none; cursor: pointer;
  font-family: var(--hth-f-body); font-size: var(--hth-t-b);
  font-weight: 500; line-height: 1.45; color: var(--hth-head);
}
.hth-faq__item summary::-webkit-details-marker { display: none; }
.hth-faq__item summary::marker { content: ""; }
.hth-faq__item summary::after {
  content: "+";
  position: absolute; inset-inline-end: 0; inset-block-start: 50%;
  display: grid; place-items: center;
  width: 44px; height: 44px; translate: 0 -50%;
  font-family: var(--hth-f-body); font-size: 24px; font-weight: 400;
  color: var(--hth-accent);
}
.hth-faq__item[open] summary::after { content: "−"; }
.hth-faq__item summary:hover { color: var(--hth-accent); }
.hth-faq__item summary:focus-visible {
  outline: 2px solid var(--hth-accent); outline-offset: 4px;
}
.hth-faq__answer {
  max-width: 70ch;
  padding: 0 56px var(--hth-6) 0;
  font-family: var(--hth-f-body); font-size: var(--hth-t-b);
  line-height: 1.70; color: var(--hth-body);
}
.hth-faq__answer > * { margin: 0; }
.hth-faq__answer > * + * { margin-top: var(--hth-4); }
.hth-faq__answer a {
  color: var(--hth-head); text-decoration: underline;
  text-decoration-color: var(--hth-accent); text-underline-offset: 3px;
}

.hth-faq__contact {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--hth-6); margin-top: var(--hth-7);
}
.hth-faq__contact > p { margin: 0; }

@media (max-width: 560px) {
  .hth-faq__header { margin-bottom: var(--hth-7); }
  .hth-faq__item summary { min-height: 64px; padding-right: 48px; }
  .hth-faq__answer { padding-right: 0; }
  .hth-faq__contact { align-items: flex-start; flex-direction: column; }
  .hth-faq__contact .hth-btn { width: 100%; justify-content: center; }
}


/* =====================================================================
   16g. CONTACT RAIL — immediate human contact + payment reassurance
   ---------------------------------------------------------------------
   A high-ticket landing page needs a short path to a person. This is a
   utility, not a second hero: contact actions receive compact controls,
   while financing and HSA/FSA stay visually quieter on the far side of
   one hairline. No carousel and no overflow rail on phone screens.
   ===================================================================== */
.hth-contact-rail {
  display: flex; align-items: center; gap: var(--hth-5);
  min-width: 0;
}
.hth-contact-rail__eyebrow {
  flex: 0 0 auto; max-width: 170px; margin: 0;
}

.hth-contact-rail__actions {
  display: flex; flex: 1 1 auto; align-items: stretch;
  min-width: 0; gap: var(--hth-2);
}
.hth-contact-rail__chip {
  display: inline-flex; align-items: center; justify-content: flex-start;
  gap: 10px; min-height: 48px; min-width: 0;
  padding: 9px 14px;
  border: 1px solid var(--hth-rule); border-radius: var(--hth-r);
  background: transparent; color: var(--hth-head); text-decoration: none;
  font-family: var(--hth-f-body);
  transition: background var(--hth-fast) var(--hth-expo),
              border-color var(--hth-fast) var(--hth-expo),
              translate var(--hth-fast) var(--hth-expo);
}
.hth-contact-rail__chip:hover {
  background: var(--hth-surface); border-color: var(--hth-head);
  translate: 0 -1px;
}
.hth-contact-rail__chip:focus-visible,
.hth-contact-rail__payment:focus-visible {
  outline: 2px solid var(--hth-accent); outline-offset: 3px;
}
.hth-contact-rail__chip--lead {
  background: var(--hth-ink); border-color: var(--hth-ink); color: #fff;
}
.hth-contact-rail__chip--lead:hover {
  background: var(--hth-ink-2); border-color: var(--hth-ink-2); color: #fff;
}
.hth--dark .hth-contact-rail__chip--lead {
  background: #fff; border-color: #fff; color: var(--hth-ink);
}
.hth-contact-rail__icon {
  display: grid; place-items: center; flex: 0 0 20px;
  width: 20px; height: 20px; color: currentColor;
}
.hth-contact-rail__icon svg { display: block; width: 20px; height: 20px; }
.hth-contact-rail__chip > span:last-child { min-width: 0; }
.hth-contact-rail__chip small {
  display: block; margin-bottom: 1px;
  font-size: 10px; line-height: 1.2; font-weight: 500;
  letter-spacing: var(--hth-tr-lab); text-transform: uppercase;
  color: var(--hth-label);
}
.hth-contact-rail__chip--lead small { color: rgba(255,255,255,.68); }
.hth--dark .hth-contact-rail__chip--lead small { color: var(--hth-ink-3); }
.hth-contact-rail__chip strong {
  display: block; overflow: hidden; text-overflow: ellipsis;
  font-size: var(--hth-t-s); line-height: 1.35; font-weight: 500;
  white-space: nowrap; color: inherit;
}

.hth-contact-rail__separator {
  flex: 0 0 1px; align-self: stretch;
  width: 1px; min-height: 44px; background: var(--hth-rule);
}
.hth-contact-rail__payments {
  display: flex; flex: 0 0 auto; align-items: stretch; gap: var(--hth-2);
}
.hth-contact-rail__payment {
  display: flex; flex-direction: column; justify-content: center;
  min-width: 102px; min-height: 48px; padding: 7px 10px;
  border-radius: var(--hth-r); color: var(--hth-head); text-decoration: none;
  transition: background var(--hth-fast) var(--hth-expo);
}
.hth-contact-rail__payment:hover { background: var(--hth-surface); }
.hth-contact-rail__payment-label {
  margin-bottom: 4px;
  font-family: var(--hth-f-body); font-size: 10px; line-height: 1.2;
  font-weight: 500; letter-spacing: var(--hth-tr-lab); text-transform: uppercase;
  color: var(--hth-label);
}
.hth-contact-rail__provider {
  display: flex; align-items: center; min-height: 18px;
  font-family: var(--hth-f-body); font-size: var(--hth-t-s);
  line-height: 1.2; font-weight: 600; color: var(--hth-head);
}
.hth-contact-rail__logo {
  display: block; width: auto; height: 18px; max-width: 92px;
  object-fit: contain; object-position: left center;
  opacity: .78;
  transition: opacity var(--hth-fast) var(--hth-expo);
}
/* Never monochrome payment raster logos with brightness(0). A mark such as
   Klarna has opaque brand colour behind dark lettering, so flattening every
   opaque pixel to black turns the whole image into one blank rectangle. On a
   dark rail, preserve the artwork and give it a small neutral backing. */
.hth--dark .hth-contact-rail__provider--logo {
  width: max-content; padding: 3px 6px;
  border-radius: 4px; background: #fff;
}
.hth-contact-rail__payment:hover .hth-contact-rail__logo { opacity: 1; }

@media (max-width: 1180px) {
  .hth-contact-rail { flex-wrap: wrap; }
  .hth-contact-rail__eyebrow { flex-basis: 100%; max-width: none; }
  .hth-contact-rail__actions { flex-grow: 1; }
}

@media (max-width: 800px) {
  .hth-contact-rail { align-items: stretch; flex-direction: column; gap: var(--hth-4); }
  .hth-contact-rail__actions { width: 100%; }
  .hth-contact-rail__separator {
    width: 100%; height: 1px; min-height: 1px;
  }
  .hth-contact-rail__payments { width: 100%; }
  .hth-contact-rail__payment { flex: 1 1 0; }
}

@media (max-width: 560px) {
  .hth-contact-rail__actions {
    display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .hth-contact-rail__chip { width: 100%; }
  .hth-contact-rail__chip--callback { grid-column: 1 / -1; }
  .hth-contact-rail__payments {
    display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .hth-contact-rail__payment { min-width: 0; }
}


/* =====================================================================
   17. ACCESSIBILITY + PRINT
   ===================================================================== */
@media (prefers-reduced-motion: reduce) {
  .hth-reveal-ready .hth-reveal,
  .hth-reveal-ready .hth-settle > img { opacity: 1 !important; transform: none !important;
    transition: none !important; }
  .hth-pin { height: auto; }
  .hth-pin__stage { position: static;
                    height: auto;   /* see the note in §12b — never clip */
                    min-height: min(72svh, calc(var(--hth-pin-stage, 1) * 100svh));
                    padding-top: calc(var(--hth-gut) + var(--hth-pin-head, 0px)); }
  .hth-btn svg { transition: none; }
  .hth-model__media > img { transition: none; }
  .hth-contact-rail__chip,
  .hth-contact-rail__logo { transition: none; }
  .hth-contact-rail__chip:hover { translate: none; }
  /* The gallery stays usable — snap still works, it just doesn't glide. */
  .hth-gallery__track { scroll-behavior: auto; }
  .hth-gallery__nav { opacity: 1; }
}
.hth-sr {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
@media print {
  .hth-pin { height: auto; } .hth-pin__stage { position: static; height: auto; }
  .hth-reveal { opacity: 1 !important; transform: none !important; }
}


/* =====================================================================
   18. JEVELIN TABLE OVERRIDE — the comparison table only
   ---------------------------------------------------------------------
   MEASURED, not guessed. Inspected on a live preview page:

     Jevelin style.css   table td, table th { border: 1px solid #e5e5e5
                                              !important }
                         table td           { padding: 15px 20px
                                              !important }
                         .sh-jevelin-style3 table td, th { border: 0 }

   Two consequences, both of which showed up as "the design system is
   broken" rather than as anything a person could name:

   1. BORDERS. The theme declares them !important, so §16g's hairlines,
      the head rule and the row divider could never win — regardless of
      specificity, because !important outranks everything that isn't.
      The style3 skin then zeroes them, which is why the table rendered
      with NO rules at all rather than with Jevelin's grey ones.

   2. PADDING. Every cell was 15px 20px, not the §16g spacing. That is
      why a divider row looked identical to an ordinary one even once
      its border was drawn — the extra breathing space that makes a
      divider read as a break was being thrown away too.

   The fix has to clear both and re-declare at a weight nothing can
   outrank. `table.hth-cmp` is (0,1,3), which beats Jevelin's most
   specific table selector at (0,1,2) outright — so this does not depend
   on stylesheet load order, which WP Rocket and Autoptimize are both
   entitled to change.

   Scoped entirely to .hth-cmp. WooCommerce's cart, checkout and any
   editor-inserted table keep Jevelin's styling untouched.

   The two branches are explicit rather than relying on this block
   sitting after §16g's media query — same reasoning as above.
   ===================================================================== */

table.hth-cmp,
table.hth-cmp > thead, table.hth-cmp > tbody, table.hth-cmp > tfoot,
table.hth-cmp tr, table.hth-cmp th, table.hth-cmp td {
  border: 0 !important;
  padding: 0 !important;
  background-image: none !important;
}
table.hth-cmp { border-collapse: separate !important; border-spacing: 0 !important; }

@media (min-width: 761px) {
  table.hth-cmp thead th {
    padding: var(--hth-3) var(--hth-5) var(--hth-4) !important;
    border-bottom: 1px solid var(--hth-head) !important;
  }
  table.hth-cmp thead th:first-child { padding-left: 0 !important; }

  table.hth-cmp tbody th,
  table.hth-cmp tbody td {
    padding: var(--hth-4) var(--hth-5) !important;
    border-top: 1px solid var(--hth-rule) !important;
  }
  table.hth-cmp tbody th:first-child { padding-left: 0 !important; }

  table.hth-cmp tbody tr:first-child > th,
  table.hth-cmp tbody tr:first-child > td { border-top: 0 !important; }

  table.hth-cmp tbody tr.hth-cmp__group > th {
    padding: var(--hth-7) 0 var(--hth-3) !important;
    border-top: 1px solid var(--hth-rule) !important;
  }
  table.hth-cmp tbody tr.hth-cmp__group + tr > th,
  table.hth-cmp tbody tr.hth-cmp__group + tr > td { border-top-color: transparent !important; }

  table.hth-cmp tbody tr.has-divider > th,
  table.hth-cmp tbody tr.has-divider > td {
    border-bottom: 1px solid var(--hth-head) !important;
    padding-bottom: var(--hth-6) !important;
  }
  table.hth-cmp tbody tr.has-divider + tr > th,
  table.hth-cmp tbody tr.has-divider + tr > td {
    border-top-color: transparent !important;
    padding-top: var(--hth-6) !important;
  }

  table.hth-cmp tfoot td {
    padding: var(--hth-5) var(--hth-5) var(--hth-6) !important;
    border-top: 1px solid var(--hth-rule) !important;
  }
  table.hth-cmp tfoot td:first-child { padding-left: 0 !important; }
}

@media (max-width: 760px) {
  /* Stacked: the rule belongs to the row block, and each cell carries a
     hairline separating it from the one above. */
  table.hth-cmp tbody tr {
    border-top: 1px solid var(--hth-rule) !important;
    padding-block: var(--hth-5) !important;
  }
  table.hth-cmp tbody tr:first-child { border-top: 0 !important; }

  table.hth-cmp tbody th { padding: 0 !important; border: 0 !important; }
  table.hth-cmp tbody td {
    padding: var(--hth-3) 0 0 !important;
    border-top: 1px solid var(--hth-rule) !important;
  }

  table.hth-cmp tbody tr.hth-cmp__group > th {
    padding: var(--hth-6) 0 0 !important;
    border-top: 0 !important;
  }

  table.hth-cmp tbody tr.has-divider {
    border-bottom: 1px solid var(--hth-head) !important;
    padding-bottom: var(--hth-5) !important;
  }
  table.hth-cmp tbody tr.has-divider > th,
  table.hth-cmp tbody tr.has-divider > td { border-bottom: 0 !important; }
  table.hth-cmp tbody tr.has-divider + tr { border-top: 0 !important; }

  table.hth-cmp tfoot td { padding: var(--hth-4) 0 0 !important; border: 0 !important; }
}


/* =====================================================================
   19. WOOD SWATCHES
   ---------------------------------------------------------------------
   A row of circular material samples with a name under each. Rebuilds
   the swatch row from the old /infrared-saunas/ page, which lived in a
   Raw HTML block with its own inline styles.

   A PRIMITIVE, NOT A BLOCK — and this is the reason it exists at all.
   The natural place for these is inside a Split Feature's body copy,
   under the paragraph, exactly where the old page had them. That field
   runs through wp_kses_post, which passes `class` but filters `style`
   through safecss_filter_attr's property allowlist — and the properties
   a circular swatch needs (object-fit, aspect-ratio, gap, and grid in
   older WordPress) are not dependably on it. An editor pasting styled
   HTML would watch half their CSS silently vanish on save, with nothing
   to indicate why.

   So the styling lives here and the pasted markup carries classes only.
   That is also the better outcome: swap the palette once and every
   swatch row on the site follows.

   The image is cropped by object-fit rather than by a square source, so
   any photograph works and nothing distorts.
   ===================================================================== */
/* GRID, NOT FLEX — and the reason is worth writing down.
   ---------------------------------------------------------------------
   The first version was a flex row with a gap. The GAPS were equal and
   the swatches still looked wrong, because a flex item is sized by its
   content: "European Poplar (aspen)" wraps to two lines and is wider
   than "Okoume", so the three circles sat at irregular intervals even
   though the space between the tiles was identical. Equal gaps between
   unequal boxes is not even spacing — what the eye tracks is the
   distance between the circles.

   Equal-width columns fix it at the source: each tile occupies the same
   track and its circle centres within it, so the intervals are equal no
   matter how long the names get. It also restores the old page's
   layout, where the swatches spread across the full width of the copy.

   auto-fit rather than a fixed count so the row wraps on its own when
   the tracks would drop below --hth-swatch-col, instead of squeezing
   five swatches into an unreadable line. */
.hth-swatches {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(var(--hth-swatch-col, 120px), 1fr));
  gap: clamp(20px, 3vw, 40px) clamp(12px, 2vw, 24px);
  align-items: start;
  justify-items: center;
  margin: var(--hth-6) 0 0;
  padding: 0;
  list-style: none;
}

/* Tighter alternative: equal tracks at a fixed width, grouped to the
   left rather than spread across the row. Same even intervals. */
.hth-swatches--pack {
  grid-template-columns: repeat(auto-fit, var(--hth-swatch-col, 120px));
  justify-content: start;
}

.hth-swatch {
  text-align: center;
  max-width: 160px;
}

/* The row is a <ul> pasted INSIDE .hth-rich, so it inherits that block's
   list treatment: 22px of left padding and an accent dash drawn by
   ::before on every item. Both have to go, and both need two classes to
   outrank `.hth-rich ul li` at (0,1,2). Vertical rhythm comes from the
   flex gap, so the inherited li margin goes too. */
.hth-swatches .hth-swatch { padding-left: 0; margin-top: 0; }
.hth-swatches .hth-swatch::before { content: none; }

.hth-swatch img {
  display: block;
  width: var(--hth-swatch, 96px);
  height: var(--hth-swatch, 96px);
  border-radius: 50%;
  object-fit: cover;
  margin-inline: auto;
  background: var(--hth-sand-deep);   /* shows while the image decodes */
}

.hth-swatch__n {
  display: block;
  margin-top: var(--hth-3);
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s);
  font-weight: 500;
  line-height: 1.35;
  color: var(--hth-head);
}

/* Optional second line — "Foundation only", "Smart series only". The
   thing a buyer most needs to know about a wood is often which models
   it comes on. */
.hth-swatch__note {
  display: block;
  margin-top: 2px;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs);
  line-height: 1.4;
  color: var(--hth-label);
}

/* A swatch may be wrapped in a link to its wood page. The whole tile is
   the target; only the name shows an affordance. */
.hth-swatch a {
  display: block;
  color: inherit;
  text-decoration: none;
}
.hth-swatch a .hth-swatch__n {
  text-decoration: underline;
  text-decoration-color: var(--hth-rule);
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: text-decoration-color var(--hth-fast) var(--hth-expo);
}
.hth-swatch a:hover .hth-swatch__n { text-decoration-color: var(--hth-accent); }

.hth-swatches--sm { --hth-swatch: 72px; }
.hth-swatches--lg { --hth-swatch: 128px; }

@media (max-width: 480px) {
  .hth-swatches { gap: var(--hth-5); }
  .hth-swatches { --hth-swatch: 72px; }
}


/* =====================================================================
   20. CARD MARKS — a swatch, logo or icon inside a card
   ---------------------------------------------------------------------
   A card body could not previously hold an image. Its Copy field runs
   through hth_inline(), whose kses allowlist is a/strong/b/em/i/del/s/br,
   so an <img> pasted there is stripped on save with nothing to say why —
   the same reason §19 exists as a primitive rather than as pasted HTML.
   The slot therefore has to be a real field, and this is its CSS.

   NOTHING HERE DRAWS A MARK. Two primitives already do, and the block
   emits one tree or the other rather than inventing a third:

     §19  .hth-swatches / .hth-swatch — circular, object-fit:cover, a name
          line and an optional note. Any photograph works. The wood chip.
     §15i .hth-badges — normalised on HEIGHT with object-fit:contain and
          an optional monochrome filter. The logo, badge or icon.

   This section only does what a CARD changes about them: kill the
   standalone top margin, pack the tiles to the left instead of spreading
   them across a page-width row, and re-scale for a box a third of a page
   wide. Later in the file than both, and the same specificity, so it wins
   on source order. Do not move §20 above §15i or §19.

   SIZE ARRIVES AS --hth-mark-base, AND ONLY AS THAT.
   Same trap §15i documents. The block sets --hth-mark-base inline; the
   variables actually used (--hth-swatch, --hth-badge-h) are DERIVED here,
   so the phone step-down at the bottom can still reach them. Setting
   either of those inline would beat every stylesheet rule, media query
   included, and the mobile size would silently do nothing.
   ===================================================================== */
.hth-card__marks {
  --hth-mark-base: 96px;
  /* §19 carries --hth-6 of top margin and §15i --hth-8, both correct when
     the row is the only thing in a column. In a card the body's flex gap
     already owns the vertical rhythm and the margin doubles it. */
  margin: 0;
}

/* ---- Swatch style --------------------------------------------------- */
.hth-card__marks--swatch {
  --hth-swatch: var(--hth-mark-base);
  /* One track per circle, rather than §19's 120px minimum. A card is not
     a page: at three columns that minimum is wider than the card itself,
     so auto-fit would collapse to a single track and the marks would
     stack vertically — a column of circles, not a row of samples. */
  --hth-swatch-col: var(--hth-mark-base);
  gap: var(--hth-4) var(--hth-5);
}

/* A fixed track does not grow to fit its item, and §19's tile is centred
   at its max-content width up to 160px — so a two-word label in a 96px
   track overflows it, and .hth-card clips overflow. Constrain the tile to
   its track and let the label wrap instead. */
.hth-card__marks--swatch .hth-swatch { max-width: 100%; }

/* ---- Logo style ----------------------------------------------------- */
.hth-card__marks--logo {
  --hth-badge-h: var(--hth-mark-base);
  --hth-badge-w: calc(var(--hth-badge-h) * 4);
  gap: var(--hth-3) var(--hth-5);
}

/* §15i has one label line; the swatch has two, and an editor should not
   have to learn which field disappears in which style. So the badge item
   gets the second line here rather than the block growing a third label
   vocabulary. Not uppercase — it is a sentence, not a category. */
.hth-card__marks .hth-badges__note {
  display: block;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs);
  line-height: 1.4;
  color: var(--hth-label);
  text-transform: none;
  letter-spacing: 0;
}

/* ---------------------------------------------------------------------
   PLACEMENT — beside the title
   ---------------------------------------------------------------------
   The kicker and the title are normally direct children of the flex
   column that is .hth-card__body, and they inherit its gap. To put a mark
   beside them they have to be wrapped, and the wrapper has to reproduce
   that gap exactly — including the wider one the rich modifier sets —
   or a grid with marks would sit a few pixels off one without.
   --------------------------------------------------------------------- */
.hth-card__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--hth-4);
}
.hth-card__head-text {
  flex: 1 1 auto; min-width: 0;
  display: flex; flex-direction: column; gap: var(--hth-3);
}
.hth-card--rich .hth-card__head-text { gap: var(--hth-4); }

/* Never squeezed by a long title, and nudged down so a circle sits on the
   kicker's optical line rather than its cap line. */
.hth-card__marks--title { flex: 0 0 auto; margin-top: 2px; }

/* PLACEMENT — under the copy needs no rule at all. The body is a flex
   column and the mark row is an ordinary item in it. Written down so the
   absence reads as a decision rather than an omission. */

/* ---------------------------------------------------------------------
   PHONE
   ---------------------------------------------------------------------
   The card is full width by then, so the marks have room — what they lose
   is the space around them. min() rather than a flat value, so a grid
   already set to Small is not scaled UP on a phone.
   --------------------------------------------------------------------- */
@media (max-width: 480px) {
  .hth-card__marks--swatch {
    --hth-swatch:     min(var(--hth-mark-base), 72px);
    --hth-swatch-col: min(var(--hth-mark-base), 72px);
  }
  .hth-card__marks--logo { --hth-badge-h: min(var(--hth-mark-base), 32px); }
}


/* =====================================================================
   20b. CARD INLINE IMAGES — a strip in the body, sized by height
   ---------------------------------------------------------------------
   Used by inc/blocks/card-grid.php. The card's own picture is the media
   box at the top: one shape for the whole grid, cover crop, and it says
   HERE IS THE THING. This one says HERE IS HOW BIG IT IS, or HERE ARE
   THE DETAILS — so it is sized card by card, nothing is ever cropped,
   and one card can hold several pictures side by side.

   HEIGHT IS THE SHARED DIMENSION, WIDTH IS NOT.
   Several images in a row read as one set only if they share a baseline
   AND a cap height. Sized by width, three photographs of different
   proportions give three different heights and the strip becomes a
   skyline. So the height is set and every width follows its own file —
   which means the strip is usually wider than the card. That is the
   feature, not a fault: a picture cut off by the card edge is what says
   there is more to drag to.

   WHAT ARRIVES INLINE, AND ONLY THIS.
     --hth-inline-h     the height every image in this card is drawn at.
     --hth-inline-slot  on the GRID: the tallest height in the row, so
                        every card reserves the same space and the
                        pictures stand on one ground line.
     --hth-inline-w     LEGACY, single image, from the width field this
     --hth-inline-p     replaced — see the block. Both are dead the day
                        the last `inlinew` is gone from post_content.

   The corner radius arrives as a CLASS instead — --r-soft or --r-round,
   or nothing at all for square. See the corners note below for why that
   one is not a variable.

   The block sets the slot only when it knows every card's height, and
   there is deliberately no CSS fallback. A wrong slot crops nothing and
   breaks nothing; it puts a hole in one card on one page, which is the
   hardest kind of fault to be told about.
   ===================================================================== */
.hth-card__inline { margin: 0; }

/* ---- the strip ------------------------------------------------------
   A scroll rail, and the third in the file — §13b's gallery snaps one
   full-width image at a time, §23's reel rail runs fixed-width tiles
   across a page. This one runs images of whatever width they turn out
   to be inside a box a third of a page wide, so it borrows the parts
   that are the same (hidden scrollbar, proximity snap, gutter bleed)
   and none of the parts that are not.

   IT BLEEDS OVER THE BODY PADDING, which is the §23 trick: pull the box
   out by the padding, then put the padding back INSIDE as padding and
   scroll-padding. The first image still lines up with the text, the
   last one can still be scrolled clear of the edge, and what a visitor
   sees mid-scroll is an image running under the card's own edge rather
   than stopping short of it in a way that reads as a mistake.
   --------------------------------------------------------------------- */
.hth-card__inline-track {
  display: flex;
  /* BOTTOM, not centre. Objects compared for size share a ground line;
     centring them floats the small one and the row stops reading as a
     scale drawing and starts reading as clip art. */
  align-items: flex-end;
  gap: var(--hth-3);

  overflow-x: auto;
  /* A drag that runs past the end must not scroll the page or trigger a
     browser back-swipe. */
  overscroll-behavior-x: contain;
  /* PROXIMITY, not mandatory — §23's reasoning exactly. These are images
     of unequal width, and mandatory snap on a rail whose items do not
     divide the box evenly fights every drag. */
  scroll-snap-type: x proximity;

  scrollbar-width: none;              /* Firefox                        */
  -ms-overflow-style: none;           /* old Edge                       */

  margin-inline:        calc(-1 * var(--hth-5));
  padding-inline:       var(--hth-5);
  scroll-padding-inline: var(--hth-5);
}
.hth-card__inline-track::-webkit-scrollbar { display: none; }

/* The rich modifier widens the body padding, so the bleed has to widen
   with it or the strip stops short of the card edge on exactly the
   grids that gave it the most room. */
.hth-card--rich .hth-card__inline-track {
  margin-inline:        calc(-1 * var(--hth-6));
  padding-inline:       var(--hth-6);
  scroll-padding-inline: var(--hth-6);
}

/* `safe` matters: a centred flex row that overflows puts its first item
   at a NEGATIVE offset, where no amount of scrolling can reach it. Safe
   alignment falls back to start the moment the content does not fit.
   A browser that does not know the keyword drops the whole declaration
   and lands on start, which is the same answer. */
.hth-card__inline--left   .hth-card__inline-track { justify-content: flex-start; }
.hth-card__inline--center .hth-card__inline-track { justify-content: safe center; }
.hth-card__inline--right  .hth-card__inline-track { justify-content: safe flex-end; }

.hth-card__inline-track > img {
  display: block;
  flex: 0 0 auto;
  height: var(--hth-inline-h, auto);
  width: auto;
  max-width: none;
  scroll-snap-align: start;
  /* The pointer drag below owns this gesture; the browser's own image
     drag would otherwise start a ghost-image drag on the first move. */
  -webkit-user-drag: none;
  user-select: none;
}

/* No height set and nothing legacy — a single image, filling the body,
   which is what the field did before it could hold more than one. Its
   own class rather than a var fallback: an unset --hth-inline-h resolves
   to `auto`, and an auto-height image with no width cap paints at its
   intrinsic size. */
.hth-card__inline--fill .hth-card__inline-track > img {
  height: auto;
  width: 100%;
  max-width: 100%;
}

/* ---- corners ---------------------------------------------------------
   A CLASS, NOT A VARIABLE, and for once that is not the usual reason.
   Nothing here needs to be reached by a later media query, so the trap
   §15i and §20 document does not apply; a class is chosen because SQUARE
   has to be a real answer that emits no declaration at all, and "square"
   spelled as a custom property is `border-radius: 0`, which is a rule
   that beats anything a future section might want to set on a specific
   strip. An absent class cannot beat anything.

   The block defaults to soft, so every strip already on the site rounds
   on the next page load with nothing to go back and set. The two values
   are the site's own — §1's --hth-r and --hth-r-lg — rather than numbers
   invented here, so the strip rounds by the same amount as the buttons
   under it and the card around it.

   ROUNDING IS ONLY VISIBLE ON AN IMAGE THAT HAS AN EDGE. A card surface
   is --hth-shell or white, so a cut-out product shot or a line drawing
   on a white background has no corner to round and this does nothing at
   all — correctly. On a TRUE SCALE DRAWING it can do slightly worse than
   nothing by clipping linework that runs into the corner, which is why
   Square survives as an option rather than being dropped as the old
   behaviour. Both are the block's editor note to give, not the CSS's.

   It applies to every sizing path — height, fill and legacy — because it
   is drawn on the image and those three only ever set its box.
   --------------------------------------------------------------------- */
.hth-card__inline--r-soft  .hth-card__inline-track > img { border-radius: var(--hth-r); }
.hth-card__inline--r-round .hth-card__inline-track > img { border-radius: var(--hth-r-lg); }

/* NO HOVER SCALE, unlike §15's media box. That one grows because it is a
   crop and there is more photograph behind the frame; these are already
   whole, and a drawing that swells under the pointer reads as a fault.
   Written down so the absence is a decision. */

/* ---- drag ------------------------------------------------------------
   The cursor is a promise, so it is only made where the script has
   found something to drag. Snap and smooth scrolling are both switched
   off mid-drag: a snap point fights a scrollLeft set every pointermove,
   and the result is a strip that stutters backwards under the hand. */
.hth-card__inline-track.is-draggable { cursor: grab; }
.hth-card__inline-track.is-dragging {
  cursor: grabbing;
  scroll-snap-type: none;
  scroll-behavior: auto;
}

/* ---- the arrows ------------------------------------------------------
   .hth-reels__btn IS the button — the same object §23 defines, not a
   copy of it, so there is one square ghost control on the site and one
   place to change it. This section only steps it down for a box a third
   of a page wide, and lays the pair out under the pictures.

   Hidden until the script finds a strip that actually overflows: a
   button that scrolls nothing is worse than no button, and whether one
   overflows depends on the card width, which only a browser knows. */
.hth-card__inline-nav {
  display: flex;
  gap: var(--hth-2);
  margin-top: var(--hth-3);
}
.hth-card__inline-nav[hidden] { display: none; }
.hth-card__inline--center .hth-card__inline-nav { justify-content: center; }
.hth-card__inline--right  .hth-card__inline-nav { justify-content: flex-end; }

.hth-card__inline-btn { width: 32px; height: 32px; }
.hth-card__inline-btn svg { width: 14px; height: 14px; }

/* Touch has the drag itself, which is the better control and needs no
   chrome — the same call §13b and §23 both make. */
@media (hover: none) {
  .hth-card__inline-nav { display: none; }
}

/* ---- the shared slot -------------------------------------------------
   Carried on the grid, so a card with no inline images reserves nothing
   — an empty band in a card that has no pictures would be worse than
   the misalignment it is trying to fix. */
.hth-grid--inline-slot .hth-card__inline-track { min-height: var(--hth-inline-slot, 0); }

/* ---- LEGACY: the single image sized by width -------------------------
   Everything below reproduces the behaviour of the width field this
   replaced, for cards saved before it existed: natural height, capped
   to the card, and shrunk in proportion with its neighbours rather than
   alone. --hth-inline-p is its share of the widest in the grid, and the
   min() is what makes a narrow screen scale the set together — clamping
   with max-width alone would shrink the widest one and quietly rewrite
   the ratio the widths existed to express.

   Delete this whole section, and the two media queries under it, once
   `inlinew` is gone from post_content.
   --------------------------------------------------------------------- */
.hth-card__inline--legacy .hth-card__inline-track > img {
  height: auto;
  width: min(var(--hth-inline-w, 100%), var(--hth-inline-p, 100%));
  max-width: 100%;
}

/* A legacy width is a px number chosen for the desktop card, and the
   images under it shrink with the viewport — so its slot stops being
   the right height exactly when the cards stack and there is no row
   left to align against. A height-sized strip has neither problem: the
   height is the height at every width, so its slot stands.
   THESE TWO BREAKPOINTS ARE §8'S GRID COLLAPSE. If those move, move these. */
@media (max-width: 900px) {
  .hth-grid--inline-slot:not(.hth-grid--4)
    .hth-card__inline--legacy .hth-card__inline-track { min-height: 0; }
}
@media (max-width: 560px) {
  .hth-grid--inline-slot
    .hth-card__inline--legacy .hth-card__inline-track { min-height: 0; }
}


/* =====================================================================
   21. BUY BOX — the configurator panel
   ---------------------------------------------------------------------
   Used by inc/blocks/gallery-buy.php. Everything a visitor needs to
   commit, inside one panel at the bottom of the split's info column:
   price, options, cart, reassurance, spec sheet.

   WHY A PANEL AND NOT MORE OF THE SAME PAGE
   Section 15b's wood toggle is three lines of type on the open field and
   that is right for two woods at two prices. Three options and a live
   Woo price is a form, and a form needs an edge — without one, the
   option rows read as more body copy and the eye does not know where the
   decision starts. The panel is the surface tone, not white: it should
   sit half a step off the field, not cut a hole in it.

   The radius, surface and hairline are all tokens, so the panel inherits
   the dark theme by doing nothing.
   ===================================================================== */
.hth-buybox {
  background: var(--hth-surface);
  border: 1px solid var(--hth-rule);
  border-radius: var(--hth-r-lg);
  padding: var(--hth-6);
  margin-top: var(--hth-6);
}
.hth-buybox > .hth-eyebrow:first-of-type { margin-bottom: var(--hth-3); }

/* The message line under the price — "Out of stock", or the block's
   unavailable text. Reserved by content, not by height: an empty line
   holding space above the options is worse than the reflow. */
.hth-buybox__msg {
  font-size: var(--hth-t-s); color: var(--hth-label);
  margin: var(--hth-3) 0 0;
}
.hth-buybox__msg--warn { color: #B4442E; }
.hth-buybox__msg--warn p { margin: var(--hth-2) 0 0; }
.hth-buybox__msg--warn strong { font-weight: 600; }

/* ---------------------------------------------------------------------
   OPTION GROUPS
   ---------------------------------------------------------------------
   auto-fit rather than a fixed column count, so two choices are two
   halves, three are thirds, and five wrap — the editor decides how many
   choices an option has and the CSS should not have an opinion. 130px is
   the narrowest a two-line choice reads at without hyphenating.
   --------------------------------------------------------------------- */
.hth-optgroup { margin-top: var(--hth-5); }
/* A group down to one choice is no decision — the block renders it hidden
   (still in the DOM, see gallery-buy.php). Stated so no display rule wins. */
.hth-optgroup[hidden] { display: none; }
.hth-optgroup .hth-eyebrow { margin-bottom: var(--hth-3); }
.hth-optgroup__note { color: var(--hth-label); opacity: .72; }

.hth-opts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
  gap: var(--hth-2);
}

/* Not .hth-woods: these carry a second line and have to be equal-height
   boxes rather than pills, and the selected state is a ring rather than a
   fill — a filled button reads as "pressed me", and half the grid being
   filled ink at 3 options x 2 choices is a lot of ink. */
.hth-opt {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 3px; min-height: 60px; padding: 12px 14px; text-align: center;
  font-family: var(--hth-f-body); cursor: pointer;
  background: transparent; color: var(--hth-body);
  border: 1px solid var(--hth-rule); border-radius: var(--hth-r);
  transition: border-color var(--hth-fast) var(--hth-expo),
              box-shadow   var(--hth-fast) var(--hth-expo),
              color        var(--hth-fast) var(--hth-expo),
              opacity      var(--hth-fast) var(--hth-expo);
}
/* The name and note are wrapped so the button can put an image beside
   them. Always emitted, image or not, so one rule set covers both. */
.hth-opt__text {
  display: flex; flex-direction: column; align-items: center;
  gap: 3px; min-width: 0;
}
.hth-opt__name { font-size: var(--hth-t-s); font-weight: 500; line-height: 1.25; color: var(--hth-head); }
.hth-opt__note {
  font-size: 11px; letter-spacing: var(--hth-tr-lab); text-transform: uppercase;
  color: var(--hth-label); line-height: 1.2;
}
.hth-opt:hover:not([disabled]) { border-color: var(--hth-head); }

/* The ring is drawn with an inset shadow rather than a 2px border, so the
   box does not change size between states and the grid does not twitch. */
.hth-opt[aria-pressed="true"] {
  border-color: var(--hth-accent);
  box-shadow: inset 0 0 0 1px var(--hth-accent);
}
.hth-opt[aria-pressed="true"] .hth-opt__name { color: var(--hth-head); }

/* Unreachable with the current selection. Dimmed rather than hidden:
   buttons that vanish as you click read as a glitch, and the visitor
   loses the information that the choice exists at all. */
.hth-opt[disabled] { opacity: .38; cursor: not-allowed; }
.hth-opt[disabled] .hth-opt__name { text-decoration: line-through; text-decoration-thickness: 1px; }

.hth-opt:focus-visible { outline: 2px solid var(--hth-azure); outline-offset: 2px; }

/* ---------------------------------------------------------------------
   A SWATCH, LOGO OR ICON INSIDE THE BUTTON
   ---------------------------------------------------------------------
   Reuses §19's swatch treatment rather than inventing a third circle:
   border-radius 50%, object-fit cover, and a sand placeholder behind it
   so a pale wood does not flash white while it decodes.

   The size comes in as --hth-opt-mark, set per option group by the
   block. A custom property rather than a set of modifier classes
   because the editor picks from a dropdown of heights, and four classes
   that differ only in one number is the kind of CSS that grows a fifth.
   --------------------------------------------------------------------- */
.hth-opt__mark {
  flex: 0 0 auto;
  display: block;
  width:  var(--hth-opt-mark, 28px);
  height: var(--hth-opt-mark, 28px);
}
.hth-opt__mark img { display: block; width: 100%; height: 100%; }

/* SWATCH — the wood chip. Cropped to fill, so any photograph works. */
.hth-opts--swatch .hth-opt__mark {
  border-radius: 50%;
  overflow: hidden;
  background: var(--hth-sand-deep);
}
.hth-opts--swatch .hth-opt__mark img { object-fit: cover; }

/* An empty slot holds its space so labels still line up when only some
   choices have been photographed — but drops the placeholder tint, or a
   missing image reads as a grey disc somebody forgot to replace. */
.hth-opt__mark:empty { background: none; }

/* LOGO / ICON — normalised on HEIGHT with contain, exactly as §15i does,
   so a square icon and a wide wordmark sit in one row without one of
   them going enormous. Width is free up to a limit; the limit exists so
   a very wide mark cannot squeeze the label out of a narrow button. */
/* THE CAP GOES ON THE IMAGE, NOT THE WRAPPER.
   With max-width on the span and width:auto on the img, the img sizes
   itself from its own aspect ratio and simply overflows the span — the
   wordmark ran into the label and swallowed the gap, while the span
   still reported the right width. Capping the img makes the span
   shrink-wrap to what is actually drawn.

   1.8x, not 2.6x: at 2.6 a wide wordmark beside a 40px height ate 104px
   of a 178px content box and pushed "WE RECOMMEND" out of the button.
   The label is the control; the mark only identifies it. */
.hth-opts--logo .hth-opt__mark { width: auto; }
.hth-opts--logo .hth-opt__mark img {
  width: auto;
  max-width: calc(var(--hth-opt-mark, 28px) * 1.8);
  object-fit: contain;
}

/* LEFT — image, then text, text ragged-left.
   Centred text beside a left-hand image looks accidental: the eye reads
   the image edge as the margin and every label starts somewhere else. */
.hth-opts--mark-left .hth-opt {
  flex-direction: row;
  align-items: center;
  justify-content: flex-start;
  text-align: left;
  gap: var(--hth-3);
}
.hth-opts--mark-left .hth-opt__text { align-items: flex-start; }

/* The track has to grow with the mark, or the label is squeezed into
   whatever is left. The 130px default assumed a button that was all
   text; a 56px circle plus a gap needs its own room BEFORE the label
   gets any. Derived from the size rather than fixed, so picking a bigger
   image widens the column instead of overflowing it. */
.hth-opts--mark-left {
  grid-template-columns: repeat(auto-fit, minmax(calc(150px + var(--hth-opt-mark, 28px)), 1fr));
}

/* Last resort: an unbroken word in a narrow column breaks rather than
   escapes the button. */
.hth-opt__name, .hth-opt__note { overflow-wrap: break-word; }

/* ABOVE — the column the button already was, with the image on top. */
.hth-opts--mark-above .hth-opt { gap: var(--hth-2); }

/* The image dims WITH the button rather than staying bright next to
   struck-through text, which would read as the one live thing in a dead
   control. Inherited opacity on the button already does this — written
   down so nobody "fixes" it by exempting the image. */


.hth--dark .hth-opt[aria-pressed="true"],
.hth--photo .hth-opt[aria-pressed="true"] {
  border-color: var(--hth-azure-lift);
  box-shadow: inset 0 0 0 1px var(--hth-azure-lift);
}

/* ---------------------------------------------------------------------
   ADD-ONS — accessories ticked on above Add to cart
   ---------------------------------------------------------------------
   Rendered by gallery-buy.php from the product's Buy-box add-ons (set in
   WooCommerce, see inc/commerce/addons.php); hth-ds.js shows the rows the
   selected combination offers and folds ticked ones into the cart link.

   Rows rather than the option grid's boxes: an add-on carries a picture,
   a name, a line of copy and a price, and four things do not fit a
   130px tile. The selected state borrows the option ring exactly —
   accent border plus a 1px inset shadow — so "chosen" looks the same
   everywhere in the panel and the row does not change size when ticked.

   The native checkbox stays in the DOM and in the tab order (visually
   hidden, not display:none), so keyboard and screen readers get a real
   checkbox; the drawn box is decoration that follows its :checked state.
   --------------------------------------------------------------------- */
.hth-addons { border: 0; padding: 0; margin: var(--hth-6) 0 0; min-width: 0; }
.hth-addons[hidden] { display: none; }
.hth-addons > legend.hth-eyebrow { padding: 0; margin-bottom: var(--hth-3); }

.hth-addons__list { list-style: none; margin: 0; padding: 0;
                    display: flex; flex-direction: column; gap: var(--hth-2); }
.hth-addon { position: relative; margin: 0; }
.hth-addon[hidden] { display: none; }

.hth-addon__check {
  position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0;
  pointer-events: none;
}
.hth-addon__row {
  display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto;
  align-items: center; gap: var(--hth-3);
  padding: 10px 14px; margin: 0; cursor: pointer;
  border: 1px solid var(--hth-rule); border-radius: var(--hth-r);
  font-family: var(--hth-f-body);
  transition: border-color var(--hth-fast) var(--hth-expo),
              box-shadow   var(--hth-fast) var(--hth-expo);
}
.hth-addon__row:hover { border-color: var(--hth-head); }
.hth-addon__check:checked + .hth-addon__row {
  border-color: var(--hth-accent);
  box-shadow: inset 0 0 0 1px var(--hth-accent);
}
.hth-addon__check:focus-visible + .hth-addon__row {
  outline: 2px solid var(--hth-azure); outline-offset: 2px;
}

/* The drawn box. The tick is two borders of a rotated rectangle, in the
   panel's own surface colour so it inverts correctly in the dark theme. */
.hth-addon__box {
  position: relative; width: 18px; height: 18px; flex: none;
  border: 1px solid var(--hth-label); border-radius: 4px;
  transition: background var(--hth-fast) var(--hth-expo),
              border-color var(--hth-fast) var(--hth-expo);
}
.hth-addon__check:checked + .hth-addon__row .hth-addon__box {
  background: var(--hth-accent); border-color: var(--hth-accent);
}
.hth-addon__check:checked + .hth-addon__row .hth-addon__box::after {
  content: ""; position: absolute; left: 5px; top: 1px; width: 5px; height: 10px;
  border: solid var(--hth-surface); border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

/* 48px square, cover-cropped, on the sand placeholder the swatches use. */
.hth-addon__thumb {
  width: 48px; height: 48px; border-radius: 6px; overflow: hidden;
  background: var(--hth-sand-deep); flex: none;
}
.hth-addon__thumb:empty { background: var(--hth-rule); }
.hth-addon__thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

.hth-addon__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.hth-addon__name {
  font-size: var(--hth-t-s); font-weight: 500; line-height: 1.3; color: var(--hth-head);
  overflow-wrap: break-word;
}
.hth-addon__blurb {
  font-size: var(--hth-t-xs); line-height: 1.45; color: var(--hth-label);
}
.hth-addon__price {
  display: flex; flex-direction: column; align-items: flex-end;
  font-size: var(--hth-t-s); color: var(--hth-head); white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.hth-addon__was { font-size: var(--hth-t-xs); color: var(--hth-label); }

/* Appears once something is ticked. The headline price above stays the
   SKU's own price — it is what the options choose between — and this is
   the sum the visitor is about to put in the cart. */
.hth-addons__total {
  display: flex; justify-content: space-between; align-items: baseline;
  gap: var(--hth-4); margin: var(--hth-3) 0 0;
  font-family: var(--hth-f-body); font-size: var(--hth-t-s); color: var(--hth-label);
}
.hth-addons__total[hidden] { display: none; }
.hth-addons__total strong {
  font-weight: 600; color: var(--hth-head); font-variant-numeric: tabular-nums;
}

/* Narrow column: the thumbnail is the first thing to go. */
@media (max-width: 380px) {
  .hth-addon__row { grid-template-columns: auto minmax(0, 1fr) auto; }
  .hth-addon__thumb { display: none; }
}

/* Spec sheet directly above: its closing rule plus the list's own top
   margin is enough separation; no extra rule. Add-ons directly above the
   buttons: tighten the gap a step, they are one decision. */
.hth-addons + .hth-buybox__actions { margin-top: var(--hth-5); }

/* ---------------------------------------------------------------------
   ACTIONS — stacked and full width inside the panel
   ---------------------------------------------------------------------
   Scoped to the panel rather than added as a .hth-btn--block modifier,
   so §10 stays two buttons and only two.
   --------------------------------------------------------------------- */
.hth-buybox__actions {
  display: flex; flex-direction: column; gap: var(--hth-3);
  margin-top: var(--hth-6);
}
.hth-buybox__actions .hth-btn { width: 100%; justify-content: center; }

/* Each button is wrapped, because a button can carry a note under it and
   that note has to sit tight against ITS button rather than float in the
   var(--hth-3) channel between two of them. The wrapper owns the small
   gap, the column owns the large one, and a button with no note is a
   wrapper with one child — the same pixel result the bare <a> gave. */
.hth-buybox__action { display: flex; flex-direction: column; gap: var(--hth-2); }

/* Quieter than the fine print below the stack: this is a footnote to one
   button, not a claim about the purchase. Centred because the button it
   belongs to is.

   The family is named rather than inherited, the same way the spec rows
   name it. The parent theme's <p> is display serif in some templates, and
   a serif footnote under a sans button reads as a styling accident. */
.hth-buybox__btnnote {
  margin: 0; text-align: center;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs); line-height: 1.5; color: var(--hth-label);
}

/* A label long enough to wrap — "Call 1-800-794-5355" in a narrow column —
   wraps centred under itself rather than ragging left against a centred
   button. Only inside the panel, where buttons are full width; §10's
   inline buttons are sized by their text and never wrap. */
.hth-buybox__actions .hth-btn__label { text-align: center; }

/* §10 slides the icon 4px right on hover, which is the arrow saying "this
   goes somewhere". A handset in front of the label is a noun, not a
   direction — sliding it just shoves it into the text. */
.hth-btn:hover .hth-btn__ico--lead { transform: none; }

/* No product for this combination. Still in the layout — a cart button
   that disappears takes the panel's bottom edge with it. */
.hth-btn.is-disabled {
  pointer-events: none; opacity: .45;
  --_bg: var(--hth-label); --_bd: var(--hth-label);
}

/* The reassurance line. Renders exactly what the editor typed, line
   breaks included — separators are theirs to put in, not ours to
   generate. line-height is a little open because this is often three
   short stacked lines rather than one wrapped paragraph. */
.hth-buybox__fine {
  margin: var(--hth-4) 0 0;
  font-size: var(--hth-t-s); line-height: 1.6; color: var(--hth-label);
}

/* Inline markup is allowed in this field (hth_inline), so it needs to be
   styled here rather than left to whatever the parent theme does with a
   bare <a>.

   The link takes the fine print's own muted ink and an underline, NOT the
   accent: at 14px in the quietest line of the panel, a coloured link next
   to an Add to cart button competes with the thing the visitor is meant
   to press. Emphasis goes the other way — <strong> steps UP to heading
   ink, because bold inside muted grey is otherwise invisible. */
.hth-buybox__fine a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-thickness: 1px;
  text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
  transition: color var(--hth-fast) var(--hth-expo),
              text-decoration-color var(--hth-fast) var(--hth-expo);
}
.hth-buybox__fine a:hover {
  color: var(--hth-head);
  text-decoration-color: currentColor;
}
.hth-buybox__fine a:focus-visible { outline: 2px solid var(--hth-azure); outline-offset: 2px; }

.hth-buybox__fine strong,
.hth-buybox__fine b { font-weight: 600; color: var(--hth-head); }

/* =====================================================================
   21b. SPEC ROWS — label left, value right
   ---------------------------------------------------------------------
   A second spec format, alongside §14's grid and flat list. Use this one
   inside the buy box and nowhere else: it is a data sheet read top to
   bottom while deciding, where §14's grid is a summary read across.

   The value is tabular-nums and right-aligned so the numerals form a
   column — 48¾" over 77⅞" over 13.5 A only reads as a spec sheet when
   the digits line up. The hairline is on the row, not between rows, so
   the list has an edge at the top and the eye can find where it begins.
   ===================================================================== */
.hth-specrows { margin: var(--hth-6) 0 0; }

/* CLOSE THE LIST WHEN SOMETHING FOLLOWS IT.
   Each row carries its own top hairline, which is all a list needs when
   it is the last thing in the panel — the panel edge closes it. With the
   spec sheet moved ABOVE the Add to cart button it is a block in the
   middle of the column, and a block that opens with a rule and ends with
   nothing does not read as finished: the space below it looks like slack
   rather than a boundary, and the button loses its own top edge.
   Conditional, so a layout that ends on the specs does not gain a rule
   sitting on top of the panel's own border. */
.hth-specrows:not(:last-child) { border-bottom: 1px solid var(--hth-rule); }
.hth-specrow {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--hth-4);
  border-top: 1px solid var(--hth-rule);
  padding: var(--hth-3) 0;
}
.hth-specrow dt {
  font-family: var(--hth-f-body); font-size: var(--hth-t-s);
  color: var(--hth-label); margin: 0;
}
.hth-specrow dd {
  font-family: var(--hth-f-body); font-size: var(--hth-t-s);
  color: var(--hth-head); margin: 0; text-align: right;
  font-variant-numeric: tabular-nums;
}
/* A bare line with no pipe is a label with no value — let it run full
   width rather than leaving an empty right column. */
.hth-specrow dd:empty { display: none; }

@media (max-width: 380px) {
  .hth-specrow { flex-direction: column; gap: 2px; }
  .hth-specrow dd { text-align: left; }
}


/* =====================================================================
   22. LINKED CARDS, AND THE CARD META LINE
   ---------------------------------------------------------------------
   Two things the editorial card needed and the product card never did.
   Built for HTH Post Rail; both are general and any card grid may use
   them.

   A · THE WHOLE CARD IS THE TARGET
   The card in section 15 already behaves as though it were interactive —
   .hth-card:hover raises a shadow and scales the image to 1.04 — but
   nothing in it was ever clickable, because every card that needed a
   destination carried a ghost button. An article card should not: three
   "Read more" buttons in a row is chrome that three headlines already
   provide, and it is the difference between an editorial rail and a
   directory listing.

   The mechanism is ONE real link, on the title, stretched over the card
   by an empty ::after. That keeps exactly one link per card in the
   accessibility tree. Wrapping both the image and the title — the
   obvious alternative — gives a screen reader two links to the same
   place, one of which has no text.

   .hth-card therefore has to establish a positioning context. Adding
   position:relative to it globally is safe, and this was checked rather
   than assumed: the only absolutely-positioned descendant anywhere in a
   card is .hth-card__points li::before, and its li already sets
   position:relative, so no containing block changes.

   FOCUS. .hth-card is overflow:hidden, which would clip a focus ring
   drawn on the stretched ::after — the ring would appear as two short
   segments or vanish entirely. So the ring goes on the CARD instead, via
   :has(). An element's own outline is not clipped by its own overflow,
   and a ring around the whole card is the honest shape of what is about
   to be activated. The suppression of the link's own ring is inside the
   @supports gate on purpose: where :has() is unavailable the link keeps
   its default ring, clipped but present, rather than none at all.

   Anything that must stay separately clickable inside a linked card — a
   category chip, a link inside the excerpt — needs position:relative of
   its own to rise above the stretched layer. Nothing does today; if one
   is added, that is the fix.

   B · THE META LINE
   Date and byline, pinned to the bottom of the card the same way and for
   the same reason .hth-card__body .hth-btn is. Without margin-top:auto
   it floats mid-card the moment the grid is set to equal heights and one
   excerpt is a line shorter than its neighbour's.

   THE TRAP THAT COMES WITH THAT, and it is the one section 20 documents
   for marks: two margin-top:auto siblings in one flex column SPLIT the
   free space between them rather than both sinking. A card carrying both
   a meta line and a button would therefore float both into the middle.
   Post cards have no button by design, so this cannot happen here — but
   a future card that wants both wants a wrapper around the pair, not a
   second auto.
   ===================================================================== */
.hth-card { position: relative; }

.hth-card__link {
  color: inherit;
  text-decoration: none;
}
.hth-card__link::after {
  content: ""; position: absolute; inset: 0;
}

/* No underline at rest. The card's own hover shadow and image scale are
   the affordance, and a permanent rule under a display-face h3 reads as
   a broken heading rather than as a link. */
.hth-card:hover .hth-card__link,
.hth-card__link:focus-visible {
  text-decoration: underline;
  text-decoration-color: var(--hth-rule);
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
}

@supports selector(:has(*)) {
  .hth-card__link:focus-visible { outline: none; }
  .hth-card:has(.hth-card__link:focus-visible) {
    outline: 2px solid var(--hth-accent);
    outline-offset: 3px;
  }
}

.hth-card__body .hth-card__meta {
  margin: auto 0 0;
  padding-top: var(--hth-4);
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs);
  line-height: 1.5;
  color: var(--hth-label);
  font-variant-numeric: tabular-nums;
}


/* =====================================================================
   23. REEL RAIL — customer video, playing in place
   ---------------------------------------------------------------------
   A horizontal filmstrip of 9:16 clips that start themselves when they
   reach the viewport, with the attribution set as real text underneath.

   WHY THIS IS NOT .hth-gallery (§13b), which is also a scroll-snap rail:
   that one shows ONE slide at a time inside a fixed 5:4 frame and exists
   to show a product from several angles. This shows MANY tiles at once,
   is portrait, and each tile carries a caption of its own. Sharing the
   primitive would have meant a `--reels` modifier overriding flex-basis,
   aspect-ratio, snap alignment, nav position and the caption rules —
   which is a second component wearing the first one's class names.

   THE COST MODEL, because this is the block most able to ruin the page.

   Every byte here is deferred behind two gates and neither is optional:

     1. The poster is an ordinary lazy <img>. It is the only thing that
        paints, and it is what the layout is sized from, so the rail
        costs a handful of WebPs whether or not anything ever plays.
     2. The <video> carries NO src attribute. Not an empty one, not a
        lazy one — none. hth-ds.js attaches it on intersection. A
        browser cannot speculatively fetch a source that is not in the
        DOM, which is the whole trick, and it is why `preload="none"`
        alone is not sufficient: preload governs buffering, not the
        metadata request, and Safari has historically issued one anyway.

   The reference implementation this was measured against serves 45.6 MB
   for a single tile. Ours are 0.9-4.8 MB because they were encoded for
   the web. THE BUDGET IS THE COMPONENT: a rail of correctly-encoded
   clips is cheaper than one hero photograph done badly, and a rail of
   phone-camera originals will be slower than every other section on the
   page combined. See REELS-BLOCK.md for the encoding recipe.

   LAYERING inside .hth-reel__stage
     z0  poster <img>       always present, always lazy
     z1  <video>            opacity 0 until it is genuinely playing
     z2  gradient scrim     only under the controls, only when needed
     z3  buttons            sound toggle, facade play
   ===================================================================== */

.hth-reels {
  position: relative;

  /* Bleed the track through the page gutter so tiles run to the edge of
     the content column and the last one is visibly clipped — that clip
     IS the affordance saying there is more to the right. Padding puts
     the inside edge back where the headline sits, and scroll-padding
     makes the snap land there too rather than hard against the bleed. */
  margin-inline:         calc(-1 * var(--hth-gut));
  padding-inline:        var(--hth-gut);
  scroll-padding-inline: var(--hth-gut);
}

.hth-reels__track {
  display: flex;
  gap: var(--hth-5);
  /* Unrevealed figures translate down 14px, including tiles off to the
     right. Reserve that travel inside the scrollport: overflow-x: auto
     also makes overflow-y compute to auto, so an unpadded track becomes
     vertically scrollable and consumes wheel gestures over the reels.
     Matching negative margins preserve the section's existing spacing. */
  margin: calc(-1 * var(--hth-4)) 0;
  padding: var(--hth-4) var(--hth-gut) var(--hth-4) 0;
                                  /* trailing gutter lets the last tile
                                     snap fully clear of the edge */
  list-style: none;

  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;   /* PROXIMITY, not mandatory — §13b
                                      shows one slide and must land on
                                      it exactly. Here several tiles are
                                      readable at once and mandatory
                                      fights every small trackpad nudge. */
  scroll-behavior: smooth;
  scrollbar-width: none;
  -ms-overflow-style: none;

  /* The §13b note applies verbatim: the track IS the scroll container,
     so `pan-y` would forbid the one gesture it runs on. */
  touch-action: manipulation;
}
.hth-reels__track::-webkit-scrollbar { display: none; }

.hth-reels__item {
  flex: 0 0 var(--hth-reel-w, 280px);
  scroll-snap-align: start;
  margin: 0;
}

/* --- the tile ------------------------------------------------------- */

.hth-reel { margin: 0; display: flex; flex-direction: column; height: 100%; }

.hth-reel__stage {
  position: relative;
  aspect-ratio: 9 / 16;
  border-radius: var(--hth-r);
  overflow: hidden;
  background: var(--hth-sand-deep);   /* shows while the poster loads */
  isolation: isolate;                 /* keeps z-indexes local to the tile */
}
.hth--dark .hth-reel__stage { background: rgba(255,255,255,.06); }

.hth-reel__poster,
.hth-reel__video {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover; object-position: center;
  display: block;
}
.hth-reel__poster { z-index: 0; }

/* The video fades UP over its own poster rather than replacing it. Two
   reasons, both visible: a <video> paints one frame of undecoded colour
   before it has anything to show, and a clip whose first frame differs
   from the chosen poster otherwise jumps. Fading also means a clip that
   never loads simply stays a photograph, which is a correct outcome. */
.hth-reel__video {
  z-index: 1;
  opacity: 0;
  transition: opacity .45s var(--hth-expo);
}
.hth-reel.is-playing .hth-reel__video { opacity: 1; }

/* --- controls ------------------------------------------------------- */

/* One scrim, drawn only where a control sits, and only once a control is
   actually there. A full-tile gradient over every clip would flatten the
   photography this section exists to show. */
.hth-reel__stage::after {
  content: "";
  position: absolute; inset: auto 0 0 0; height: 38%;
  z-index: 2; pointer-events: none;
  background: linear-gradient(to top, rgba(20,28,36,.42), rgba(20,28,36,0));
  opacity: 0;
  transition: opacity var(--hth-fast) var(--hth-expo);
}
.hth-reel--has-controls .hth-reel__stage::after { opacity: 1; }

.hth-reel__sound {
  position: absolute; right: var(--hth-3); bottom: var(--hth-3);
  z-index: 4;                         /* ABOVE the toggle, which covers the
                                         whole tile. At the same z-index the
                                         toggle won on DOM order and the mute
                                         button became unclickable. */
  width: 36px; height: 36px; padding: 0; border: 0;
  border-radius: var(--hth-r-pill);
  display: grid; place-items: center;
  background: var(--hth-white); color: var(--hth-ink);
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(20,28,36,.28);
  transition: transform var(--hth-fast) var(--hth-expo);
}
.hth-reel__sound:hover { transform: scale(1.06); }
.hth-reel__sound:focus-visible {
  outline: 2px solid var(--hth-white); outline-offset: 2px;
}
.hth-reel__sound svg { width: 16px; height: 16px; display: block; }

/* Muted and unmuted are ONE button with two icons, toggled by
   aria-pressed rather than by swapping innerHTML — so the accessible
   state and the drawn state cannot drift apart. */
.hth-reel__sound .hth-reel__icon--on  { display: none; }
.hth-reel__sound[aria-pressed="false"] .hth-reel__icon--on  { display: block; }
.hth-reel__sound[aria-pressed="false"] .hth-reel__icon--off { display: none; }

/* The facade play button — a hosted clip (YouTube, Vimeo) that cannot
   autoplay in place. Deliberately large and centred: it is not a state
   indicator, it is the whole interaction for that tile. */
/* THE PLAY / PAUSE TOGGLE — one button covering the whole tile.
   -----------------------------------------------------------------------
   It is ALWAYS present and always clickable, and only its GLYPH is
   conditional. That is the difference between the first version of this
   component and this one: the button used to be `hidden` while a clip
   played, so a visitor had no way to stop anything. Clicking the picture
   is the thing everyone tries first, and there was nothing there.

   The rule the visitor learns without being told:

     a glyph is showing   ->  it is stopped, press to play
     no glyph             ->  it is playing, press to pause

   Hovering a playing tile fades the pause glyph up, so the affordance is
   discoverable without drawing chrome over every face at rest.

   `[hidden]` is restated because the UA sheet's `[hidden]{display:none}`
   is a single attribute selector and loses to this rule's class. Nothing
   sets it today — the toggle is permanent — but the cascade trap cost a
   debugging session once and the guard is one line. */
.hth-reel__play[hidden] { display: none; }

.hth-reel__play {
  position: absolute; inset: 0;
  z-index: 3;
  width: 100%; height: 100%; padding: 0; border: 0;
  display: grid; place-items: center;
  background: transparent; cursor: pointer;
}
.hth-reel__play span {
  width: 60px; height: 60px;
  border-radius: var(--hth-r-pill);
  display: grid; place-items: center;
  background: rgba(255,255,255,.92); color: var(--hth-ink);
  box-shadow: 0 4px 16px rgba(20,28,36,.32);
  opacity: 0;
  transition: opacity var(--hth-fast) var(--hth-expo),
              transform var(--hth-fast) var(--hth-expo);
}

/* Showing: a stopped clip, and a hosted tile that has not been clicked. */
.hth-reel.is-idle   .hth-reel__play span,
.hth-reel--facade   .hth-reel__play span { opacity: 1; }

/* And on hover or keyboard focus, whatever the state — this is what makes
   pause reachable at all. */
.hth-reel__play:hover span            { opacity: 1; transform: scale(1.08); }
.hth-reel__play:focus-visible span    { opacity: 1; outline: 2px solid var(--hth-white); outline-offset: 3px; }

/* Which of the two glyphs. A facade never shows pause — clicking it hands
   over to YouTube's own player, which brings its own controls. */
.hth-reel__play .hth-reel__icon--pause { display: none; }
.hth-reel:not(.is-idle):not(.hth-reel--facade) .hth-reel__play .hth-reel__icon--play  { display: none; }
.hth-reel:not(.is-idle):not(.hth-reel--facade) .hth-reel__play .hth-reel__icon--pause { display: block; }

.hth-reel__play svg { width: 22px; height: 22px; display: block; }
.hth-reel__play .hth-reel__icon--play { margin-left: 3px; }   /* optical centring
                                                                 of the triangle */

/* The injected iframe replaces the stage's contents entirely. */
.hth-reel__frame {
  position: absolute; inset: 0; z-index: 5;
  width: 100%; height: 100%; border: 0;
}

/* --- attribution ---------------------------------------------------- */

.hth-reel__by {
  padding-top: var(--hth-4);
  display: flex; flex-direction: column; gap: var(--hth-2);
}

.hth-stars {
  display: flex; gap: 3px;
  color: var(--hth-head);
  line-height: 0;
}
.hth--dark .hth-stars { color: var(--hth-white); }
.hth-stars svg { width: 14px; height: 14px; display: block; }
.hth-stars .hth-stars__off { opacity: .22; }

.hth-reel__name {
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s);
  font-weight: 600;
  line-height: 1.4;
  color: var(--hth-head);
}
.hth-reel__meta {
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s);
  font-weight: 500;
  line-height: 1.4;
  color: var(--hth-head);
}
.hth-reel__quote {
  margin: var(--hth-1) 0 0;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s);
  line-height: 1.6;
  color: var(--hth-body);
}

/* --- rail chrome ---------------------------------------------------- */

/* Arrows sit in the section header beside the headline, NOT floating over
   the tiles the way §13b's do. A control drawn on top of a face is a
   control drawn on top of the thing the section is about. */
.hth-reels__nav { display: flex; gap: var(--hth-2); }

.hth-reels__btn {
  width: 44px; height: 44px; padding: 0;
  border: 1px solid var(--hth-rule);
  border-radius: var(--hth-r);
  background: transparent; color: var(--hth-head);
  display: grid; place-items: center;
  cursor: pointer;
  transition: background var(--hth-fast) var(--hth-expo),
              border-color var(--hth-fast) var(--hth-expo),
              opacity var(--hth-fast) var(--hth-expo);
}
.hth-reels__btn:hover { background: var(--hth-sand-deep); border-color: var(--hth-head); }
.hth--dark .hth-reels__btn { border-color: var(--hth-line-dark); color: var(--hth-white); }
.hth--dark .hth-reels__btn:hover { background: rgba(255,255,255,.1); }
.hth-reels__btn:focus-visible { outline: 2px solid var(--hth-accent); outline-offset: 2px; }
.hth-reels__btn[disabled] { opacity: .3; cursor: default; }
.hth-reels__btn[disabled]:hover { background: transparent; border-color: var(--hth-rule); }
.hth-reels__btn svg { width: 16px; height: 16px; }

/* Swiping is the control on touch, exactly as §13b decided. */
@media (hover: none) { .hth-reels__nav { display: none; } }

/* --- responsive ----------------------------------------------------- */

/* The tile narrows before the count does. A 9:16 tile below ~180px is
   too small for a face to read at all, which is the point of failure
   worth designing around — not the number of tiles on screen. */
@media (max-width: 900px) {
  .hth-reels__track { gap: var(--hth-4); }
  .hth-reels__item  { flex-basis: min(var(--hth-reel-w, 280px), 60vw); }
}
@media (max-width: 520px) {
  .hth-reels__item { flex-basis: min(var(--hth-reel-w, 280px), 72vw); }
}

/* --- motion and data preferences ------------------------------------ */

/* Reduced motion does NOT mean "no video" — a testimonial is content, not
   decoration, and hiding it would remove information. It means nothing
   starts moving without being asked. hth-ds.js reads the same query and
   skips autoplay; this makes every tile show its facade button so there
   is still a way in. The scroll-behavior reset matters too: a smooth
   programmatic scroll is exactly the kind of motion the setting is for. */
@media (prefers-reduced-motion: reduce) {
  .hth-reels__track { scroll-behavior: auto; }
  .hth-reel__video  { transition: none; }
}


/* =====================================================================
   24. CLIP — the shared video layer
   ---------------------------------------------------------------------
   Twelve declarations, and they are the whole of the visual contract
   between hth-ds.js section 24 and every block that carries a clip.

   THE POSTER IS THE LAYOUT. The <img> is a normal, in-flow child and it
   is what gives the frame its height. The <video> is absolute, covers
   it, and starts invisible. Nothing about the box changes when a clip
   arrives or fails to, which is why a page with no footage yet is not a
   page with holes in it.

   THE CROSSFADE IS ON `playing`, NOT ON play(). hth-ds.js adds
   `is-playing` from the video's own `playing` event, so the poster is
   only given up once a real frame has been decoded. That removes the
   black first frame without prefetching a byte, and it is why there is
   no "loading" state here to style — a clip that is still coming looks
   exactly like a clip that was never added.

   THE CHILD COMBINATOR IS LOAD-BEARING. `is-playing` lands on the
   element carrying [data-hth-clip] and the video must be its direct
   child. With a descendant selector, a mosaic tile playing inside a
   section would fade in a clip belonging to a different block.

   pointer-events: none, because the clip is decoration painted over a
   photograph — hover, focus and clicks belong to the tile underneath it,
   and a <video> that swallows them is how you get a link that stops
   working the moment it starts moving.
   ===================================================================== */

[data-hth-clip] { position: relative; }

.hth-clip {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--hth-mid) var(--hth-expo);
}

[data-hth-clip].is-playing > .hth-clip { opacity: 1; }

/* Belt and braces. hth-ds.js already refuses to attach a source under
   this query, so this only matters if a clip is somehow already playing
   when the preference changes mid-session — but the setting means "stop
   moving", and honouring it late is better than not at all. */
@media (prefers-reduced-motion: reduce) {
  .hth-clip { display: none; }
}


/* =====================================================================
   25. EXPAND RAIL — panels that widen under the pointer
   ---------------------------------------------------------------------
   Two to four panels, equal at rest; the one you point at grows and its
   neighbours shrink to pay for it. All copy is legible at rest — what
   the pointer adds is width, a deeper scrim, a small deliberate zoom,
   and a button that unrolls from a circle into a labelled pill.

   IT IS flex-grow THAT ANIMATES, not width. Every panel is `flex: 1 1 0`,
   so widths land in proportion to their grow values and the track always
   adds up to itself — no measuring, no ResizeObserver, and no arithmetic
   that breaks when the wrap changes. flex-grow is a <number> and is
   animatable; width on six siblings is not, usefully.

   THE TRACK'S ASPECT RATIO IS DERIVED, NOT CHOSEN. The block computes
   A_track = A_open / f_open and hands it over as --hth-xrail-ar, so the
   OPEN panel is exactly the shape the editor picked at any panel count,
   any expansion, any viewport. Declaring a track HEIGHT instead — which
   this section used to do — means the panel shape silently changes every
   time somebody adds a panel, and the upload that matched last week no
   longer does.

   FOUR DECLARATIONS ARE LOAD-BEARING AND LOOK OPTIONAL

     min-width: 0        without it the image's intrinsic width sets a
                         floor and the closed panels refuse to close. The
                         flexbox default nobody remembers.
     object-fit: cover   `cover` never distorts, so a widening panel
                         REVEALS more of the frame. Sizing the <img> to
                         its natural width instead gives the squeezed
                         accordion every cheap copy of this effect has,
                         and it is invisible in a screenshot.
     container-type      the copy column is pinned to the CLOSED panel's
                         width in cqw units, measured against the TRACK.
                         Percentages would resolve against the panel,
                         which is the one box whose width is changing.
     the foot is IN FLOW  .hth-xrail__foot is a normal flex child, not
                         absolute, so .hth-xrail__go--whole::after
                         resolves against the PANEL and stretches over
                         all of it. Position the foot and the click
                         target silently collapses to the button.

   WHY THE COPY IS WIDTH-PINNED. Text that re-wraps while the panel
   animates is the single most obvious way this device looks cheap. The
   copy column is held at the closed panel's width in both states, so an
   opening panel gives the text more empty space to its right rather than
   re-flowing it. That is also why the block warns past four panels: at
   six, the closed panel is 190px and there is no width to pin to.

   THE OPEN STATE HAS THREE TRIGGERS and they are one rule: `.is-open`
   (the starting position the editor chose, which section 25 of hth-ds.js
   removes the moment the visitor takes over), `:hover`, and
   `:focus-within`. Keyboard therefore gets the identical rail with no
   tabindex work — tabbing the buttons walks the panels open.

   BELOW 900px THERE IS NO HOVER, so the rail becomes a scroll-snap
   filmstrip with every panel at its open shape and every button already
   unrolled. Not tap-to-expand: that spends a tap to reveal information
   and steals the tap that meant "open this model".
   ===================================================================== */

.hth-xrail {
  display: flex;
  align-items: stretch;
  gap: var(--hth-2);
  aspect-ratio: var(--hth-xrail-ar, 3);
  container-type: inline-size;
}

.hth-xrail__panel {
  position: relative;
  flex: 1 1 0;
  min-width: 0;                    /* load-bearing — see above */
  overflow: hidden;
  border-radius: var(--hth-r-lg);

  display: flex;
  flex-direction: column;
  justify-content: flex-end;

  transition: flex-grow var(--hth-mid) var(--hth-expo);

  /* THE PANEL IS A PHOTOGRAPHIC OBJECT, NOT A THEMED SURFACE, and that is
     a deliberate exception to how .hth-card works.

     A card that followed the section would be near-white on sand, which
     is the catalogue-card look this whole page exists to get away from —
     and it would make the mobile card look nothing like the desktop
     panel, whose copy sits on a dark scrim on EVERY theme. Ink on light
     fields is what makes the two views the same object.

     So the panel does not follow the section; it steps AWAY from it,
     in whichever direction has contrast. On a light field that is ink.
     On a dark field ink would vanish, so it takes --hth-surface — which
     .hth--dark already defines as exactly this: the colour of a panel
     sitting above a dark section, the same value .hth-card uses there.
     No new colour is introduced for either case.

     On desktop this is only visible while the photograph decodes — and
     see the min-width block below, which is where it gets PAINTED on
     desktop, a pixel in from the edge. It is the mobile card, where the
     copy sits on the panel rather than on the picture, that this
     declaration is for. */
  background: var(--hth-ink);
  box-shadow: var(--hth-shadow);
}

/* Shadows do not read on ink, so the edge is drawn instead — inset, so a
   flex item's width does not change when it gains one. */
.hth--dark .hth-xrail__panel {
  background: var(--hth-surface);
  box-shadow: inset 0 0 0 1px var(--hth-line-dark);
}

/* --- the decode placeholder moves off the edge, on desktop only ------
   The same 1px dark rim that section 26 documents on the mosaic tile,
   and the same cause: the panels are flex items sized by flex-grow, so
   their left and right edges land on FRACTIONAL pixel boundaries at
   essentially every viewport width — more reliably here than in a grid,
   because a 1fr track at least starts from a whole container. Chrome
   paints the element's background to that fractional border-box and
   composites the photograph, clipped by the panel's own overflow, to
   the same fractional box. Two rasters of one edge, and their coverage
   does not cancel; about a fifth of the background leaks into the
   photograph's straight edges.

   Measured on a light photograph over sand: the edge pixel went from
   (242,217,190), a clean photo-to-page blend, to (191,171,149). A
   51-level drop, on roughly two thousand pixels per rail, and it does
   not go away at 2x — there are simply twice as many edge pixels to
   darken.

   THE COLOUR IS UNCHANGED, deliberately. The argument above — that the
   panel is a photographic object which steps away from the section, ink
   on light and --hth-surface on dark — is about what the MOBILE CARD is
   made of, and lightening the desktop placeholder would make the two
   views different colours for no gain. Only its geometry changes: inset
   by a pixel, where it cannot reach the boundary the photograph is
   antialiased against. object-fit: cover fills the panel, so once the
   photograph has decoded none of this is visible either way.

   SCOPED TO DESKTOP because below 900px the media becomes a block at
   the top of the card and the copy sits on the panel's own background —
   there it is a surface carrying white text, not a placeholder, and
   removing it would put that text on the page colour. */
@media (min-width: 901px) {

  /* Both selectors, because the dark rule above is (0,2,0) and would
     otherwise keep painting its own background to the edge. */
  .hth-xrail__panel,
  .hth--dark .hth-xrail__panel { background: transparent; }

  .hth-xrail__panel::before {
    content: "";
    position: absolute;
    inset: 1px;                      /* never touches the antialiased edge */
    border-radius: inherit;
    background: var(--hth-ink);
  }

  /* The inset hairline on dark is drawn by the panel and stays there, so
     the placeholder sits just inside it rather than under it. */
  .hth--dark .hth-xrail__panel::before { background: var(--hth-surface); }
}

.hth-xrail__panel.is-open,
.hth-xrail__panel:hover,
.hth-xrail__panel:focus-within { flex-grow: var(--hth-xrail-open, 2.2); }

/* Media is absolute, so it leaves the flex flow and the foot is the only
   thing being laid out. */
.hth-xrail__media { position: absolute; inset: 0; }

.hth-xrail__img,
.hth-xrail__clip {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

/* The zoom is a real transform, and it is declared to the browser as one:
   the block multiplies it into the image's `sizes`, exactly as
   hth_parallax_scale() is multiplied into the band's. Without that the
   larger file is never requested and the zoom is a resample. */
.hth-xrail__img  { transition: scale var(--hth-mid) var(--hth-expo),
                               filter var(--hth-mid) var(--hth-expo); }
.hth-xrail__clip { transition: scale var(--hth-mid) var(--hth-expo),
                               opacity var(--hth-mid) var(--hth-expo); }

.hth-xrail__panel.is-open      .hth-xrail__img,
.hth-xrail__panel:hover        .hth-xrail__img,
.hth-xrail__panel:focus-within .hth-xrail__img,
.hth-xrail__panel.is-open      .hth-xrail__clip,
.hth-xrail__panel:hover        .hth-xrail__clip,
.hth-xrail__panel:focus-within .hth-xrail__clip { scale: var(--hth-xrail-zoom, 1); }

/* The copy is readable at rest, so the scrim is not a hover state — it is
   the floor the type stands on. It only deepens. */
.hth-xrail__scrim {
  position: absolute;
  inset: 0;
  z-index: 1;
  opacity: .88;
  background: linear-gradient(to top,
              rgba(13,18,24,.94) 0%,
              rgba(13,18,24,.80) 18%,
              rgba(13,18,24,.34) 48%,
              rgba(13,18,24,0)   78%);
  transition: opacity var(--hth-mid) var(--hth-expo);
}
.hth-xrail__panel.is-open      .hth-xrail__scrim,
.hth-xrail__panel:hover        .hth-xrail__scrim,
.hth-xrail__panel:focus-within .hth-xrail__scrim { opacity: 1; }

/* Dimming the rest is most of what makes the open panel read as LIT
   rather than merely wider, and it costs one filter. Only while
   something is actually open — a dimmed rail at rest looks broken. */
.hth-xrail--dim:hover .hth-xrail__panel:not(:hover) .hth-xrail__img,
.hth-xrail--dim:focus-within .hth-xrail__panel:not(:focus-within) .hth-xrail__img {
  filter: brightness(.55) saturate(.85);
}

/* --- the foot -------------------------------------------------------- */

.hth-xrail__foot {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: flex-end;
  gap: var(--hth-4);
  padding: clamp(16px, 1.6vw, 26px);
}

/* Pinned to the closed panel's width, in container units against the
   track — so it is identical in both states and the text never re-wraps
   mid-animation. max-width, not width, so a panel narrower than the
   pinned figure (a small viewport) still contains its own copy. */
.hth-xrail__copy {
  min-width: 0;
  max-width: 100%;
  max-width: min(100%, calc(var(--hth-xrail-copy, 16) * 1cqw));
}

.hth-xrail__title {
  display: block;
  margin-top: var(--hth-1);
  font-family: var(--hth-f-display);
  font-weight: 300;
  font-size: var(--hth-t-sm);
  letter-spacing: var(--hth-tr-sm);
  line-height: 1.15;
  color: var(--hth-head);
}

.hth-xrail__line {
  margin: var(--hth-2) 0 0;
  font-size: var(--hth-t-s);
  line-height: 1.45;
  color: var(--hth-body);
}

/* The chip. What the reference sites use for a status fact — "Gasoline",
   "Currently unavailable" — as opposed to a section label. Sentence
   case and no tracking, deliberately: it is a fact, not a heading. */
.hth-xrail__chip {
  display: inline-block;
  margin-bottom: var(--hth-3);
  padding: 5px 12px;
  border-radius: var(--hth-r-pill);
  background: rgba(255,255,255,.16);
  backdrop-filter: blur(8px);
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs);
  line-height: 1.3;
  letter-spacing: 0;
  text-transform: none;
  color: #FFFFFF;
}

/* --- the button ------------------------------------------------------
   A circle at rest, a labelled pill on open. The width animates because
   the LABEL's max-width animates, not the button's — `width: auto` is
   not interpolatable and every attempt to fake it with a fixed open
   width breaks the first time somebody types a longer label. */
.hth-xrail__go {
  flex: 0 0 auto;
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0;
  height: 40px;
  min-width: 40px;
  padding: 0 12px;
  border-radius: var(--hth-r-pill);
  border: 1px solid rgba(255,255,255,.28);
  background: rgba(255,255,255,.12);
  backdrop-filter: blur(8px);
  color: #FFFFFF;
  text-decoration: none;
  white-space: nowrap;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s);
  transition: gap var(--hth-mid) var(--hth-expo),
              background var(--hth-fast) var(--hth-expo),
              border-color var(--hth-fast) var(--hth-expo);
}
.hth-xrail__go svg { flex: 0 0 auto; width: 14px; height: 14px; }

.hth-xrail__go-label {
  display: inline-block;
  max-width: 0;
  opacity: 0;
  overflow: hidden;
  transition: max-width var(--hth-mid) var(--hth-expo),
              opacity var(--hth-fast) var(--hth-expo);
}

.hth-xrail__panel.is-open      .hth-xrail__go,
.hth-xrail__panel:hover        .hth-xrail__go,
.hth-xrail__panel:focus-within .hth-xrail__go {
  gap: var(--hth-2);
  background: rgba(255,255,255,.22);
  border-color: rgba(255,255,255,.42);
}
.hth-xrail__panel.is-open      .hth-xrail__go-label,
.hth-xrail__panel:hover        .hth-xrail__go-label,
.hth-xrail__panel:focus-within .hth-xrail__go-label {
  max-width: 24ch;
  opacity: 1;
  /* Behind the width, never during it — the label arrives after the
     panel has made room for it. */
  transition-delay: .14s;
}

/* ONE link, one accessible name, one tab stop, and a hit area that
   happens to be the whole panel. A second transparent anchor over the
   card is the commonest accessibility defect in this pattern: two links
   to the same place, one of them unnamed. */
.hth-xrail__go--whole::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 3;
}

.hth-xrail__go:focus-visible {
  outline: 2px solid var(--hth-azure-lift);
  outline-offset: 3px;
}
.hth-xrail__go--whole:focus-visible { outline: none; }
.hth-xrail__go--whole:focus-visible::after {
  outline: 2px solid var(--hth-azure-lift);
  outline-offset: -4px;
  border-radius: var(--hth-r-lg);
}

/* --- the paging dots -------------------------------------------------
   Wide screens have every panel on one line, so there is nothing to page
   through and nothing to show. They exist for the filmstrip only, and
   hth-ds.js removes their `hidden` attribute — dots that cannot page are
   dead chrome, and section 13b established that dead chrome gets removed
   rather than disabled. */
.hth-xrail__dots { display: none; }
.hth-xrail__dots[hidden] { display: none; }

.hth-xrail__dot {
  width: 8px;
  height: 8px;
  padding: 0;
  border: 0;
  border-radius: var(--hth-r-pill);
  background: var(--hth-rule);
  cursor: pointer;
  transition: width var(--hth-fast) var(--hth-expo),
              background var(--hth-fast) var(--hth-expo);
}
.hth-xrail__dot.is-on {
  width: 22px;
  background: var(--hth-head);
}
.hth-xrail__dot:focus-visible {
  outline: 2px solid var(--hth-azure-lift);
  outline-offset: 3px;
}

/* --- copy below the photograph ---------------------------------------
   .hth-xrail--under is set when ANY of eyebrow, title or line is placed
   below; .hth-xrail--bare when ALL of them are. See the block docblock
   for why placement is per rail and not per panel.

   THE PHOTOGRAPH KEEPS THE GEOMETRY. Its height is 100cqw / A_track —
   exactly the height the whole track had before — so the open panel is
   still the shape the editor chose, at any count, and the sizing advice
   in EXPAND-RAIL-BLOCK.md still holds. The track gives up its own
   aspect-ratio and simply grows to fit the caption.

   THE CAPTION SITS ON THE SECTION, not on a card, in the section's own
   ink. That ink has to be captured on the TRACK, which is still inside
   the section, because every panel carries .hth--photo and turns it
   white. A custom property holding var() is substituted where it is
   declared, so --_xr-* are the section's values, frozen, and the caption
   hands them back.

   --_xr-stage is the other way round: 100cqw is NOT resolved where it is
   declared — an unregistered custom property is a token list — but where
   it is used, on the media, the scrim, the foot and the placeholder,
   whose container is the track. Which is the point.

   The panel stops being the rounded, shadowed, clipping object and the
   MEDIA becomes it, so the shadow is not clipped at the panel's edge and
   the caption is not inside the rounded box. */
.hth-xrail--under {
  aspect-ratio: auto;
  --_xr-head:   var(--hth-head);
  --_xr-body:   var(--hth-body);
  --_xr-label:  var(--hth-label);
  --_xr-rule:   var(--hth-rule);
  --_xr-accent: var(--hth-accent);
  --_xr-stage:  calc(100cqw / var(--hth-xrail-ar, 3));
}

/* After the .hth--dark panel rule, at the same specificity, so this wins
   on every theme. */
.hth-xrail--under .hth-xrail__panel {
  justify-content: flex-start;
  overflow: visible;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
}

.hth-xrail--under .hth-xrail__media,
.hth-xrail--under .hth-xrail__scrim {
  bottom: auto;
  height: var(--_xr-stage);
  border-radius: var(--hth-r-lg);
}
.hth-xrail--under .hth-xrail__media {
  overflow: hidden;
  isolation: isolate;              /* keeps the radius clipping the zoomed image in WebKit */
  box-shadow: var(--hth-shadow);
}
.hth--dark .hth-xrail--under .hth-xrail__media {
  box-shadow: inset 0 0 0 1px var(--hth-line-dark);
}

/* The foot is still in flow — the whole-panel ::after depends on that —
   so it becomes the photograph-height spacer, and its copy and button
   sit at the photograph's bottom edge as they always did. */
.hth-xrail--under .hth-xrail__foot {
  box-sizing: border-box;
  min-height: var(--_xr-stage);
}

.hth-xrail__below {
  --hth-head:   var(--_xr-head);
  --hth-body:   var(--_xr-body);
  --hth-label:  var(--_xr-label);
  --hth-eyebrow: initial;          /* back to --hth-label: off the photo */
  --hth-eyebrow-shadow: initial;
  --hth-rule:   var(--_xr-rule);
  --hth-accent: var(--_xr-accent);

  flex: 1 0 auto;
  min-width: 0;
  max-width: 100%;
  max-width: min(100%, calc(var(--hth-xrail-below, 20) * 1cqw));
  padding: var(--hth-4) 2px 0;
  color: var(--hth-body);
}
.hth-xrail__below > :first-child { margin-top: 0; }
.hth-xrail__below .hth-eyebrow   { margin-bottom: var(--hth-2); }

/* The chip's frosted glass means nothing on a flat field, so below the
   photograph it is a hairline pill in the section's ink. */
.hth-xrail__below .hth-xrail__chip {
  margin-bottom: var(--hth-2);
  background: transparent;
  backdrop-filter: none;
  box-shadow: inset 0 0 0 1px var(--hth-rule);
  color: var(--hth-head);
}

/* Nothing over the photograph but the button: the full scrim has no copy
   to hold up and would only grey the product, so a corner shade is left
   for the glass button to read against on a light frame. It still
   deepens on open, by the opacity rule above. */
.hth-xrail--bare .hth-xrail__scrim {
  opacity: .8;
  background: radial-gradient(280px 170px at 100% 100%,
              rgba(13,18,24,.52) 0%,
              rgba(13,18,24,0)   72%);
}

@media (min-width: 901px) {
  /* The decode placeholder covers the photograph only, not the caption. */
  .hth-xrail--under .hth-xrail__panel::before {
    bottom: auto;
    height: calc(var(--_xr-stage) - 2px);
    border-radius: calc(var(--hth-r-lg) - 1px);
  }
}

/* --- below 900px: a stacked card, not an overlay ---------------------
   THE OVERLAY IS A DESKTOP AFFORDANCE AND ONLY A DESKTOP ONE. It works
   because the panel is 600-900px of photograph and the copy sits in a
   quiet corner of it. At 340px the same copy lands across whatever the
   crop happens to contain — a window, a sky, a lit cabin — and no scrim
   strong enough to fix that leaves a photograph worth showing.

   So the card comes apart: the image becomes the top of the card at the
   shape the editor chose, and the copy moves BELOW it onto the panel's
   own ink, where contrast is a constant rather than a gamble. The scrim
   is not softened, it is removed — there is nothing under the text to
   scrim. This is what the reference sites do at this width too.

   The button goes full width for the same reason it does in every other
   mobile pattern: a 40px circle in the corner of a card is a target
   people miss, and there is no hover to unroll it with, so it is
   labelled from the start.

   NOT tap-to-expand. That spends a tap to reveal information and steals
   the tap that meant "open this model". The filmstrip pages instead, and
   hth-ds.js plays the snapped panel's clip — the touch equivalent of
   "the one you are looking at". */
@media (max-width: 900px) {
  .hth-xrail {
    aspect-ratio: auto;
    align-items: stretch;
    gap: var(--hth-3);
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
  }
  .hth-xrail::-webkit-scrollbar { display: none; }

  .hth-xrail__panel,
  .hth-xrail__panel.is-open,
  .hth-xrail__panel:hover,
  .hth-xrail__panel:focus-within {
    flex: 0 0 min(82vw, 420px);
    scroll-snap-align: center;
    justify-content: flex-start;   /* the image is the top of the card */
  }

  /* The photograph stops being a background and becomes a block. Same
     ratio as the open panel on desktop, so one upload serves both and
     the sizing advice in EXPAND-RAIL-BLOCK.md stays true. */
  .hth-xrail__media {
    position: relative;
    inset: auto;
    flex: 0 0 auto;
    aspect-ratio: var(--hth-xrail-panel-ar, 1.5);
  }

  .hth-xrail__scrim { display: none; }

  .hth-xrail__foot {
    margin-top: auto;
    flex-direction: column;
    align-items: stretch;
    gap: var(--hth-4);
    padding: var(--hth-5);
  }

  .hth-xrail__copy  { max-width: 100%; }
  .hth-xrail__title { font-size: var(--hth-t-md); }
  .hth-xrail__line  { font-size: var(--hth-t-b); }

  .hth-xrail__go {
    margin-left: 0;
    width: 100%;
    height: 48px;
    gap: var(--hth-2);
    background: rgba(255,255,255,.16);
  }
  /* No hover to unroll it with. */
  .hth-xrail__go-label { max-width: 24ch; opacity: 1; transition: none; }

  .hth-xrail__dots {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: var(--hth-2);
    margin-top: var(--hth-5);
  }
  .hth-xrail__dots[hidden] { display: none; }

  /* Nothing expands here, so nothing zooms or dims. */
  .hth-xrail__panel.is-open .hth-xrail__img,
  .hth-xrail__panel:hover   .hth-xrail__img { scale: 1; }
  .hth-xrail--dim:hover .hth-xrail__panel:not(:hover) .hth-xrail__img { filter: none; }
}

/* --- below 900px, with copy below the photograph ---------------------
   The card is already stacked here, so "below" changes what the copy
   sits ON: the section, as it does on desktop, instead of the panel's
   ink. The two views stay one object — a rounded photograph with a
   caption under it.

   All copy is below at this width whatever was chosen, as it always
   was. The foot dissolves (display: contents) so its copy and button
   become panel rows and can be put in reading order around the caption:
   copy placed "over", then copy placed "below", then the button. The
   button stays full width and labelled, for the reasons above, and turns
   solid in the section's ink because glass on sand is invisible. */
@media (max-width: 900px) {
  .hth-xrail--under .hth-xrail__panel {
    --hth-head:   var(--_xr-head);
    --hth-body:   var(--_xr-body);
    --hth-label:  var(--_xr-label);
    --hth-eyebrow: initial;        /* back to --hth-label: off the photo */
    --hth-eyebrow-shadow: initial;
    --hth-rule:   var(--_xr-rule);
    --hth-accent: var(--_xr-accent);
  }

  .hth-xrail--under .hth-xrail__media { height: auto; background: var(--hth-ink); }
  .hth--dark .hth-xrail--under .hth-xrail__media { background: var(--hth-surface); }

  .hth-xrail--under .hth-xrail__foot  { display: contents; }
  .hth-xrail--under .hth-xrail__copy  { order: 1; padding: var(--hth-4) 2px 0; }
  .hth-xrail--under .hth-xrail__copy .hth-eyebrow { margin-bottom: var(--hth-2); }
  .hth-xrail--under .hth-xrail__below {
    order: 2;
    max-width: 100%;
    padding: var(--hth-2) 2px var(--hth-5);
  }
  .hth-xrail--under .hth-xrail__foot:not(:has(> .hth-xrail__copy)) + .hth-xrail__below {
    padding-top: var(--hth-4);
  }

  /* Three classes deep so it outranks the .is-open / :hover button rule
     above, which would otherwise put the glass back on whichever panel
     the editor chose to start open — and on touch that class is never
     removed. */
  .hth-xrail--under .hth-xrail__panel .hth-xrail__go {
    order: 3;
    background: var(--hth-head);
    border-color: var(--hth-head);
    backdrop-filter: none;
    color: var(--hth-bg);
  }
}

/* --- motion preference ----------------------------------------------
   The expansion still HAPPENS — it carries information, and removing it
   would remove the rail. It simply stops being animated, and the zoom,
   which is decoration rather than information, is dropped entirely. */
@media (prefers-reduced-motion: reduce) {
  .hth-xrail__panel,
  .hth-xrail__img,
  .hth-xrail__clip,
  .hth-xrail__scrim,
  .hth-xrail__go,
  .hth-xrail__go-label { transition-duration: .01ms; transition-delay: 0s; }

  .hth-xrail__panel.is-open      .hth-xrail__img,
  .hth-xrail__panel:hover        .hth-xrail__img,
  .hth-xrail__panel:focus-within .hth-xrail__img { scale: 1; }
}


/* =====================================================================
   26. MOSAIC — a preset tile grid at several scales
   ---------------------------------------------------------------------
   A 12-column grid with EXPLICIT placement per preset. Not masonry, and
   not auto-placement: the block's own docblock has the argument, and the
   short version is that an editor has to be able to know what they will
   get before they publish, and a grid that repacks after its images load
   is the largest layout shift a page can volunteer for.

   ONE ROW UNIT, `--hth-mos-row`. Every preset is expressed in whole
   rows, so a tile is one row or two and never a fraction, and the whole
   composition rescales from a single clamp.

   THE PLACEMENT RULES LIVE IN ONE min-width QUERY at the foot of this
   section. Below it every tile is a half — the compositions are designed
   for a wide viewport and a six-column feature tile on a phone is a
   letterbox. Scoping them into the query rather than overriding them
   afterwards is what keeps !important out of this file.

   THE CAPTION IS IN FLOW, deliberately, so `.hth-mosaic__link::after`
   resolves against the TILE and stretches over all of it. The same
   detail the expand rail depends on in section 25, and the same failure
   if it is absolutely positioned: a click target silently shrunk to the
   caption.

   FOUR POINTER EFFECTS — zoom, spotlight, bloom, push — chosen in the
   editor and switched by one .hth-mosaic--hov-* class. They are not
   four intensities of the same idea: each one assumes something
   different about the composition it is running on, and the block's
   help text says which. The comment above them has the argument.

   EVERY DISTANCE AND DURATION IN THIS SECTION IS A CUSTOM PROPERTY, and
   that is structural rather than tidy. Three of the effects need
   :has(), which pushes their selectors to (0,4,0) and (0,5,0); the
   reduced-motion query at the foot cannot out-specify that without
   !important, but it can set the inputs to their null values on the
   container and have all of it compute to no movement. Setting inputs
   instead of fighting outputs is why that query is a dozen lines rather
   than a second copy of this section.
   ===================================================================== */

.hth-mosaic {
  --hth-mos-row: clamp(150px, 17vw, 250px);

  /* --- the motion contract -------------------------------------------
     TWO PROPERTIES DRIVE ALL FOUR EFFECTS, and section 6 reads them
     through !important to get past the Customizer's
     `img { transition: none !important }`. Changing an effect's speed
     means changing a number here, and never touching section 6.

     .8s IN, 1.15s OUT. A pointer effect that leaves at the speed it
     arrived feels like a light switch; the eye reads the RETURN as the
     end of the gesture and wants it unhurried. The switch happens
     because :hover re-points --hth-mos-d at the other duration, and an
     element handed a new value uses it for the transition it is about
     to run.

     THE INDIRECTION IS DELIBERATE — two named durations and a pointer
     at one of them, rather than two literals — so the reduced-motion
     query at the foot can neutralise both by setting the two inputs on
     the container. The :hover rule that re-points --hth-mos-d is
     (0,2,0) and would beat anything that query could write against it,
     but what it resolves to is --hth-mos-in, which nothing below the
     container ever redefines.

     NOT --hth-expo HERE. cubic-bezier(.165,.84,.44,1) spends most of
     its travel in the first sixth of its duration — at --hth-slow's
     1.4s and 6% of scale, what is actually visible is a fast pop
     followed by a second of movement too small and too slow to
     perceive. That is the whole reason this block read as unanimated in
     the places where the cascade did let it run. easeOutCubic keeps the
     settle and gives the travel somewhere to happen. */
  --hth-mos-in:  .8s;
  --hth-mos-out: 1.15s;
  --hth-mos-d:   var(--hth-mos-out);
  --hth-mos-e:   cubic-bezier(.22, .61, .36, 1);

  /* How far a Push column opens. Fr units are proportional, so 1.3fr
     against three resting 1fr columns is about +18% of the row for the
     tile under the pointer and about -6% for each of the others. The -2
     value is for a tile spanning two column groups, where the same
     number would otherwise be applied twice and the tile would take
     half the row. */
  --hth-mos-grow:   1.3fr;
  --hth-mos-grow2:  1.15fr;

  /* How far a Spotlight tile lifts, and how far the rest fall back. */
  --hth-mos-lift:   1.035;
  --hth-mos-recede: .975;

  display: grid;
  grid-template-columns: repeat(12, 1fr);
  grid-auto-rows: var(--hth-mos-row);
  gap: var(--hth-mos-gap);
}

/* The arrival. Kept in its own rule, and kept OFF :has(), because a
   selector list containing an unsupported :has() is discarded whole —
   the container rule below is a separate one for exactly that reason,
   and it is an enhancement, not a dependency. */
.hth-mosaic__tile:hover,
.hth-mosaic__tile:focus-within { --hth-mos-d: var(--hth-mos-in); }

/* Everything that reacts to a tile OTHER than itself — the dimming half
   of Spotlight and Bloom, and the whole of Push — lives on the
   container, so the container needs the arrival speed too. */
.hth-mosaic:has(.hth-mosaic__tile:hover),
.hth-mosaic:has(.hth-mosaic__tile:focus-within) { --hth-mos-d: var(--hth-mos-in); }

/* --- gaps -------------------------------------------------------------
   Five steps, and they are NOT one scale. Standard, Wide and Generous
   are rungs of the spacing scale and they step down on a narrow screen
   like every other rung. Hairline and None are absolutes: 2px is a seam
   and 0 is no seam, and neither means anything different at 390px than
   at 1400px.

   Generous is a lot. The grid is twelve columns, so a gap is paid for
   eleven times across the row — at 48px that is about 260px of a 1400px
   page spent on air, and every tile is smaller for it. That is a
   composition decision rather than a spacing tweak, which is why these
   are named rather than offered as a number.

   THE BASE IS IN :where() ON PURPOSE. It carries zero specificity, so a
   modifier always wins it no matter which of the two is written later —
   and the narrow override below can restate the three scale steps
   without having to also restate Hairline and None to keep them from
   being flattened. Before this, `gap: var(--hth-3)` in that query beat
   `.hth-mosaic--gap-none` on source order alone, and the material strip
   quietly grew 12px seams on every phone. */
:where(.hth-mosaic)   { --hth-mos-gap: var(--hth-4); }
.hth-mosaic--gap-wide { --hth-mos-gap: var(--hth-6); }
.hth-mosaic--gap-gen  { --hth-mos-gap: var(--hth-7); }
.hth-mosaic--gap-hair { --hth-mos-gap: 2px; }
.hth-mosaic--gap-none { --hth-mos-gap: 0; }

/* THE TILE PAINTS NO BACKGROUND OF ITS OWN, and that is the fix for a
   1px dark rim that used to sit around every photograph in this block.

   IT WAS NOT THE ROUNDED CORNERS. The grid is repeat(12, 1fr), so a
   tile's left and right edges land on FRACTIONAL pixel boundaries at
   almost every viewport width. Chrome paints the element's background
   to that fractional border-box and composites the image, in its own
   overflow clip, to the same fractional box — two separate rasters of
   one edge, and their coverage does not cancel. About a fifth of the
   background leaks into the straight edges of the photograph.

   Measured on a light photograph over sand: the edge pixel went from
   (243,217,190), which is a clean photo-to-page blend, to (191,171,148)
   with `background: var(--hth-ink)` on the tile. A 52-level drop, on
   every vertical edge, at every width tested — and ink is the worst
   possible colour to leak, being maximum contrast against both the page
   and the photography this block is built for. The top and bottom edges
   were usually clean, which is why it reads as a thin outline rather
   than a frame: the row height is a clamp that mostly resolves to a
   whole pixel, the column widths almost never do.

   SO THE PLACEHOLDER MOVES OFF THE EDGE. It is real — a lazy image on a
   slow connection still needs the tile to look like a picture arriving
   rather than a hole — but it lives on a pseudo-element inset by a
   pixel, where it cannot reach the boundary the photograph is
   antialiased against. object-fit: cover fills the box completely, so
   once the photograph has decoded this is never seen again.

   AND IT IS NO LONGER INK. A placeholder's job is to be one step off
   the page, not a hole punched through it — a grid of near-black
   rectangles resolving into photographs is a worse load than a grid of
   soft ones.

   MIXED TOWARD GREY RATHER THAN TOWARD INK, which is the difference
   between one rule and two. The mosaic can sit on a dark section, where
   --hth-bg IS ink and mixing further toward ink returns the page colour
   exactly — an invisible placeholder. A mid grey is darker than sand and
   lighter than ink, so the same single declaration steps away from the
   background in whichever direction that section needs. */
.hth-mosaic__tile {
  position: relative;
  overflow: hidden;
  border-radius: var(--hth-r-lg);

  display: flex;
  flex-direction: column;
  justify-content: flex-end;
}
.hth-mosaic--square .hth-mosaic__tile { border-radius: 0; }

.hth-mosaic__tile::before {
  content: "";
  position: absolute;
  inset: 1px;                        /* never touches the antialiased edge */
  border-radius: inherit;
  background: color-mix(in srgb, var(--hth-bg, #EDE7DD) 88%, #808080);
}

.hth-mosaic__media { position: absolute; inset: 0; overflow: hidden; }

.hth-mosaic__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

/* --- THE FOUR POINTER EFFECTS ----------------------------------------
   inc/blocks/mosaic.php offers these as "Pointer effect" and emits one
   .hth-mosaic--hov-* class. The block also multiplies each one's growth
   factor into the image's `sizes` — an effect that paints a photograph
   larger than the file it was served is a resample, and no amount of
   easing rescues a soft one.

   WHAT SEPARATES THEM is not how far they move but WHAT THEY ASSUME:

     zoom   nothing. Touches only the tile under the pointer. The right
            default and the only safe answer when the composition is
            doing the work.
     spot   that there are gaps, and that the tiles are doors. It lifts
            one tile ABOVE the grid plane and pushes the rest back.
     bloom  neither. It is the only effect that survives edge to edge,
            because it never changes a box — which makes it the one for
            the material strip. It also means the section is muted at
            rest, which is a composition decision, not only a hover.
     push   a wide viewport and short captions. It is the only one that
            changes LAYOUT, and the only one gated on both.

   THE CLIP TRACKS THE STILL IN ALL FOUR, transform and filter alike, so
   a crossfade is never also a jump and a clip never blooms while the
   photograph under it is still muted. It is a <video>, so the
   Customizer rule in section 6 never reached it and this declaration
   needs no !important — but it does need the same tokens, or the two
   layers of one image animate at two speeds. */
.hth-mosaic__clip { transition: transform var(--hth-mos-d) var(--hth-mos-e),
                                filter    var(--hth-mos-d) var(--hth-mos-e),
                                opacity   var(--hth-mid)   var(--hth-expo); }

/* ZOOM — 1.06, not 1.15. The reference sits near 1.05, and past about
   1.08 this stops reading as depth and starts reading as a stock-photo
   widget. */
.hth-mosaic--hov-zoom .hth-mosaic__tile:hover        .hth-mosaic__img,
.hth-mosaic--hov-zoom .hth-mosaic__tile:hover        .hth-mosaic__clip,
.hth-mosaic--hov-zoom .hth-mosaic__tile:focus-within .hth-mosaic__img,
.hth-mosaic--hov-zoom .hth-mosaic__tile:focus-within .hth-mosaic__clip
  { transform: scale(1.06); }

/* SPOTLIGHT — the tile itself moves, so the tile needs the transition.
   It is an <article>, which the Customizer rule never touched, so no
   !important is needed here and none should be added. */
.hth-mosaic--hov-spot .hth-mosaic__tile {
  transition: transform  var(--hth-mos-d) var(--hth-mos-e),
              filter     var(--hth-mos-d) var(--hth-mos-e),
              box-shadow var(--hth-mos-d) var(--hth-mos-e);
}
.hth-mosaic--hov-spot .hth-mosaic__tile:hover        .hth-mosaic__img,
.hth-mosaic--hov-spot .hth-mosaic__tile:hover        .hth-mosaic__clip,
.hth-mosaic--hov-spot .hth-mosaic__tile:focus-within .hth-mosaic__img,
.hth-mosaic--hov-spot .hth-mosaic__tile:focus-within .hth-mosaic__clip
  { transform: scale(1.04); }

/* Without :has() the above is the whole of Spotlight — a slightly
   gentler zoom. That is a complete effect, not a broken one, which is
   the test any progressive enhancement has to pass. */
@supports selector(:has(*)) {

  /* The receding half. :not() IS LOAD-BEARING, not tidiness — without
     it this rule reaches (0,4,0) and matches the hovered tile too,
     which is a higher specificity than the lit rule below and would
     leave the tile under the pointer receding with the others. The
     expand rail draws the same distinction the same way in section 25.

     filter on the tile also dims its caption, which is correct: the
     caption of a tile you are not pointing at is not the one being
     read. */
  .hth-mosaic--hov-spot:has(.hth-mosaic__tile:hover) .hth-mosaic__tile:not(:hover),
  .hth-mosaic--hov-spot:has(.hth-mosaic__tile:focus-within) .hth-mosaic__tile:not(:focus-within) {
    transform: scale(var(--hth-mos-recede));
    filter: brightness(.58) saturate(.8);
  }

  /* The lit half. 1.035 and a long soft shadow — the shadow is what
     makes it read as ABOVE the others rather than merely bigger, and
     without it the scale alone reads as a rendering error. */
  .hth-mosaic--hov-spot .hth-mosaic__tile:hover,
  .hth-mosaic--hov-spot .hth-mosaic__tile:focus-within {
    transform: scale(var(--hth-mos-lift));
    filter: none;
    z-index: 4;
    box-shadow: 0 24px 60px rgba(16, 22, 29, .30);
  }

  /* GAPLESS DROPS THE LIFT. A tile growing over its neighbour is
     exactly the one-object reading that Gaps: None exists to produce,
     undone — so edge to edge keeps the dimming and nothing else. Bloom
     is the better choice there and the block's help text says so. */
  .hth-mosaic--gap-none.hth-mosaic--hov-spot .hth-mosaic__tile:hover,
  .hth-mosaic--gap-none.hth-mosaic--hov-spot .hth-mosaic__tile:focus-within {
    transform: none;
    box-shadow: none;
  }
}

/* BLOOM — the only effect that changes no box at all, so it is the only
   one that works on a gapless strip and the only one with a resting
   state worth designing. .86/.88 is a deliberate half-step: enough that
   full colour arriving reads as an event, not so much that the section
   looks like it failed to load. */
.hth-mosaic--hov-bloom .hth-mosaic__img,
.hth-mosaic--hov-bloom .hth-mosaic__clip { filter: brightness(.86) saturate(.88); }

.hth-mosaic--hov-bloom .hth-mosaic__tile:hover        .hth-mosaic__img,
.hth-mosaic--hov-bloom .hth-mosaic__tile:hover        .hth-mosaic__clip,
.hth-mosaic--hov-bloom .hth-mosaic__tile:focus-within .hth-mosaic__img,
.hth-mosaic--hov-bloom .hth-mosaic__tile:focus-within .hth-mosaic__clip {
  filter: none;
  transform: scale(1.04);
}

/* With :has(), the rest deepen rather than merely staying put — the
   same plus-and-minus as Spotlight, done entirely in light. :not() for
   the same reason it is there: scoped to the OTHER tiles, this rule
   never has to out-specify the one above it. */
@supports selector(:has(*)) {
  .hth-mosaic--hov-bloom:has(.hth-mosaic__tile:hover) .hth-mosaic__tile:not(:hover) .hth-mosaic__img,
  .hth-mosaic--hov-bloom:has(.hth-mosaic__tile:hover) .hth-mosaic__tile:not(:hover) .hth-mosaic__clip,
  .hth-mosaic--hov-bloom:has(.hth-mosaic__tile:focus-within) .hth-mosaic__tile:not(:focus-within) .hth-mosaic__img,
  .hth-mosaic--hov-bloom:has(.hth-mosaic__tile:focus-within) .hth-mosaic__tile:not(:focus-within) .hth-mosaic__clip {
    filter: brightness(.6) saturate(.7);
  }
}

/* PUSH lives at the foot of this section, with the compositions — it is
   a change to grid-template-columns and it belongs next to the rules
   that define those columns, not up here with the transforms. */

.hth-mosaic__scrim {
  position: absolute;
  inset: 0;
  z-index: 1;
  opacity: 0;
  background: linear-gradient(to top,
              rgba(16,22,29,.76) 0%,
              rgba(16,22,29,.30) 42%,
              rgba(16,22,29,0)   76%);
  transition: opacity var(--hth-fast) var(--hth-expo);
}

.hth-mosaic__cap {
  position: relative;
  z-index: 2;
  padding: clamp(16px, 1.8vw, 26px);
  opacity: 0;
  translate: 0 12px;
  transition: opacity var(--hth-fast) var(--hth-expo),
              translate var(--hth-fast) var(--hth-expo);
}

.hth-mosaic--cap-always .hth-mosaic__scrim,
.hth-mosaic__tile:hover .hth-mosaic__scrim,
.hth-mosaic__tile:focus-within .hth-mosaic__scrim { opacity: 1; }

.hth-mosaic--cap-always .hth-mosaic__cap,
.hth-mosaic__tile:hover .hth-mosaic__cap,
.hth-mosaic__tile:focus-within .hth-mosaic__cap {
  opacity: 1;
  translate: 0 0;
  /* Behind the zoom, never with it — the picture moves first and the
     words arrive on top of a scrim that is already there. */
  transition-delay: .08s;
}

.hth-mosaic__title {
  display: block;
  margin-top: var(--hth-1);
  font-family: var(--hth-f-display);
  font-weight: 300;
  font-size: var(--hth-t-sm);
  letter-spacing: var(--hth-tr-sm);
  color: var(--hth-head);
}

.hth-mosaic__line {
  margin: var(--hth-2) 0 0;
  max-width: 40ch;
  font-size: var(--hth-t-s);
  line-height: 1.45;
  color: var(--hth-body);
}

.hth-mosaic__link { color: inherit; text-decoration: none; }
.hth-mosaic__link::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 3;
}

/* A LINKED TILE WITH NO CAPTION — the material strip that still goes
   somewhere. The anchor stays IN FLOW (1px, clipped) rather than
   absolutely positioned, because a positioned anchor becomes the
   containing block for its own ::after and the click target collapses to
   the anchor's own 1px. Its text is the photograph's alt text, so the
   link has a real name in the accessibility tree — an unnamed link is a
   link nobody can follow on purpose. */
.hth-mosaic__link--bare {
  width: 1px;
  height: 1px;
  margin: -1px 0 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  font-size: 1px;
  color: transparent;
}

.hth-mosaic__link:focus-visible { outline: none; }
.hth-mosaic__link:focus-visible::after {
  outline: 2px solid var(--hth-azure-lift);
  outline-offset: -4px;
  border-radius: inherit;
}

/* --- the compositions ------------------------------------------------
   Explicit, one rule per position, wide viewports only. Every preset
   fills its rows completely — there are no orphan cells by design, which
   is exactly the property auto-placement cannot promise. */
@media (min-width: 901px) {

  /* Feature left — one 6x2, four 3x1. */
  .hth-mosaic--feat-left  > :nth-child(1) { grid-column: 1  / span 6; grid-row: 1 / span 2; }
  .hth-mosaic--feat-left  > :nth-child(2) { grid-column: 7  / span 3; grid-row: 1; }
  .hth-mosaic--feat-left  > :nth-child(3) { grid-column: 10 / span 3; grid-row: 1; }
  .hth-mosaic--feat-left  > :nth-child(4) { grid-column: 7  / span 3; grid-row: 2; }
  .hth-mosaic--feat-left  > :nth-child(5) { grid-column: 10 / span 3; grid-row: 2; }

  /* Feature right — the mirror, placed rather than reversed. `direction`
     or row-reverse would also flip the reading order of the small tiles,
     which is not what "mirror" means to the person filling the fields. */
  .hth-mosaic--feat-right > :nth-child(1) { grid-column: 7 / span 6; grid-row: 1 / span 2; }
  .hth-mosaic--feat-right > :nth-child(2) { grid-column: 1 / span 3; grid-row: 1; }
  .hth-mosaic--feat-right > :nth-child(3) { grid-column: 4 / span 3; grid-row: 1; }
  .hth-mosaic--feat-right > :nth-child(4) { grid-column: 1 / span 3; grid-row: 2; }
  .hth-mosaic--feat-right > :nth-child(5) { grid-column: 4 / span 3; grid-row: 2; }

  /* Stagger — three columns of four, offset so no two neighbours start
     and end together. This is the preset that carries the most scale
     variance and the one closest to the reference. */
  .hth-mosaic--stagger > :nth-child(1) { grid-column: 1 / span 4; grid-row: 1 / span 2; }
  .hth-mosaic--stagger > :nth-child(2) { grid-column: 5 / span 4; grid-row: 1; }
  .hth-mosaic--stagger > :nth-child(3) { grid-column: 9 / span 4; grid-row: 1 / span 2; }
  .hth-mosaic--stagger > :nth-child(4) { grid-column: 5 / span 4; grid-row: 2 / span 2; }
  .hth-mosaic--stagger > :nth-child(5) { grid-column: 1 / span 4; grid-row: 3; }
  .hth-mosaic--stagger > :nth-child(6) { grid-column: 9 / span 4; grid-row: 3; }

  /* Stagger four — Stagger with its middle column taken out: two
     columns of six, the left tall over short and the right short over
     tall, so the one vertical seam never has a horizontal one meeting
     it. Order is the same walk as Stagger: down the left, across, down
     the right, back to the left.

     THE ROW UNIT IS TALLER HERE, and it has to be. At half the wrap a
     single-row tile on the shared --hth-mos-row is close to 3:1 — a
     letterbox, which is the one shape every preset here is built to
     avoid. The larger unit brings the short tiles to about 2.3:1 and the
     tall ones to about 1.15:1, the same pairing of a panorama and a
     near-square that Stagger's own columns make at a third of the
     width. */
  .hth-mosaic--stagger4 { --hth-mos-row: clamp(170px, 21vw, 300px); }
  .hth-mosaic--stagger4 > :nth-child(1) { grid-column: 1 / span 6; grid-row: 1 / span 2; }
  .hth-mosaic--stagger4 > :nth-child(2) { grid-column: 7 / span 6; grid-row: 1; }
  .hth-mosaic--stagger4 > :nth-child(3) { grid-column: 7 / span 6; grid-row: 2 / span 2; }
  .hth-mosaic--stagger4 > :nth-child(4) { grid-column: 1 / span 6; grid-row: 3; }

  /* Three tall — three equal portraits. The quietest preset, and the
     right one when the three photographs are equally good. */
  .hth-mosaic--three-tall > :nth-child(1) { grid-column: 1 / span 4; grid-row: 1 / span 2; }
  .hth-mosaic--three-tall > :nth-child(2) { grid-column: 5 / span 4; grid-row: 1 / span 2; }
  .hth-mosaic--three-tall > :nth-child(3) { grid-column: 9 / span 4; grid-row: 1 / span 2; }

  /* Editorial — two wide over four small. */
  .hth-mosaic--editorial > :nth-child(1) { grid-column: 1  / span 6; grid-row: 1 / span 2; }
  .hth-mosaic--editorial > :nth-child(2) { grid-column: 7  / span 6; grid-row: 1 / span 2; }
  .hth-mosaic--editorial > :nth-child(3) { grid-column: 1  / span 3; grid-row: 3; }
  .hth-mosaic--editorial > :nth-child(4) { grid-column: 4  / span 3; grid-row: 3; }
  .hth-mosaic--editorial > :nth-child(5) { grid-column: 7  / span 3; grid-row: 3; }
  .hth-mosaic--editorial > :nth-child(6) { grid-column: 10 / span 3; grid-row: 3; }

  /* Band of four — the material strip. With Gaps: None and Corners:
     Square this is the device HOMEPAGE-DESIGN.md deferred. */
  .hth-mosaic--band4 > :nth-child(1) { grid-column: 1  / span 3; grid-row: 1 / span 2; }
  .hth-mosaic--band4 > :nth-child(2) { grid-column: 4  / span 3; grid-row: 1 / span 2; }
  .hth-mosaic--band4 > :nth-child(3) { grid-column: 7  / span 3; grid-row: 1 / span 2; }
  .hth-mosaic--band4 > :nth-child(4) { grid-column: 10 / span 3; grid-row: 1 / span 2; }
}


/* --- PUSH — the tile widens and its neighbours give way ---------------
   The only effect here that changes layout rather than paint, and the
   only one with three gates on it: a pointer that can hover, a viewport
   wide enough for the compositions to exist at all, and :has(), which
   is the only way to ask "is one of my children being pointed at"
   without JavaScript.

   IT IS THE COLUMNS THAT MOVE, not the tiles. Every preset above places
   tiles into a repeat(12, 1fr) track list, so widening a tile means
   widening the COLUMNS IT SITS IN and letting fr do the arithmetic on
   the rest — which is what makes the neighbours contract without a
   single rule saying so. Rows are deliberately left alone: a grid that
   moves in both axes at once is a rearrangement, not an effect.

   TWO SCHEMES COVER ALL SEVEN PRESETS, which is the whole reason this is
   twenty rules and not seventy. Every composition above divides
   cleanly into either four groups of three columns or three groups of
   four, and a tile is always exactly one or two whole groups:

     X  four 3s   feat-left, feat-right, editorial, band4, stagger4
     Y  three 4s  stagger, three-tall

   So the track list is written once per scheme against four (or three)
   custom properties, and each hover rule only reassigns those. A
   twelve-value grid-template-columns per tile position would say the
   same thing at six times the length and would be six times as easy to
   get wrong.

   WHY THIS INTERPOLATES AT ALL: grid-template-columns is animatable
   between two track lists OF THE SAME LENGTH, and both states here are
   twelve <flex> values. The custom properties are not themselves
   transitioned — they flip instantly, and the property that reads them
   is what animates, exactly as a transitioned background does when its
   var() changes.

   THE CAPTION IS IN FLOW, so a tile changing width re-wraps its own
   caption while the columns move. That is why the block's help text
   asks for a short Line on this effect, and why the measure is pulled
   in here — a caption that re-wraps once looks intentional, one that
   re-wraps three times looks broken. */
@media (min-width: 901px) and (hover: hover) {

  .hth-mosaic--hov-push {
    transition: grid-template-columns var(--hth-mos-d) var(--hth-mos-e);
  }

  /* A little image scale rides along so the crop opens as the column
     does — without it the photograph is only revealed, never pushed. */
  .hth-mosaic--hov-push .hth-mosaic__tile:hover        .hth-mosaic__img,
  .hth-mosaic--hov-push .hth-mosaic__tile:hover        .hth-mosaic__clip,
  .hth-mosaic--hov-push .hth-mosaic__tile:focus-within .hth-mosaic__img,
  .hth-mosaic--hov-push .hth-mosaic__tile:focus-within .hth-mosaic__clip
    { transform: scale(1.03); }

  .hth-mosaic--hov-push .hth-mosaic__line { max-width: 32ch; }

  /* --- scheme X: four groups of three -------------------------------- */
  .hth-mosaic--hov-push.hth-mosaic--feat-left,
  .hth-mosaic--hov-push.hth-mosaic--feat-right,
  .hth-mosaic--hov-push.hth-mosaic--editorial,
  .hth-mosaic--hov-push.hth-mosaic--band4,
  .hth-mosaic--hov-push.hth-mosaic--stagger4 {
    --x1: 1fr; --x2: 1fr; --x3: 1fr; --x4: 1fr;
    grid-template-columns:
      var(--x1) var(--x1) var(--x1)
      var(--x2) var(--x2) var(--x2)
      var(--x3) var(--x3) var(--x3)
      var(--x4) var(--x4) var(--x4);
  }

  /* Feature left — tile 1 owns groups 1+2; the four small tiles pair
     off onto groups 3 and 4. */
  .hth-mosaic--hov-push.hth-mosaic--feat-left:has(> :nth-child(1):is(:hover, :focus-within))
    { --x1: var(--hth-mos-grow2); --x2: var(--hth-mos-grow2); }
  .hth-mosaic--hov-push.hth-mosaic--feat-left:has(> :nth-child(2):is(:hover, :focus-within)),
  .hth-mosaic--hov-push.hth-mosaic--feat-left:has(> :nth-child(4):is(:hover, :focus-within))
    { --x3: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--feat-left:has(> :nth-child(3):is(:hover, :focus-within)),
  .hth-mosaic--hov-push.hth-mosaic--feat-left:has(> :nth-child(5):is(:hover, :focus-within))
    { --x4: var(--hth-mos-grow); }

  /* Feature right — the mirror, so the feature owns groups 3+4. */
  .hth-mosaic--hov-push.hth-mosaic--feat-right:has(> :nth-child(1):is(:hover, :focus-within))
    { --x3: var(--hth-mos-grow2); --x4: var(--hth-mos-grow2); }
  .hth-mosaic--hov-push.hth-mosaic--feat-right:has(> :nth-child(2):is(:hover, :focus-within)),
  .hth-mosaic--hov-push.hth-mosaic--feat-right:has(> :nth-child(4):is(:hover, :focus-within))
    { --x1: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--feat-right:has(> :nth-child(3):is(:hover, :focus-within)),
  .hth-mosaic--hov-push.hth-mosaic--feat-right:has(> :nth-child(5):is(:hover, :focus-within))
    { --x2: var(--hth-mos-grow); }

  /* Editorial — two wide over four small, one group each below. */
  .hth-mosaic--hov-push.hth-mosaic--editorial:has(> :nth-child(1):is(:hover, :focus-within))
    { --x1: var(--hth-mos-grow2); --x2: var(--hth-mos-grow2); }
  .hth-mosaic--hov-push.hth-mosaic--editorial:has(> :nth-child(2):is(:hover, :focus-within))
    { --x3: var(--hth-mos-grow2); --x4: var(--hth-mos-grow2); }
  .hth-mosaic--hov-push.hth-mosaic--editorial:has(> :nth-child(3):is(:hover, :focus-within))
    { --x1: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--editorial:has(> :nth-child(4):is(:hover, :focus-within))
    { --x2: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--editorial:has(> :nth-child(5):is(:hover, :focus-within))
    { --x3: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--editorial:has(> :nth-child(6):is(:hover, :focus-within))
    { --x4: var(--hth-mos-grow); }

  /* Band of four — one tile per group, and the preset this effect suits
     best: four equal slices where one opening is unmistakable. */
  .hth-mosaic--hov-push.hth-mosaic--band4:has(> :nth-child(1):is(:hover, :focus-within))
    { --x1: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--band4:has(> :nth-child(2):is(:hover, :focus-within))
    { --x2: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--band4:has(> :nth-child(3):is(:hover, :focus-within))
    { --x3: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--band4:has(> :nth-child(4):is(:hover, :focus-within))
    { --x4: var(--hth-mos-grow); }

  /* Stagger four — two columns of two groups each, and the tiles pair
     off by column exactly as Stagger's do. Both groups take the FULL
     grow, not grow2: the feature presets use grow2 because their small
     tiles are moving too and the feature only needs to hold its own,
     but here the column is the only thing that can move, and at grow2 a
     50/50 split becomes 53/47 — a change nobody would see. */
  .hth-mosaic--hov-push.hth-mosaic--stagger4:has(> :nth-child(1):is(:hover, :focus-within)),
  .hth-mosaic--hov-push.hth-mosaic--stagger4:has(> :nth-child(4):is(:hover, :focus-within))
    { --x1: var(--hth-mos-grow); --x2: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--stagger4:has(> :nth-child(2):is(:hover, :focus-within)),
  .hth-mosaic--hov-push.hth-mosaic--stagger4:has(> :nth-child(3):is(:hover, :focus-within))
    { --x3: var(--hth-mos-grow); --x4: var(--hth-mos-grow); }

  /* --- scheme Y: three groups of four -------------------------------- */
  .hth-mosaic--hov-push.hth-mosaic--stagger,
  .hth-mosaic--hov-push.hth-mosaic--three-tall {
    --y1: 1fr; --y2: 1fr; --y3: 1fr;
    grid-template-columns:
      var(--y1) var(--y1) var(--y1) var(--y1)
      var(--y2) var(--y2) var(--y2) var(--y2)
      var(--y3) var(--y3) var(--y3) var(--y3);
  }

  /* Stagger — three columns of tiles, two tiles per column, so the
     pairs share a group. This is the preset where the effect is most
     visible, because the offset rows mean a widening column moves two
     tiles that do not start or end together. */
  .hth-mosaic--hov-push.hth-mosaic--stagger:has(> :nth-child(1):is(:hover, :focus-within)),
  .hth-mosaic--hov-push.hth-mosaic--stagger:has(> :nth-child(5):is(:hover, :focus-within))
    { --y1: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--stagger:has(> :nth-child(2):is(:hover, :focus-within)),
  .hth-mosaic--hov-push.hth-mosaic--stagger:has(> :nth-child(4):is(:hover, :focus-within))
    { --y2: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--stagger:has(> :nth-child(3):is(:hover, :focus-within)),
  .hth-mosaic--hov-push.hth-mosaic--stagger:has(> :nth-child(6):is(:hover, :focus-within))
    { --y3: var(--hth-mos-grow); }

  /* Three tall — one portrait per group. The quietest preset and the
     clearest reading of the effect: three equal panels, one opening. */
  .hth-mosaic--hov-push.hth-mosaic--three-tall:has(> :nth-child(1):is(:hover, :focus-within))
    { --y1: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--three-tall:has(> :nth-child(2):is(:hover, :focus-within))
    { --y2: var(--hth-mos-grow); }
  .hth-mosaic--hov-push.hth-mosaic--three-tall:has(> :nth-child(3):is(:hover, :focus-within))
    { --y3: var(--hth-mos-grow); }
}

/* --- narrow ----------------------------------------------------------
   None of the six presets survives a phone: a six-column feature tile at
   390px is a letterbox, and the composition was the whole point of the
   preset. So below 900px the block stops placing presets and places one
   of two NARROW ARRANGEMENTS instead, chosen in the editor.

   WHY THE STACK WAS NOT ENOUGH. Halves-then-singles ends, on a phone, in
   a column of identically shaped pictures — which is a carousel someone
   has to scroll rather than a composition they can see. Every tile the
   same shape is exactly the failure the block was built to fix; it was
   just fixed at one width and not the other.

   WHAT ancientritual.com DOES, since it is the reference this block came
   from: nothing clever. Its gallery is flex rows with align-items:
   flex-start and selected items pulled up by a NEGATIVE MARGIN so they
   interlock with the row above — a hand-authored composition, same
   argument against masonry the docblock in mosaic.php makes. The part
   worth stealing is not the mechanism but the invariant: measured across
   its breakpoints, every tile keeps its aspect ratio at every width
   (165%, 100%, 100%, 100%, 81% — identical desktop and mobile). Only the
   ARRANGEMENT changes. There is no width at which its pictures all
   become the same shape, which is why it never reads as a stack.

   SO: TWO COLUMNS, ALTERNATING TALL. Tiles group into 2x2 blocks — a
   double-height tile on one side, two single-height tiles on the other —
   and the side that is tall alternates block to block:

       +-------+---+        +---+-------+
       |       | B |        | D |       |
       |   A   +---+        +---+   F   |
       |       | C |        | E |       |
       +-------+---+        +---+-------+

   Three tiles fill a block exactly, six fill two, five fill one block
   plus a pair, four fill one block plus a full-width closer. Placement
   is explicit per tile COUNT, which mosaic.php emits as .hth-mosaic--nN,
   so there are no orphan cells and no auto-placement — the same promise
   the desktop presets make, kept at the other end.

   ROWS ARE AUTO HERE, NOT --hth-mos-row, and that is what makes the
   interlock hold. The single-height tiles carry an aspect-ratio and the
   rows size to them; a double-height tile takes no ratio and stretches
   across exactly two of those rows plus the gap between. The arithmetic
   is done by the grid, so it stays correct at any width, inside any
   container, with any gap setting — no vw math that assumes this block
   sits directly in a section gutter.

   81%, TAKEN FROM THE REFERENCE. A single tile is slightly landscape, so
   a double-height one lands at about 165% — the two shapes the reference
   uses, and the pair that makes a two-column grid tile perfectly. */
@media (max-width: 900px) {

  /* The three scale steps come down a rung; Hairline and None are left
     alone, which is the whole point of the :where() base above. */
  :where(.hth-mosaic)   { --hth-mos-gap: var(--hth-3); }
  .hth-mosaic--gap-wide { --hth-mos-gap: var(--hth-5); }
  .hth-mosaic--gap-gen  { --hth-mos-gap: var(--hth-6); }

  /* Push needs no undoing here — its whole block is scoped into a
     min-width query of its own, so down here the base repeat(12, 1fr)
     is simply still in force. */

  /* --- STACK — the old behaviour, kept as the opt-out --------------- */
  .hth-mosaic--nar-stack { --hth-mos-row: clamp(130px, 26vw, 200px); }
  .hth-mosaic--nar-stack > * { grid-column: span 6; grid-row: span 1; }
  .hth-mosaic--nar-stack > :first-child { grid-column: span 12; grid-row: span 2; }
  .hth-mosaic--band4.hth-mosaic--nar-stack > :first-child {
    grid-column: span 6; grid-row: span 1;
  }

  /* --- MOSAIC — two columns, alternating tall -----------------------
     Rows are auto in both of the composed arrangements: the tiles carry
     the ratios and the rows size to them, so --hth-mos-row is a stack-only
     concern below 900px. */
  .hth-mosaic--nar-mosaic,
  .hth-mosaic--nar-strip { grid-auto-rows: auto; }

  /* min-height: 0 so a long caption cannot push one tile taller than its
     ratio and drag the shared row — and therefore the tall tile beside
     it — out with it. The tile already clips, exactly as it does at every
     other width. */
  .hth-mosaic--nar-mosaic > * {
    grid-column: span 6;
    aspect-ratio: 1 / .81;
    min-height: 0;
  }

  /* One tile: no composition to make, so it simply leads. */
  .hth-mosaic--nar-mosaic.hth-mosaic--n1 > :nth-child(1) {
    grid-column: 1 / span 12; aspect-ratio: 3 / 2;
  }

  /* Two: a pair. A 2x2 block needs three. */
  .hth-mosaic--nar-mosaic.hth-mosaic--n2 > :nth-child(1) { grid-column: 1 / span 6; }
  .hth-mosaic--nar-mosaic.hth-mosaic--n2 > :nth-child(2) { grid-column: 7 / span 6; }

  /* THE FIRST BLOCK, shared by every count from three up — tall left,
     two singles stacked right. */
  .hth-mosaic--nar-mosaic.hth-mosaic--n3 > :nth-child(1),
  .hth-mosaic--nar-mosaic.hth-mosaic--n4 > :nth-child(1),
  .hth-mosaic--nar-mosaic.hth-mosaic--n5 > :nth-child(1),
  .hth-mosaic--nar-mosaic.hth-mosaic--n6 > :nth-child(1) {
    grid-column: 1 / span 6; grid-row: 1 / span 2; aspect-ratio: auto;
  }
  .hth-mosaic--nar-mosaic.hth-mosaic--n3 > :nth-child(2),
  .hth-mosaic--nar-mosaic.hth-mosaic--n4 > :nth-child(2),
  .hth-mosaic--nar-mosaic.hth-mosaic--n5 > :nth-child(2),
  .hth-mosaic--nar-mosaic.hth-mosaic--n6 > :nth-child(2) {
    grid-column: 7 / span 6; grid-row: 1;
  }
  .hth-mosaic--nar-mosaic.hth-mosaic--n3 > :nth-child(3),
  .hth-mosaic--nar-mosaic.hth-mosaic--n4 > :nth-child(3),
  .hth-mosaic--nar-mosaic.hth-mosaic--n5 > :nth-child(3),
  .hth-mosaic--nar-mosaic.hth-mosaic--n6 > :nth-child(3) {
    grid-column: 7 / span 6; grid-row: 2;
  }

  /* Four: a full-width closer, at one tile's height so it completes the
     block rather than starting a new one. */
  .hth-mosaic--nar-mosaic.hth-mosaic--n4 > :nth-child(4) {
    grid-column: 1 / span 12; grid-row: 3; aspect-ratio: 2 / .81;
  }

  /* Five: a plain pair under the block. */
  .hth-mosaic--nar-mosaic.hth-mosaic--n5 > :nth-child(4) { grid-column: 1 / span 6; grid-row: 3; }
  .hth-mosaic--nar-mosaic.hth-mosaic--n5 > :nth-child(5) { grid-column: 7 / span 6; grid-row: 3; }

  /* Six: the second block, MIRRORED — tall right. The alternation is the
     whole effect; two identical blocks stacked would read as a pattern
     rather than a composition. */
  .hth-mosaic--nar-mosaic.hth-mosaic--n6 > :nth-child(4) { grid-column: 1 / span 6; grid-row: 3; }
  .hth-mosaic--nar-mosaic.hth-mosaic--n6 > :nth-child(5) { grid-column: 1 / span 6; grid-row: 4; }
  .hth-mosaic--nar-mosaic.hth-mosaic--n6 > :nth-child(6) {
    grid-column: 7 / span 6; grid-row: 3 / span 2; aspect-ratio: auto;
  }

  /* STAGGER FOUR KEEPS ITS OWN SHAPE. It is already two columns, so
     the generic four — a block plus a full-width closer — would throw
     away a composition that fits a phone as drawn. Only at the full
     count: an editor one tile short gets the generic three, which is
     still a complete block.

     BOTH TALL TILES CARRY A RATIO HERE, where every other tall tile is
     auto, because this is the one arrangement with a row that nothing
     single-height sits in. Row 2 is spanned by tile 1 AND tile 3 and
     owned by neither, so with auto on both it would collapse and take
     the stagger with it. 1 / 1.62 is two singles' height: tile 2 fixes
     row 1 at .81, tile 1 then sizes row 2 at .81 less a gap, and tile 3
     across rows 2 and 3 comes out at exactly 1.62 again. The two
     columns end level at every width and every gap setting, with the
     grid doing the arithmetic, as above. */
  .hth-mosaic--stagger4.hth-mosaic--nar-mosaic.hth-mosaic--n4 > :nth-child(1) {
    grid-column: 1 / span 6; grid-row: 1 / span 2; aspect-ratio: 1 / 1.62;
  }
  .hth-mosaic--stagger4.hth-mosaic--nar-mosaic.hth-mosaic--n4 > :nth-child(2) {
    grid-column: 7 / span 6; grid-row: 1;
  }
  .hth-mosaic--stagger4.hth-mosaic--nar-mosaic.hth-mosaic--n4 > :nth-child(3) {
    grid-column: 7 / span 6; grid-row: 2 / span 2; aspect-ratio: 1 / 1.62;
  }
  .hth-mosaic--stagger4.hth-mosaic--nar-mosaic.hth-mosaic--n4 > :nth-child(4) {
    grid-column: 1 / span 6; grid-row: 3; aspect-ratio: 1 / .81;
  }

  /* --- STRIP — Band of four, which is one object, not a composition ---
     The material strip is the one preset whose POINT is that every tile
     is the same: four images edge to edge, equal, no chrome, reading as
     a single piece of material rather than a set of pictures. The mosaic
     arrangement above would hand it a tall tile and a full-width closer
     and turn it into a stagger — undoing, at the narrowest width, the
     one device this block exists to be able to make.

     So it stays four across as long as four across is still a picture.
     At 560px that is about 130px a tile, which is a colour swatch, so it
     folds to a 2x2 block of the same squares — still one object, still
     uniform, just in two rows. mosaic.php picks this class off the
     preset, which is why no rule here needs a :not() to stay out of the
     count placements' way. */
  .hth-mosaic--nar-strip > * {
    grid-column: span 3;
    grid-row: auto;
    aspect-ratio: 1;
  }
}

@media (max-width: 560px) {
  .hth-mosaic--nar-strip > * { grid-column: span 6; }
}

/* The stack collapses to singles on a phone. The mosaic does NOT — it is
   two columns all the way down, which is the entire point of it. */
@media (max-width: 560px) {
  .hth-mosaic--nar-stack > *,
  .hth-mosaic--nar-stack > :first-child { grid-column: span 12; grid-row: span 1; }
  .hth-mosaic--nar-stack { --hth-mos-row: clamp(200px, 56vw, 320px); }
}

/* --- touch -----------------------------------------------------------
   No hover, so the caption is not behind one. The composition is
   complete at rest and the hover state is an enhancement over it — never
   the only way to read the section. The zoom simply does not happen. */
@media (hover: none) {
  .hth-mosaic__scrim { opacity: 1; }
  .hth-mosaic__cap   { opacity: 1; translate: 0 0; transition: none; }

  /* BLOOM IS THE ONE EFFECT WITH A RESTING STATE, and on a touch screen
     the resting state is the only state there will ever be — so the
     mute is lifted rather than leaving the whole section permanently
     dimmed waiting for a pointer that is not coming. Every other effect
     here simply does not happen, which needs no rule. */
  .hth-mosaic--hov-bloom .hth-mosaic__img,
  .hth-mosaic--hov-bloom .hth-mosaic__clip { filter: none; }
}

/* --- motion preference ----------------------------------------------
   The same division the expand rail draws in section 25: what carries
   information stays, what is decoration goes. Here NOTHING under the
   pointer carries information — the captions and the links are the
   payload and they are unaffected — so all four effects lose their
   movement outright.

   BLOOM KEEPS ITS COLOUR. Dimming a photograph is not motion, the
   resting mute is a composition the editor chose, and honouring the
   preference means removing the animation, not repainting the page. */
@media (prefers-reduced-motion: reduce) {

  /* FOUR TOKENS SET TO THEIR NULL VALUES, and every movement in this
     section computes to nothing — including the Push rules that reach
     (0,4,0) through :has() and the Spotlight ones at (0,5,0), neither of
     which a rule written here could out-specify without !important.
     Setting the inputs instead of fighting the outputs is why this query
     is six lines rather than thirty. scale(1) is not a no-op the way
     `transform: none` is, but it is indistinguishable and it is what
     these rules are able to be handed.

     The two durations are neutralised at their SOURCE, not where they
     are selected: `.hth-mosaic__tile:hover { --hth-mos-d: ... }` is
     (0,2,0) and would beat anything written here, but what it resolves
     to is --hth-mos-in, and nothing below the container ever touches
     that. From there it flows into section 6's !important declaration,
     which is the only route to the image at all. */
  .hth-mosaic {
    --hth-mos-in:     .01ms;
    --hth-mos-out:    .01ms;
    --hth-mos-grow:   1fr;
    --hth-mos-grow2:  1fr;
    --hth-mos-lift:   1;
    --hth-mos-recede: 1;
  }

  .hth-mosaic__clip,
  .hth-mosaic__scrim,
  .hth-mosaic__cap,
  .hth-mosaic--hov-spot .hth-mosaic__tile,
  .hth-mosaic--hov-push { transition-duration: .01ms; transition-delay: 0s; }

  /* The literal scales left in the section — Zoom's 1.06 and the small
     ones that ride along with Spotlight, Bloom and Push. All four are on
     the image at (0,4,0), so this clears them at (0,5,0) rather than on
     source order, which a later insertion could otherwise reverse. */
  .hth-mosaic .hth-mosaic__tile:hover        .hth-mosaic__media .hth-mosaic__img,
  .hth-mosaic .hth-mosaic__tile:hover        .hth-mosaic__media .hth-mosaic__clip,
  .hth-mosaic .hth-mosaic__tile:focus-within .hth-mosaic__media .hth-mosaic__img,
  .hth-mosaic .hth-mosaic__tile:focus-within .hth-mosaic__media .hth-mosaic__clip
    { transform: none; }

  .hth-mosaic--hov-spot .hth-mosaic__tile:hover,
  .hth-mosaic--hov-spot .hth-mosaic__tile:focus-within { box-shadow: none; }

  /* The caption still appears — it is content. */
  .hth-mosaic__scrim { opacity: 1; }
  .hth-mosaic__cap   { opacity: 1; translate: 0 0; }
}

/* The note below the mosaic — optional small print under the grid. Type,
   colour and the <p> reset come from .hth-fine (section 16); this only
   spaces it off the tiles, gives it a measure, and places that measure. */
.hth-mosaic__note {
  margin-top: var(--hth-5);
  max-width: var(--hth-measure);
}
.hth-mosaic__note--center { margin-inline: auto; text-align: center; }
.hth-mosaic__note--right  { margin-left: auto;   text-align: right; }
.hth-mosaic__note a {
  color: var(--hth-head);
  text-decoration: underline;
  text-decoration-color: var(--hth-rule);
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: text-decoration-color var(--hth-fast) var(--hth-expo) !important;
}
.hth-mosaic__note a:hover { text-decoration-color: var(--hth-accent); }



/* =====================================================================
   27. REFERENCE LIST — a picture and a paragraph, per row
   ---------------------------------------------------------------------
   inc/blocks/reference-list.php · first caller is the wood library on
   /sauna-wood/ · design rationale in SAUNA-WOOD-PAGE-DESIGN.md §7

   Full-width rows: a mark at left in a fixed track, copy at right on a
   measure. A hairline between rows, and nothing else — no panel, no
   radius, no shadow. Seven panels is seven boxes; a rule keeps seven
   entries reading as one list.

   NOTHING COLLAPSES. There is no disclosure here and there must not be
   one: the first caller is a page that earns search traffic on exactly
   this copy, and a <details> hides the substance the page is ranked for.
   If a future caller wants collapsing rows it wants HTH FAQ.

   THE MARK TRACK IS FIXED, NOT INTRINSIC. `auto` would let a row whose
   image failed to load collapse its own track, so that row's copy would
   start further left than its neighbours' — which is the exact failure
   the column of marks exists to prevent. The track is the mark size plus
   its gutter, whether or not a picture arrives.

   ONE VARIABLE COMES FROM THE BLOCK. reference-list.php sets
   --hth-ref-base inline and nothing else; the grid track, the swatch
   diameter and the badge height are all derived from it HERE, so the
   narrow-screen step-down below can still reach them. An inline
   --hth-swatch would beat every rule in this file, media queries
   included, and the phone size would silently do nothing — the same trap
   §15i and §20 each document.
   ===================================================================== */
.hth-ref {
  --hth-ref-mark: var(--hth-ref-base, 128px);
  --hth-ref-gap:  var(--hth-7);

  /* WIDER THAN --hth-measure (62ch), deliberately, and this is the one
     place in the system that departs from it.

     An entry here is SCANNED — the reader is looking for their wood, not
     reading seven essays top to bottom — and the line is already
     shortened by a fixed 128px column to its left. At 62ch the row runs
     about 780px inside a 1400px wrap, which leaves the right half of the
     section empty and reads as a layout that failed rather than as
     margin. 74ch brings the row to roughly 900px, which is the width
     the FAQ block settled on for the same reason: a narrow column
     stranded in a wide empty section looks unfinished, however correct
     its measure is.

     max(), not a flat ch value, and that is not fussiness: `ch` is the
     width of a "0" in the CURRENT font, so the same number lands at
     592px in a fallback stack and past 700px in DM Sans. A measure
     stated only in ch therefore changes with whether the webfont
     arrived, which is the one thing it must not do. The floor keeps the
     row near 900px whatever renders; the ch half still lets the column
     grow if someone raises the base type size.

     Past about 78ch it stops being scannable and the 128px mark no
     longer anchors the row, so this is not a knob to keep turning. */
  --hth-ref-measure: max(62ch, 700px);

  display: flex;
  flex-direction: column;
}

/* The derived values. Both primitives read their own variable, so this
   is the whole integration with §19 and §15i. */
.hth-ref__mark--swatch { --hth-swatch: var(--hth-ref-mark); }
.hth-ref__mark--logo   { --hth-badge-base: var(--hth-ref-mark); }

.hth-ref__row {
  display: grid;
  grid-template-columns: var(--hth-ref-mark) minmax(0, 1fr);
  gap: var(--hth-ref-gap);
  align-items: start;
  padding-block: var(--hth-7);
}
.hth-ref--nomark .hth-ref__row { grid-template-columns: minmax(0, 1fr); }
.hth-ref--nomark .hth-ref__figure { display: none; }

/* The rule sits ABOVE each row rather than below, so the list never ends
   on a trailing hairline pointing at nothing. :first-child loses it so
   the list does not open with one either. */
.hth-ref--ruled .hth-ref__row { border-top: 1px solid var(--hth-rule); }
.hth-ref--ruled .hth-ref__row:first-child { border-top: 0; }
.hth-ref__row:first-child { padding-top: 0; }
.hth-ref__row:last-child  { padding-bottom: 0; }

/* Clears the sticky header when a row is a jump target. Without it,
   /sauna-wood/#cedar parks the entry's title underneath the header and
   the reader lands on its second paragraph. */
.hth-ref__row[id] { scroll-margin-top: var(--hth-10); }

/* ---- the mark -------------------------------------------------------
   §19 owns the circle and §15i owns the fitted logo; this only places
   them and stops §19's centring from fighting the grid. The block sets
   --hth-swatch / --hth-badge-base inline, which is why neither is set
   here — an inline custom property beats a stylesheet rule, media query
   included, and re-declaring it below would silently do nothing. Same
   trap §15i and §20 both document. */
.hth-ref__figure { min-width: 0; }

.hth-ref__mark--swatch .hth-swatch {
  display: block;
  max-width: none;            /* §19 caps at 160px for a packed row */
  text-align: left;
}
.hth-ref__mark--swatch .hth-swatch img { margin-inline: 0; }

.hth-ref__mark--logo { margin: 0; justify-content: flex-start; }

/* ---- the copy ------------------------------------------------------- */
.hth-ref__copy { min-width: 0; max-width: var(--hth-ref-measure); }

.hth-ref__num {
  display: block;
  margin-bottom: var(--hth-3);
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs);
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--hth-tr-lab);
  color: var(--hth-label);
}

/* The chip. A status fact — "Wood to avoid" — rather than a section
   label, so it is sentence case with no tracking, the same call §25
   makes for the expand rail's chip.

   It does NOT reuse .hth-xrail__chip, which is white on a translucent
   blur because it sits on a photograph. This one sits on a themed
   surface, so it takes its colours from the tokens and therefore works
   on sand, shell, white and ink without a second rule. */
.hth-ref__chip {
  display: inline-block;
  margin-bottom: var(--hth-3);
  padding: 5px 12px;
  border: 1px solid var(--hth-rule);
  border-radius: var(--hth-r-pill);
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-xs);
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: 0;
  text-transform: none;
  color: var(--hth-head);
}

.hth-ref__title { margin: 0; }

.hth-ref__body { margin-top: var(--hth-4); }
.hth-ref__body p {
  margin: 0;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-b);
  line-height: 1.70;
  color: var(--hth-body);
}
.hth-ref__body p + p { margin-top: var(--hth-4); }

/* hth_inline() runs wpautop, so the body arrives already wrapped in <p>.
   Links inside it inherit the .hth-rich treatment rather than the
   theme's, so a link in a reference entry matches a link in body copy
   anywhere else — same reasoning as §16g's note for the compare table. */
.hth-ref__body a, .hth-ref__link {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--hth-rule);
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: text-decoration-color var(--hth-fast) var(--hth-expo);
}
.hth-ref__body a:hover, .hth-ref__link:hover { text-decoration-color: var(--hth-accent); }

/* ---- the row link ---------------------------------------------------
   A text link with an arrow, not a button. The system has two buttons
   and seven of them down a reference list would turn it into a row of
   calls to action. */
.hth-ref__go { margin-top: var(--hth-5); }

.hth-ref__link {
  display: inline-flex;
  align-items: center;
  gap: var(--hth-2);
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s);
  font-weight: 500;
  color: var(--hth-head);
}
.hth-ref__link svg { flex: none; transition: translate var(--hth-fast) var(--hth-expo); }
.hth-ref__link:hover svg { translate: 3px 0; }

/* ---- narrow ---------------------------------------------------------
   Below 700px the mark moves ABOVE the copy at the same size rather than
   shrinking into the margin. A 40px circle is decoration; a 96px one is
   still a material sample, and the sample is the reason the row has a
   picture at all. Same threshold §25 uses to break the expand rail into
   a filmstrip. */
@media (max-width: 700px) {
  .hth-ref__row {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--hth-5);
    padding-block: var(--hth-6);
  }
  .hth-ref__copy { max-width: none; }
}

/* min(), not a flat value, so this CLAMPS a large mark rather than
   inflating a small one — a list set to 72px must not grow to 96px on a
   phone. Same correction §12 needed for the pinned hero's mobile
   collapse. */
@media (max-width: 480px) {
  .hth-ref { --hth-ref-mark: min(96px, var(--hth-ref-base, 128px)); }
}

@media (prefers-reduced-motion: reduce) {
  .hth-ref__link svg { transition: none; }
  .hth-ref__link:hover svg { translate: none; }
}

/* =====================================================================
   LEAD HERO + OPTIONAL CAMPAIGN ROW SPACING
   Existing tokens only. Applied through blocks and WPBakery row classes.
   The provider owns submission; the theme owns presentation.
   ===================================================================== */
.hth-lead-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.hth-lead-hero { padding-block: var(--hth-7) var(--hth-6); }
.hth-lead-hero__grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(340px, 1fr); gap: var(--hth-5) var(--hth-7); align-items: start; }
.hth-lead-hero__story .hth-xl { max-width: 12ch; font-size: var(--hth-t-lg); margin-bottom: var(--hth-4); }
.hth-lead-hero__story .hth-lead { max-width: 51ch; margin: 0 0 var(--hth-5); }
.hth-lead-hero__figure { margin: 0; }
.hth-lead-hero__image { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: var(--hth-r-lg); }
.hth-lead-hero__figure figcaption { font: 400 var(--hth-t-xs)/1.5 var(--hth-f-body); color: var(--hth-label); margin-top: var(--hth-2); }
.hth-lead-hero__jump { display: none; }
.hth-lead-hero__left { min-width: 0; align-self: stretch; }
.hth-lead-hero__extra { margin-top: var(--hth-5); max-width: var(--hth-measure); }
.hth-lead-hero__extra > :last-child { margin-bottom: 0; }
/* Explicit shapes win over the default mobile crop below. */
.hth-lead-hero .hth-lead-hero__image--shaped { aspect-ratio: var(--hth-ar); }
.hth-lead-hero .hth-lead-hero__image--natural { aspect-ratio: auto; }
/* Like the gallery-buy gallery, the story holds within its grid area.
   JS only checks fit; CSS owns the scrolling and release at the row end.
   No JS / too-tall content = ordinary scrolling, with all copy accessible. */
.hth-lead-hero--sticky { --hth-lead-sticky-gap: var(--hth-6); }
@media (min-width: 900px) {
  .hth-lead-hero--sticky .hth-lead-hero__story.is-sticky {
    position: sticky;
    top: var(--hth-lead-sticky-top, calc(var(--hth-hdr-bottom, 0px) + var(--hth-lead-sticky-gap)));
  }
}
.hth-lead-hero__proof { grid-column: 1 / -1; border-top: 1px solid var(--hth-line); margin: 0; padding-top: var(--hth-5); text-align: center; }
.hth-lead-panel { background: var(--hth-bg); border: 1px solid var(--hth-rule); border-radius: var(--hth-r-lg); padding: var(--hth-6); box-shadow: var(--hth-shadow); scroll-margin-top: calc(var(--hth-hdr-bottom, 0px) + var(--hth-5)); }
.hth-lead-panel .hth-eyebrow { font-size: var(--hth-t-xs); }
.hth-lead-panel > .hth-p { margin: var(--hth-3) 0 var(--hth-5); }
.hth-lead-panel__preview { padding: var(--hth-3); border: 1px dashed var(--hth-rule); border-radius: var(--hth-r); margin: 0 0 var(--hth-4); }
.hth-lead-fields { display: grid; gap: var(--hth-4); border: 0; min-width: 0; padding: 0; margin: 0; }
.hth-lead-panel__embed label { display: block; color: var(--hth-head); font: 500 var(--hth-t-s)/1.5 var(--hth-f-body); }
.hth-lead-panel__embed :where(input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="submit"]), select, textarea) { box-sizing: border-box; display: block; width: 100%; max-width: 100%; min-height: 48px; margin-top: var(--hth-2); padding: var(--hth-3) var(--hth-4); border: 1px solid var(--hth-rule); border-radius: var(--hth-r); background: var(--hth-white); color: var(--hth-head); font: 400 var(--hth-t-b)/1.4 var(--hth-f-body); box-shadow: none; }
.hth-lead-panel__embed :where(button, input[type="submit"]) { cursor: pointer; }
.hth-lead-panel__embed .hth-btn { width: 100%; justify-content: space-between; margin-top: var(--hth-2); }
.hth-lead-panel__embed :disabled { opacity: 1; -webkit-text-fill-color: currentColor; cursor: not-allowed; }
.hth-lead-panel__embed .wpb_raw_code, .hth-lead-panel__embed .wpb_content_element { margin-bottom: 0; }
.hth-lead-panel__embed :where(input, select, textarea, button):focus-visible,
.hth-lead-panel a:focus-visible, .hth-lead-panel:focus-visible { outline: 2px solid var(--hth-accent); outline-offset: 4px; }
.hth-lead-panel__call { display: grid; gap: var(--hth-1); justify-items: center; margin-top: var(--hth-5); padding-top: var(--hth-5); border-top: 1px solid var(--hth-rule); }
.hth-lead-panel__call a { color: var(--hth-head); text-decoration: none; font: 500 var(--hth-t-bl)/1.5 var(--hth-f-body); }
.hth-campaign-row .hth-section:not(.hth-lead-hero) { padding-block: var(--hth-8); }
.hth-campaign-row .hth-section--tight:not(.hth-lead-hero) { padding-block: var(--hth-6); }
@media (max-width: 1100px) and (min-width: 900px) {
  .hth-lead-hero__grid { gap: var(--hth-5); }
  .hth-lead-panel { padding: var(--hth-5); }
}
@media (max-width: 899px) {
  .hth-lead-hero__grid { grid-template-columns: minmax(0, 1fr); gap: var(--hth-6); }
  .hth-lead-hero__story .hth-xl { max-width: 14ch; }
  .hth-lead-hero__jump { display: inline-flex; margin-bottom: var(--hth-5); }
  .hth-lead-hero__proof { grid-column: auto; }
  .hth-lead-panel { max-width: 640px; width: 100%; justify-self: center; }
}
@media (max-width: 599px) {
  .hth-lead-hero { padding-top: var(--hth-6); }
  .hth-lead-hero__story .hth-xl { font-size: var(--hth-t-xl); }
  .hth-lead-panel { padding: var(--hth-5); }
  .hth-lead-hero__image { aspect-ratio: 4 / 3; }
}

/* ---------------------------------------------------------------------
   ZOHO FORMS (HTML export) inside the Lead Hero form panel
   For /request-info/ (REQUEST-INFO-PAGE-DESIGN.md). Zoho owns the markup,
   field names, validation and submission; this only restyles it. Every
   rule is scoped to .hth-lead-panel__embed .zf-templateWrapper, so Zoho's
   own stylesheet (wherever the optimizer puts it) loses on specificity and
   nothing outside the panel is touched. Class names are Zoho's, read from
   the live form on 5 Oct 2026; if Zoho changes its export, check here.
   --------------------------------------------------------------------- */
.hth-lead-panel__embed .zf-templateWidth { width: auto; max-width: none; margin: 0; padding: 0; }
.hth-lead-panel__embed .zf-templateWrapper { width: auto; margin: 0; padding: 0; background: none; border: 0; box-shadow: none; color: var(--hth-body); font: 400 var(--hth-t-b)/1.5 var(--hth-f-body); }
.hth-lead-panel__embed .zf-templateWrapper :where(ul, li) { list-style: none; margin: 0; padding: 0; }
/* Zoho prints its internal form name ("Info Request_lite") as an H2. The panel has the real heading. */
.hth-lead-panel__embed .zf-templateWrapper .zf-tempHeadBdr { display: none; }
.hth-lead-panel__embed .zf-templateWrapper .zf-subContWrap > ul { display: grid; gap: var(--hth-5); }
.hth-lead-panel__embed .zf-templateWrapper .zf-tempFrmWrapper { float: none; width: auto; margin: 0; padding: 0; border: 0; background: none; }
.hth-lead-panel__embed .zf-templateWrapper .zf-tempContDiv { float: none; width: auto; margin: 0; padding: 0; }
.hth-lead-panel__embed .zf-templateWrapper :where(.zf-clearBoth, .clearBoth) { display: none; }
.hth-lead-panel__embed .zf-templateWrapper .zf-labelName { display: block; float: none; width: auto; margin: 0 0 var(--hth-2); padding: 0; color: var(--hth-head); font: 500 var(--hth-t-s)/1.5 var(--hth-f-body); }
.hth-lead-panel__embed .zf-templateWrapper .zf-important { margin-left: 2px; color: var(--hth-accent); font-style: normal; }
.hth-lead-panel__embed .zf-templateWrapper :where(.zf-tempContDiv input:not([type="checkbox"]):not([type="radio"]), textarea) { margin-top: 0; }
.hth-lead-panel__embed .zf-templateWrapper textarea { min-height: 120px; resize: vertical; }
.hth-lead-panel__embed .zf-templateWrapper :where(.zf-instruction, .zf-phwrapper label) { display: block; margin: var(--hth-1) 0 0; color: var(--hth-label); font: 400 var(--hth-t-xs)/1.5 var(--hth-f-body); }
/* Choices: radios, the goal checkboxes, the newsletter box and the SMS consent. */
.hth-lead-panel__embed .zf-templateWrapper :where(input[type="checkbox"], input[type="radio"]) { flex: none; width: 20px; height: 20px; margin: 2px 0 0; accent-color: var(--hth-azure-ink); cursor: pointer; float: none; }
.hth-lead-panel__embed .zf-templateWrapper .zf-overflow { display: flex; flex-wrap: wrap; gap: var(--hth-3) var(--hth-5); }
.hth-lead-panel__embed .zf-templateWrapper .zf-twoColumns .zf-overflow { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--hth-3) var(--hth-4); }
.hth-lead-panel__embed .zf-templateWrapper .zf-multiAttType { display: flex; align-items: flex-start; gap: var(--hth-2); float: none; width: auto; margin: 0; }
.hth-lead-panel__embed .zf-templateWrapper :where(.zf-radioChoice, .zf-checkChoice) { display: inline; margin: 0; color: var(--hth-head); font: 400 var(--hth-t-s)/1.5 var(--hth-f-body); cursor: pointer; }
.hth-lead-panel__embed .zf-templateWrapper .zf-decesion .zf-tempContDiv { display: flex; align-items: flex-start; gap: var(--hth-2); }
.hth-lead-panel__embed .zf-templateWrapper .zf-decesion .zf-labelName { display: inline; margin: 0; font-weight: 400; }
.hth-lead-panel__embed .zf-templateWrapper .zf-termsContainer { margin: 0; padding: var(--hth-4); border: 1px solid var(--hth-rule); border-radius: var(--hth-r); background: var(--hth-white); }
.hth-lead-panel__embed .zf-templateWrapper .zf-termScrollRemove { max-height: none; overflow: visible; margin: 0 0 var(--hth-3); padding: 0; border: 0; color: var(--hth-label); font: 400 var(--hth-t-xs)/1.6 var(--hth-f-body); }
.hth-lead-panel__embed .zf-templateWrapper .zf-termsandCond > .zf-labelName { display: none; } /* repeats the checkbox's own label */
.hth-lead-panel__embed .zf-templateWrapper .zf-termScrollRemove a { color: var(--hth-accent); }
/* The one exception to 'no !important here': Zoho's consent text arrives pasted from a mail client, with
   inline Arial / small / purple / white-background styles on every span. Only !important beats inline style. */
.hth-lead-panel__embed .zf-templateWrapper .zf-termScrollRemove :is(span, a, div) { font: inherit !important; color: inherit !important; background: none !important; letter-spacing: inherit !important; }
.hth-lead-panel__embed .zf-templateWrapper .zf-termScrollRemove a { color: var(--hth-accent) !important; text-decoration: underline !important; }
.hth-lead-panel__embed .zf-templateWrapper .zf-termScrollRemove > div:last-child:empty, .hth-lead-panel__embed .zf-templateWrapper .zf-termScrollRemove br:last-child { display: none; }
.hth-lead-panel__embed .zf-templateWrapper .zf-termsAccept { display: flex; align-items: flex-start; gap: var(--hth-2); }
.hth-lead-panel__embed .zf-templateWrapper .zf-termsAccept label { display: inline; margin: 0; font: 500 var(--hth-t-s)/1.5 var(--hth-f-body); }
/* Zoho shows these with style.display = 'block' when a field fails validation. */
.hth-lead-panel__embed .zf-templateWrapper .zf-errorMessage { margin: var(--hth-2) 0 0; color: #B4432A; font: 500 var(--hth-t-xs)/1.5 var(--hth-f-body); }
/* Submit: the .hth-btn look, full width, without changing Zoho's button. */
.hth-lead-panel__embed .zf-templateWrapper .zf-fmFooter { margin: var(--hth-6) 0 0; padding: 0; text-align: left; }
.hth-lead-panel__embed .zf-templateWrapper .zf-submitColor {
  display: flex; align-items: center; justify-content: center; width: 100%; min-height: 52px; margin: 0;
  padding: 17px 26px; border: 1px solid var(--hth-ink); border-radius: var(--hth-r);
  background: var(--hth-ink); color: #fff; box-shadow: none; text-shadow: none;
  font: 500 var(--hth-t-s)/1 var(--hth-f-body); letter-spacing: 0; text-transform: none; cursor: pointer;
  transition: background var(--hth-fast) var(--hth-expo), border-color var(--hth-fast) var(--hth-expo);
}
.hth-lead-panel__embed .zf-templateWrapper .zf-submitColor:hover { background: #2E3E4D; border-color: #2E3E4D; }
.hth-lead-panel__embed .zf-templateWrapper .zf-submitColor:focus-visible { outline: 2px solid var(--hth-azure); outline-offset: 3px; }
@media (max-width: 599px) {
  .hth-lead-panel__embed .zf-templateWrapper .zf-twoColumns .zf-overflow { grid-template-columns: minmax(0, 1fr); }
}

/* =====================================================================
   31. EDUCATIONAL APPARATUS — contents and editorial byline
   Quiet rules and body type; no animation, cards, or new colour palette.
   ===================================================================== */
.hth-contents { --hth-contents-cols: 2; }
.hth-contents--1 { --hth-contents-cols: 1; }
.hth-contents--3 { --hth-contents-cols: 3; }
.hth-contents__desktop > .hth-eyebrow { margin-bottom: var(--hth-5); }
.hth-contents__desktop > h2 { margin-bottom: var(--hth-6); }
.hth-contents .hth-contents__list,
.hth-contents-rail .hth-contents__list {
  display: grid; grid-template-columns: repeat(var(--hth-contents-cols, 1), minmax(0, 1fr));
  column-gap: var(--hth-7); padding: 0; margin: 0; list-style: none; counter-reset: hth-contents;
}
.hth-contents__item { min-width: 0; border-top: 1px solid var(--hth-rule); counter-increment: hth-contents; }
.hth-contents__entry {
  display: flex; align-items: baseline; gap: var(--hth-4); min-height: 48px;
  padding-block: var(--hth-4); color: var(--hth-head); text-decoration: none; line-height: 1.5;
  font-size: var(--hth-t-b); overflow-wrap: anywhere;
}
.hth-contents--numbered .hth-contents__entry::before {
  content: counter(hth-contents, decimal-leading-zero); flex: 0 0 2ch;
  color: var(--hth-label); font-size: var(--hth-t-s); font-variant-numeric: tabular-nums;
}
.hth-contents__copy { flex: 1; min-width: 0; }
.hth-contents__label { display: block; font-weight: 500; }
.hth-contents__note { display: block; margin-top: var(--hth-1); color: var(--hth-label); font-size: var(--hth-t-s); font-weight: 400; }
.hth-contents__entry:is(a)::after { content: '\2197'; color: var(--hth-accent); flex: 0 0 auto; }
.hth-contents__entry:is(a):hover .hth-contents__label,
.hth-contents__entry[aria-current="true"] .hth-contents__label { color: var(--hth-accent); text-decoration: underline; text-underline-offset: .22em; }
.hth-contents__entry--unavailable { color: var(--hth-label); }
.hth-contents__entry:focus-visible,
.hth-contents__mobile > summary:focus-visible,
.hth-byline a:focus-visible { outline: 2px solid var(--hth-accent); outline-offset: 4px; border-radius: 2px; }
.hth-contents__mobile { display: none; }
.hth-contents__mobile > summary {
  display: flex; align-items: center; gap: var(--hth-4); min-height: 48px;
  color: var(--hth-head); font-weight: 500; cursor: pointer; list-style: none;
}
.hth-contents__mobile > summary::-webkit-details-marker { display: none; }
.hth-contents__mobile > summary::after { content: '+'; font-size: 24px; font-weight: 400; }
.hth-contents__mobile[open] > summary::after { content: '\2212'; }
.hth-contents__count { margin-left: auto; color: var(--hth-label); font-size: var(--hth-t-s); font-weight: 400; }
.hth-contents__mobile[open] > nav { padding-top: var(--hth-4); }
.hth-contents-rail { display: none; }
@media (min-width: 1100px) {
  .hth-section.hth-contents-chapter { padding-left: calc(var(--hth-gut) + 264px); }
  .hth-contents-rail:not([hidden]) {
    display: block; position: fixed; left: var(--hth-gut); top: calc(var(--hth-hdr-bottom, 96px) + var(--hth-5));
    width: 216px; max-height: calc(100dvh - var(--hth-hdr-bottom, 96px) - var(--hth-7));
    overflow-y: auto; overscroll-behavior: contain; z-index: 80;
    padding: var(--hth-4); border: 1px solid var(--hth-rule); border-radius: var(--hth-r);
    background: var(--hth-bg); color: var(--hth-body); box-sizing: border-box;
  }
  .hth-contents-rail .hth-contents__entry { font-size: var(--hth-t-s); gap: var(--hth-2); padding-block: var(--hth-3); }
  .hth-contents-rail .hth-contents__note { display: none; }
  .hth-contents-rail .hth-eyebrow { margin-bottom: var(--hth-4); }
  .hth-contents-rail .hth-md { font-family: var(--hth-f-display); font-weight: 300; font-size: var(--hth-t-sm); margin: 0 0 var(--hth-4); }
}
@media (max-width: 759px) {
  .hth-contents__desktop { display: none; }
  .hth-contents__mobile { display: block; }
  .hth-contents .hth-contents__list { grid-template-columns: minmax(0, 1fr); }
  .hth-contents-section { padding-block: var(--hth-4); }
}
.hth-byline { font-size: var(--hth-t-s); line-height: 1.5; max-width: 1040px; }
.hth-byline__people { display: flex; flex-wrap: wrap; align-items: center; gap: var(--hth-5) var(--hth-7); }
.hth-byline__author,
.hth-byline__reviewer { display: flex; align-items: center; gap: var(--hth-4); }
.hth-byline__portrait { width: 48px; height: 48px; object-fit: cover; border-radius: 50%; flex-shrink: 0; }
.hth-byline__person { display: grid; gap: 2px; }
.hth-byline__label { color: var(--hth-label); font-size: var(--hth-t-xs); }
.hth-byline__name { color: var(--hth-head); font-weight: 500; font-size: var(--hth-t-b); }
.hth-byline__name a { color: inherit; text-decoration: underline; text-decoration-color: var(--hth-rule); text-underline-offset: .2em; }
.hth-byline__name a:hover { color: var(--hth-accent); text-decoration-color: currentColor; }
.hth-byline__credentials { color: var(--hth-label); }
.hth-byline .hth-byline__dates { display: flex; flex-wrap: wrap; gap: var(--hth-2) var(--hth-5); list-style: none; margin: var(--hth-5) 0 0; padding: 0; color: var(--hth-label); }
.hth-byline__dates:empty { display: none; }
/* A lifted panel (Page Contents lifts by default) rises var(--hth-7) into the
   section above it, which covered the dates row. When one follows the byline,
   reserve that overlap inside the byline on top of its own padding. Covers the
   same column and the usual one-block-per-row WPBakery layout. */
.hth-byline-section:has(+ .hth-lift) .hth-byline::after,
.vc_row:has(.hth-byline-section):has(+ .vc_row .hth-lift) .hth-byline::after { content: ""; display: block; height: var(--hth-7); }
.hth-byline__disclosure { border-top: 1px solid var(--hth-rule); padding-top: var(--hth-4); margin-top: var(--hth-4); color: var(--hth-label); }
.hth-byline__disclosure a { color: var(--hth-accent); text-decoration: underline; text-underline-offset: .2em; }
@media print {
  .hth-contents-section, .hth-contents-rail { display: none !important; }
  .hth-section.hth-contents-chapter { padding-left: var(--hth-gut); }
  .hth-byline { break-inside: avoid; }
}


/* =====================================================================
   32. FEATURE ROWS — a photograph and a few paragraphs, per row
   ---------------------------------------------------------------------
   inc/blocks/feature-rows.php · first caller is the benefits page, §6 of
   SAUNA-BENEFITS-PAGE-DESIGN.md · specified in EDUCATIONAL-PAGES-DESIGN.md
   §6E

   A Split Feature in miniature, repeated inside one section: the same
   5 : 7 proportions, the same .hth-figure frame, the same stat callout,
   so a row sits next to a real Split Feature on the same page without
   looking like a cousin.

   THE PHOTOGRAPH IS ALWAYS FIRST IN THE MARKUP and the alternation is
   `order`, so every row stacks photo-then-copy on a phone. See the block
   file for why that matters in a list and not in a single Split Feature.

   ROWS ARE CENTRED VERTICALLY, where Split Feature aligns to the top.
   One Split Feature is a section on its own; a column of rows is read as
   a sequence, and top-aligned copy beside a tall portrait leaves a hole
   under every short entry that repeats all the way down.

   THE GAP BETWEEN ROWS IS --hth-9 (96px), which is less than a section's
   --hth-sec (up to 128px) on purpose: rows are one group, and the space
   between them must read as smaller than the space around the group.
   ===================================================================== */
.hth-frows__head {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: var(--hth-6);
  flex-wrap: wrap;
  margin-bottom: var(--hth-8);
}
.hth-frows__intro { max-width: var(--hth-measure); }
.hth-frows__lede  { margin-top: var(--hth-4); }

.hth-frows { display: flex; flex-direction: column; gap: var(--hth-9); }

.hth-frow {
  display: grid;
  grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
  gap: var(--hth-7);
  align-items: center;
}
.hth-frow--flip { grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); }
.hth-frow--flip > .hth-frow__figure { order: 2; }
.hth-frow--nofig { grid-template-columns: minmax(0, 1fr); }

/* Same 112px the section anchors use (§8), so a jump from the contents to
   a row and a jump to a section land at the same height under the header. */
.hth-frow[id] { scroll-margin-top: 112px; }

.hth-frow__copy  { min-width: 0; }
.hth-frow__title { margin: 0; }
.hth-frow__body  { margin-top: var(--hth-5); }
.hth-frow__go    { margin-top: var(--hth-5); }

/* Optional hairline. Above each row rather than below, so the group never
   ends on a rule pointing at nothing — §27's rule, for the same reason. */
.hth-frows--ruled { gap: 0; }
.hth-frows--ruled > .hth-frow { padding-block: var(--hth-8); border-top: 1px solid var(--hth-rule); }
.hth-frows--ruled > .hth-frow:first-child { border-top: 0; padding-top: 0; }
.hth-frows--ruled > .hth-frow:last-child  { padding-bottom: 0; }

/* ---- stacked --------------------------------------------------------
   Same 900px break as .hth-grid--5-7, so a row and a Split Feature on the
   same page stack at the same width.

   A PORTRAIT GOES TO 5:4 ONCE STACKED. At full width a 4:5 photograph is
   taller than it is wide — on a 768px tablet, 960px of picture per row,
   nine times. One Split Feature can afford that; a list cannot. Only the
   portrait shape changes: a square is often an illustration that must not
   be cropped, and the landscape shapes are already short. */
@media (max-width: 900px) {
  .hth-frows { gap: var(--hth-8); }
  .hth-frows--ruled > .hth-frow { padding-block: var(--hth-7); }
  .hth-frow,
  .hth-frow--flip { grid-template-columns: minmax(0, 1fr); gap: var(--hth-5); }
  .hth-frow--flip > .hth-frow__figure { order: 0; }
  .hth-frow__figure.hth-ar-45 { aspect-ratio: 5 / 4; --hth-ar: 5 / 4; }
}


/* =====================================================================
   33. EXPERT QUOTES  —  HTH Expert Quotes (inc/blocks/expert-quotes.php)
   ---------------------------------------------------------------------
   Doctors and practitioners: round photo, name + credentials, title, a
   pull quote, and an optional full quote that opens in a panel.

     <div class="hth-xq hth-xq--m hth-xq--n3" style="--hth-xq-n:3;
          --hth-xq-ph:128px" data-hth-xq role="region" ...>
       <ul class="hth-xq__track">
         <li class="hth-xq__item"><div class="hth-xq__card">
           <img class="hth-xq__img"> name · title · quote · [Read full quote]
         </div></li> ...
       </ul>
       <div class="hth-xq__nav"> bars · 01/03 · ‹ › </div>
     </div>
     <dialog class="hth-xq-dialog"> ... one per expert with a full quote

   THE CONTROLS ARE SPLIT CAROUSEL'S (§13g), class names and all:
   .hth-scar__bars / __bar / __fill / __count / __btns / __btn are styled
   without reference to .hth-scar, so they are used here unchanged. Two
   carousels on one page then share one control vocabulary, and a change
   to the controls lands on both. Only the row that holds them is ours.

   N ON SCREEN, MEASURED NOT HARD-CODED. The block sets --hth-xq-n (2-5)
   and each card is (100% - gaps) / n. Below 1100px n is capped at 3,
   below 900px at 2, and below 600px it is one card at 84% with the
   track bled through the page gutter so the next card shows at the
   edge. hth-ds.js reads the result off the layout (it never assumes the
   breakpoints), so the bar count, the counter and the page size follow
   whatever CSS decided.

   PAGES SNAP, NOT CARDS. Once JS is up only the first card of each
   screenful keeps scroll-snap-align (.is-snap), so a swipe or a
   trackpad flick moves a whole screenful and the counter always means
   something. Without JS every card snaps — a plain swipe strip.

   THE PHOTO CARRIES ITS OWN PLACEHOLDER FILL, on the <img> rather than
   a frame around it, for the reason the 2026-10-01 .hth-figure fix
   records: a filled, clipped frame under a clipped image anti-aliases
   twice and leaves a light ring on the edge at 125% / 150% scaling.
   --------------------------------------------------------------------- */
.hth-xq__head {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: var(--hth-6);
  flex-wrap: wrap;
  margin-bottom: var(--hth-8);
}
/* An eyebrow on its own is a section label: one step above the content. */
.hth-xq__head--label { margin-bottom: var(--hth-5); }
.hth-xq__head--label .hth-eyebrow { margin-bottom: 0; }
.hth-xq__intro { max-width: var(--hth-measure); }
.hth-xq__lede  { margin-top: var(--hth-4); }

.hth-xq {
  --_n:   var(--hth-xq-n, 3);
  --_gap: var(--hth-5);
  --_ph:  var(--hth-xq-ph, 128px);
  --_qs:  clamp(19px, 1.45vw, 22px);
  position: relative;
}
.hth-xq--n4,
.hth-xq--n5 { --_qs: 18px; }

/* ---- the strip ---- */
.hth-xq__track {
  /* position: relative makes the track the containing block for anything
     absolute inside a card (the .hth-sr text in "Read full quote"). Without
     it those boxes escape the track's clipping and widen the PAGE by the
     whole strip's length — measured: a 3,805px-wide page at 1440px. */
  position: relative;
  display: flex;
  gap: var(--_gap);
  margin: 0;
  padding: 0;
  list-style: none;

  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
  -ms-overflow-style: none;
  /* The track IS the scroll container — `pan-y` would forbid the one
     gesture it runs on (§13b / §23). */
  touch-action: manipulation;
}
.hth-xq__track::-webkit-scrollbar { display: none; }

.hth-xq__item {
  flex: 0 0 calc((100% - (var(--_n) - 1) * var(--_gap)) / var(--_n));
  min-width: 0;
  display: flex;               /* the card fills it, so a row is one height */
  margin: 0;
  scroll-snap-align: start;
}
.hth-xq.is-ready .hth-xq__item:not(.is-snap) { scroll-snap-align: none; }

/* ---- the card ---- */
.hth-xq__card {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding: var(--hth-7) var(--hth-6) var(--hth-6);
  background: var(--hth-surface);
  border-radius: var(--hth-r-lg);
}
.hth-xq--open .hth-xq__card { background: none; padding: 0; border-radius: 0; }
.hth-xq--left .hth-xq__card { align-items: flex-start; text-align: left; }

.hth-xq__img {
  display: block;
  flex: none;
  width: var(--_ph);
  height: var(--_ph);
  aspect-ratio: 1;
  margin: 0 0 var(--hth-5);
  border-radius: 50%;
  object-fit: cover;
  object-position: 50% 35%;    /* a head-and-shoulders crop keeps the eyes
                                  above centre; this keeps them in frame */
  background: var(--hth-sand-deep);
}
.hth--dark .hth-xq__img { background: rgba(255,255,255,.08); }
.hth-xq__img--initials {
  display: grid;
  place-items: center;
  font-family: var(--hth-f-display);
  font-weight: 300;
  font-size: calc(var(--_ph) * .34);
  line-height: 1;
  letter-spacing: .02em;
  color: var(--hth-label);
  user-select: none;
}

.hth-xq__who { margin-bottom: var(--hth-5); }
.hth-xq__name {
  margin: 0;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-b);
  font-weight: 600;
  line-height: 1.35;
  color: var(--hth-head);
}
.hth-xq__creds { font-weight: 400; color: var(--hth-label); }
.hth-xq__title {
  margin: var(--hth-1) 0 0;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s);
  line-height: 1.45;
  color: var(--hth-label);
}

/* Display face at weight 300 — the §15d lead-quote voice, one size down
   so three of them can sit side by side. A short hairline separates the
   person from what they said. */
.hth-xq__quote {
  margin: 0 0 var(--hth-5);
  font-family: var(--hth-f-display);
  font-weight: 300;
  font-size: var(--_qs);
  line-height: 1.42;
  letter-spacing: var(--hth-tr-sm);
  color: var(--hth-head);
  text-wrap: pretty;
}
/* The hairline is a background, not a ::before: Jevelin decorates
   blockquote::before with an icon-font glyph and its own box, and §16
   hides it for every blockquote we draw. (0,2,0) so it beats §16's
   `.hth-section blockquote { padding: 0 }`. */
.hth-section .hth-xq__quote {
  padding-top: calc(var(--hth-5) + 1px);
  background: linear-gradient(var(--hth-rule), var(--hth-rule)) no-repeat 50% 0 / 32px 1px;
}
.hth-section .hth-xq--left .hth-xq__quote { background-position: 0 0; }
.hth-xq__quote p { margin: 0; }
.hth-xq__quote a { color: var(--hth-accent); }

/* "Read full quote" — a text link, pinned to the bottom of the card so
   the links line up across a row whatever the quote lengths. The parent
   theme styles bare buttons, so every property it touches is reset. */
.hth-xq__more,
.hth-xq-dialog__close {
  -webkit-appearance: none; appearance: none;
  margin: 0;
  background: none;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  text-transform: none;
  letter-spacing: 0;
  min-height: 0;
  cursor: pointer;
}
.hth-xq__more {
  margin-top: auto;
  display: inline-flex;
  align-items: center;
  gap: var(--hth-2);
  padding: var(--hth-1) 0;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-s);
  font-weight: 600;
  line-height: 1.4;
  color: var(--hth-accent);
  text-decoration: underline;
  text-decoration-color: transparent;
  text-underline-offset: 4px;
  transition: text-decoration-color var(--hth-fast) var(--hth-expo);
}
.hth-xq__more svg { display: block; flex: none; transition: translate .5s var(--hth-expo); }
.hth-xq__more:hover { text-decoration-color: currentColor; }
.hth-xq__more:hover svg { translate: 2px 0; }
.hth-xq__more:focus-visible {
  outline: 2px solid var(--hth-accent);
  outline-offset: 3px;
  border-radius: 2px;
}

/* ---- controls row (the controls themselves are §13g's) ---- */
.hth-xq__nav {
  display: flex;
  align-items: center;
  gap: var(--hth-4);
  margin-top: var(--hth-6);
}
.hth-xq__nav[hidden] { display: none; }
/* Before JS: the row keeps its height so nothing jumps when it appears. */
.hth-xq:not(.is-ready) .hth-xq__nav { visibility: hidden; }
/* Every expert fits on a desktop screen: no controls to reserve room for. */
@media (min-width: 1101px) {
  .hth-xq--fits:not(.is-ready) .hth-xq__nav { display: none; }
}

/* ---- narrower screens ---- */
@media (max-width: 1100px) {
  .hth-xq { --_n: min(var(--hth-xq-n, 3), 3); }
}
@media (max-width: 900px) {
  .hth-xq { --_n: min(var(--hth-xq-n, 3), 2); --_gap: var(--hth-4); --_qs: 19px; }
  .hth-xq__card { padding: var(--hth-6) var(--hth-5) var(--hth-5); }
}
@media (max-width: 600px) {
  .hth-xq { --_n: 1; }
  .hth-xq--l { --_ph: 128px; }
  .hth-xq--m { --_ph: 112px; }
  /* Bleed through the gutter so the next card shows at the edge — §23's
     affordance — while the snap still lands where the headline starts. */
  .hth-xq__track {
    margin-inline: calc(-1 * var(--hth-gut));
    padding-inline: var(--hth-gut);
    scroll-padding-inline: var(--hth-gut);
  }
  .hth-xq__item { flex-basis: 84%; }
  .hth-xq--many .hth-scar__bars { display: none; }
}

/* ---- the full-quote panel ----
   A native modal <dialog>: focus is trapped and Escape closes it for
   free. It is a DOM child of the section, so it inherits the section's
   theme tokens even though it paints in the top layer. */
.hth-xq-dialog {
  --_ph: 88px;
  width: min(720px, calc(100% - 2 * var(--hth-4)));
  max-width: none;
  max-height: calc(100dvh - 2 * var(--hth-4));
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--hth-body);
  overflow: visible;

  opacity: 0;
  translate: 0 10px;
  transition: opacity .2s var(--hth-expo), translate .2s var(--hth-expo),
              overlay .2s allow-discrete, display .2s allow-discrete;
}
.hth-xq-dialog[open] {
  opacity: 1;
  translate: 0 0;
  transition: opacity .4s var(--hth-expo), translate .6s var(--hth-expo),
              overlay .4s allow-discrete, display .4s allow-discrete;
}
@starting-style {
  .hth-xq-dialog[open] { opacity: 0; translate: 0 18px; }
}
.hth-xq-dialog::backdrop {
  background: rgba(20, 28, 36, .56);
  -webkit-backdrop-filter: blur(3px);
  backdrop-filter: blur(3px);
  opacity: 0;
  transition: opacity .2s linear, overlay .2s allow-discrete, display .2s allow-discrete;
}
.hth-xq-dialog[open]::backdrop { opacity: 1; transition-duration: .35s; }
@starting-style {
  .hth-xq-dialog[open]::backdrop { opacity: 0; }
}

/* Page behind does not scroll while a panel is open. The scrollbar's
   width (--hth-sbw, measured by hth-ds.js; 0 where scrollbars overlay)
   goes back as padding so the page does not shift sideways. Not
   scrollbar-gutter: that keeps an empty gutter the backdrop cannot
   cover, which shows as a pale stripe down the right edge. */
html:has(.hth-xq-dialog[open]) { overflow: hidden; padding-right: var(--hth-sbw, 0px); }

.hth-xq-dialog__panel {
  position: relative;
  max-height: calc(100dvh - 2 * var(--hth-4));
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--hth-8) var(--hth-7) var(--hth-7);
  background: var(--hth-surface);
  border-radius: var(--hth-r-lg);
  box-shadow: var(--hth-shadow-lg);
}
.hth--sand .hth-xq-dialog__panel { background: var(--hth-shell); }

.hth-xq-dialog__close {
  position: absolute;
  top: var(--hth-4);
  right: var(--hth-4);
  width: 40px; height: 40px;
  padding: 0;
  display: grid;
  place-items: center;
  border: 1px solid var(--hth-rule);
  border-radius: 50%;
  color: var(--hth-head);
  transition: background var(--hth-fast) var(--hth-expo), border-color var(--hth-fast) var(--hth-expo);
}
.hth-xq-dialog__close:hover { background: var(--hth-bg); border-color: var(--hth-bg); }
.hth-xq-dialog__close:focus-visible { outline: 2px solid var(--hth-azure); outline-offset: 2px; }
.hth--dark .hth-xq-dialog__close:focus-visible { outline-color: var(--hth-azure-lift); }
.hth-xq-dialog__close svg { display: block; }

.hth-xq-dialog__head {
  display: flex;
  align-items: center;
  gap: var(--hth-5);
  padding: 0 48px var(--hth-6) 0;   /* clear of the close button */
  margin-bottom: var(--hth-6);
  border-bottom: 1px solid var(--hth-rule);
}
.hth-xq-dialog__img { margin: 0; }
.hth-xq-dialog .hth-xq__name { font-size: var(--hth-t-bl); }

.hth-xq-dialog__quote {
  margin: 0;
  font-family: var(--hth-f-body);
  font-size: var(--hth-t-bl);
  line-height: 1.7;
  color: var(--hth-body);
  text-wrap: pretty;
}
.hth-xq-dialog__quote p { margin: 0; }
.hth-xq-dialog__quote p + p { margin-top: 1em; }
.hth-xq-dialog__quote a { color: var(--hth-accent); }

@media (max-width: 600px) {
  .hth-xq-dialog { --_ph: 72px; width: calc(100% - 2 * var(--hth-2)); max-height: calc(100dvh - 2 * var(--hth-2)); }
  .hth-xq-dialog__panel { max-height: calc(100dvh - 2 * var(--hth-2)); padding: var(--hth-7) var(--hth-5) var(--hth-6); }
  .hth-xq-dialog__head { gap: var(--hth-4); }
  .hth-xq-dialog__quote { font-size: var(--hth-t-b); }
}

@media (prefers-reduced-motion: reduce) {
  .hth-xq__track { scroll-behavior: auto; }
  .hth-xq__more svg { transition: none; }
  .hth-xq-dialog,
  .hth-xq-dialog[open],
  .hth-xq-dialog::backdrop { transition: none; translate: none; }
}
@media print {
  .hth-xq__nav { display: none !important; }
  .hth-xq__track { flex-wrap: wrap; overflow: visible; }
  .hth-xq__more { display: none; }
}


/* =====================================================================
   34. POST INDEX + THE LEAD-STORY CARD
   ---------------------------------------------------------------------
   HTH Post Index (inc/blocks/post-index.php) and HTH Post Rail set to
   One (§22b below). Reference: POST-INDEX-BLOCK.md. Page: /blog/.

   The cards are §15 + §22 unchanged — hth_post_card() draws both blocks,
   so nothing here restyles a card except the lead story. What is new is
   the furniture around the grid: a header, a toolbar of topic chips and
   a search box, a status line, and a pager.

   CHIPS ARE LINKS, NOT BUTTONS. Each one loads a URL (/blog/?topic=…),
   so the selected chip is marked aria-current="page", which is also what
   draws it filled. No JavaScript reads or writes any of this.

   SPECIFICITY. Jevelin styles bare input[type=search] and form buttons
   (height, border, a blue fill on focus). Every form rule here is scoped
   to .hth-pindex__search and carries the element in the selector, which
   beats the theme's (0,1,1) without !important.
   ===================================================================== */

/* ---- 22b · the lead story: one wide card, image beside the text ----- */
.hth-card--feature {
  display: grid;
  grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
  align-items: stretch;
}
/* The photo fills its column and crops; it never sets the column's width.
   It used to be `height: 100%` — and with a 16:10 aspect-ratio on the box,
   a text column taller than the photo made the box grow WIDER to keep its
   shape, sliding over the body's left padding (seen live, 7 Oct: the copy
   sat 8px from the photo). Now the width is pinned to the column, the ratio
   is only a minimum height, min-height:100% stretches it to the row, and
   the image is laid absolutely over the box so it cannot feed back into it. */
.hth-card--feature .hth-card__media {
  position: relative; width: 100%; height: auto; min-height: 100%;
}
.hth-card--feature .hth-card__media > img {
  position: absolute; inset: 0; width: 100%; height: 100%;
}
.hth-card--feature .hth-card__body {
  padding: clamp(var(--hth-6), 4vw, var(--hth-8));
  justify-content: center;
  gap: var(--hth-4);
}
.hth-card--feature .hth-card__body .hth-rich { font-size: var(--hth-t-bl); line-height: 1.6; }
/* Centred, not pinned: in a centred body the meta line belongs with the
   text, and margin-top:auto would push it to the card's bottom edge. */
.hth-card--feature .hth-card__body .hth-card__meta { margin-top: var(--hth-2); padding-top: 0; }
.hth-card--nomedia .hth-card__body { padding-top: var(--hth-7); }

@media (max-width: 900px) {
  .hth-card--feature { grid-template-columns: 1fr; }
  .hth-card--feature .hth-card__media { min-height: 0; }
}

/* ---- the header ------------------------------------------------------ */
.hth-pindex__head { max-width: var(--hth-measure); margin-bottom: var(--hth-7); }
.hth-pindex__intro { margin-top: var(--hth-4); }

/* ---- the toolbar: chips left, search right --------------------------- */
.hth-pindex__bar {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
  gap: var(--hth-4) var(--hth-6);
  padding-bottom: var(--hth-5);
  border-bottom: 1px solid var(--hth-rule);
}
.hth-pindex__topics { flex: 1 1 520px; min-width: 0; }
.hth-pindex__topics ul {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--hth-2);
}
.hth-pindex__topics li { margin: 0; padding: 0; }
.hth-pindex__topics li::before { content: none; }

.hth-pindex .hth-pindex__chip {
  display: inline-flex; align-items: center; min-height: 40px;
  padding: 0 var(--hth-4);
  border: 1px solid var(--hth-rule); border-radius: var(--hth-r-pill);
  font-family: var(--hth-f-body); font-size: var(--hth-t-s); font-weight: 500; line-height: 1;
  color: var(--hth-head); text-decoration: none; white-space: nowrap;
  transition: background var(--hth-fast) var(--hth-expo),
              border-color var(--hth-fast) var(--hth-expo),
              color var(--hth-fast) var(--hth-expo) !important;   /* §6c */
}
.hth-pindex .hth-pindex__chip:hover { background: var(--hth-sand-deep); border-color: var(--hth-sand-deep); }
.hth-pindex .hth-pindex__chip[aria-current="page"] {
  background: var(--hth-ink); border-color: var(--hth-ink); color: #fff;
}
.hth-pindex .hth-pindex__chip:focus-visible { outline: 2px solid var(--hth-azure); outline-offset: 2px; }

.hth-pindex__search {
  position: relative; flex: 0 1 320px; min-width: 220px; margin: 0;
}
.hth-pindex .hth-pindex__search input[type="search"] {
  -webkit-appearance: none; appearance: none;
  box-sizing: border-box; display: block; width: 100%; height: 44px; margin: 0;
  padding: 0 48px 0 var(--hth-4);
  background: var(--hth-white); color: var(--hth-head);
  border: 1px solid var(--hth-rule); border-radius: var(--hth-r-pill); box-shadow: none;
  font-family: var(--hth-f-body); font-size: 16px; line-height: 1;   /* 16px: no zoom-on-focus in iOS */
}
.hth-pindex .hth-pindex__search input[type="search"]::placeholder { color: var(--hth-label); opacity: 1; }
.hth-pindex .hth-pindex__search input[type="search"]:focus {
  outline: 2px solid var(--hth-azure); outline-offset: 2px; border-color: var(--hth-rule);
}
.hth-pindex .hth-pindex__search input[type="search"]::-webkit-search-cancel-button { cursor: pointer; }
.hth-pindex .hth-pindex__search button.hth-pindex__go {
  position: absolute; top: 2px; right: 2px; width: 40px; height: 40px;
  display: grid; place-items: center; margin: 0; padding: 0;
  background: transparent; color: var(--hth-head);
  border: 0; border-radius: var(--hth-r-pill); box-shadow: none; cursor: pointer;
}
.hth-pindex .hth-pindex__search button.hth-pindex__go:hover { background: var(--hth-sand); }
.hth-pindex .hth-pindex__search button.hth-pindex__go:focus-visible { outline: 2px solid var(--hth-azure); outline-offset: 0; }

/* ---- the status line ------------------------------------------------- */
.hth-pindex__status {
  margin: var(--hth-5) 0 0;
  font-family: var(--hth-f-body); font-size: var(--hth-t-s); line-height: 1.5;
  color: var(--hth-label); font-variant-numeric: tabular-nums;
}
.hth-pindex__status a { color: var(--hth-accent); text-underline-offset: 3px; }

.hth-pindex__grid { margin-top: var(--hth-7); }

/* §8 drops a 3-up grid straight to one column at 900px. Twelve article
   cards one under another on a tablet is a very long page, so the index
   holds two columns down to 600px. Scoped: no other grid changes. */
@media (max-width: 900px) and (min-width: 601px) {
  .hth-pindex__grid.hth-grid--3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* ---- the pager: Previous · 1 … 4 5 6 … 9 · Next ----------------------- */
.hth-pindex__pager {
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  gap: var(--hth-4);
  margin-top: var(--hth-8); padding-top: var(--hth-5);
  border-top: 1px solid var(--hth-rule);
  font-family: var(--hth-f-body); font-size: var(--hth-t-s);
  font-variant-numeric: tabular-nums;
}
.hth-pindex__pager ol {
  list-style: none; margin: 0; padding: 0;
  display: flex; align-items: center; gap: var(--hth-1);
}
.hth-pindex__pager li { margin: 0; padding: 0; }
.hth-pindex__pager li::before { content: none; }
.hth-pindex .hth-pindex__num {
  display: grid; place-items: center; min-width: 44px; height: 44px; padding: 0 var(--hth-2);
  border-radius: var(--hth-r); color: var(--hth-head); text-decoration: none; font-weight: 500;
}
.hth-pindex a.hth-pindex__num:hover { background: var(--hth-sand-deep); }
.hth-pindex .hth-pindex__num[aria-current="page"] { background: var(--hth-ink); color: #fff; }
.hth-pindex__gap { min-width: 24px; text-align: center; color: var(--hth-label); }
.hth-pindex .hth-pindex__step {
  display: inline-flex; align-items: center; gap: var(--hth-2); min-height: 44px;
  color: var(--hth-head); text-decoration: none; font-weight: 500;
}
.hth-pindex .hth-pindex__step[rel="next"] { justify-self: end; }
.hth-pindex a.hth-pindex__step:hover span { text-decoration: underline; text-underline-offset: 3px; }
.hth-pindex a.hth-pindex__num:focus-visible,
.hth-pindex a.hth-pindex__step:focus-visible { outline: 2px solid var(--hth-azure); outline-offset: 2px; }

/* Dark section: the same pieces on ink. */
.hth--dark .hth-pindex__chip,
.hth--dark .hth-pindex__num,
.hth--dark .hth-pindex__step,
.hth--dark .hth-pindex__search button.hth-pindex__go { color: #fff; }
.hth--dark .hth-pindex .hth-pindex__chip[aria-current="page"],
.hth--dark .hth-pindex .hth-pindex__num[aria-current="page"] { background: #fff; color: var(--hth-ink); border-color: #fff; }

@media (max-width: 600px) {
  /* Chips become one swipeable row rather than four wrapped ones, bled to
     the screen edge so a cut-off chip reads as "there is more". */
  .hth-pindex__topics { flex-basis: 100%; margin-inline: calc(-1 * var(--hth-gut)); }
  .hth-pindex__topics ul {
    flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
    padding-inline: var(--hth-gut); scroll-padding-inline: var(--hth-gut);
    scroll-snap-type: x proximity;
  }
  .hth-pindex__topics ul::-webkit-scrollbar { display: none; }
  .hth-pindex__topics li { scroll-snap-align: start; flex: none; }
  .hth-pindex__search { flex-basis: 100%; }
  /* Arrows only; the words stay for screen readers. */
  .hth-pindex__step span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
  .hth-pindex .hth-pindex__step { min-width: 44px; justify-content: center; }
  .hth-pindex__pager { gap: var(--hth-1); }
  .hth-pindex .hth-pindex__num { min-width: 40px; height: 40px; }
}

@media (prefers-reduced-motion: reduce) {
  .hth-pindex .hth-pindex__chip { transition: none !important; }
}
@media print {
  .hth-pindex__bar, .hth-pindex__pager { display: none; }
}
