/* ============================================================
   406-site-kit — CHASSIS
   ============================================================
   Structure only. Authored ONLY against the token contract
   (tokens/contract.css) — a literal color in this file is a
   defect, and `python scripts/lint-site-kit.py --self-test`
   fails the build on one.

   Extracted 2026-08-09 from airtight-site (the reference build)
   with the focus/a11y layer folded in. Brand-specific pieces
   (Air Tight's .brand-hvacr slash, the .safety ribbon) stay in
   that site's own CSS — this file must work for every brand.

   Load order in a consuming site:
     fonts.css → tokens.css (the filled contract) → chassis.css
     → <page>.css → site a11y overrides (optional, last)
   ============================================================ */

/* ---- Reset-ish ---- */
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
  margin: 0;
  font-family: var(--body);
  font-size: var(--fs-400);
  line-height: 1.6;
  color: var(--ink);
  background: var(--surface);
  -webkit-font-smoothing: antialiased;
}
img { max-width: 100%; height: auto; display: block; }
a { color: var(--accent-deep); }
a:hover { color: var(--ink); }
h1, h2, h3 { font-family: var(--display); line-height: 1.05; margin: 0 0 0.5em; }
p { margin: 0 0 1em; }

.wrap { max-width: var(--wrap); margin: 0 auto; padding: 0 clamp(1.25rem, 4vw, 2.5rem); }

/* ---- A11y primitives ---- */
.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}
.skip-link {
  position: absolute; left: -999px; top: 0; z-index: 100;
  background: var(--surface-dark); color: var(--on-dark); padding: 0.6rem 1rem;
}
.skip-link:focus { left: 0; color: var(--on-dark); }

/* Keyboard-only focus, branded */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 2px;
}
.site-header :focus-visible,
.site-footer :focus-visible,
.section-dark :focus-visible,
.btn-solid:focus-visible {
  outline-color: var(--on-dark);
}

/* Never break a phone number or email across lines */
a[href^="tel:"],
a[href^="mailto:"],
.nowrap { white-space: nowrap; }

/* ---- Buttons ---- */
.btn {
  display: inline-block;
  font-family: var(--display);
  font-weight: 600;
  font-size: var(--fs-400);
  letter-spacing: 0.01em;
  text-decoration: none;
  border-radius: var(--radius-btn);
  padding: 0.78rem 1.5rem;
  transition: transform var(--motion-fast, 0.15s) var(--ease), background var(--motion-fast, 0.15s) var(--ease), color var(--motion-fast, 0.15s) var(--ease);
  white-space: nowrap;
}
.btn:active { transform: translateY(1px); }
.btn-solid { background: var(--accent); color: var(--accent-ink); }
.btn-solid:hover { background: var(--accent-hover); color: var(--accent-ink); transform: translateY(-1px); }
.btn-ghost { border: 1.5px solid var(--ink); color: var(--ink); }
.btn-ghost:hover { background: var(--ink); color: var(--surface-alt); transform: translateY(-1px); }
.btn-ghost-light { border: 1.5px solid var(--on-dark-faint); color: var(--on-dark); }
.btn-ghost-light:hover { background: var(--on-dark); color: var(--ink); transform: translateY(-1px); }

/* ---- Header ---- */
.site-header {
  background: var(--surface-dark);
  position: sticky;
  top: 0;
  z-index: 50;
}
.header-row {
  display: flex; align-items: center; justify-content: space-between;
  min-height: 72px; gap: 1rem;
}
.brand { display: flex; align-items: center; gap: 0.7rem; text-decoration: none; }
.brand-mark { width: 52px; height: auto; }
.brand-mark--padded { background: var(--surface-alt); border-radius: 6px; padding: 3px 5px; }
.brand-text { display: flex; flex-direction: column; line-height: 1.1; }
.brand-name {
  font-family: var(--display); font-weight: 700; font-size: var(--fs-600);
  color: var(--on-dark); letter-spacing: 0.045em;
}
.brand-sub {
  font-size: var(--fs-100); color: var(--on-dark-faint);
  letter-spacing: 0.14em; text-transform: uppercase;
}
.nav-list {
  display: flex; align-items: center; gap: clamp(0.9rem, 2vw, 1.8rem);
  list-style: none; margin: 0; padding: 0;
}
.nav-list a:not(.btn) {
  color: var(--on-dark-muted); text-decoration: none;
  font-weight: 500; font-size: var(--fs-300); padding: 0.3rem 0;
  border-bottom: 2px solid transparent;
}
.nav-list a:not(.btn):hover,
.nav-list a[aria-current="page"]:not(.btn) { color: var(--on-dark); border-bottom-color: var(--accent); }
.nav-toggle { display: none; }

