/* cultiva.id — the whole stylesheet. v2.0.0.
 *
 * Implements the 2026-08-18 redesign mock-up on the real templates. Every colour, size and
 * spacing value comes from tokens.css; nothing is hardcoded here.
 *
 * The four composition rules, and where each one lives:
 *   1. two hairline weights .................. --rule (structural) vs --rule-soft (card edge)
 *   2. section-head rhythm ................... §4 .shead
 *   3. weight carries hierarchy, green rationed §2 type, and the green-only-where-it-earns-it
 *                                              rule in tokens.css
 *   4. only actionable things move ........... §5 .btn, §7 a.card — nothing else transitions
 *
 * Never write a `style` attribute in a template: the CSP has no 'unsafe-inline' for style-src
 * and a nonce cannot authorise an attribute, only a <style> element. Dynamic values go through
 * app/styles.py. tests/test_content.py fails the build if a style attribute appears.
 */

/* ==========================================================================
   1. Fonts and reset
   ========================================================================== */

/* Latin subsets, 48 KB for all four weights. Every face used on the site is preloaded in
 * base.html — a face that arrives late moves the layout, and one missing preload has cost
 * 0.198 of CLS on another build. */
@font-face {
  font-family: "Mont";
  src: url("/static/fonts/Mont-Light.woff2") format("woff2");
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Mont";
  src: url("/static/fonts/Mont-Book.woff2") format("woff2");
  font-weight: 350;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Mont";
  src: url("/static/fonts/Mont-Regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Mont";
  src: url("/static/fonts/Mont-Bold.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html {
  /* NO `scroll-behavior: smooth` HERE, and it must not come back.
     It applies to the browser's own scroll-into-view when focus moves, not just to anchor
     clicks. Tabbing through a page then starts an animation per Tab; a normal tab sequence
     outruns them and the page never catches up, so focus ends up on an element that is
     nowhere near the viewport. Measured on this site: 22 tabs left focus at y=3891 in a 900px
     viewport with the page still at scroll 0. The focus ring was drawn perfectly — on
     something nobody could see. WCAG 2.4.7, failing in practice.
     Two in-page jumps (#test-methods, #inquiry-form) lose their animation. Worth it.
     tests/test_contrast.py::test_the_stylesheet_does_not_smooth_scroll guards this. */
  scroll-padding-top: 5rem;   /* keeps an anchor target clear of the sticky header */
  -webkit-text-size-adjust: 100%;
}

/* Composition rule 4: motion is a hint that something is actionable. Turn the hint off and
   nothing about the layout changes. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition: none !important;
    animation: none !important;
  }
}

body {
  font-family: var(--font-sans);
  font-weight: var(--fw-regular);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--text);
  background: var(--paper);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img {
  max-width: 100%;
  display: block;
}

/* ==========================================================================
   2. Type
   ========================================================================== */

/* A word longer than its line breaks inside itself rather than run under an overflow:hidden
   box: at a 24px default font "antimicrobial" in the home page's headline is 333px on a 320px
   phone, and 13px of it was cut off by the hero; "pertumbuhannya" in a four-across step card
   on /lab/astm-g21 lost up to 42px (2.6.7). It changes nothing where every word fits, which at
   the default 16px is every heading on the site. */
h1,
h2,
h3 {
  font-weight: var(--fw-bold);
  line-height: var(--lh-heading);
  letter-spacing: var(--tracking-heading);
  overflow-wrap: break-word;
}

h1 {
  color: var(--heading);
  font-size: var(--fs-h1);
}

h2 {
  color: var(--heading);
  font-size: var(--fs-h2);
}

h3 {
  color: var(--ink);
  font-size: var(--fs-h3);
}

p + p,
p + ul,
p + ol,
ul + p,
h3 + p {
  margin-top: 0.9rem;
}

.eyebrow {
  font-size: var(--fs-eyebrow);
  font-weight: var(--fw-bold);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
  color: var(--green-primary);
  margin-bottom: 0.9rem;
}

.lede {
  font-size: var(--fs-lede);
  font-weight: var(--fw-book);
  line-height: var(--lh-lede);
}

.small {
  font-size: var(--fs-small);
}

.measure {
  max-width: var(--measure);
}

a {
  color: var(--link-color);
  text-decoration: none;
}

a:hover {
  color: var(--link-hover);
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* Prose links keep their underline: inside a paragraph, colour alone is not an affordance. */
.prose a,
.card__body a,
.shead p a {
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* One focus ring, on every focusable thing (2026-09-27 motion pass). The tokens are in
   tokens.css: 2px of --focus-color, 3px clear of the element. It is an outline, so it takes no
   space, and it is never transitioned: a ring that fades in is a ring that is not there yet.

   The universal selector replaces a list of seven element selectors that also set
   border-radius: 2px. That radius out-ranked .btn, .card and every input, so a focused pill
   button squared off and a focused card lost its 14px corners. An outline follows the
   element's own radius, so none is set here.

   Dark ground turns the ring white. The green and deep bands (the closing band, the footer)
   re-bind --focus-color in tokens.css; the photo hero does it here, because a scrim is an image
   and not a band, and so does the glow (§7), whose ground is deep to primary green
   wherever there is anything to focus — white on those is 7.27:1 or better. `.hero--photo` and
   `.hero--glow`, not `.hero`: a hero without either is white, and a white ring on it would be
   invisible. White over the hero scrim: the buttons sit on its flat floor, black
   at --overlay (clamped to >= 0.55), so the lightest pixel a photograph can put behind the ring
   is 255 x 0.45 = 115 and white on it is 4.7:1 — past the 3:1 WCAG 1.4.11 asks of a focus
   indicator for any photograph. The brand tint above the floor can only darken. */
:focus-visible {
  outline: var(--focus-ring) solid var(--focus-color);
  outline-offset: var(--focus-offset);
}

.hero--photo,
.hero--glow,
.cta-band {
  --focus-color: var(--paper);
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 300;
  background: var(--green-primary);
  color: var(--paper);
  padding: 0.7rem 1.2rem;
  border-radius: 0 0 var(--radius-small) 0;
  font-weight: var(--fw-bold);
}

.skip-link:focus {
  left: 0;
  text-decoration: none;
}

/* The skip link sits in the corner of the viewport, over the green logo: a ring outside it
   would be off-screen on two sides and green on green on the third. Inside it instead, white on
   its own green, clear of the text by the padding. */
.skip-link {
  --focus-color: var(--paper);
}

.skip-link:focus-visible {
  outline-offset: calc(-2 * var(--focus-offset));
}

/* ==========================================================================
   3. Layout primitives
   ========================================================================== */

/* min(100%, …) rather than a bare px max-width: a px ceiling plus padding overflows a phone
   by the padding, which is a bug that has shipped elsewhere in this stack. */
.wrap {
  max-width: min(100%, var(--maxw));
  margin-inline: auto;
  padding-inline: var(--pad-x);
}

.section {
  padding-block: var(--pad-section);
}

.section--tight {
  padding-block: var(--pad-tight);
}

.band-soft {
  background: var(--paper-soft);
}

.band-green {
  background: var(--green-primary);
  color: var(--text);
}

.band-deep {
  background: var(--green-deep);
  color: var(--text);
}

.grid {
  display: grid;
  gap: var(--gap-grid);
}

/* The column ladder is in em, 43.75em and 63.75em: 700px and 1020px at the browser's default
   16px, 1050px and 1530px at a 24px default. A card's words and padding are in rem, so the width
   a column needs grows with the reader's font, and a px ladder put three columns of 24px type
   into a 700px screen: the cards ran 58-121px off the side on /insights, /solutions,
   /textile-auxiliaries and /about, and four columns at 1020px did the same on /enzymes and
   /wastewater (2.6.7). The card images' `sizes` (card_grid.html, photo_cards.html,
   step_list.html) name the same two widths. */
@media (min-width: 43.75em) {
  .grid[data-cols="2"],
  .grid[data-cols="4"],
  .grid[data-cols="6"] {
    grid-template-columns: 1fr 1fr;
  }
  .grid[data-cols="3"] {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media (min-width: 63.75em) {
  .grid[data-cols="4"] {
    grid-template-columns: repeat(4, 1fr);
  }
  .grid[data-cols="6"] {
    grid-template-columns: repeat(3, 1fr);
  }
}

.mt1 { margin-top: 1rem; }
.mt2 { margin-top: 2rem; }
.mt3 { margin-top: 3rem; }

/* ==========================================================================
   4. Section head — composition rule 2
   ========================================================================== */

/* The site's rhythm device: a structural hairline the full width of the container, the h2 in
   the left five columns, the intro in the right seven. Either the right field carries an
   intro, or the head is `--solo` and the h2 runs narrow on its own. Both are compositions;
   neither is a heading stranded over half an empty screen. */
.shead {
  display: grid;
  gap: 0.7rem 3rem;
  border-top: 1px solid var(--rule);
  padding-top: 1.5rem;
  margin-bottom: var(--gap-shead);
}

@media (min-width: 860px) {
  .shead {
    grid-template-columns: 5fr 7fr;
    align-items: end;
  }
  .shead--solo {
    grid-template-columns: 1fr;
    max-width: 46ch;
  }
}

.shead p {
  color: var(--text);
}

.shead--tight {
  margin-bottom: 1.2rem;
}

/* ==========================================================================
   5. Buttons — composition rule 4 lives here
   ========================================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  min-height: 48px;
  padding: 0.78rem 1.6rem;
  border-radius: var(--radius-pill);
  border: 2px solid transparent;
  font-family: var(--font-sans);
  font-size: var(--fs-button);
  font-weight: var(--fw-bold);
  line-height: 1.2;
  cursor: pointer;
  text-align: center;
  text-decoration: none;
  transition: transform var(--dur) var(--ease), background var(--dur) var(--ease),
    color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}

.btn:hover {
  transform: translateY(-1px);
  text-decoration: none;
}

.btn--primary {
  background: var(--btn-primary-bg);
  color: var(--btn-primary-fg);
}

.btn--primary:hover {
  background: var(--btn-primary-bg-hover);
  color: var(--btn-primary-fg);
}

.btn--ghost {
  border-color: var(--btn-ghost-border);
  color: var(--btn-ghost-fg);
  background: transparent;
}

.btn--ghost:hover {
  background: var(--btn-ghost-bg-hover);
  color: var(--btn-ghost-fg);
}

/* The two heroes and the green band sit on dark ground, where the roles invert without the
   band class: a hero's scrim is an image, not a background token. */
.btn--inverted {
  background: var(--paper);
  color: var(--green-primary);
}

.btn--inverted:hover {
  background: #eaf3ee;
  color: var(--green-primary);
}

.btn--ghost-light {
  border-color: var(--paper);
  color: var(--paper);
  background: transparent;
}

.btn--ghost-light:hover {
  background: rgb(var(--white-rgb) / 0.12);
  color: var(--paper);
}

.btn--wa {
  background: var(--paper);
  color: var(--green-primary);
  border-color: var(--rule-soft);
}

.btn--wa:hover {
  border-color: var(--green-primary);
  color: var(--green-primary);
}

.btn--wa svg {
  width: 18px;
  height: 18px;
  fill: currentColor;
  flex: none;
}

.btn-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.8rem;
  margin-top: 2rem;
}

.btn-row--tight {
  margin-top: 1.2rem;
}

/* ==========================================================================
   6. Header
   ========================================================================== */

.site-header {
  position: sticky;
  top: 0;
  z-index: 200;
  background: rgb(var(--white-rgb) / 0.94);
  backdrop-filter: saturate(1.4) blur(10px);
  border-bottom: 1px solid var(--rule-soft);
}

.site-header__inner {
  display: flex;
  align-items: center;
  gap: 1.4rem;
  max-width: min(100%, var(--maxw));
  margin-inline: auto;
  padding: 0.65rem var(--pad-x);
}

/* Where the nav is expanded (wider than the 70em breakpoint below), the header's box is the
   page column, 1180px, or 70rem when that is wider. At 16px or smaller it is the page column as
   before. At a 24px default font the nav needs 1602px and the column stayed 1180px, so the nav
   could never fit at any screen width: its labels broke onto two lines and the logo was
   squeezed (2.6.7). It grows only here: with the menu button the box stays the page column, so
   the logo stays level with the page's text. The exact complement of the breakpoint's 70em,
   so no width has both or neither; its 1120px floor needs nothing here, because below a 16px
   default 70rem is narrower than the page column anyway. */
@media not all and (max-width: 70em) {
  .site-header__inner {
    max-width: min(100%, max(var(--maxw), 70rem));
  }
}

/* The logo never gives its width to the nav. `img { max-width: 100% }` takes its minimum width
   to zero, so a crowded header squeezed it: a 44px-tall logo drawn 73px wide in the Indonesian
   header at 1012px, and at a 24px default font squeezed at every desktop width, to nothing in
   the Indonesian header (2.6.7). */
.site-header__logo {
  flex-shrink: 0;
}

.site-header__logo img {
  height: 44px;
  width: auto;
}

.primary-nav {
  display: flex;
  gap: 0.2rem;
  margin-left: auto;
  align-items: center;
}

.primary-nav__list {
  display: flex;
  gap: 0.2rem;
  align-items: center;
  list-style: none;
}

.primary-nav__item {
  position: relative;
}

.primary-nav__link,
.primary-nav__disclosure {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.62rem 0.85rem;
  border-radius: var(--radius-small);
  font-family: var(--font-sans);
  font-size: var(--fs-nav);
  font-weight: var(--fw-bold);
  line-height: 1;
  color: var(--ink);
  background: none;
  border: 0;
  cursor: pointer;
  transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
}

.primary-nav__link:hover,
.primary-nav__disclosure:hover {
  color: var(--green-primary);
  background: rgb(var(--green-primary-rgb) / 0.06);
  text-decoration: none;
}

.primary-nav__link[aria-current="page"] {
  color: var(--green-primary);
}

/* The parent is a real link AND opens a menu, so the two jobs get two controls: the label
   navigates, the chevron discloses. Squarespace could not do this; hand-built it can. */
.primary-nav__group {
  display: inline-flex;
  align-items: center;
}

.primary-nav__disclosure {
  padding-inline: 0.35rem;
}

.primary-nav__chevron {
  width: 10px;
  height: 10px;
  border-right: 1.8px solid currentColor;
  border-bottom: 1.8px solid currentColor;
  transform: rotate(45deg) translate(-2px, -2px);
}

.primary-nav__sublist {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  min-width: 260px;
  background: var(--paper);
  border: 1px solid var(--rule-soft);
  border-radius: 12px;
  box-shadow: var(--shadow-drop);
  padding: 0.5rem;
  list-style: none;
  display: none;
  z-index: 210;
}

.primary-nav__sublist[data-open="true"] {
  display: block;
}

.primary-nav__sublink {
  display: block;
  padding: 0.62rem 0.8rem;
  border-radius: var(--radius-small);
  color: var(--ink);
  font-weight: var(--fw-regular);
  font-size: var(--fs-nav);
}

.primary-nav__sublink:hover {
  background: var(--paper-soft);
  color: var(--green-primary);
  text-decoration: none;
}

.primary-nav__sublink b {
  display: block;
  font-weight: var(--fw-bold);
}

.primary-nav__sublink span {
  display: block;
  font-size: var(--fs-eyebrow);
  color: var(--gray-70);
  margin-top: 0.1rem;
  font-weight: var(--fw-regular);
}

.header-cta {
  margin-left: 0.4rem;
  min-height: var(--tap-min);
  padding: 0.55rem 1.25rem;
  font-size: 0.9rem;
}

.lang-switch {
  display: flex;
  gap: 0.1rem;
  margin-left: 0.5rem;
  border: 1px solid var(--rule-soft);
  border-radius: var(--radius-pill);
  padding: 0.14rem;
  list-style: none;
}

/* 24px minimum, WCAG 2.2 SC 2.5.8 — at 0.78rem the padding alone lands at 23px. The switcher
   only renders when the site is bilingual, which is why this was invisible until the second
   language existed; scripts/screenshot.py caught it on the first Indonesian capture. */
.lang-switch a {
  display: flex;
  align-items: center;
  min-height: 24px;
  font-size: 0.78rem;
  font-weight: var(--fw-bold);
  line-height: 1;
  color: var(--gray-70);
  padding: 0.34rem 0.6rem;
  border-radius: var(--radius-pill);
}

.lang-switch a:hover {
  text-decoration: none;
  color: var(--green-primary);
}

.lang-switch a[aria-current="true"] {
  background: var(--green-primary);
  color: var(--paper);
}

/* The pills are 1.6px apart, so a ring drawn outside one lands on the other: green on the
   green current-language pill is 1:1. Inside the pill instead, and white on the green one. */
.lang-switch a:focus-visible {
  outline-offset: calc(-1 * var(--focus-offset));
}

.lang-switch a[aria-current="true"] {
  --focus-color: var(--paper);
}

.nav-toggle {
  display: none;
  margin-left: auto;
  background: none;
  border: 0;
  cursor: pointer;
  padding: 0.5rem;
  min-height: var(--tap-min);
  min-width: var(--tap-min);
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-sans);
  font-weight: var(--fw-bold);
  font-size: var(--fs-nav);
  color: var(--ink);
}

.nav-toggle__bars,
.nav-toggle__bars::before,
.nav-toggle__bars::after {
  display: block;
  width: 22px;
  height: 2px;
  background: currentColor;
  content: "";
}

.nav-toggle__bars {
  position: relative;
}

.nav-toggle__bars::before {
  position: absolute;
  top: -7px;
}

.nav-toggle__bars::after {
  position: absolute;
  top: 7px;
}

/* The nav collapses below 70em: 1120px at the browser's default 16px, 1680px at a 24px default.
   In em, not px, because everything in the header but the logo is sized in rem, so the width it
   needs grows with the reader's font size; a media query's em is that default font size. Sized
   for the bilingual header, the wider of its two languages: Indonesian with the language switch
   needs 1100px at 16px and 1602px at 24px. The old 1010px left it 90px short at 16px, and
   at 24px the header ran up to 185px off the side of the page (2.6.7). One breakpoint for both
   languages, so switching language never changes the header's shape. Never below 1120px: the
   80px logo does not shrink with the font, so at a 12px default 70em (840px) put the
   Indonesian labels on two lines at 841-848px. Change a nav label and re-measure: the label's
   length is what these numbers are for. */
@media (max-width: 70em), (max-width: 1120px) {
  .primary-nav {
    display: none;
  }
  .nav-toggle {
    display: inline-flex;
  }
  .site-header__logo img {
    height: 38px;
  }
  .lang-switch {
    margin-left: auto;
  }
  /* The mobile panel is the same <nav>, re-laid-out — one nav in the DOM, so the links are
     never duplicated for a screen reader. */
  .primary-nav[data-open="true"] {
    display: block;
    position: absolute;
    left: 0;
    right: 0;
    top: 100%;
    margin: 0;
    background: var(--paper);
    border-top: 1px solid var(--rule-soft);
    border-bottom: 1px solid var(--rule-soft);
    box-shadow: var(--shadow-drop);
    padding: 0.4rem var(--pad-x) 1.2rem;
    max-height: calc(100vh - var(--tap-min) - 2rem);
    overflow-y: auto;
  }
  .primary-nav[data-open="true"] .primary-nav__list {
    display: block;
  }
  .primary-nav[data-open="true"] .primary-nav__link {
    display: flex;
    padding: 0.85rem 0.2rem;
    border-bottom: 1px solid var(--rule-soft);
    border-radius: 0;
  }
  .primary-nav[data-open="true"] .primary-nav__group {
    display: flex;
    justify-content: space-between;
    align-items: center;
    border-bottom: 1px solid var(--rule-soft);
  }
  .primary-nav[data-open="true"] .primary-nav__group .primary-nav__link {
    border-bottom: 0;
    flex: 1;
  }
  .primary-nav[data-open="true"] .primary-nav__disclosure {
    min-height: var(--tap-min);
    min-width: var(--tap-min);
    justify-content: center;
  }
  .primary-nav[data-open="true"] .primary-nav__sublist {
    position: static;
    box-shadow: none;
    border: 0;
    border-radius: 0;
    padding: 0 0 0 1rem;
    min-width: 0;
  }
  .primary-nav[data-open="true"] .primary-nav__item--cta .header-cta {
    display: flex;
    width: 100%;
    margin: 1rem 0 0;
    font-size: var(--fs-button);
  }
}

/* The collapsed header on a screen narrower than 20em (no phone at 16px; 320-478px at a 24px
   default font). The logo, the menu button and the language switch need 402px at 24px with
   the header's 1.4rem gaps, so on a 390px phone the button was squeezed narrower than its own
   bars and word and the two ran into each other (by up to 56px at 320px; at 20px, 27px). The
   gaps close to 0.6rem, which is enough down to 364px at 24px; below 15.5em (372px at 24px) the
   word goes too and the bars stand alone, the word still the button's name for a screen
   reader, the switch untouched (2.6.7). */
@media (max-width: 19.9375em) {
  .site-header__inner {
    gap: 0.6rem;
  }
}

@media (max-width: 15.5em) {
  .nav-toggle__label {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
  }
}

/* On a 375x667 phone — an iPhone SE, still common — the browser chrome leaves about 553px of
   page. The hero's desktop rhythm put the buttons on /lab and /wastewater at 624px and 635px,
   just under that line, along with the credential strip that carries the certifications.
   Tightening the opening below 700px recovers roughly 100px and changes no words, so the
   Indonesian tree gets the same benefit without a single line of new copy to review. */
@media (max-width: 700px) {
  :root {
    --pad-hero-top: 3rem;
    --pad-hero-bottom: 2.6rem;
    --fs-lede: 1rem;
    --fs-h1: clamp(1.9rem, 7.4vw, 2.4rem);
  }
}

/* ==========================================================================
   7. Hero
   ========================================================================== */

.hero {
  position: relative;
  overflow: hidden;
}

.hero--photo {
  padding-block: 0;
}

/* One picture height on every page (tokens.css --hero-photo-h). The copy column takes the
   free space as equal auto margins, so a short headline sits in the middle of the picture
   rather than at its top over an empty band; the strip stays on the bottom edge. `width` is
   restated because an auto inline margin in a flex column otherwise shrinks the column to
   its text. scripts/screenshot.py flags a page whose copy outgrows the height.

   Not on a phone: the tallest copy there (/lab, 741px at 390px wide) is taller than the
   screen, and holding /insights to it would put 300px of bare photograph between the reader
   and the first card. Not in print, where a fixed height is only a blank space. */
@media screen and (min-width: 701px) {
  .hero--photo {
    display: flex;
    flex-direction: column;
    min-height: var(--hero-photo-h);
  }
  .hero--photo .hero__inner {
    width: 100%;
    margin-block: auto;
  }
}

.hero__picture {
  position: absolute;
  inset: 0;
  display: block;
}

.hero__bg {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--focal, 50% 50%);
  transition: opacity 0.6s var(--ease);
}

/* The hero cinemagraphs (home 2026-09-30, /lab 2026-10-07). The loop lies under .hero__picture, so the
   scrim (on .hero__picture::after) darkens it exactly as it darkens the photograph, and the
   headline's proven contrast does not move. Once the video is playing, js/hero-loop.js adds
   .hero--looping and the photograph fades out over it; the loop's first frame is the
   photograph (within 1/255), so the crossfade shows no jump. */
.hero__loop {
  position: absolute;
  inset: 0;
}

.hero__video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--focal, 50% 50%);
}

.hero--looping .hero__bg {
  opacity: 0;
}

/* The pause control. Quiet, but where a reader looks for it: the hero's top-right corner,
   1rem in from both edges (Brian, 2026-10-07; it sat on the text container's right edge, well
   inside the corner on a wide screen). On a phone that is where it already was, within 2px.
   Since 2026-10-07 a small round icon button (Brian: "really small with a pause
   logo instead of a text"): 28px to the eye, but ::before widens what a finger can hit to
   --tap-min (44px) without drawing anything. Solid enough to read over any frame of the loop:
   white on black/0.45 over the scrimmed photograph is at least 5:1 wherever the button sits. */
.hero__loop-toggle {
  position: absolute;
  z-index: 2;
  top: 1rem;
  right: 1rem;
  display: grid;
  place-items: center;
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  border: 1px solid rgb(var(--white-rgb) / 0.45);
  border-radius: 50%;
  background: rgb(var(--black-rgb) / 0.45);
  color: var(--paper);
  cursor: pointer;
  transition: background-color var(--dur) var(--ease);
}

.hero__loop-toggle::before {
  content: "";
  position: absolute;
  inset: calc((1.75rem - var(--tap-min)) / 2);
}

.hero__loop-icon {
  width: 0.75rem;
  height: 0.75rem;
  fill: currentColor;
}

.hero__loop-icon--play,
.hero__loop-toggle.is-paused .hero__loop-icon--pause {
  display: none;
}

.hero__loop-toggle.is-paused .hero__loop-icon--play {
  display: block;
}

.hero__loop-toggle:hover {
  background: rgb(var(--black-rgb) / 0.65);
}

/* `hidden` loses to any display a rule sets, so it is restated. */
.hero__loop-toggle[hidden] {
  display: none;
}

/* Reduced motion gets the photograph, whatever the script did: a reader who turns the setting
   on after the loop started must not be left with the photograph faded out. Phones play the
   loop since 2026-10-07 (Brian: auto run on every cinemagraph); they were excluded here before. */
@media (prefers-reduced-motion: reduce) {
  .hero__loop,
  .hero__loop-toggle {
    display: none;
  }
  .hero--looping .hero__bg {
    opacity: 1;
  }
}

/* The scrim is TWO layers on one element and the order matters. The bottom layer is the
   floor — flat rgb(0 0 0 / --overlay), which app/styles.py clamps to 0.55, where white text
   clears 4.5:1 over the lightest patch a photograph can contain (tests/test_contrast.py
   proves it). Since 2026-09-01 the floor HOLDS rather than covers: flat to calc(50% + 400px)
   — comfortably past the right edge of every text box the hero can produce (h1 is 21ch
   ≈ 50% + 155px, lede 52ch, kicker capped at 52ch) — then fades to 0.25 so the photograph's
   subject shows through on the right, which is the mock-up's look Brian asked for. Below
   ~1200px viewport the hold point passes 100% and the whole hero keeps the full floor, so
   phones never see the fade. The directional gradient paints on top for the composition and
   can only ever darken further. The credential strip crosses the faded zone with text, so it
   carries its own floor — see .hero__strip. */
.hero__picture::after {
  content: "";
  position: absolute;
  inset: 0;
  /* The brand tint, cut to a wash on Brian's 2026-09-01 "lose the heavy filter" ruling —
     it colors the floor without adding murk. It can only darken, so the AA proof below is
     untouched by its value. */
  background-image: linear-gradient(
    100deg,
    rgb(var(--green-deep-rgb) / 0.32) 0%,
    rgb(var(--green-deep-rgb) / 0.14) 46%,
    rgb(var(--green-deep-rgb) / 0.04) 100%
  ),
  linear-gradient(
    90deg,
    rgb(var(--black-rgb) / var(--overlay)) 0%,
    rgb(var(--black-rgb) / var(--overlay)) calc(50% + 400px),
    rgb(var(--black-rgb) / 0.25) 100%
  );
}

.hero__inner {
  position: relative;
  z-index: 1;
  padding-block: var(--pad-hero-top) var(--pad-hero-bottom);
}

.hero--photo h1 {
  color: var(--paper);
  max-width: 21ch;
  text-wrap: balance;
}

.hero--photo .hero__lede {
  color: rgb(var(--white-rgb) / 0.93);
  max-width: 52ch;
  margin-top: 1.15rem;
}

.hero__kicker {
  font-size: 0.82rem;
  font-weight: var(--fw-bold);
  letter-spacing: 0.13em;
  text-transform: uppercase;
  margin-bottom: 1rem;
  color: var(--green-primary);
  /* Caps the box so every text element in a photo hero provably ends left of the scrim
     floor's hold point (h1 21ch, lede 52ch, this 52ch). */
  max-width: 52ch;
}

.hero--photo .hero__kicker {
  color: rgb(var(--white-rgb) / 0.92);
}

/* The credential strip: the four things a buyer verifies, before they scroll. It spans the
   full width, so it crosses the zone where the hero floor fades — it cannot borrow the
   scrim's guarantee and carries its own: a black tint under a deep-green tint (palette
   channels only, per the tokens.css rule), and tests/test_contrast.py recomputes the worst
   case — white text over a white photo patch through the faded floor and both strip layers —
   and proves it clears 4.5:1. The mock-up's near-black band (rgb(0 30 18 / .35)) is what
   this compound reproduces. */
.hero__strip {
  position: relative;
  z-index: 1;
  border-top: 1px solid rgb(var(--white-rgb) / 0.22);
  background-image:
    linear-gradient(rgb(var(--green-deep-rgb) / 0.3), rgb(var(--green-deep-rgb) / 0.3)),
    linear-gradient(rgb(var(--black-rgb) / 0.35), rgb(var(--black-rgb) / 0.35));
  backdrop-filter: blur(6px);
}

.hero__strip ul {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 2.4rem;
  padding-block: 0.85rem;
  align-items: center;
  list-style: none;
}

.hero__strip li {
  color: rgb(var(--white-rgb) / 0.9);
  font-size: var(--fs-small);
}

.hero__strip b {
  font-weight: var(--fw-bold);
  color: var(--paper);
}

/* The generated-image marker (hero `img_note`). Quiet but genuinely visible: the honesty rule
   is words a reader can find, not a flag in the manifest. Lives in the strip band, whose
   green floor keeps white text comfortably past 4.5:1. */
.hero__note {
  padding-bottom: 0.7rem;
  margin-top: -0.35rem;
  text-align: right;
  font-size: var(--fs-eyebrow);
  letter-spacing: 0.02em;
  color: rgb(var(--white-rgb) / 0.8);
}

/* The note with no credential strip above it (/enzymes, /textile-auxiliaries, since
   2026-09-30). The rule above tucks the note under the strip's list; alone in the band, that
   pull-up put the text on the band's top hairline. Even space above and below instead. */
.hero__strip .wrap > .hero__note:first-child {
  margin-top: 0;
  padding-block: 0.6rem;
}

/* The sub-page hero: breadcrumbs, eyebrow, h1, lede, on the glow below. No
   photograph, because these pages answer a question and a picture would only push the answer
   down. */
.hero--page {
  padding-block: var(--pad-page-hero-top) var(--pad-page-hero-bottom);
}

/* The section carries the rhythm for a page hero; the inner wrapper must not add its own on
   top, or the two stack into ~200px of void above the breadcrumb. Only the photo hero, whose
   type has to clear the image, uses the tall inner padding. */
.hero--page .hero__inner {
  padding-block: 0;
}

.hero--page h1 {
  max-width: 24ch;
  text-wrap: balance;
}

.hero--page .hero__lede {
  max-width: 62ch;
  margin-top: 1rem;
  color: var(--text);
}

/* The glow (Brian, 2026-10-01: "the current web seems a bit disconnected from the
   hero"). A CSS port of the AnimatedGradientBackground React component he sent; the site is
   Flask and Jinja, so there is no React, no framer-motion and no script. The component redraws
   its gradient from requestAnimationFrame on every frame for as long as the page is open, even
   with its breathing switched off; here the gradient is painted once and the entrance moves
   only transform and opacity, which the compositor runs without repainting.

   The sub-page opener now sits on the homepage hero's dark green, which is what ties the
   fifteen text pages to it, and the glow's last ring is the page's white, so the opener runs
   into the first section instead of stopping on a line. Every value is in tokens.css, with
   the contrast argument; tests/test_glow.py recomputes it.

   Three layers, bottom up. The section's own background is the floor: deep green, fading to
   the page's white across the horizon only, where there is no text. It is what the first frame
   shows, and all a browser without color-mix() ever shows (the painted layer's declaration is
   then invalid, so it paints nothing), and either way the opener already runs into the page
   rather than stopping on a line. Over it, .glow__paint: the radial, its core at the top of a
   box of fixed height anchored to the bottom edge, so the rings sit at fixed distances from
   that edge whatever the title's length. Then the copy, in the room the horizon padding leaves
   above the glow.

   The entrance scales the paint from 1.5 about the ellipse's own centre and fades it in over
   the floor. Both only darken what is under the text on the way (a bigger ellipse puts each
   point on an inner ring; under the text the floor is deep green, so the fade mixes toward
   it), so the settled frame is the worst case for contrast, and that is the one the test
   measures. Under reduced motion §1's
   rule removes the animation and the settled frame is all there is. It plays once: two
   seconds, under WCAG 2.2.2's five, so it needs no pause control. The component's optional
   breathing loop is left out for that reason. */
.hero--glow {
  isolation: isolate;
  background-color: var(--green-deep);
  background-image: linear-gradient(
    to bottom,
    var(--green-deep) calc(100% - var(--glow-horizon)),
    var(--glow-edge, var(--paper)) 100%
  );
  padding-bottom: calc(var(--pad-page-hero-bottom) + var(--glow-horizon));
}

.glow {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}

.glow__paint {
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  height: var(--glow-h);
  background-image: radial-gradient(
    var(--glow-rx) 100% at var(--glow-x) 0%,
    var(--green-deep) var(--glow-core-end),
    var(--green-primary) var(--glow-safe),
    var(--green-secondary) var(--glow-glow),
    var(--glow-mint) var(--glow-mint-at),
    var(--glow-haze) var(--glow-haze-at),
    var(--glow-edge, var(--paper)) 100%
  );
  transform-origin: var(--glow-x) 0%;
  animation: glow-in var(--glow-in) var(--glow-ease) both;
}

@keyframes glow-in {
  from {
    opacity: 0;
    transform: scale(var(--glow-from-scale));
  }
}

.hero--glow h1 {
  color: var(--paper);
}

.hero--glow .eyebrow {
  color: rgb(var(--white-rgb) / 0.9);
}

.hero--glow .hero__lede,
.hero--glow .byline {
  color: rgb(var(--white-rgb) / 0.92);
}

.hero--glow .byline b {
  color: var(--paper);
}

/* Every text box in the opener has a width, so the contrast proof has a right-hand edge to work
   from. The h1 (24ch) and lede (62ch) carried one already; these did not, and are short enough
   that 62ch of their own smaller type never binds. The button row is empty on every opener
   today. */
.hero--glow .crumbs,
.hero--glow .eyebrow,
.hero--glow .byline,
.hero--glow .btn-row {
  max-width: 62ch;
}

/* No page opens onto the tinted band today; if one does, the glow fades into the tint. */
.hero--glow:has(+ .band-soft) {
  --glow-edge: var(--paper-soft);
}

.crumbs {
  font-size: 0.82rem;
  color: var(--gray-70);
  margin-bottom: 1.1rem;
}

.crumbs ol {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

.crumbs a {
  color: var(--gray-70);
}

.crumbs a:hover {
  color: var(--green-primary);
}

.crumbs [aria-current="page"] {
  color: var(--ink);
}

/* On a photo hero the trail sits over the scrim, so it inverts with everything else there.
   White at 0.82 over the 0.55 flat floor clears AA the same way the lede does. */
.hero--photo .crumbs,
.hero--photo .crumbs a {
  color: rgb(var(--white-rgb) / 0.82);
}

.hero--photo .crumbs a:hover,
.hero--photo .crumbs [aria-current="page"] {
  color: var(--paper);
}

/* The same on the glow. The trail is the top line of the opener, on deep green, where
   white at 0.82 is 7.36:1; even on the primary ring it would be 5.45:1. */
.hero--glow .crumbs,
.hero--glow .crumbs a {
  color: rgb(var(--white-rgb) / 0.82);
}

.hero--glow .crumbs a:hover,
.hero--glow .crumbs [aria-current="page"] {
  color: var(--paper);
}

.byline {
  font-size: var(--fs-small);
  color: var(--text);
  margin-top: 1rem;
}

.byline b {
  color: var(--ink);
}

/* ==========================================================================
   8. Cards
   ========================================================================== */

.card-grid {
  list-style: none;
}

.card {
  background: var(--paper);
  border: 1px solid var(--rule-soft);
  border-radius: var(--radius);
  padding: 1.5rem 1.4rem;
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
  height: 100%;
}

.card h3 {
  color: var(--green-primary);
}

.card__body {
  color: var(--text);
}

.card__num {
  font-size: 0.95rem;
  font-weight: var(--fw-bold);
  line-height: 1;
  color: var(--green-primary);
}

/* Composition rule 4: a card that goes somewhere lifts on hover. A card that is just a card
   never moves, so movement stays a reliable signal. */
a.card {
  color: inherit;
  transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease),
    border-color var(--dur) var(--ease);
}

a.card:hover {
  text-decoration: none;
  transform: translateY(-3px);
  box-shadow: var(--shadow-card);
  border-color: var(--gray-30);
}

.card__cue {
  margin-top: auto;
  padding-top: 0.7rem;
  font-weight: var(--fw-bold);
  font-size: 0.9rem;
  color: var(--green-primary);
}

.photo-card {
  border-radius: var(--radius);
  overflow: hidden;
  border: 1px solid var(--rule-soft);
  background: var(--paper);
  height: 100%;
}

/* `height: auto` is load-bearing. Every <img> carries intrinsic width/height attributes so the
   browser reserves the right box before the file arrives (CLS), and those attributes become a
   specified `height` in px. Without this line that specified height beats `aspect-ratio`, and a
   portrait photograph renders at its own shape instead of the card's — which is exactly what
   happened to the two Bandung site photos on /about before this was caught in the browser. */
.photo-card img {
  width: 100%;
  height: auto;
  aspect-ratio: var(--card-ratio, 3 / 2);
  object-fit: cover;
  object-position: var(--focal, 50% 50%);
}

/* A certificate is a document, not a picture: it is read, so it is never cropped. */
.photo-card--document img {
  object-fit: contain;
  background: var(--paper);
  padding: 1rem;
}

.photo-card figcaption {
  padding: 1rem 1.2rem 1.2rem;
}

.photo-card b {
  color: var(--ink);
  display: block;
}

.photo-card span {
  font-size: var(--fs-small);
  color: var(--text);
}

/* ==========================================================================
   9. Split feature, stats, chips, steps, marks, tables
   ========================================================================== */

.split {
  display: grid;
  gap: clamp(1.6rem, 4vw, 3.6rem);
  align-items: center;
}

@media (min-width: 900px) {
  .split {
    grid-template-columns: 6fr 5fr;
  }
  .split--flip > .split__media {
    order: -1;
  }
}

/* The rounding lives on the picture, not the figure, so a caption under it sits outside the
   rounded box. Without a caption the two are the same thing. */
.split__media picture {
  display: block;
  border-radius: var(--radius);
  overflow: hidden;
}

.split__caption {
  margin-top: 0.75rem;
  max-width: 46ch;
  font-size: var(--fs-small);
  color: var(--text);
  text-wrap: pretty;
}

/* `tall`: one block, not two. The row stops centring its items, the picture takes the full
   height of the text column (eyebrow to button), and the photograph covers that height. It is
   a tall, narrow frame, which suits a subject that is tall itself (the settling cone). Below
   900px the split stacks and the picture keeps its own ratio. */
@media (min-width: 900px) {
  .split--tall {
    align-items: stretch;
  }
  .split--tall .split__media {
    display: flex;
    flex-direction: column;
  }
  .split--tall .split__media picture {
    position: relative;
    flex: 1 1 auto;
    min-height: 22rem;
  }
  .split--tall .split__media img {
    position: absolute;
    inset: 0;
    height: 100%;
    aspect-ratio: auto;
  }
}

/* On a phone a tall split stacks, and the picture would land after the button, where it reads
   as an afterthought. It goes directly under the heading instead, beside the paragraph it
   illustrates: the text column hands its children to the grid (`display: contents`, on a plain
   div, so nothing is lost to assistive tech) and the picture is ordered between the heading and
   the copy. Grid items do not collapse margins the way the column's blocks did, so the picture
   takes no top margin (the heading's own gives the gap) and the last tick drops its bottom
   margin, which would otherwise add to the button row's. */
@media (max-width: 899px) {
  .split--tall {
    row-gap: 0;
  }
  .split--tall .split__text {
    display: contents;
  }
  .split--tall .split__text > * {
    order: 2;
  }
  .split--tall .split__text > .eyebrow,
  .split--tall .split__text > .shead {
    order: 0;
  }
  .split--tall .split__media {
    order: 1;
    margin-block: 0 1.6rem;
  }
  .split--tall .ticks > li:last-child {
    margin-bottom: 0;
  }
}

.split__media img {
  width: 100%;
  height: auto;                        /* see the note on .photo-card img */
  object-fit: cover;
  object-position: var(--focal, 50% 50%);
  aspect-ratio: var(--card-ratio, 4 / 3);
}

.stats {
  display: grid;
  gap: 1.2rem;
  list-style: none;
}

@media (min-width: 700px) {
  .stats {
    grid-template-columns: repeat(4, 1fr);
  }
  /* Six figures. Two-up here and three-up at 1020, the steps .grid[data-cols="6"] takes at the
     default 16px (its ladder is in em since 2.6.7; these are still px),
     so a six-item row never lands as four and a stranded two. */
  .stats[data-cols="3"] {
    grid-template-columns: 1fr 1fr;
  }
}

@media (min-width: 1020px) {
  .stats[data-cols="3"] {
    grid-template-columns: repeat(3, 1fr);
  }
}

.stat b {
  display: block;
  font-size: var(--fs-stat);
  font-weight: var(--fw-bold);
  color: var(--green-primary);
  line-height: 1.1;
}

.stat span {
  font-size: 0.9rem;
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  list-style: none;
}

.chip {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
  font-size: var(--fs-eyebrow);
  font-weight: var(--fw-bold);
  line-height: 1;
  color: var(--green-primary);
  background: rgb(var(--green-primary-rgb) / 0.07);
  border: 1px solid rgb(var(--green-primary-rgb) / 0.18);
  padding: 0.5rem 0.9rem;
  border-radius: var(--radius-pill);
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease);
}

a.chip:hover {
  text-decoration: none;
  background: rgb(var(--green-primary-rgb) / 0.13);
  border-color: var(--green-primary);
}

.steps {
  list-style: none;
}

.step {
  border-top: 1px solid var(--rule);
  padding-top: 1.1rem;
}

.step__number {
  font-size: 1rem;
  font-weight: var(--fw-bold);
  line-height: 1;
  color: var(--green-primary);
  display: block;
  margin-bottom: 0.55rem;
}

.mark-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 1rem 1.2rem;
  list-style: none;
}

.mark-row img {
  height: 74px;
  width: auto;
  background: var(--paper);
  padding: 0.7rem 1.1rem;
  border: 1px solid var(--rule-soft);
  border-radius: 12px;
}

.mark-row__note {
  font-size: var(--fs-small);
  color: var(--text);
  flex-basis: 100%;
  margin-top: 0.4rem;
}

/* Wide content scrolls inside its own box. The page body must never scroll sideways. */
.table-scroll {
  overflow-x: auto;
}

.tbl {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.93rem;
}

.tbl th {
  text-align: left;
  font-weight: var(--fw-bold);
  color: var(--ink);
  border-bottom: 2px solid var(--green-primary);
  padding: 0.6rem 0.7rem;
}

.tbl td {
  border-bottom: 1px solid var(--rule-soft);
  padding: 0.6rem 0.7rem;
  vertical-align: top;
}