/* ---- Footer ---- */
.site-footer { background: var(--surface-dark); color: var(--on-dark-muted); margin-top: clamp(4rem, 8vw, 7rem); }
.footer-grid {
  display: grid; grid-template-columns: 1.4fr 1fr 1fr;
  gap: clamp(1.5rem, 4vw, 4rem); padding-top: 3.5rem; padding-bottom: 2.5rem;
}
.footer-logo { margin-bottom: 0.9rem; }
.footer-logo--padded { background: var(--surface-alt); border-radius: 8px; padding: 6px 10px; }
.footer-name { font-family: var(--display); font-weight: 600; font-size: var(--fs-400); color: var(--on-dark); margin-bottom: 0.2rem; }
.footer-line { font-size: var(--fs-200); color: var(--on-dark-faint); }
.footer-head {
  font-size: var(--fs-100); letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--on-dark-faint); margin-bottom: 0.9rem; font-family: var(--body); font-weight: 600;
}
.site-footer a { color: var(--on-dark); text-decoration: none; }
.site-footer a:hover { color: var(--accent); }
.footer-col p { margin-bottom: 0.45rem; }
.hours-row { display: flex; justify-content: space-between; gap: 1rem; max-width: 300px; }
.hours-row span:last-child { color: var(--on-dark); }
.footer-base {
  display: flex; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem;
  border-top: 1px solid var(--line-on-dark);
  padding-top: 1.4rem; padding-bottom: 1.6rem; font-size: var(--fs-200); color: var(--on-dark-faint);
}
.footer-base p { margin: 0; }
.powered a,
.footer-legal a { color: var(--on-dark-faint); }
.powered a:hover,
.footer-legal a:hover { color: var(--on-dark); }

/* ---- Sections / shared ---- */
.section { padding: var(--section-pad) 0; }
.section-dark { background: var(--surface-dark); color: var(--on-dark); }
.kicker {
  font-family: var(--display); font-weight: 600; font-size: var(--fs-200);
  letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent-deep);
  margin-bottom: 0.6rem;
}
.section-dark .kicker { color: var(--accent); }
.h-xl { font-size: clamp(2.6rem, 6vw, 4.4rem); font-weight: 700; letter-spacing: -0.01em; }
.h-lg { font-size: clamp(2rem, 4.2vw, 3rem); font-weight: 700; }
.h-md { font-size: clamp(1.35rem, 2.4vw, 1.7rem); font-weight: 600; }
.lede { font-size: clamp(1.1rem, 1.8vw, 1.3rem); color: var(--ink-muted); max-width: 34em; }
.section-dark .lede { color: var(--on-dark-muted); }

/* ============================================================
   NAMING: every component added from v0.6.0 onward is `k-` prefixed.

   🔴 v0.6.0 shipped them unprefixed and that was a mistake with teeth. The
   names were generic -- .card, .field, .grid-2, .prose, .section-head, .meta,
   .muted, .rule -- and TWO OF THREE consuming sites had already defined the
   same names with DIFFERENT semantics: frostline's .field is a block with a
   bottom margin, the chassis's is a grid; frostline's .section-head is a
   max-width container, the chassis's is a flex row. Upgrading frostline to
   v0.7.1 restyled 10 of its 11 pages. Measured, not guessed: a per-page pixel
   diff with a best-fit vertical offset, which ruled out "it only shifted".

   The pre-v0.6.0 names (.wrap, .btn, .section, .kicker, .h-xl, .lede,
   .section-dark, the header/footer classes) stay unprefixed forever: sites
   were BUILT on them, they are the shared vocabulary, and renaming them would
   break every site to fix nothing.

   Form CONTROLS are scoped too (.k-form input, .k-field input) rather than
   styled as bare elements, so a site's own forms are untouched until its
   markup opts in.
   ============================================================ */

/* ---- Layout primitives (v0.6.0, renamed v0.8.0) ----
   Every one of these was defined separately by two or three consuming sites
   before it lived here: .k-grid-2, .k-grid-3, .k-card, .k-prose, .k-page-head,
   .k-section-head, .k-rule, .k-muted, .k-meta, .k-cta-band. Structure only -- no site's
   look is decided here, so a brand can still be anything.

   🔴 min-width:0 on every grid child is the load-bearing line, not a nicety.
   A grid track defaults to min-width:auto, so one unshrinkable child (a nowrap
   uppercase link, a long word, an input with a default size) pushes the track
   past the viewport and takes the page with it. Measured on fifield-photo-site
   2026-09-11: scrollWidth 499 against clientWidth 375 at every phone width,
   from a single nowrap link. Fixing it per site means fixing it again on the
   next site. */