.tbl tr:nth-child(even) td {
  background: var(--paper-soft);
}

.band-soft .tbl tr:nth-child(even) td {
  background: var(--paper);
}

/* ==========================================================================
   10. Prose
   ========================================================================== */

.prose {
  max-width: var(--measure);
}

.prose h3 {
  margin-top: 1.8rem;
  margin-bottom: 0.5rem;
}

.prose ul,
.prose ol {
  padding-left: 1.3rem;
  margin-top: 0.9rem;
}

.prose li {
  margin-bottom: 0.4rem;
}

.prose table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.93rem;
  margin-top: 1.2rem;
}

.prose th {
  text-align: left;
  font-weight: var(--fw-bold);
  color: var(--ink);
  border-bottom: 2px solid var(--green-primary);
  padding: 0.6rem 0.7rem;
}

.prose td {
  border-bottom: 1px solid var(--rule-soft);
  padding: 0.6rem 0.7rem;
  vertical-align: top;
}

.prose strong {
  color: var(--ink);
}

.ticks {
  list-style: none;
  padding-left: 0;
}

.ticks li {
  padding-left: 1.7rem;
  position: relative;
  margin-bottom: 0.55rem;
}

.ticks li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.32em;
  width: 1em;
  height: 1em;
  background: var(--green-primary);
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E")
    center / contain no-repeat;
}

.faq h3 {
  color: var(--ink);
  margin-top: 1.8rem;
}

.faq h3:first-of-type {
  margin-top: 0;
}

/* ==========================================================================
   11. Closing band
   ========================================================================== */

.cta-band {
  text-align: center;
}

.cta-band h2 {
  color: var(--paper);
  max-width: 24ch;
  margin-inline: auto;
  text-wrap: balance;
}

.cta-band__body {
  color: rgb(var(--white-rgb) / 0.88);
  max-width: 52ch;
  margin: 1rem auto 0;
}

.cta-band .btn-row {
  justify-content: center;
  margin-top: 1.9rem;
}

/* The glow, mirrored (2026-10-01). The opener's glow turned upside down: its white
   edge meets the section above, the glow runs down into green, and the core is the footer's
   deep green, so band and footer read as one ground and the page ends the way a sub-page now
   begins. The horizon is extra top padding, so the copy starts below the glow, on deep to
   primary green; the supporting line's 0.88 white is proven on primary already
   (tests/test_contrast.py). No entrance: the band is below the fold on every page, and an
   animation that runs before anyone scrolls to it is spent on nobody. */
.cta-band--glow {
  isolation: isolate;
  position: relative;
  overflow: hidden;
  background-color: var(--green-deep);
  background-image: linear-gradient(
    to top,
    var(--green-deep) calc(100% - var(--glow-horizon)),
    var(--glow-edge, var(--paper)) 100%
  );
  padding-top: calc(var(--pad-section) + var(--glow-horizon));
}