.k-grid { display: grid; gap: clamp(1rem, 2.5vw, 1.6rem); }
.k-grid-2 { display: grid; gap: clamp(1.5rem, 4vw, 3rem); grid-template-columns: 1fr 1fr; align-items: start; }
.k-grid-3 { display: grid; gap: clamp(1rem, 2.5vw, 1.6rem); grid-template-columns: repeat(3, 1fr); }
.k-grid-auto { display: grid; gap: clamp(1rem, 2.5vw, 1.6rem); grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
.k-grid > *, .k-grid-2 > *, .k-grid-3 > *, .k-grid-auto > * { min-width: 0; }

.k-card {
  background: var(--surface-alt);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  padding: clamp(1.2rem, 2.5vw, 1.8rem);
}
.section-dark .k-card { background: transparent; border-color: var(--line-on-dark); }

/* .k-prose caps the measure. Long-form text set to the full container width is
   the single most common tell of an unconsidered page. */
.k-prose { max-width: 46rem; }
.k-prose > * + * { margin-top: 1em; }
.k-prose h2 { margin-top: 2.2rem; }
.k-prose p { color: var(--ink-soft); }

.k-page-head { padding-bottom: clamp(1rem, 2.5vw, 2rem); }
.k-section-head {
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: 1.5rem; flex-wrap: wrap; margin-bottom: clamp(1.6rem, 3vw, 2.4rem);
}
.k-section-head h2, .k-section-head h3 { margin-bottom: 0; }
.k-section-tight { padding: calc(var(--section-pad) / 2) 0; }
.k-section-tint { background: var(--tint); }
/* An accent hue that passes on --surface can fail on --tint, so links inside a
   tinted band revert to ink with an underline rather than carrying the accent.
   Measured on one palette at 3.42:1 before this rule existed. */
.k-section-tint a:not(.btn) { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
.k-section-tint .kicker { color: var(--ink-muted); }

.k-rule { border: 0; border-top: 1px solid var(--line); margin: clamp(2rem, 5vw, 3.5rem) 0; }
.k-muted { color: var(--ink-muted); }
.k-meta { font-size: var(--fs-200); color: var(--ink-muted); }
.section-dark .k-muted, .section-dark .k-meta { color: var(--on-dark-faint); }

.k-cta-band { background: var(--surface-alt); }
.k-cta-inner { text-align: center; max-width: 40rem; margin: 0 auto; }
.k-cta-inner .lede { margin-left: auto; margin-right: auto; margin-bottom: 1.6rem; }

/* ---- Forms (v0.6.0) ----
   Three sites built the same form three ways, and the chassis styled no control
   at all -- not one input, label or select. All three also arrived
   independently at the identical Netlify honeypot markup, which is the clearest
   possible signal that it belonged here.

   Markup this expects:
     <div class="field">
       <label for="x">Label <span class="req" aria-hidden="true">*</span></label>
       <input id="x" name="x" type="text" required>
     </div>
   A fieldset of radios uses <fieldset class="field"><legend>…</legend>
   <div class="choices">…</div></fieldset>. Rows of two use .k-field-row.

   🔴 .k-hp-field is the honeypot wrapper and must stay visually hidden but NOT
   display:none -- a display:none input is skipped by some bots and by some
   browsers' autofill, which defeats it. It is clipped instead, the same way
   .visually-hidden is, so it cannot contribute to layout either. */
.k-field { display: grid; gap: 0.4rem; border: 0; padding: 0; margin: 0; min-width: 0; }
.k-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(1rem, 2.5vw, 1.4rem); }
.k-field-row > * { min-width: 0; }
.k-field > label, .k-field > legend {
  font-family: var(--body); font-size: var(--fs-200); letter-spacing: 0.12em;
  text-transform: uppercase; color: var(--ink-muted); padding: 0;
}
.k-req { color: var(--accent-deep); }
.k-optional { text-transform: none; letter-spacing: 0; color: var(--ink-muted); font-size: var(--fs-200); }
.k-field-hint { margin: 0; font-size: var(--fs-300); color: var(--ink-soft); }

.k-form input[type="text"], .k-form input[type="email"], .k-form input[type="tel"],
.k-form input[type="url"], .k-form input[type="number"], .k-form input[type="date"],
.k-form select, .k-form textarea,
.k-field input[type="text"], .k-field input[type="email"], .k-field input[type="tel"],
.k-field input[type="url"], .k-field input[type="number"], .k-field input[type="date"],
.k-field select, .k-field textarea {
  font-family: var(--body); font-size: var(--fs-400); color: var(--ink);
  background: var(--surface-alt); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 0.75rem 0.85rem; width: 100%;
  box-sizing: border-box;
}
.k-form textarea, .k-field textarea { resize: vertical; line-height: 1.6; }
.k-form input:focus-visible, .k-form select:focus-visible, .k-form textarea:focus-visible,
.k-field input:focus-visible, .k-field select:focus-visible, .k-field textarea:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent);
}
.k-form [aria-invalid="true"], .k-field [aria-invalid="true"] { border-color: var(--accent-deep); }
.k-field-error { font-size: var(--fs-200); color: var(--accent-deep); margin: 0; }