.cta-band--glow .glow__paint {
  top: 0;
  bottom: auto;
  background-image: radial-gradient(
    var(--glow-rx) 100% at 50% 100%,
    var(--green-deep) var(--glow-core-end),
    var(--green-primary) var(--glow-safe),
    var(--green-secondary) var(--glow-glow),
    var(--glow-mint) var(--glow-mint-at),
    var(--glow-haze) var(--glow-haze-at),
    var(--glow-edge, var(--paper)) 100%
  );
  animation: none;
}

/* Where the band follows the tinted band, its edge is the tint, or a seam shows. */
.band-soft + .cta-band--glow {
  --glow-edge: var(--paper-soft);
}

/* ==========================================================================
   12. Forms
   ========================================================================== */

.form-layout {
  display: grid;
  gap: clamp(1.6rem, 4vw, 3rem);
  align-items: start;
}

@media (min-width: 900px) {
  .form-layout {
    grid-template-columns: 7fr 5fr;
  }
}

.form-grid {
  display: grid;
  gap: 1rem;
}

@media (min-width: 700px) {
  .form-grid {
    grid-template-columns: 1fr 1fr;
  }
  .field--full {
    grid-column: 1 / -1;
  }
}

label {
  display: block;
  font-weight: var(--fw-bold);
  font-size: 0.9rem;
  color: var(--ink);
  margin-bottom: 0.35rem;
}

label .opt {
  font-weight: var(--fw-regular);
  color: var(--gray-70);
}

/* Every input declares a type in the template, so this selector list is the whole set. An
   <input> with no type matches none of it and renders as a bare native box — 94 of those
   shipped once in another app in this stack. */
input[type="text"],
input[type="email"],
input[type="tel"],
select,
textarea {
  width: 100%;
  font-family: var(--font-sans);
  font-size: 16px; /* the iOS zoom floor — never drop this below 16px on mobile */
  font-weight: var(--fw-regular);
  line-height: 1.4;
  color: var(--ink);
  padding: 0.8rem 0.9rem;
  border: 1px solid var(--field-border);
  border-radius: var(--radius-input);
  background: var(--paper);
  min-height: var(--tap-min);
}

input::placeholder,
textarea::placeholder {
  color: var(--gray-70);
}

textarea {
  min-height: 132px;
  resize: vertical;
}

.field__help,
.form__legend {
  font-size: var(--fs-small);
  color: var(--text);
  margin-top: 0.3rem;
}

.field--invalid input,
.field--invalid select,
.field--invalid textarea {
  border-color: var(--green-hover);
  border-width: 2px;
}

.field__error {
  font-size: var(--fs-small);
  font-weight: var(--fw-bold);
  color: var(--green-hover);
  margin-top: 0.3rem;
}

.req {
  color: var(--green-primary);
}

.form-note {
  border: 1px solid var(--rule-soft);
  border-left: 4px solid var(--green-primary);
  border-radius: var(--radius-small);
  padding: 1rem 1.2rem;
  margin-bottom: 1.6rem;
  background: var(--paper-soft);
}

.form-note h3 {
  color: var(--green-primary);
}

.form-note--error {
  border-left-color: var(--green-hover);
}

/* Honeypot. Hidden by a class, never an inline style — the CSP forbids style attributes. */
.hp-field {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* The office map in the contact card (form_section.html). The picture is 400x260 CSS px
   (scripts/build_office_map.py), drawn at 3x, with every street name placed whole inside it. It
   scales; it is never cropped, because a crop cut the main road's name at every phone width. Its
   15-16px lettering prints at about 10px on a 360 phone (box 278), 9px at 320, 6px at 240, and
   at 98% (box 393) from about 1180, where the card stops growing. Capped at 30rem so the one-column layout (562-899px viewports) does not
   blow it up past 1.2x; the cap is on the figure, so the credit's corner is the picture's corner.
   `height: auto` because the intrinsic height attribute would otherwise pin the box (README).
   Nothing here moves (rule 4). */
.contact-map {
  position: relative;
  max-width: 30rem;
  container: contact-map / inline-size;
}

.contact-map img {
  display: block;
  width: 100%;
  max-width: 30rem;
  height: auto;
  background: var(--paper-soft);
  border: 1px solid var(--rule-soft);
  border-radius: var(--radius-small);
}

/* The map's credit, in the picture's bottom-right corner (form_section.html), where map credits
   sit. 11px in --green-primary on a near-white plate: 6:1 or better over the darkest map ink under
   it (review, 2026-10-20); inside the 1px border, its outer corner following the picture's
   rounding. On one row it covers only buildings and streets: the street names end above it. */
.contact-map__credit {
  position: absolute;
  right: 1px;
  bottom: 1px;
  max-width: calc(100% - 2px);
  padding: 0.05rem 0.45rem;
  background: rgb(var(--white-rgb) / 0.88);
  border-top-left-radius: var(--radius-small);
  border-bottom-right-radius: calc(var(--radius-small) - 1px);
  font-size: 0.6875rem;
  line-height: 1.5;
}

.contact-map__credit p {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 0.5rem 0.6rem;
  margin: 0;
}

.contact-map__credit a {
  color: var(--green-primary);
  white-space: nowrap;
}

/* A box too narrow for the credit on one row (under 14rem: a phone under about 306px, or a
   larger one zoomed or set to a bigger default font) would stack it into a plate over a third of
   the map and the street beside the pin. There it sits directly under the picture instead, the
   other place both licences allow; the row gap keeps stacked links' centres 24.5px apart (WCAG
   2.5.8 spacing). rem, not px, so a bigger default font moves the switch with the text. */
@container contact-map (width < 14rem) {
  .contact-map__credit {
    position: static;
    max-width: none;
    padding: 0.3rem 0 0;
    background: none;
    border-radius: 0;
  }

  .contact-map__credit p {
    justify-content: flex-start;
  }
}

/* ==========================================================================
   13. Footer
   ========================================================================== */

.site-footer {
  color: rgb(var(--white-rgb) / 0.85); /* 7.78:1 on the deep green */
  font-size: 0.92rem;
}

.site-footer a {
  color: var(--paper);
}

.site-footer__grid {
  display: grid;
  gap: 2.2rem;
  padding-block: clamp(2.6rem, 6vh, 4rem) 2rem;
}

@media (min-width: 860px) {
  .site-footer__grid {
    grid-template-columns: 2fr 1fr 1fr 1.4fr;
  }
}

.site-footer h2 {
  color: var(--paper);
  font-size: 0.95rem;
  margin-bottom: 0.9rem;
}

.site-footer ul {
  list-style: none;
}

.site-footer li {
  margin-bottom: 0.55rem;
}

.site-footer__logo {
  height: 46px;
  width: auto;
  margin-bottom: 1rem;
}

.site-footer__creds {
  font-size: 0.82rem;
  color: rgb(var(--white-rgb) / 0.66); /* 5.34:1 on the deep green */
  margin-top: 1rem;
}

/* Each credential holds together while it fits on the line, so the line wraps between them
   (footer.html); one wider than the whole line ("halal-certified manufacturing" at a 24px default
   font on a 320px phone) wraps inside itself rather than pushing the footer off the screen, which
   `white-space: nowrap` did (review, 2026-10-20). */
.site-footer__cred {
  display: inline-block;
}

.site-footer address {
  font-style: normal;
}

.site-footer__legal {
  border-top: 1px solid rgb(var(--white-rgb) / 0.18);
  padding-block: 1.2rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem 2rem;
  justify-content: space-between;
  font-size: 0.82rem;
  color: rgb(var(--white-rgb) / 0.66);
}

.social-row {
  display: flex;
  gap: 0.4rem;
  list-style: none;
  margin-top: 1rem;
}

.social-row a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--tap-min);
  height: var(--tap-min);
  border-radius: var(--radius-small);
}

.social-row svg {
  width: 22px;
  height: 22px;
  fill: currentColor;
}

.social-row a:hover {
  background: rgb(var(--white-rgb) / 0.12);
}

/* The glow at the foot (2026-10-01), on a page with no closing band: /contact, /privacy, the
   error and portal pages. The same glow as the closing band's, hanging from the footer's top
   edge, so the page fades into the footer instead of stopping on a line. The footer's quiet
   lines (white at 0.66) clear AA on deep green only, so its text must sit in the core: the box
   is sized from the gap above that text (tokens.css --glow-foot-h) and drawn wider, and
   tests/test_glow.py holds every point the text can reach to --glow-core-end. Static, like
   the band. */
.site-footer--glow {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background-color: var(--green-deep);
  background-image: linear-gradient(
    to top,
    var(--green-deep) calc(100% - var(--glow-horizon)),
    var(--glow-edge, var(--paper)) 100%
  );
  padding-top: var(--glow-horizon);
}

.site-footer--glow .glow__paint {
  top: 0;
  bottom: auto;
  height: var(--glow-foot-h);
  background-image: radial-gradient(
    var(--glow-foot-rx) 100% at 50% 100%,
    var(--green-deep) var(--glow-core-end),
    var(--green-primary) var(--glow-safe),
    var(--green-secondary) var(--glow-glow),
    var(--glow-mint) var(--glow-mint-at),
    var(--glow-haze) var(--glow-haze-at),
    var(--glow-edge, var(--paper)) 100%
  );
  animation: none;
}

.site-footer--after-soft {
  --glow-edge: var(--paper-soft);
}

/* The glow on paper. Browsers drop backgrounds unless the reader asks for them, which would
   leave the opener's white type on white paper, title included, on the pages a buyer is most
   likely to print: the methods. On paper the opener goes back to what it was before the glow.
   The closing band and the footer lose their horizon; their white-on-green type was already
   a screen-only design.
   This block must stay below every screen rule it overrides: a media query adds no
   specificity, so an earlier block loses to them (tests/test_glow.py checks the order). */
@media print {
  .glow {
    display: none;
  }
  .hero--glow {
    background: none;
    padding-bottom: var(--pad-page-hero-bottom);
    --btn-primary-bg: var(--green-primary);
    --btn-primary-fg: var(--paper);
    --btn-ghost-fg: var(--green-primary);
    --btn-ghost-border: var(--green-primary);
  }
  .cta-band--glow {
    padding-top: var(--pad-section);
  }
  .site-footer--glow {
    padding-top: 0;
  }
  .hero--glow h1 {
    color: var(--heading);
  }
  .hero--glow .eyebrow {
    color: var(--green-primary);
  }
  .hero--glow .hero__lede,
  .hero--glow .byline,
  .hero--glow .crumbs,
  .hero--glow .crumbs a {
    color: var(--text);
  }
  .hero--glow .byline b,
  .hero--glow .crumbs [aria-current="page"] {
    color: var(--ink);
  }
}

/* ==========================================================================
   14. Error pages
   ========================================================================== */

.error-page {
  padding-block: clamp(4rem, 12vh, 7rem);
}

.error-page__code {
  font-size: var(--fs-stat);
  font-weight: var(--fw-bold);
  color: var(--gray-30);
  line-height: 1;
  margin-bottom: 0.5rem;
}

.error-page h1 {
  margin-bottom: 1rem;
}

.error-page p + p {
  margin-top: 1rem;
}

/* ==========================================================================
   15. Target size
   ==========================================================================
   WCAG 2.2 SC 2.5.8 wants 24x24 CSS px on anything you point at. A link that is part of a
   sentence is exempt; a link in a navigation list or a table cell is not, and at the footer's
   0.92rem a bare anchor lands at 20px. Padding, not font size — the type stays where the
   design put it. scripts/screenshot.py flags anything under 24px on every capture. */

.site-footer li a,
.tbl td a,
.card__cue a {
  display: inline-flex;
  align-items: center;
  min-height: 24px;
}