.k-choices { display: flex; gap: 1.4rem; flex-wrap: wrap; margin-top: 0.2rem; }
.k-choices label {
  display: flex; align-items: center; gap: 0.45rem; font-size: var(--fs-400);
  color: var(--ink); text-transform: none; letter-spacing: 0;
}
.k-choices input[type="radio"], .k-choices input[type="checkbox"] { width: auto; accent-color: var(--accent); }

.k-form { display: grid; gap: clamp(1.1rem, 2.5vw, 1.5rem); max-width: 40rem; }
.k-form .btn { justify-self: start; }
.k-form-foot { font-size: var(--fs-300); color: var(--ink-muted); margin: 0; }
.k-hp-field {
  position: absolute; width: 1px; height: 1px;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}

/* ---- Asymmetry (v0.7.0) — OPT IN, nothing changes unless a page uses one ----
   The "predictable column layout" is the template tell, and until v0.7.0 every
   primitive here was a symmetric two-up or three-up, so the chassis was
   institutionalising exactly that. These four are the deliberate way out. Use
   ONE per page as a signature move; three asymmetric sections in a row is just
   a different kind of noise.

   ⚠️ There is deliberately NO .bleed / full-viewport-width helper. Every
   implementation of one relies on 100vw or `calc(50% - 50vw)`, both of which
   include the scrollbar and therefore reintroduce horizontal page overflow --
   the precise bug measured at scrollWidth 499 against clientWidth 375 and
   fixed in v0.6.0. If a site needs edge-to-edge media, give the section its own
   full-width wrapper rather than making a child escape .wrap. */

/* Unequal columns, with the narrower one dropped down the page. */
.k-feature {
  display: grid; grid-template-columns: 7fr 5fr;
  gap: clamp(1.5rem, 4vw, 3.5rem); align-items: start;
}
.k-feature > * { min-width: 0; }
.k-feature > :last-child { margin-top: clamp(1.5rem, 5vw, 4rem); }
.k-feature--flip { grid-template-columns: 5fr 7fr; }
.k-feature--flip > :last-child { margin-top: 0; }
.k-feature--flip > :first-child { margin-top: clamp(1.5rem, 5vw, 4rem); }

/* The second child rides up over the first: media, then a card of words. */
.k-overlap { display: grid; grid-template-columns: 1fr; }
.k-overlap > * { min-width: 0; }
.k-overlap > * + * {
  position: relative; z-index: 1;
  width: min(100%, 34rem); justify-self: end;
  margin-top: calc(-1 * clamp(2rem, 7vw, 6rem));
}

/* Alternate children step down, so a grid of images reads as composed rather
   than as a contact sheet. */
.k-stagger {
  display: grid; grid-template-columns: repeat(2, 1fr);
  gap: clamp(1rem, 2.5vw, 1.6rem); align-items: start;
}
.k-stagger > * { min-width: 0; }
.k-stagger > :nth-child(even) { margin-top: clamp(2rem, 6vw, 5rem); }

/* A heading that runs wider than the text it introduces. Pure type, no layout. */
.k-wide-head { max-width: 22ch; }

/* ---- Grain (v0.7.0) — OPT IN via the .k-grain class ----
   Flat token colors read as sterile at large sizes; a trace of noise reads as
   printed. Strength is --grain-opacity (0 disables it), and the texture is an
   inline SVG so it costs no request and cannot 404.
   ⚠️ z-index: -1 with isolation keeps it behind content without a stacking
   context escape, and pointer-events: none keeps it from eating clicks. */
.k-grain { position: relative; isolation: isolate; }
.k-grain::after {
  content: ""; position: absolute; inset: 0; z-index: -1;
  pointer-events: none;
  opacity: var(--grain-opacity, 0.035);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* ============================================================
   PREMIUM COMPONENTS (v0.9.0) — all OPT IN, nothing changes unless a
   page uses the class. Built for AM Strength (2026-09-27) as reusable
   parts from day one, on Gus's instruction, so the next site starts at
   the same bar instead of re-deriving it.

   Colors only from the contract. Scrims are the contract's own
   --surface-dark mixed toward transparent (color-mix), so a brand's dark
   decides the tone of every photo overlay.
   ============================================================ */

/* ---- Label type (v0.9.0) ----
   Small uppercase labels: kickers on photo heroes, stat captions, offer
   numbers. --label-font is OPTIONAL; a site that sets it to a mono or a
   condensed face gets "precision" detailing, and one that does not simply
   gets its body face, so the two-family rule still holds by default. A third
   family must be declared as a KIT-EXCEPTION in the site. */
.k-label {
  font-family: var(--label-font, var(--body));
  font-size: var(--fs-100); font-weight: 500;
  letter-spacing: 0.2em; text-transform: uppercase;
}

/* ---- Photo hero (v0.9.0) ----
   A full-width section with a real photograph behind the words. Markup:
     <section class="k-hero-photo">
       <img class="k-hero-photo-img" src alt width height fetchpriority="high">
       <div class="wrap k-hero-photo-body"> label / h1 / lede / buttons </div>
     </section>
   It IS the full-width section the asymmetry note asks for: nothing escapes
   .wrap, so no 100vw overflow. Crop focus is --hero-focus on the section.
   Motion: one slow push-in (--motion-hero, OPTIONAL); reduced-motion users get
   a still photo. Set --motion-hero: 0s to switch it off for a brand. */
.k-hero-photo {
  position: relative; isolation: isolate; overflow: hidden;
  display: flex; align-items: flex-end;
  min-height: min(88vh, 860px); min-height: min(88svh, 860px);
  background: var(--surface-dark); color: var(--on-dark);
}
.k-hero-photo-img {
  position: absolute; inset: 0; z-index: -2;
  width: 100%; height: 100%; object-fit: cover;
  object-position: var(--hero-focus, 50% 30%);
}
.k-hero-photo::before {
  content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
  background:
    linear-gradient(0deg, color-mix(in srgb, var(--surface-dark) 96%, transparent) 0%, color-mix(in srgb, var(--surface-dark) 45%, transparent) 55%, color-mix(in srgb, var(--surface-dark) 70%, transparent) 100%),
    linear-gradient(90deg, color-mix(in srgb, var(--surface-dark) 70%, transparent) 0%, transparent 70%);
}
.k-hero-photo-body { position: relative; width: 100%; padding-top: 7rem; padding-bottom: clamp(3rem, 8vh, 5.5rem); }
.k-hero-photo .lede { color: var(--on-dark-muted); }
.k-hero-photo .k-label { color: var(--on-dark-muted); display: flex; align-items: center; gap: 0.9rem; margin-bottom: 1.4rem; }
.k-hero-photo .k-label::before { content: ""; width: 2.2rem; height: 2px; background: var(--accent); flex: none; }
@media (prefers-reduced-motion: no-preference) {
  .k-hero-photo-img { animation: k-hero-push var(--motion-hero, 22s) var(--ease) both; }
}
@keyframes k-hero-push { from { transform: scale(1.08); } to { transform: scale(1); } }

/* ---- Stat / credibility band (v0.9.0) ----
   The strongest facts, as a row of big values with small labels. Every value
   must be literally true and citable: a number reads as a measurement.
     <ul class="k-stat-band"><li><span class="k-stat-value">SSC</span>
       <span class="k-stat-label">Starting Strength Coach</span></li></ul>
   Styled for a dark band (it sits under a photo hero); wrap it in .section-dark. */
.k-stat-band {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  border-top: 1px solid var(--line-on-dark); border-bottom: 1px solid var(--line-on-dark);
}
.k-stat-band > li { min-width: 0; padding: clamp(1.2rem, 3vw, 2rem) clamp(0.9rem, 2vw, 1.4rem); border-left: 1px solid var(--line-on-dark); }
.k-stat-band > li:first-child { border-left: 0; padding-left: 0; }
.k-stat-value { display: block; font-family: var(--display); font-size: clamp(1.5rem, 2.6vw, 2.1rem); line-height: 1.05; color: var(--on-dark); }
.k-stat-label { display: block; margin-top: 0.45rem; font-family: var(--label-font, var(--body)); font-size: var(--fs-100); letter-spacing: 0.16em; text-transform: uppercase; color: var(--on-dark-faint); }

/* ---- Featured quote (v0.9.0) ----
   One review, set large. A wall of equal review cards reads as volume; one
   quote at display size reads as confidence. The rest go in a .k-carousel. */
.k-quote-feature { max-width: 52rem; margin: 0; }
.k-quote-feature blockquote { margin: 0; }
.k-quote-feature blockquote p {
  font-family: var(--display); font-size: clamp(1.5rem, 3.2vw, 2.4rem); line-height: 1.22; margin: 0;
}
.k-quote-feature blockquote p::before { content: "\201C"; color: var(--accent); margin-right: 0.08em; }
.k-quote-feature blockquote p::after { content: "\201D"; color: var(--accent); }
.k-quote-feature figcaption { margin-top: 1.2rem; font-family: var(--label-font, var(--body)); font-size: var(--fs-100); letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-muted); }
.section-dark .k-quote-feature figcaption { color: var(--on-dark-faint); }