/* ==========================================================================
   16. Entrance reveal — composition rule 4, extended to arrivals
   ==========================================================================

   Rule 4 is "only actionable things move". An entrance is the one exception the rule can
   carry, because it runs ONCE per group on first sight and never again: nothing here responds
   to scroll position, hovering or pointing. There is no parallax and no moving decoration.
   The one other entrance on the site is the glow's (§7, 2026-10-01): decorative, two seconds,
   once, at load, and gone under reduced motion. Its case is made there.

   Technique read out of otsuka-air.jp (2026-08-26 design study). Transform leads, opacity
   trails it by --reveal-fade-delay. The element finishes moving while still slightly
   transparent, so it reads as arriving rather than appearing. At delay 0 the effect dies.

   GROUPS, NOT ELEMENTS (2026-09-27 homepage motion pass). `data-reveal` sits on the PARENT of
   things that arrive together, and its children are what move, in DOM order. The value names
   the group — hero, head, grid, marks, stats, steps — and only "hero" and "head" are read by a
   selector. When several groups of one section enter together, app/static/js/motion.js gives
   the second `is-after-1` and the third `is-after-2`, so a heading lands before its cards and
   the stat band's marks before its figures. A group that was scrolled past before it could
   enter gets `is-instant`: shown, with no transition, so a fast scroll never finds a blank.
   The handoff's `.js` and `.is-in` are this file's `.js-reveal` and `.is-revealed`.

   The hidden state is scoped to html.js-reveal, and motion.js adds that class itself. No JS,
   or a 404 on that file, therefore means "no animation" rather than "blank page" — which is
   the failure mode every scroll reveal has and almost none guards.

   The stagger comes from :nth-child(), NOT from a style attribute: style-src has no
   'unsafe-inline' and tests/test_content.py fails the build on any style attribute. The child
   position is what the index means anyway, so this is the honest expression of it.

   Only transform and opacity are animated — neither triggers layout, so every element holds
   its final box from the first paint and nothing here can cost CLS.

   The hero's credential strip is a full-width band, not a child of the copy column, and moving
   it in there would change the layout. Its content joins the hero group from the column's next
   sibling (`~ .hero__strip > .wrap`) and takes the fifth place in the stagger. The band itself
   never moves: its tint is part of the hero's scrim, and it carries a hairline and a
   backdrop blur, so fading it would fade the scrim over the photograph. The photograph and its
   scrim are the page's LCP and paint as they are from the first frame.

   A focused element never waits for its entrance. Tab can land on a card whose group has not
   arrived, and a ring inside an element at opacity 0 is a ring nobody can see; the
   :focus-within rule below shows that one child at once while its siblings arrive as normal. */

.js-reveal [data-reveal] > *,
.js-reveal [data-reveal="hero"] ~ .hero__strip > .wrap {
  opacity: 0;
  transform: translate3d(0, var(--reveal-shift), 0);
}

/* --d is this element's start: its group's place in the batch, plus its own place in the
   group. Computed here, on the element, so --i is the element's own and --reveal-offset is
   inherited from its group. */
[data-reveal] > *,
[data-reveal="hero"] ~ .hero__strip > .wrap {
  --i: 0;
  --d: calc(var(--reveal-offset, 0ms) + var(--i) * var(--reveal-stagger));
}

/* The stagger. Capped at the sixth child: past that the last card waits longer than a reader
   will, and every grid on this site is three or four wide. A seventh sibling and beyond simply
   share the sixth step rather than accumulating. */
[data-reveal] > :nth-child(2) { --i: 1; }
[data-reveal] > :nth-child(3) { --i: 2; }
[data-reveal] > :nth-child(4) { --i: 3; }
[data-reveal] > :nth-child(5) { --i: 4; }
[data-reveal] > :nth-child(n + 6) { --i: 5; }
/* Kicker, h1, lede, buttons, then the strip: 0, 55, 110, 165, 220ms, done by 740ms. */
[data-reveal="hero"] ~ .hero__strip > .wrap { --i: 4; }

[data-reveal].is-revealed > *,
[data-reveal="hero"].is-revealed ~ .hero__strip > .wrap {
  opacity: 1;
  transform: none;
  transition:
    transform var(--reveal-move) var(--reveal-ease) var(--d),
    opacity var(--reveal-fade) var(--reveal-ease) calc(var(--d) + var(--reveal-fade-delay));
}

[data-reveal].is-after-1 { --reveal-offset: var(--reveal-after); }
[data-reveal].is-after-2 { --reveal-offset: calc(var(--reveal-after) * 2); }

[data-reveal].is-instant > *,
[data-reveal="hero"].is-instant ~ .hero__strip > .wrap {
  transition: none;
}

.js-reveal [data-reveal] > :focus-within {
  opacity: 1;
  transform: none;
  transition: none;
}

/* A phone gets lighter entrances: tokens.css halves the travel and shortens the stagger, and
   this caps it at the third child, holds section headings still, and brings the hero in three
   steps — kicker, h1 and lede together, then the buttons, then the strip, done by 580ms. These
   rules must stay AFTER the base ones above: same specificity, so source order decides. */
@media (max-width: 600px) {
  [data-reveal] > :nth-child(n + 3) { --i: 2; }
  .js-reveal [data-reveal="head"] > * {
    opacity: 1;
    transform: none;
  }
  [data-reveal="hero"] > :nth-child(-n + 3) { --i: 0; }
  [data-reveal="hero"] > :nth-child(4) { --i: 1; }
  [data-reveal="hero"] ~ .hero__strip > .wrap { --i: 2; }
}

/* Reduced motion: main.css §1 already kills every transition, but that alone would leave the
   hidden state applied and the page waiting on an observer that motion.js never starts.
   Remove the hidden state outright, so the first paint is the final layout. */
@media (prefers-reduced-motion: reduce) {
  .js-reveal [data-reveal] > *,
  .js-reveal [data-reveal="hero"] ~ .hero__strip > .wrap {
    opacity: 1 !important;
    transform: none !important;
  }
}

/* Never hand a half-revealed page to the printer. */
@media print {
  .js-reveal [data-reveal] > *,
  .js-reveal [data-reveal="hero"] ~ .hero__strip > .wrap {
    opacity: 1 !important;
    transform: none !important;
  }
}

/* ==========================================================================
   17. Panel row — the expanding industries row
   ==========================================================================

   Modelled on produx.design (design study, 2026-08-26). Their version is GSAP animating
   measured top/left/width/height — a FLIP — because the panel moves between two layouts. This
   one only ever grows within a flex row, and flex-grow is animatable, so the browser does the
   measuring and no library is needed. Same read, 0 KB.

   The half of their tween that is easy to miss: while the box opens, the image's
   object-position is counter-animated so the photograph holds still inside its moving frame.
   Drop that and the picture visibly slides as the panel opens. §17 does it with object-position
   on the same curve.

   Green is rationed to one panel at a time — the open one is the deep green, the closed ones
   are the band. That is composition rule 3, and it is also why this section can ship before any
   industry photographs exist: a panel with no image is a field, not a placeholder.

   Rule 4 still holds. A panel moves because the reader pointed at it. Nothing here moves on
   scroll, and nothing loops. */

.panel-row {
  display: flex;
  gap: 0.4rem;
  height: var(--panel-h);
  list-style: none;
  margin: 0;
  padding: 0;
}

.panel {
  flex: 1 1 0;
  min-width: 0;
  transition: flex-grow var(--panel-dur) var(--panel-ease);
}
.panel.is-open { flex-grow: var(--panel-grow); }

.panel__inner {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  overflow: hidden;
  border-radius: var(--radius-small);
  background: var(--paper-soft);
  text-decoration: none;
  color: inherit;
  transition: background var(--panel-dur) var(--panel-ease);
}
.panel.is-open .panel__inner { background: var(--green-deep); }
/* A photo panel keeps the photograph; the scrim below carries the contrast instead. */
.panel:not(.panel--field) .panel__inner,
.panel:not(.panel--field).is-open .panel__inner { background: var(--green-deep); }

/* The ring is §2's, green on the white section. Lifted over its neighbours so the part that
   crosses into the next panel is drawn on top of it rather than under it: the stacked layout
   (820px and below, and reduced motion at any width) leaves only a 2px gap, narrower than the
   ring's 3px offset. On top is not enough there, because the top and bottom of the ring then
   sit on the neighbours' dark scrim at about 1.2:1. So a stacked panel also lays a white mat
   two pixels wider than the ring while it is focused: the ring reads green on white on all four
   sides (7.3:1), and the mat covers 5px of each neighbour's edge only for as long as focus
   stays. A spread with no blur is a ring, not a cast shadow. Row width, 6.4px gaps: not needed. */
.panel__inner:focus-visible {
  z-index: 1;
}

@media (max-width: 820px), (prefers-reduced-motion: reduce) {
  .panel__inner:focus-visible {
    box-shadow: 0 0 0 calc(var(--focus-offset) + var(--focus-ring) + 2px) var(--paper);
  }
}

/* ---- photograph ---------------------------------------------------------- */
.panel__media { position: absolute; inset: 0; display: block; }
.panel__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The counter-move. A closed panel is a narrow slice, so it looks into the middle of the
     frame; opening walks object-position back to centre on the same curve the box uses, which
     is what stops the image sliding. */
  object-position: 42% 50%;
  transition: object-position var(--panel-dur) var(--panel-ease);
}
.panel.is-open .panel__media img { object-position: 50% 50%; }

.panel__scrim {
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* One layer, present only while there is text for it to serve (Brian, 2026-08-27, three
     rulings in a row: the full-face wash, then the left spine band, then the residual floor
     all read darker than the pictures are). A closed sliver is now the unshaded photograph —
     its label carries its own chip below — and the floor fades in with the open panel's copy
     block, on the panel's own curve. Which is composition rule 3 stated exactly: green is
     rationed to one panel at a time, the open one. */
  opacity: 0;
  transition: opacity var(--panel-dur) var(--panel-ease);
  background:
    linear-gradient(to top,
              rgb(var(--green-deep-rgb) / 0.90) 0%,
              rgb(var(--green-deep-rgb) / 0.42) 24%,
              rgb(var(--green-deep-rgb) / 0.10) 56%,
              rgb(var(--green-deep-rgb) / 0.02) 100%);
}
.panel.is-open .panel__scrim { opacity: 1; }

/* ---- closed: the spine --------------------------------------------------- */
.panel__spine {
  position: absolute;
  left: 0;
  bottom: 0;
  padding: 1.1rem 0 1.1rem 1.1rem;
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  font-weight: var(--fw-bold);
  font-size: var(--fs-small);
  line-height: 1.2;
  white-space: nowrap;
  color: var(--green-primary);
  transition: opacity 260ms var(--panel-ease), color var(--panel-dur) var(--panel-ease);
}
/* Over a photograph the spine stops borrowing darkness from the scrim and carries its own:
   the same chip treatment as .panel__cat, rotated. Margin holds the offsets the padding used
   to (a background would have filled that padding into a slab), and the chip's padding hugs
   the glyphs — 9px along the reading direction, 5px across it, .panel__cat's numbers turned
   ninety degrees. The photograph behind it stays untouched. */
.panel:not(.panel--field) .panel__spine {
  color: var(--paper);
  margin: 0 0 1.1rem 1.1rem;
  padding: 9px 5px;
  background: rgb(var(--green-primary-rgb) / 0.9);
  border-radius: var(--radius-small);
}
.panel.is-open .panel__spine { opacity: 0; transition-duration: 180ms; }

/* ---- open: the copy ------------------------------------------------------ */
.panel__open {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 1.3rem;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.5rem;
  opacity: 0;
  transform: translate3d(0, 10px, 0);
  pointer-events: none;
  transition: opacity 380ms var(--panel-ease), transform 520ms var(--panel-ease);
}
.panel.is-open .panel__open {
  opacity: 1;
  transform: none;
  pointer-events: auto;
  /* The copy waits for the box to be most of the way open, then arrives. Same idea as the
     entrance reveal in §16 — movement leads, the fade trails it — applied across two elements
     instead of within one. */
  transition-delay: 520ms, 480ms;
}