/* ---- Carousel (v0.9.0) ----
   CSS scroll-snap first: it swipes and scrolls with no JavaScript at all.
   js/site.js wires the prev/next buttons and disables them at the ends; with
   no JS the buttons are hidden. Markup:
     <div class="k-carousel" data-k-carousel>
       <div class="k-carousel-track" tabindex="0" role="region" aria-label="…"> slides </div>
       <div class="k-carousel-nav">
         <button type="button" class="k-carousel-btn" data-dir="-1" aria-label="Previous">…</button>
         <button type="button" class="k-carousel-btn" data-dir="1" aria-label="Next">…</button>
       </div>
     </div> */
.k-carousel { position: relative; }
.k-carousel-track {
  display: grid; grid-auto-flow: column; grid-auto-columns: min(86%, 26rem);
  gap: clamp(1rem, 2.5vw, 1.5rem); overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory; scroll-padding-inline: 2px; padding-bottom: 0.6rem;
  scrollbar-width: thin;
}
/* margin: 0 is load-bearing. Slides are usually <figure>s, which carry a UA side margin of
   40px; the track then snapped to scrollLeft 38 on load, so the first slide sat off the edge
   and "Previous" was live on arrival (caught by scripts/components-check.mjs, 2026-09-27).
   The same default margin had already offset a whole review grid on a site the day before. */
.k-carousel-track > * { scroll-snap-align: start; min-width: 0; margin: 0; }
.k-carousel-nav { display: flex; gap: 0.6rem; margin-top: 1.2rem; }
.k-carousel-btn {
  width: 2.9rem; height: 2.9rem; display: inline-grid; place-items: center;
  border-radius: 50%; border: 1px solid var(--line); background: transparent; color: var(--ink);
  cursor: pointer; font-size: var(--fs-500); line-height: 1;
  transition: background var(--motion-fast, 0.15s) var(--ease), color var(--motion-fast, 0.15s) var(--ease), opacity var(--motion-fast, 0.15s);
}
.k-carousel-btn:hover:not([disabled]) { background: var(--ink); color: var(--surface); }
.k-carousel-btn[disabled] { opacity: 0.35; cursor: default; }
.section-dark .k-carousel-btn { border-color: var(--line-on-dark); color: var(--on-dark); }
.section-dark .k-carousel-btn:hover:not([disabled]) { background: var(--on-dark); color: var(--surface-dark); }
html:not(.js) .k-carousel-nav { display: none; }

/* ---- Gallery + lightbox (v0.9.0) ----
   Each item is a link to the full image, so without JavaScript a tap simply
   opens the photo. js/site.js turns [data-k-lightbox] galleries into a
   <dialog> viewer: arrow keys, swipe, Escape, focus returned on close.
     <ul class="k-gallery" data-k-lightbox><li><a href="full.webp">
       <img src="small.webp" alt="…" width height loading="lazy"></a></li></ul>
   Crops favor the top of the frame (--gallery-focus) so heads stay in.
   Two across on a phone: one-up turned a 20-photo gallery into 8,500px. */
.k-gallery { list-style: none; margin: 0; padding: 0; display: grid; gap: clamp(0.5rem, 1.2vw, 0.9rem); grid-template-columns: repeat(auto-fill, minmax(min(46%, 15rem), 1fr)); }
.k-gallery > li { min-width: 0; }
.k-gallery a { display: block; overflow: hidden; border-radius: var(--radius); }
.k-gallery img { width: 100%; aspect-ratio: 1; object-fit: cover; object-position: var(--gallery-focus, 50% 30%); transition: transform var(--motion-base, 0.35s) var(--ease); }
.k-gallery a:hover img, .k-gallery a:focus-visible img { transform: scale(1.04); }
.k-lightbox {
  width: 100vw; height: 100vh; height: 100dvh; max-width: none; max-height: none;
  margin: 0; padding: 0; border: 0; background: color-mix(in srgb, var(--surface-dark) 96%, transparent);
  color: var(--on-dark);
}
.k-lightbox::backdrop { background: color-mix(in srgb, var(--surface-dark) 80%, transparent); }
.k-lightbox-stage { position: absolute; inset: 3.5rem 1rem 4.5rem; display: grid; place-items: center; }
.k-lightbox-stage img { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; }
.k-lightbox-bar { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; }
.k-lightbox-count { font-family: var(--label-font, var(--body)); font-size: var(--fs-100); letter-spacing: 0.18em; color: var(--on-dark-faint); }
.k-lightbox-btn {
  width: 2.9rem; height: 2.9rem; display: inline-grid; place-items: center; border-radius: 50%;
  border: 1px solid var(--line-on-dark); background: transparent; color: var(--on-dark); cursor: pointer; font-size: var(--fs-500);
}
.k-lightbox-btn:hover { background: var(--on-dark); color: var(--surface-dark); }
.k-lightbox-close { position: absolute; top: 0.8rem; right: 0.8rem; }
.k-lightbox :focus-visible { outline-color: var(--on-dark); }

/* ---- FAQ (v0.9.0) ----
   Native <details>: works with no JavaScript, keyboard accessible for free.
   Pair with the faq()/faqSchema() macros in includes/components.njk, which
   render the SAME items as FAQPage structured data (AI answers quote
   question-shaped passages, and the answer must be literally on the page). */
.k-faq { border-top: 1px solid var(--line); max-width: 52rem; }
.k-faq details { border-bottom: 1px solid var(--line); }
.k-faq summary {
  list-style: none; cursor: pointer; display: flex; justify-content: space-between; align-items: center; gap: 1.2rem;
  padding: 1.25rem 0; font-family: var(--display); font-size: var(--fs-600); line-height: 1.3;
}
.k-faq summary::-webkit-details-marker { display: none; }
.k-faq summary::after { content: "+"; flex: none; color: var(--accent); font-family: var(--body); font-size: var(--fs-700); line-height: 1; transition: transform var(--motion-fast, 0.15s) var(--ease); }
.k-faq details[open] summary::after { transform: rotate(45deg); }
.k-faq-answer { padding: 0 0 1.4rem; max-width: 44rem; color: var(--ink-soft); }
.k-faq-answer > :last-child { margin-bottom: 0; }
.section-dark .k-faq, .section-dark .k-faq details { border-color: var(--line-on-dark); }
.section-dark .k-faq-answer { color: var(--on-dark-muted); }

/* ---- Offers (v0.9.0) ----
   "Ways to work with me": a numbered row of what can be bought, each with an
   optional meta line (a price, or "by application").
     <ol class="k-offers"><li class="k-offer"><span class="k-offer-num">01</span>
       <h3>…</h3><p>…</p><p class="k-offer-meta">…</p></li></ol> */
.k-offers { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: clamp(1rem, 2.5vw, 1.6rem); }
.k-offer { min-width: 0; display: flex; flex-direction: column; padding: clamp(1.4rem, 3vw, 2rem); border: 1px solid var(--line-soft); border-radius: var(--radius); background: var(--surface-alt); }
.k-offer h3 { font-size: var(--fs-700); margin: 0.9rem 0 0.5rem; }
.k-offer p { color: var(--ink-soft); }
.k-offer-num { font-family: var(--label-font, var(--body)); font-size: var(--fs-100); letter-spacing: 0.2em; color: var(--accent-deep); }
.k-offer-meta { margin-top: auto; margin-bottom: 0; padding-top: 1rem; border-top: 1px solid var(--line-soft); font-family: var(--label-font, var(--body)); font-size: var(--fs-100); letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-muted); }
.section-dark .k-offer { background: transparent; border-color: var(--line-on-dark); }
.section-dark .k-offer p { color: var(--on-dark-muted); }
.section-dark .k-offer-num { color: var(--accent); }
.section-dark .k-offer-meta { border-color: var(--line-on-dark); color: var(--on-dark-faint); }