.panel__cat {
  font-size: var(--fs-eyebrow);
  font-weight: var(--fw-bold);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
  color: var(--paper);
  background: rgb(var(--green-primary-rgb) / 0.9);
  padding: 4px 9px;
  border-radius: var(--radius-small);
}
.panel__name {
  font-size: var(--fs-h3);
  font-weight: var(--fw-bold);
  line-height: var(--lh-heading);
  letter-spacing: var(--tracking-heading);
  color: var(--paper);
}
.panel__body {
  font-size: var(--fs-small);
  line-height: 1.5;
  color: var(--paper);
  max-width: 34ch;
}
.panel__cue {
  font-size: var(--fs-small);
  font-weight: var(--fw-bold);
  color: var(--paper);
  border-bottom: 1px solid rgb(var(--white-rgb) / 0.5);
  padding-bottom: 2px;
}

/* ---- narrow: stop being an accordion -------------------------------------
   Six closed panels in a phone width is about 30px each, which is not a panel. Below this the
   row becomes six stacked blocks with every one open, which is the same information without
   the interaction. */
@media (max-width: 820px) {
  .panel-row { flex-direction: column; height: auto; gap: 2px; }
  .panel, .panel.is-open { flex: none; height: 180px; }
  .panel__spine { display: none; }
  .panel__open {
    opacity: 1;
    transform: none;
    pointer-events: auto;
    transition-delay: 0s;
  }
  .panel__media img { object-position: 50% 50%; }
  /* Stacked cards put the copy block across the whole 180px card, so the open-only desktop
     scrim is not enough here — every card shows copy, so every card keeps the original
     full-face floor, always on. */
  .panel__scrim {
    opacity: 1;
    background: linear-gradient(to top,
                rgb(var(--green-deep-rgb) / 0.88) 0%,
                rgb(var(--green-deep-rgb) / 0.34) 46%,
                rgb(var(--green-deep-rgb) / 0.06) 100%);
  }
}

/* Rule 4's off switch. main.css §1 already kills the transition; this also removes the state
   the transition was carrying, so a reduced-motion reader gets the stacked list rather than a
   row where five of six panels are a 30px spine that never opens. */
@media (prefers-reduced-motion: reduce) {
  .panel-row { flex-direction: column; height: auto; gap: 2px; }
  .panel, .panel.is-open { flex: none; height: 180px; }
  .panel__spine { display: none; }
  .panel__open { opacity: 1; transform: none; pointer-events: auto; }
  /* Same stacked shape as the narrow layout, same reason: copy spans the card, so the
     scrim stays on. */
  .panel__scrim {
    opacity: 1;
    background: linear-gradient(to top,
                rgb(var(--green-deep-rgb) / 0.88) 0%,
                rgb(var(--green-deep-rgb) / 0.34) 46%,
                rgb(var(--green-deep-rgb) / 0.06) 100%);
  }
}

/* Never print a row where five of six panels are a spine. */
@media print {
  .panel-row { flex-direction: column; height: auto; }
  .panel, .panel.is-open { flex: none; height: auto; }
  .panel__spine { display: none; }
  .panel__open { position: static; opacity: 1; transform: none; }
}

/* ==========================================================================
   18. Evidence made visible — visual batch 1 (2026-09-29)
   ==========================================================================

   Brian called the build bland on 2026-09-28 and the audit measured why: 14 of 20 pages with no
   picture, the trial results set as gray paragraphs, one composition repeated 46 times. None of
   what follows is decoration. Each block draws something already published: a figure, a pair
   of before-and-after values, the steps of a test, a certificate, a photograph of the place.

   The four composition rules still hold. Green stays rationed — the result figures and the
   fact labels take it the way card titles and step numerals already do, and the closing band is
   still the page's only green surface (Brian's ruling 3 left the choice open; the results sit
   on the tint, in white cards, so buildcheck rule 2 is unchanged). Only actionable things move:
   the certificate thumbnails and the specimen link lift, the cards with no link do not. */

/* ---- Result cards (finding 1) ------------------------------------------- */
.results {
  list-style: none;
}

/* One card per row until there is room for two with a readable chart in each: at 700-899px two
   cards left the bar tracks 33px wide and ran the axis labels together. The column is
   minmax(0, 1fr) on a phone too: an `auto` column grows to its widest card's longest unbreakable
   line, and at a 24px default font that took the cards 17px past a 320px screen (41px in
   Indonesian) and scrolled the page sideways (2.6.7). The switch is in em, 900px at the default
   16px: at 24px two cards side by side wait for 1350px, where each card's text column is wide
   enough for the widest chart (at 900px they pushed it 30px out of its card). One column is
   written as the exact complement, so no width at any font size falls between the two. */
@media not all and (min-width: 56.25em) {
  .results.grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

@media (min-width: 56.25em) {
  .results.grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* Two cards side by side are one height, and their parts line up (Brian, 2026-10-02: "the
   left and right figures are not at the same size"). Each card spans five rows of the list's
   grid and lays its children on those rows (subgrid): tag, figure, the line under it, chart,
   method. A row is as tall as the taller of the pair, so the figures, the charts and the
   method's rule start level across the row whatever each one's length. The chart and the
   method are pinned to their rows, so a card missing one does not slide the other up; the
   2020 cotton trial, with neither, leaves those rows empty rather than filling them. */
.result {
  background: var(--paper);
  border: 1px solid var(--rule-soft);
  border-radius: var(--radius);
  padding: 1.6rem 1.5rem 1.4rem;
  display: grid;
  grid-row: span 5;
  grid-template-rows: subgrid;
  row-gap: 0.45rem;
  align-content: start;
}

/* A card with neither a chart nor a method — the 2020 cotton trial. Beside a neighbour it stays
   on the shared rows, so its year, figure and line sit level with the card next to it and the
   space it has no evidence for falls at its foot (Brian, 2026-10-02: centred, its text sat
   84-107px below its neighbour's). Stacked on its own below 900px it has no neighbour to line up
   with, and the two empty shared rows would only add their gaps, so it takes its own. The same
   width as the list's one column above. */
@media not all and (min-width: 56.25em) {
  .result:not(:has(> .result__chart, > .result__detail)) {
    grid-template-rows: none;
  }
}

/* Inline, not flex: on a narrow card the industry wraps as running text after the year, instead
   of dropping to a line of its own with the divider stranded in front of it. */
.result__tag {
  font-size: var(--fs-eyebrow);
  font-weight: var(--fw-bold);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
  color: var(--text);
  text-wrap: balance;   /* "…NYLON AND / POLYESTER" left one word alone on its second line */
}

/* A separator glyph, not a hairline: composition rule 1 keeps the two rule weights for section
   heads and card edges. */
.result__tag span + span::before {
  content: "·";
  margin: 0 0.5rem;
}

/* The figure is the point of the card: about 72px at desktop width, where the audit sketched
   it. The hedge the page publishes ("about", "up to about") rides in front at heading size,
   so the number never reads as more exact than the trial was. */
.result__figure {
  margin-top: 0.4rem;
  font-size: var(--fs-result);
  font-weight: var(--fw-bold);
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--green-primary);
}

.result__prefix {
  font-size: var(--fs-h3);
  letter-spacing: 0;
}

.result__what {
  font-size: var(--fs-h3);
  font-weight: var(--fw-bold);
  line-height: var(--lh-heading);
  color: var(--ink);
}

/* `start`: a table stretched to a taller row spreads its own rows apart. */
.result__chart {
  grid-row: 4;
  align-self: start;
  width: 100%;
  margin-top: 0.9rem;
  border-collapse: collapse;
  font-size: var(--fs-small);
}

.result__chart th,
.result__chart td {
  padding: 0.25rem 0;
  vertical-align: middle;
}

.result__chart th {
  width: 1%;
  padding-right: 0.9rem;
  font-weight: var(--fw-regular);
  text-align: left;
  white-space: nowrap;
  color: var(--text);
}

.bar__cell {
  width: 100%;
}

.bar__track {
  position: relative;
  display: block;
  height: 14px;
  border-radius: 3px;
  background: var(--paper-soft);
  overflow: hidden;
}

/* --from and --to come from app/styles.py, one rule per bar, as percentages of the chart's
   axis. A value bar starts at 0; a range bar (the 2020 confluence, 30–40% then 50–70%) floats. */
.bar__fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--from, 0%);
  width: calc(var(--to, 0%) - var(--from, 0%));
  border-radius: 3px;
  background: rgb(var(--green-primary-rgb) / 0.32);
}

.bar__fill--after {
  background: var(--green-primary);
}

.result__chart .bar__value {
  width: 1%;
  padding-left: 0.8rem;
  font-weight: var(--fw-bold);
  color: var(--ink);
  white-space: nowrap;
}

.bar__axis td {
  padding-top: 0;
  font-size: var(--fs-eyebrow);
  color: var(--text);
}

.bar__axis td span {
  display: inline-block;
  width: 50%;
}

.bar__axis td span:first-child {
  width: 25%;
}

.bar__axis td span:nth-child(2) {
  text-align: center;
}

.bar__axis td span:last-child {
  width: 25%;
  text-align: right;
}

/* A screen narrower than 22.4375em: 359px at the browser's default 16px, 538px at a 24px
   default. Each chart row puts its label and value on one line and the bar under them, the
   card's full width, and a value that cannot fit beside its label wraps under it rather than
   past the card. The widest chart, the Indonesian 2023 dairy trial on /wastewater ("Sesudah
   diolah", "kira-kira 1591 ppm"), needs a 263px text column with its bar at nothing: a 348px
   screen at 16px, 521px at 24px. On a 320px phone at 16px it ran 10px off the side, and at
   24px the cards ran up to 174px off a 320px screen (2.6.7). Change a chart's label or value and
   re-measure. The table keeps its rows and header cells: Chrome's accessibility tree reads
   the same table, two rows of row header, cell, cell. */
@media (max-width: 22.4375em) {
  .result__chart,
  .result__chart tbody {
    display: block;
  }
  .result__chart tr {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    align-items: baseline;
    column-gap: 0.8rem;
  }
  .result__chart th,
  .result__chart .bar__value {
    width: auto;
    padding-inline: 0;
  }
  .result__chart .bar__cell {
    order: 1;
    flex-basis: 100%;
    padding-top: 0;
  }
  .bar__axis td:empty {
    display: none;
  }
  .bar__axis td {
    flex-basis: 100%;
  }
}

/* A screen narrower than 20em: no phone at 16px (19.9375em is 319px), but a 320-478px phone
   at a 24px default font, where a card's text column is 222px. "2020·PENCELUPAN" has no break
   between the year, the dot and the first word, and needs 241px there, so the year takes its
   own line without the dot. The figure drops from 2.8rem to 2.2rem: at 2.8rem "50–70%" is 260px
   and broke after its dash, a range read as two numbers; at 2.2rem it is 204px (2.6.7). */
@media (max-width: 19.9375em) {
  .result__tag span {
    display: block;
  }
  .result__tag span + span::before {
    content: none;
  }
  .result__figure {
    font-size: 2.2rem;
  }
}

/* The rule above the method starts at the top of row 5, level with its neighbour's. An auto
   margin here would drop the shorter method to the row's foot and the two rules apart. */
.result__detail {
  grid-row: 5;
  margin-top: 1rem;
  padding-top: 0.9rem;
  border-top: 1px solid var(--rule-soft);
  font-size: var(--fs-small);
  color: var(--text);
}

.result__note {
  margin-top: 1.6rem;
  max-width: 76ch;
  font-size: var(--fs-small);
  color: var(--text);
}

/* ---- Certificate thumbnails in the proof band (finding 8) --------------- */
.doc-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 1rem 1.4rem;
  list-style: none;
}