/* ---- Reveal on scroll (js/site.js adds .in) ----
   FAILS VISIBLE. The hidden state is scoped to `html.js`, a class set by the
   boot script at the top of includes/header.njk. If that script never runs --
   blocked, stripped by a proxy, an old browser, a JS error earlier on the page
   -- .reveal simply stays visible, instead of hiding content forever with
   nothing left to un-hide it.

   Measured on a consuming site before this changed: three homepage cards sat
   at opacity 0 permanently with JavaScript disabled, and nothing in the build,
   the lint or the browser audit said so. A screenshot found it.

   🔴 Do NOT move the hidden state back onto bare `.reveal`, and do not remove
   the boot script from header.njk -- either one alone restores the bug.
   `lint-site-kit.py --self-test` checks both. */
@media (prefers-reduced-motion: no-preference) {
  html.js .reveal { opacity: 0; transform: translateY(22px); transition: opacity var(--motion-slow, 0.65s) var(--ease), transform var(--motion-slow, 0.65s) var(--ease); }
  html.js .reveal.in { opacity: 1; transform: none; }
}

/* ---- Reduced motion: kill everything ---- */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

/* ---- Mobile ---- */
@media (max-width: 860px) {
  .nav-toggle {
    display: inline-flex; flex-direction: column; gap: 5px;
    background: none; border: 0; padding: 0.6rem; cursor: pointer;
  }
  .nav-toggle-bar { width: 24px; height: 2px; background: var(--on-dark); transition: transform var(--motion-fast, 0.15s) var(--ease), opacity var(--motion-fast, 0.15s); }
  .nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(1) { transform: translateY(7px) rotate(45deg); }
  .nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(2) { opacity: 0; }
  .nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
  .nav-list {
    display: none; position: absolute; left: 0; right: 0; top: 100%;
    flex-direction: column; align-items: stretch; gap: 0;
    background: var(--surface-dark); padding: 0.5rem 1.25rem 1.25rem;
    border-top: 1px solid var(--line-on-dark);
    /* The list hangs off a STICKY header, so it scrolls with the header and the
       page can never bring its lower items into view. On a landscape phone
       (844x390) it ran 72-479px and its last two items were unreachable by
       touch and focused offscreen by keyboard (v0.8.1). It gets its own scroll
       instead, capped at the viewport below the 72px header row. dvh tracks
       the mobile browser bar; the vh line is the fallback for browsers
       without it. `contain` stops a scroll at the list's end from carrying on
       into the page underneath. The scroll padding keeps a focused item's
       focus ring clear of the list's clipped edge when focus scrolls it in. */
    max-height: calc(100vh - 72px);
    max-height: calc(100dvh - 72px);
    overflow-y: auto;
    overscroll-behavior: contain;
    scroll-padding-block: 0.5rem;
  }
  .nav-list.open { display: flex; }
  .k-grid-2, .k-grid-3, .k-field-row { grid-template-columns: 1fr; }
  /* Asymmetry is a desktop composition. On a phone every one of these is a
     single column with no offsets -- an offset in a one-column stack is just a
     gap, and a negative one is a collision. */
  .k-feature, .k-feature--flip, .k-stagger { grid-template-columns: 1fr; }
  .k-feature > :last-child,
  .k-feature--flip > :first-child,
  .k-stagger > :nth-child(even) { margin-top: 0; }
  .k-overlap > * + * { width: 100%; justify-self: stretch; margin-top: 0; }
  .nav-list li { border-bottom: 1px solid var(--line-on-dark); }
  .nav-list li:last-child { border-bottom: 0; }
  .nav-list a:not(.btn) { display: block; padding: 0.9rem 0; border-bottom: 0; }
  .nav-cta { padding-top: 0.9rem; }
  .nav-cta .btn { display: block; text-align: center; }
  .site-nav { position: static; }
  .header-row { position: relative; }
  .footer-grid { grid-template-columns: 1fr; }
  .footer-base { flex-direction: column; }
  .k-hero-photo { min-height: 82svh; }
  .k-stat-band { grid-template-columns: 1fr 1fr; }
  .k-stat-band > li { border-left: 0; padding-left: 0; border-top: 1px solid var(--line-on-dark); }
  .k-stat-band > li:nth-child(-n+2) { border-top: 0; }
}