.doc {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
  width: 100%;
  color: var(--link-color);
  font-size: var(--fs-small);
  font-weight: var(--fw-bold);
  transition: transform var(--dur) var(--ease);
}

/* Two across on a phone rather than stacked; the note under them takes the full row. */
.doc-row > li:not(.mark-row__note) {
  width: calc((100% - 1.4rem) / 2);
  max-width: 11.25rem;
}

/* A certificate is read, so it is contained, never cropped. */
.doc img {
  width: 100%;
  height: auto;
  aspect-ratio: 1.29;
  object-fit: contain;
  padding: 0.45rem;
  background: var(--paper);
  border: 1px solid var(--rule-soft);
  border-radius: 10px;
  transition: border-color var(--dur) var(--ease);
}

a.doc:hover {
  transform: translateY(-2px);
}

a.doc:hover img {
  border-color: var(--gray-30);
}

/* Scans left, figures right, the two halves one height (Brian, 2026-10-02: the left and
   right "just doesn't look pleasant"). Three things made it lopsided: the note stood under the
   scans and doubled their column; the three figures ran across, 120px tall beside 350px; and
   the columns were not the section head's, so the figures started 30px left of the intro
   above them. Now the columns are the head's 5fr 7fr, the note runs under both (stat_band.html),
   and the figures stack as three rows, a number and its label, sharing the scans' height.

   Side by side from 700px, not 1020px (the review of 2026-10-02): between the two, the scans
   stood alone over a 300-400px empty half and the figures dropped below as a row. Halves of
   equal width while the head above is stacked, its 5fr 7fr once the head splits at 860px. */
@media (min-width: 700px) {
  .proof-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 2rem;
    align-items: stretch;
  }
  .proof-row > ul.stats[data-cols] {
    margin-top: 0;
    grid-template-columns: minmax(0, 1fr);
    grid-auto-rows: 1fr;
    gap: 0;
  }
  .proof-row .stat {
    display: grid;
    grid-template-columns: 4.5rem minmax(0, 1fr);
    align-items: center;
    column-gap: 1rem;
    padding-block: 0.9rem;
    border-top: 1px solid var(--rule-soft);
  }
  .proof-row .stat:last-child {
    border-bottom: 1px solid var(--rule-soft);
  }
}

/* The scans fill their column once it is wide enough: capped at 180px they left 57px unused at
   1440 and wrapped "AATCC PTP4 participation" to three lines beside the other's two. */
@media (min-width: 860px) {
  .proof-row {
    grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
    gap: 3rem;
  }
  .proof-row .stat {
    grid-template-columns: 5.5rem minmax(0, 1fr);
  }
  .proof-row .doc-row > li:not(.mark-row__note) {
    max-width: none;
  }
}

.proof-row__note {
  margin-top: 2rem;
  max-width: 76ch;
  font-size: var(--fs-small);
  color: var(--text);
}

.doc span {
  text-wrap: balance;
}

.doc__num {
  display: block;
  white-space: nowrap;
}

/* ---- Photographs in card rows and step rows (finding 4) ----------------- */
.card-grid--media .card {
  padding-top: 0;
  overflow: hidden;
}

.card__media {
  margin: 0 -1.4rem 0.55rem;
  aspect-ratio: 3 / 2;
  background: var(--paper-soft);
  overflow: hidden;
}

.card__media picture,
.step__media picture {
  display: block;
  height: 100%;
}

.card__media img,
.step__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--focal, 50% 50%);
}

.card__sep {
  color: var(--text);
  font-weight: var(--fw-regular);
}

.card__num time {
  font-weight: var(--fw-regular);
  color: var(--text);
}

/* A step with a picture or a drawing becomes a card, so the picture has an edge to sit in. */
.steps--media .step {
  display: flex;
  flex-direction: column;
  padding: 0 0 1.3rem;
  border: 1px solid var(--rule-soft);
  border-radius: var(--radius);
  background: var(--paper);
  overflow: hidden;
}

.steps--media .step > :not(.step__drawing):not(.step__media) {
  margin-inline: 1.3rem;
}

.step__media {
  aspect-ratio: 3 / 2;
  margin-bottom: 1.1rem;
  background: var(--paper-soft);
}

/* ---- The drawn test principle (finding 2) ------------------------------- */
.step__drawing {
  margin-bottom: 1.1rem;
  padding: 1.1rem 1.4rem;
  background: var(--paper-soft);
  border-bottom: 1px solid var(--rule-soft);
}

.drawing {
  display: block;
  width: 100%;
  max-width: 190px;
  height: auto;
  margin-inline: auto;
}

/* The legend under the row names the two conventions: treated is solid green, the untreated
   control is dashed grey. Grey here is a line, not text, so Gray 70 is well past the 3:1 a
   graphic needs. */
.drawing .d-t { stroke: var(--green-primary); }
.drawing .d-c { stroke: var(--gray-70); stroke-dasharray: 4 4; }
.drawing .d-ink { stroke: var(--gray-70); }
.drawing .d-thin { stroke-width: 1.3; }
.drawing .d-tint { fill: rgb(var(--green-primary-rgb) / 0.08); }
.drawing .d-dot { fill: var(--green-primary); stroke: none; }
.drawing .d-dot-line { stroke: var(--green-primary); stroke-width: 2.6; stroke-dasharray: 0.1 6; }

/* ---- Method fact strip (finding 2) --------------------------------------- */
/* Tight under the hero's definition, as part of the opening rather than a section of its own. */
.facts-band {
  padding-top: 0;
}

/* Hairlines between the cells are the grid's 1px gap showing the edge colour through. */
.facts {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1px;
  background: var(--rule-soft);
  border: 1px solid var(--rule-soft);
  border-radius: var(--radius);
  overflow: hidden;
}

.fact {
  padding: 1rem 1.1rem 1.1rem;
  background: var(--paper-soft);
}

.fact:last-child:nth-child(odd) {
  grid-column: 1 / -1;
}

.fact dt {
  margin-bottom: 0.45rem;
  font-size: var(--fs-eyebrow);
  font-weight: var(--fw-bold);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
  color: var(--green-primary);
}

.fact dd b {
  display: block;
  color: var(--ink);
  line-height: 1.35;
}

.fact dd span {
  display: block;
  margin-top: 0.25rem;
  font-size: var(--fs-small);
  line-height: 1.45;
  color: var(--text);
}

/* One fact per row below 22.4375em (359px at 16px, 538px at 24px), the result charts' narrow
   screen, and three to five across from 56.25em (900px at 16px, 1350px at 24px). The strip is
   overflow:hidden, so a fact wider than its column was not scrolled to but cut off: at a 24px
   default font "Staphylococcus aureus" is 251px with its padding, and the right-hand facts lost
   up to 155px from 320px to 472px wide, and again at 904-1024px with five across. Two across
   need about 20.7em, 331px at 16px, where the right-hand fact on /lab/aatcc-100 already lost 11px
   of its padding on a 320px phone (2.6.7). */
@media (max-width: 22.4375em) {
  .facts { grid-template-columns: 1fr; }
  .fact:last-child:nth-child(odd) { grid-column: auto; }
}

@media (min-width: 56.25em) {
  .facts[data-count="3"] { grid-template-columns: repeat(3, 1fr); }
  .facts[data-count="4"] { grid-template-columns: repeat(4, 1fr); }
  .facts[data-count="5"] { grid-template-columns: repeat(5, 1fr); }
  .fact:last-child:nth-child(odd) { grid-column: auto; }
}

/* ---- The specimen report, one click from a method page (finding 2) ------ */
.specimen-link {
  display: flex;
  align-items: center;
  gap: 1rem;
  max-width: 30rem;
  padding: 0.8rem 1rem 0.8rem 0.8rem;
  border: 1px solid var(--rule-soft);
  border-radius: var(--radius);
  background: var(--paper);
  color: inherit;
  transition: transform var(--dur) var(--ease), border-color var(--dur) var(--ease);
}

.specimen-link:hover {
  transform: translateY(-2px);
  border-color: var(--gray-30);
  text-decoration: none;
}

.specimen-link picture {
  flex: none;
}

.specimen-link img {
  width: 64px;
  height: auto;
  border: 1px solid var(--rule-soft);
  border-radius: 3px;
}

.specimen-link b {
  display: block;
  color: var(--link-color);
}

.specimen-link span span {
  display: block;
  margin-top: 0.15rem;
  font-size: var(--fs-small);
  color: var(--text);
}

/* ---- Articles (findings 6 and 7) ----------------------------------------- */
/* Measured on the running site: a 33.6px heading with 0px above and 0px below, closer to the
   paragraph before it than paragraphs are to each other. These two rules are the audit's own,
   tested there by injection on all three articles; `.prose h2` occurs nowhere else. */
.prose h2 {
  margin-top: 2.6rem;
  margin-bottom: 0.7rem;
}

.prose > h2:first-child {
  margin-top: 0;
}

.prose-figure {
  margin-block: 2rem 2.2rem;
}

.prose-figure img {
  width: 100%;
  height: auto;
  border: 1px solid var(--rule-soft);
  border-radius: var(--radius-small);
}

.prose-figure figcaption {
  margin-top: 0.6rem;
  font-size: var(--fs-small);
  color: var(--text);
}

/* A portrait drawing at the full measure would run 800px tall; it is read at a smaller size. */
.prose-figure--narrow {
  max-width: 26rem;
}

/* A table in an article scrolls in its own box on a narrow screen, like the method table: the
   markdown filter wraps every table in .table-scroll (app/content.py). */
.prose .table-scroll {
  margin-top: 1.2rem;
}

.prose .table-scroll table {
  margin-top: 0;
}

/* Links inside small gray type need more than colour to be found (WCAG 1.4.1): the green on
   Gray 70 is 1.4:1 as a colour difference, far under the 3:1 a colour-only cue needs. */
.result__note a,
.result__detail a,
.fact a,
p.small a {
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* ---- Phone (finding 14) -------------------------------------------------- */
/* Stat figures two across on a phone: six stacked stats were 483px, two across 335px. With an
   odd count the last one takes the full row rather than sitting alone in half of it. */
/* Three figures three across from 700px, not two and a stranded third. */
@media (min-width: 700px) {
  .stats[data-cols="3"]:not(:has(> :nth-child(4))) {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media (max-width: 699px) {
  .stats {
    grid-template-columns: 1fr 1fr;
    gap: 1.1rem 1rem;
  }
  .stats > :last-child:nth-child(odd) {
    grid-column: 1 / -1;
  }
}

/* Under 20em, where a result card's year takes its own line (§ result cards). Half
   a 320px phone at a 24px default font is 121px, narrower than "tersertifikasi" at 0.9rem, and
   the two columns grew past the screen: one figure per row there. Card and button side padding
   go back to about the pixels they have on a 320px phone at 16px, so the text, not the padding,
   takes the width the larger font needs: "Message us on WhatsApp" could not wrap narrower than
   240px, its card's text column was 198px, and the contact page ran 16px off the side (2.6.7). */
@media (max-width: 19.9375em) {
  .stats {
    grid-template-columns: 1fr;
  }
  .card,
  .result {
    padding-inline: 0.9rem;
  }
  /* A card's photograph bleeds to its edges by the card's own side padding. */
  .card__media {
    margin-inline: -0.9rem;
  }
  .btn {
    padding-inline: 1rem;
  }
}

/* The methods table on a phone: method and what it measures, with the typical products moved
   under the method name. Each copy is display:none where the other shows, so a screen reader
   meets the products exactly once at either width. */
.tbl__sub {
  display: none;
}

@media (max-width: 599px) {
  .tbl .tbl__wide {
    display: none;
  }
  .tbl__sub {
    display: block;
    margin-top: 0.3rem;
    font-size: var(--fs-small);
    color: var(--text);
  }
}
