/** Shopify CDN: Minification failed

Line 1034:69 Expected identifier but found whitespace
Line 1035:3 Unexpected "\"Yellow text\""

**/
/* LoFli section and component styling.
   Hand-authored: this file never passes through the build.

   Loads AFTER base.css and is LoFli's alone. base.css is inherited from
   grounded-shopify-base and backported both ways (docs/CONTRIBUTING.md), so
   brand-specific rules must not live there — see .grounded/design-audit.md.

   Everything here references the SEMANTIC tokens in lofli-tokens.css, never a
   primitive. `var(--text-primary)`, never `var(--color-neutral-900)`. */

/* ---------------------------------------------------------------- surfaces
   A surface is a fill plus the text colour that is legible on it. Pairing them
   here is what keeps white off yellow: --text-on-brand is black by definition,
   so no section can get it wrong by forgetting the rule in docs/BRAND.md.

   `inverse` is the exception — it carries data-theme="inverse" in the markup,
   which re-points every semantic token, so it needs no colours of its own. */
.gw-surface--page    { background: var(--bg-page);    color: var(--text-primary); }
.gw-surface--inverse { background: var(--bg-page);    color: var(--text-primary); }
.gw-surface--brand   { background: var(--bg-brand);   color: var(--text-on-brand);  --gw-btn-outline-hover: var(--action-primary-hover); }
.gw-surface--accent  { background: var(--bg-accent);  color: var(--text-on-accent); --gw-btn-outline-hover: var(--bg-accent-sunken); }

/* On a brand or accent fill, headings and body inherit rather than falling back
   to the base theme's --color-text, which is set from the merchant colour scheme
   and knows nothing about these fills. */
.gw-surface--brand :is(h1, h2, h3, h4, p, li),
.gw-surface--accent :is(h1, h2, h3, h4, p, li) { color: inherit; }

/* ------------------------------------------------------------- nav ink
   The nav is pinned and transparent, so it has no colour of its own — it
   borrows the ink declared by whatever surface is passing under it. Each
   surface names its own ink here, which is why src/runtime/nav.js can copy a
   computed value and never learn a colour.

   Note these are deliberately NOT the surface's own text colour. Nav ink is
   brand voice at display scale over a fill the nav does not own: on black the
   wordmark goes yellow, not white, and on cyan it goes white, not the black
   that body copy takes. Adam's call, 2026-09-16.

   --gw-nav-ink-contrast is what sits *behind* the ink, which for a surface is
   just its own fill. The cart count needs it: a filled pill cannot take the
   ink and stay legible, so it flips — ink for the pill, the fill for the
   digits. Over black that is a yellow pill with black digits, which is where
   the comp drew it. */
.gw-surface--page    { --gw-nav-ink: var(--text-primary);      --gw-nav-ink-contrast: var(--bg-page); }
.gw-surface--inverse { --gw-nav-ink: var(--color-yellow-500);  --gw-nav-ink-contrast: var(--bg-page); }
/* QA 2026-10-07: digits black on a yellow or white icon, white on a black one
   (the black state was the one the team called right) — so a black ink pairs
   with white digits here, not the surface's own yellow/cyan. */
.gw-surface--brand   { --gw-nav-ink: var(--color-neutral-900); --gw-nav-ink-contrast: var(--color-white); }
.gw-surface--accent  { --gw-nav-ink: var(--color-white);       --gw-nav-ink-contrast: var(--color-neutral-900); }

/* Full-bleed imagery and the three.js stage are not surfaces — nothing in the
   token system knows what colour a photograph is — so those sections opt out
   explicitly and name their ink. One class against one attribute is equal
   specificity, so these MUST stay below the surface rules above: an explicit
   opt-out has to beat the fill the section happens to carry. */
[data-gw-nav-ink='black']  { --gw-nav-ink: var(--color-neutral-900); --gw-nav-ink-contrast: var(--color-white); }
[data-gw-nav-ink='yellow'] { --gw-nav-ink: var(--color-yellow-500);  --gw-nav-ink-contrast: var(--color-neutral-900); }
[data-gw-nav-ink='white'],
[data-gw-nav-ink='media']  { --gw-nav-ink: var(--color-white);       --gw-nav-ink-contrast: var(--color-neutral-900); }

/* The About page's dark sections declare data-gw-nav-ink="white" (see
   sections/hero.liquid and sections/story-collage.liquid) — correct while
   #main is dark, wrong once src/runtime/surface-swap.js has put it on the
   brand surface. This has to outrank the bare attribute rule above it, not
   just override it in source order: a class (#main.gw-page-brand) plus the
   same attribute is higher specificity than the attribute alone, which is
   the ordering the comment above the bare rules already establishes — an
   explicit opt-out beats the surface, and this is a second, later opt-out
   layered on top of that first one. Values match .gw-surface--brand's own
   nav ink pair above; nothing new is invented. */
#main.gw-page-brand [data-gw-nav-ink='white'] {
  --gw-nav-ink: var(--color-neutral-900);
  --gw-nav-ink-contrast: var(--color-white);
}

/* The page scrollbar is hidden site-wide — Adam's call, QA 2026-09-29. The
   About entrance holds scroll with overflow:hidden, and a scrollbar that
   vanished and came back shifted the whole page each time. Scrolling itself
   (wheel, touch, keys) is untouched; inner scrollers keep their own bars. */
html { scrollbar-width: none; }
html::-webkit-scrollbar { display: none; }

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

/* ------------------------------------------------------------- typography
   The 20 text styles from the Figma Style Guide (`10:2`), transcribed 1:1.
   Verified against Dev Mode: line-height is a unitless multiplier and tracking
   is a PERCENTAGE of the size, so Heading/H1's "letterSpacing: -4" is -0.04em,
   which Dev Mode renders as -2.56px at 64px. Confirmed on three styles before
   transcribing the rest.

   Two rules these encode:

   1. A class sets the LOOK, never the level. `.gw-type-h1` on an <h2> is
      correct and common — a page has one <h1> but many places want that size.
      base.css makes the same point about .gw-type-h*.
   2. Sizes at or above 32px are fluid. The comps exist at 1440 and 390 and
      NOTHING at 768 (every Tablet artboard in the file is empty), so the ramp
      between them is interpolated here rather than copied. Below 32px the size
      is fixed — body text that shrinks with the viewport is just harder to read.

   The clamp midpoints are derived, not guessed: a style at S px in a 1440 comp
   is S/14.4 vw, which holds the proportion the designer drew. */

.gw-type-display-xl {
  font-family: var(--font-family-condensed);
  font-weight: 700;
  font-size: clamp(var(--font-size-96), 18vw, var(--font-size-260));
  line-height: 0.65;
  letter-spacing: -0.03em;
  margin: 0;
}
.gw-type-display-l {
  font-family: var(--font-family-display);
  font-weight: 900;
  font-size: clamp(var(--font-size-40), 6.7vw, var(--font-size-96));
  line-height: 0.81;
  letter-spacing: -0.04em;
  margin: 0;
}

.gw-type-h1 {
  font-family: var(--font-family-display);
  font-weight: 900;
  font-size: clamp(var(--font-size-32), 4.4vw, var(--font-size-64));
  line-height: 0.9;
  letter-spacing: -0.04em;
  margin: 0;
}
.gw-type-h2 {
  font-family: var(--font-family-display);
  font-weight: 900;
  font-size: clamp(var(--font-size-32), 3.3vw, var(--font-size-48));
  line-height: 0.87;
  letter-spacing: -0.04em;
  margin: 0;
}
/* H3 and H4 are the CONDENSED family — Alumni Sans ExtraBold — not Albert Sans.
   The scale deliberately switches voice at this level; H3 is also LARGER than
   H2 (64 vs 48), because it is a different treatment, not a smaller step. */
.gw-type-h3 {
  font-family: var(--font-family-condensed);
  font-weight: 800;
  font-size: clamp(var(--font-size-40), 4.4vw, var(--font-size-64));
  line-height: 0.62;
  letter-spacing: -0.02em;
  margin: 0;
}
.gw-type-h4 {
  font-family: var(--font-family-condensed);
  font-weight: 800;
  font-size: var(--font-size-32);
  line-height: 0.95;
  letter-spacing: -0.02em;
  margin: 0;
}
.gw-type-h5 {
  font-family: var(--font-family-display);
  font-weight: 900;
  font-size: var(--font-size-24);
  line-height: 0.87;
  letter-spacing: -0.04em;
  margin: 0;
}
.gw-type-h6 {
  font-family: var(--font-family-display);
  font-weight: 900;
  font-size: var(--font-size-20);
  line-height: 1.05;
  letter-spacing: -0.04em;
  margin: 0;
}

.gw-type-body {
  font-family: var(--font-family-display);
  font-weight: 600;
  font-size: var(--font-size-16);
  line-height: 1.1;
  letter-spacing: -0.04em;
}
.gw-type-body-black {
  font-family: var(--font-family-display);
  font-weight: 900;
  font-size: var(--font-size-16);
  line-height: 0.87;
  letter-spacing: -0.04em;
}
.gw-type-body-condensed {
  font-family: var(--font-family-condensed);
  font-weight: 700;
  font-size: var(--font-size-32);
  line-height: 0.74;
  letter-spacing: 0.03em;
}
.gw-type-body-sm {
  font-family: var(--font-family-display);
  font-weight: 300;
  font-size: var(--font-size-14);
  line-height: 1;
  letter-spacing: -0.03em;
}
.gw-type-caption {
  font-family: var(--font-family-display);
  font-weight: 400;
  font-size: var(--font-size-12);
  line-height: 1.4;
  letter-spacing: 0.01em;
}

.gw-type-button {
  font-family: var(--font-family-display);
  font-weight: 600;
  font-size: var(--font-size-16);
  line-height: 1.2;
  letter-spacing: 0.01em;
}
.gw-type-eyebrow {
  font-family: var(--font-family-condensed);
  font-weight: 800;
  font-size: var(--font-size-16);
  line-height: 1.2;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  margin: 0;
}

/* Numerics are the condensed family at three sizes. Figma names the smallest
   "Numeriuc XS" — a typo in the style name, not reproduced here. */
.gw-type-stat {
  font-family: var(--font-family-condensed);
  font-weight: 700;
  font-size: clamp(var(--font-size-40), 4.4vw, var(--font-size-64));
  line-height: 0.95;
}
.gw-type-stat-sm {
  font-family: var(--font-family-condensed);
  font-weight: 700;
  font-size: clamp(var(--font-size-32), 3.3vw, var(--font-size-48));
  line-height: 0.95;
}
.gw-type-stat-xs {
  font-family: var(--font-family-condensed);
  font-weight: 700;
  font-size: var(--font-size-24);
  line-height: 0.95;
}

/* Micro type is tracked wide and set in caps. Micro/6 uses Regular rather than
   Light because Light is illegible at 6px — that decision is in docs/TOKENS.md. */
.gw-type-micro-12 {
  font-family: var(--font-family-condensed);
  font-weight: 300;
  font-size: var(--font-size-12);
  line-height: 1.3;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.gw-type-micro-6 {
  font-family: var(--font-family-condensed);
  font-weight: 400;
  font-size: var(--font-size-6);
  line-height: 1.3;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

/* ----------------------------------------------------------------- button
   Three variants. The primary is the "goo" button from Figma 235:157: the body
   hugs its label and a fixed end cap carries the pill cap and knob. The two
   halves must share a baseline and a height, so the whole thing is an
   inline-flex with the cap as a rigid sibling rather than a pseudo-element. */
.gw-btn {
  /* type: .gw-type-button, applied by the snippet */
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  min-height: 32px;
  padding-inline: var(--space-16);
  border: var(--border-thick) solid transparent;
  border-radius: var(--radius-full);
  text-decoration: none;
  cursor: pointer;
  transition: background-color .18s ease, color .18s ease, border-color .18s ease;
}

.gw-btn--full { width: 100%; justify-content: center; }

.gw-btn--primary {
  background: var(--action-primary-default);
  color: var(--text-on-brand);
  border-color: var(--action-primary-default);
}
.gw-btn--primary:hover { background: var(--action-primary-hover); border-color: var(--action-primary-hover); }
.gw-btn--primary:active { background: var(--action-primary-pressed); border-color: var(--action-primary-pressed); }

.gw-btn--secondary {
  background: var(--action-secondary-default);
  color: var(--text-primary);
  border-color: var(--border-strong);
}
.gw-btn--secondary:hover { background: var(--action-secondary-hover); }
.gw-btn--secondary:active { background: var(--action-secondary-pressed); }

.gw-btn--tertiary {
  background: transparent;
  color: var(--text-brand);
  border-color: var(--text-brand);
}
/* The hover fill is brand yellow (Adam's call, 2026-09-16, matching Figma). The
   label must flip to black with it: --text-brand is yellow/500 in inverse mode,
   which would be yellow on yellow. --text-on-brand is the pairing that exists
   precisely so this cannot be got wrong. */
.gw-btn--tertiary:hover {
  background: var(--action-tertiary-hover);
  color: var(--text-on-brand);
  border-color: var(--action-tertiary-hover);
}

/* For a button sitting on a photograph or any surface whose colour the button
   cannot know. Borrows currentColor from the card rather than naming a token, so
   it stays legible whatever the scrim resolves to. The comp binds this border to
   --border-focus, which resolves to a near-white there; that looks like a slip in
   Figma, and currentColor expresses the intent without inheriting the mistake. */
.gw-btn--inverse {
  background: transparent;
  color: inherit;
  border-color: currentColor;
}
.gw-btn--inverse:hover { background: rgb(255 255 255 / 0.14); }

/* Outline — QA 2026-09-29 (Adam; comps 210:1448, 261:2164, 261:2247). Black
   stroke and label with NO fill at rest; on hover it fills with the surface's
   own "a shade deeper": primary-hover on yellow, accent-sunken on cyan.

   Stroke and label are currentColor, not --border-strong: the showcase card
   sits inside an inverse section, where --border-strong is white, but its
   panel sets `color` to the ink that belongs on it. The hover fill comes from
   --gw-btn-outline-hover, which the SURFACE sets — the distance band swaps its
   surface class at runtime, so the call site cannot know which fill applies. */
.gw-btn--outline {
  background: transparent;
  color: inherit;
  border-color: currentColor;
}
.gw-btn--outline:hover,
.gw-btn--outline:focus-visible,
.gw-card:hover .gw-btn--outline,
.gw-showcase-card:hover .gw-btn--outline {
  background: var(--gw-btn-outline-hover, var(--action-primary-hover));
  /* Both hover fills are brand-family shades, so the ink is the on-brand
     pairing — black by definition in either theme (CLAUDE.md: semantic tokens). */
  color: var(--text-on-brand);
  border-color: var(--text-on-brand);
}

.gw-btn:is([disabled], [aria-disabled='true']) {
  background: var(--action-primary-disabled);
  border-color: var(--action-primary-disabled);
  color: var(--text-disabled);
  pointer-events: none;
}

.gw-btn__end { display: inline-flex; }
.gw-btn__end .gw-icon { width: var(--size-icon-md); height: var(--size-icon-md); }

/* No focus variant by design — the browser ring plus --border-focus is the focus
   state (docs/TOKENS.md). :focus-visible, so a mouse click does not draw it. */
.gw-btn:focus-visible {
  outline: var(--border-thick) solid var(--border-focus);
  outline-offset: 2px;
}

/* -------------------------------------------------------------------- icon
   Generated from Figma by scripts/build-icons.mjs. The glyph inherits
   currentColor, so it needs no colour of its own; only a size.

   1em by default, so an icon beside a label matches the label's size wherever it
   is used. The modifiers are the Figma sizing tokens for the cases that need a
   fixed box regardless of the surrounding type. */
.gw-icon { width: 1em; height: 1em; display: block; flex: none; }
.gw-icon--sm { width: var(--size-icon-sm); height: var(--size-icon-sm); }
.gw-icon--md { width: var(--size-icon-md); height: var(--size-icon-md); }
.gw-icon--lg { width: var(--size-icon-lg); height: var(--size-icon-lg); }

/* ------------------------------------------------------------------ badge */
.gw-badge {
  /* type: .gw-type-caption, applied by the snippet */
  display: inline-flex;
  align-items: center;
  padding: var(--space-4) var(--space-8);
  border-radius: var(--radius-xs);
}
.gw-badge--neutral { background: var(--bg-inverse);           color: var(--text-inverse); }
.gw-badge--brand   { background: var(--bg-brand);             color: var(--text-on-brand); }
.gw-badge--success { background: var(--feedback-success-bg);  color: var(--feedback-success-text); }
.gw-badge--warning { background: var(--feedback-warning-bg);  color: var(--feedback-warning-text); }
.gw-badge--error   { background: var(--feedback-error-bg);    color: var(--feedback-error-text); }

/* ------------------------------------------------------------ container
   Every inline content block sits in .gw-container: at most the Page width
   setting wide (1920 by default), with 48px margins inside it — so below a
   1920 window the content is exactly 48 from each edge, and above it the extra
   width goes to the margins, not the content. max-width comes from base.css
   via --page-width; LoFli sets the margin. Phones take the mobile comps' 24.

   The gutter is a variable because things that are not containers have to line
   up with one — the About page's path layer is inset by exactly this much, and
   hard-coding 48 there would leave it wider than the copy on a phone. */
:root { --gw-gutter: var(--space-48); }
@media (max-width: 749px) {
  :root { --gw-gutter: var(--space-24); }
}
.gw-container { padding-inline: var(--gw-gutter); }

/* Running text inside a full-width container is held to a readable measure;
   the container sets where the column starts, not how long its lines run. */
.gw-prose { max-width: 72ch; }

/* -------------------------------------------------------------- spec chip */
.gw-spec { display: flex; align-items: center; gap: var(--space-4); }
.gw-spec__icon { width: var(--size-icon-lg); height: var(--size-icon-lg); flex: none; }
/* The file is fetched at 2x for sharpness, so it has to be told to fit the box. */
.gw-spec__icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
.gw-spec__text { display: flex; flex-direction: column; gap: var(--space-4); }
.gw-spec__value {
  /* type: .gw-type-h6 — Heading/H6 is exactly this style */
  text-transform: uppercase;
}
.gw-spec__label {
  /* type: .gw-type-micro-12 */
  text-transform: uppercase;
}

/* ------------------------------------------------------- media with text
   The two-column rule lives in base.css and keys off :has(), so it still
   applies. What is added here is the stacking of multiple rows and the LoFli
   type treatment. */
.gw-media-text { padding-block: var(--space-96); }
.gw-media-text__rows { display: grid; gap: var(--space-64); }
.gw-media-text__heading { color: var(--text-brand); }
.gw-media-text__text { display: flex; flex-direction: column; gap: var(--space-24); }
/* Body and spec chip are one group in the comp, 16 apart; the heading sits 24
   above the group. */
.gw-media-text__body + .gw-media-text__specs,
.gw-media-text__body + .gw-spec { margin-top: calc(var(--space-16) - var(--space-24)); }

/* Figma 244:2236 — image and text columns in the comp's 564:410 proportion,
   64 apart, the text hanging from the image's top edge. Proportional tracks
   across the full container (see .gw-container), so the pair keeps the comp's
   balance at every width rather than sitting as a fixed 1038 block. Same
   selector as base.css's two-column rule, so it replaces it rather than
   fighting it on specificity. */
.gw-media-text__inner:has(.gw-media-text__media):has(.gw-media-text__text) {
  grid-template-columns: minmax(0, 564fr) minmax(0, 410fr);
  gap: var(--space-64);
  align-items: start;
}
.gw-media-text--right:has(.gw-media-text__media):has(.gw-media-text__text) {
  grid-template-columns: minmax(0, 410fr) minmax(0, 564fr);
}

/* Every image takes the comp's 564x260 frame, cropped, whatever shape the
   photo is — so the two rows line up however different their sources. */
.gw-media-text__media { aspect-ratio: 564 / 260; border-radius: var(--radius-xl); overflow: hidden; }
.gw-media-text__media img { display: block; width: 100%; height: 100%; object-fit: cover; }

/* The model slot reserves no space: the ball is painted over the row by the
   shared stage, and giving it layout would push the text off the comp. */
.gw-media-text__model { position: absolute; inset: 0; pointer-events: none; }

/* Homepage Info rows: shorter lines (QA 2026-10-07 — "its margin 70% of the
   image"). The column is already ~73% of the image, so it is the text block
   inside it that narrows. */
@media (min-width: 750px) {
  .template-index .gw-media-text__text { max-inline-size: 70%; }
}

/* A text-only row (About's "Hi, my name is Evan") has no column to sit in and
   ran the full container as one long line. The collage's own measure, centred
   (QA 2026-09-25 A2, 2026-10-07). */
.gw-media-text__inner:not(:has(.gw-media-text__media)) .gw-media-text__text { max-inline-size: 26rem; margin-inline: auto; }
.gw-media-text__inner { position: relative; }

@media (max-width: 749px) {
  .gw-media-text { padding-block: var(--space-64); }
  /* One column again. The desktop rule above shares base.css's selector and
     comes later, so base's own single-column fallback no longer reaches. */
  .gw-media-text__inner:has(.gw-media-text__media):has(.gw-media-text__text),
  .gw-media-text--right:has(.gw-media-text__media):has(.gw-media-text__text) {
    grid-template-columns: 1fr;
    gap: var(--space-24);
  }
}

/* ------------------------------------------------------------- statement */
.gw-statement { position: relative; text-align: center; }
.gw-statement__inner { display: grid; gap: var(--space-24); justify-items: center; }
.gw-statement__text {
  /* type: .gw-type-display-xl — Alumni Sans Bold 260/0.65/-3%, confirmed
     against Figma 197:146 rather than assumed. Uppercase is applied on the
     node in the comp, not by the text style, so it lives here. */
  text-transform: uppercase;
}
.gw-statement__subline {
  /* type: .gw-type-body */
  margin: 0;
}
.gw-statement__model { position: absolute; inset: 0; pointer-events: none; }

/* The ball's park anchor (sections/statement.liquid, data-gw-ball-park). It is
   deliberately NOT a .gw-model (Ruling A — hooking the section's own outer
   element would make the ball GROW as it parks, since dom-sync lerps size
   from min(width, height) of each anchor and this section is huge), so it
   does not inherit base.css's `.gw-model { aspect-ratio: 1; }`. Without an
   explicit height rule here, dom-sync would read this anchor's rect as full
   width but zero height and render the ball at true scale 0 — present in the
   scene, invisible, no error anywhere (docs/WEBGL.md). This aspect-ratio is
   the height half of that contract; Task 5 owns the width/position rules for
   .gw-ball-anchor and .gw-ball-anchor--park and must not re-derive height
   from width some other way here. */
.gw-ball-anchor--park { aspect-ratio: 1; }

/* Task 5: how big the ball reads at each end of its journey. The travelling
   binding (dom-sync) lerps min(width, height) between the start anchor's rect
   and the park anchor's rect, so THESE SIZES ARE the "shrinks as it parks"
   decision — there is no separate scale setting anywhere in the pipeline.

   .gw-ball-anchor is the START anchor: hero.liquid renders it via
   model-proxy with `class: 'gw-ball-anchor'`, alongside the required
   .gw-model, so it keeps base.css's `.gw-model { aspect-ratio: 1; }` for its
   height (untouched by this rule) and gains a width here — .gw-model alone
   has none, and as a grid item of .gw-hero it would otherwise stretch to the
   section's full width. margin-inline: auto centers it, since a grid item
   with an explicit width no longer stretches to fill its column but is still
   placed at the column's start edge by default.

   .gw-ball-anchor--park adds ONLY a width, additive to the aspect-ratio rule
   immediately above (Ruling 1) — that rule stays exactly as Task 4 left it. */
/* Both anchors are OVERLAYS, not flow content — corrected 2026-09-21 against
   Figma `33:5`. In the comp the ball is drawn ON TOP of each band: the hero
   (`197:93`) is 1440x810 with the ball `197:58` at 214.345x216.493, centred and
   with its own centre at y=405 — exactly 50% — and the banner (`206:674`) is
   1440x550 with the ball `197:148` centred at y=250.56 within it, 45.6% down.
   Neither band's height makes room for the ball; it sits over the headline.

   Shipped first as normal-flow blocks, which silently added ~240px of band
   height the comp does not have and pushed the ball off its mark. Absolute
   positioning is what keeps the two bands at their comp heights while
   dom-sync still reads a real rect from getBoundingClientRect(), which
   accounts for both the offsets and the centring transform.

   14.9vw == 214.345/1440, so the ball tracks the comp at every width rather
   than snapping at one breakpoint. Both ends are the SAME size, because the
   comp draws them the same size — the earlier "shrinks as it parks" sizing was
   an assumption, not a measurement. (The comp DOES swell the ball to 386px
   through the ball-info band between them; a two-anchor lerp cannot express
   that, so it is deliberately not attempted here.) */
.gw-ball-anchor,
.gw-ball-anchor--park {
  position: absolute;
  left: 50%;
  transform: translate(-50%, -50%);
  margin-inline: 0;
}
/* The two widths ARE the shrink: the travelling binding lerps
   min(width, height) between the anchors' live rects, so there is no scale
   setting anywhere else in the pipeline. The park value is the comp's
   measurement (214.345/1440 == 14.9vw, Figma `197:148`); the start value is
   deliberately larger than the comp so the ball reads as an object the page
   opens on and visibly settles as it lands. */
.gw-ball-anchor { top: 50%; width: min(20vw, 290px); }
.gw-ball-anchor--park { top: 45.6%; width: min(14.9vw, 215px); }

/* Phones get a still ball at both ends instead of the 3D journey (QA 2026-10-07,
   Adam): the hero's model is gated to 750px+ (data-gw-media), so there its
   poster is the ball; the park anchor carries its own still image. */
.gw-ball-anchor__still { display: none; width: 100%; height: 100%; object-fit: contain; }

/* No mobile homepage frame exists in the comp, so these are judgement, not
   measurement: the ball keeps roughly the same share of a phone's width that
   215px takes of 1440, rounded up so it stays legible against 260px type. */
@media (max-width: 749px) {
  .gw-ball-anchor { width: min(50vw, 220px); }
  /* ~25% under the first pass (min(34vw, 150px)): at that size the still
     ball crowded the two lines of 260px type (QA 2026-10-14). */
  .gw-ball-anchor--park { width: min(26vw, 110px); }
  .gw-ball-anchor__still { display: block; }
}

/* From 750px the 3D ball flies in, so the poster must not sit under it
   waiting for is-ready. */
@media (min-width: 750px) {
  /* …unless the model failed (no WebGL): then the poster IS the ball. */
  .gw-ball-anchor:not(.is-error) .gw-model__poster { display: none; }
}

/* The photographic colourway (PLP `217:1813`). The band keeps its padding and its
   type; only the backdrop changes, so nothing above needs a variant. */
.gw-statement__bg { position: absolute; inset: 0; overflow: hidden; }
.gw-statement__img { width: 100%; height: 100%; object-fit: cover; display: block; }
.gw-statement__scrim {
  position: absolute;
  inset: 0;
  /* rgba(16,24,32,.15) in Figma — neutral-900 at 15%. Written out rather than
     tokenised because it is a raw fill on the node, not a Figma variable, and
     lofli-tokens.css only carries properties that mirror one 1:1. */
  background: rgba(16, 24, 32, .15);
}
.gw-statement--media .gw-statement__inner { position: relative; z-index: 1; }

/* ------------------------------------------------------- showcase grid
   Full-bleed: the cards run edge to edge with no container, so the grid sits
   outside .gw-container by design. Shared by the homepage's hand-picked pair
   (featured-products) and the collection-driven PLP — one rule, so a breakpoint
   correction lands on both. */
.gw-showcase-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(390px, 100%), 1fr));
}
/* More than two products (QA 2026-10-07, Adam: three sit 3-up, four sit 2x2) —
   auto-fit alone leaves an orphan (3 → 2+1 below 1170, 4 → 3+1 from 1170). */
/* Homepage and PLP only — search shares .gw-showcase-grid and keeps auto-fit. */
@media (min-width: 780px) {
  :is(.gw-featured-products__grid, .gw-collection__grid):has(> :nth-child(4):last-child) { grid-template-columns: repeat(2, 1fr); }
  /* Below 1170 three cannot sit 3-up at a legible size; the third spans the row,
     and its CARD (not the wrapper cell) drops the square so it is not 1000px tall. */
  :is(.gw-featured-products__grid, .gw-collection__grid):has(> :nth-child(3):last-child) > :nth-child(3) { grid-column: 1 / -1; }
  :is(.gw-featured-products__grid, .gw-collection__grid):has(> :nth-child(3):last-child) > :nth-child(3) .gw-showcase-card { aspect-ratio: auto; }
}
@media (min-width: 1170px) {
  :is(.gw-featured-products__grid, .gw-collection__grid):has(> :nth-child(3):last-child) { grid-template-columns: repeat(3, 1fr); }
  :is(.gw-featured-products__grid, .gw-collection__grid):has(> :nth-child(3):last-child) > :nth-child(3) { grid-column: auto; }
  :is(.gw-featured-products__grid, .gw-collection__grid):has(> :nth-child(3):last-child) > :nth-child(3) .gw-showcase-card { aspect-ratio: 1; }
}

/* ----------------------------------------------------- featured products */
.gw-featured-products__header {
  padding: var(--space-40) var(--space-48);
  display: grid;
  gap: var(--space-8);
}

/* --------------------------------------------------------- collection
   The comp gives the page no padding at either end: the first row of panels
   meets the pinned nav and the last meets the banner (217:1813 sits at y=720,
   the panels' own bottom, at 1440 and 390 alike). The old block-end padding
   plus grid margin showed as a 104px strip of page white (QA 2026-09-25). */
.gw-collection { padding-block: 0; }
.gw-collection__header { padding: var(--space-40) var(--space-48); display: grid; gap: var(--space-8); }
.gw-collection__title { margin: 0; }
.gw-collection__pagination { display: flex; justify-content: center; padding-block-end: var(--space-40); }
.gw-collection__empty { display: grid; gap: var(--space-24); justify-items: center; text-align: center; }

/* QA 2026-09-29. Scoped to the PLP: the homepage draws the same cards and
   keeps its squares.

   Two-up (two 390px minimums = 780), the panels fill the viewport so the
   banner below does not peek in. */
@media (min-width: 780px) {
  .gw-collection .gw-showcase-card { aspect-ratio: auto; min-block-size: 100svh; }
}
/* The halving above 1440 (QA 09-29, "50% of the current size") is gone: the
   team's QA 2026-10-07 asked for the PLP cards to match the homepage, whose
   ball, title and button read at full size. */

/* ------------------------------------------------------- showcase card */
.gw-showcase-card {
  position: relative;
  aspect-ratio: 1;
  display: flex;
  flex-direction: column;
  gap: var(--space-24);
  padding: var(--space-48);
  overflow: hidden;
}
/* The 390px card the nav's mega panel uses — the same component at its Mobile
   breakpoint (`355:4783`), which is the comp's px-48/py-24 rather than 48 all
   round. */
.gw-showcase-card--compact { gap: var(--space-12); padding: var(--space-24) var(--space-48); }

.gw-showcase-card__media {
  flex: 1;
  min-height: 0;
  display: grid;
  place-items: center;
}
.gw-showcase-card__img {
  width: 100%; height: 100%;
  /* Comp 210:1448 draws a ~375px ball in the 720 card's 624px media box — 60%
     (QA 2026-09-29: it was filling the box). One property so a placement (the
     PLP above 1440) can resize the ball without restating the rule. */
  max-inline-size: var(--gw-card-ball, 60%);
  object-fit: contain;
  /* Per-product size (custom.display_scale, QA 2026-10-07): the baseball sits at
     ~0.77 so the softball reads ~1.3x it. The individual `scale` property, so it
     composes with the hover transform below rather than fighting it. */
  scale: var(--gw-ball-scale, 1);
  transition: transform .4s ease;
}
.gw-showcase-card:hover .gw-showcase-card__img { transform: scale(1.04); }

/* Compact cards (the nav's mega panel): the image is pinned to the media box's
   centre, so a footer that runs a line taller on one card no longer lifts its
   ball and title out of line with its neighbour's (QA 2026-10-07). */
.gw-showcase-card--compact .gw-showcase-card__media { position: relative; }
.gw-showcase-card--compact .gw-showcase-card__img { position: absolute; inset: 0; margin: auto; }

/* The blog cards' hover, on every product card (QA 2026-10-07): the button fills
   (the .gw-card rule in the outline-button block) and the panel lightens a
   step — yellow to bg/brand-sunken, cyan to bg/accent-sunken (Adam, 2026-10-20;
   the cyan FAQ's hover token). Pointer devices only.

   On yellow the button's fill (primary-hover, yellow/400) is a step deeper
   than the panel's (yellow/300). On cyan the outline button's own hover fill
   IS accent-sunken, so it would vanish into the lightened panel; it takes the
   resting cyan instead, which keeps the same "button a step deeper" pairing. */
@media (hover: hover) {
  .gw-showcase-card:is(.gw-surface--brand, .gw-surface--accent) { transition: background-color .18s ease; }
  .gw-showcase-card.gw-surface--brand:hover { background: var(--bg-brand-sunken); }
  .gw-showcase-card.gw-surface--accent:hover {
    background: var(--bg-accent-sunken);
    --gw-btn-outline-hover: var(--bg-accent);
  }
}
@media (prefers-reduced-motion: reduce) {
  .gw-showcase-card:is(.gw-surface--brand, .gw-surface--accent) { transition: none; }
}

/* Above the media: the hover scale() makes the image its own stacking layer,
   which painted the ball over a static title (QA 2026-09-29). Lifted above
   the z:1 stretched link too, so pointer events pass through it — the title
   must stay part of the card's click target — and come back on the actions. */
.gw-showcase-card__footer {
  position: relative;
  z-index: 2;
  pointer-events: none;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-24);
  flex-wrap: wrap;
}
.gw-showcase-card__title {
  /* type: .gw-type-h2 */
  margin: 0;
}
.gw-showcase-card__actions { display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap; }

/* The stretched link makes the whole panel clickable while leaving the button as
   its own tab stop. It sits UNDER the actions so the button still receives the
   click, which a plain full-card anchor would swallow. */
.gw-showcase-card__link { position: absolute; inset: 0; z-index: 1; }
.gw-showcase-card__actions { position: relative; z-index: 2; pointer-events: auto; }

/* Phones: name and button centred under the ball (QA 2026-10-07). */
@media (max-width: 749px) {
  .gw-showcase-card__footer { flex-direction: column; align-items: center; text-align: center; }
  .gw-showcase-card__actions { justify-content: center; }
}

.gw-showcase-card.is-sold-out .gw-showcase-card__img { opacity: .55; }

@media (prefers-reduced-motion: reduce) {
  .gw-showcase-card__img { transition: none; }
  .gw-showcase-card:hover .gw-showcase-card__img { transform: none; }
}


/* ------------------------------------------------------------------- cards
   Figma 244:2371. Two styles that share a body but not a layout. */
.gw-card {
  position: relative;
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
}
.gw-card--sm   { gap: var(--space-24); padding: var(--space-24); }
.gw-card--full { gap: var(--space-48); padding: var(--space-48); }

/* Inset: the image is a rounded panel inside the card, and it takes whatever
   height is left after the text, so a row of cards with different body lengths
   still lines up at the bottom. */
.gw-card--inset .gw-card__media {
  flex: 1;
  min-height: 0;
  border-radius: var(--radius-lg);
  overflow: hidden;
}
.gw-card__img { width: 100%; height: 100%; object-fit: cover; }

/* Full-bleed: the image is the card. Content is pushed to the bottom and the
   scrim sits between them. */
.gw-card--bg { justify-content: flex-end; min-height: 22rem; }
.gw-card__bg { position: absolute; inset: 0; }
.gw-card__bg-img { width: 100%; height: 100%; object-fit: cover; }

/* Two layers: a flat wash for the whole image, and a gradient weighted to the
   text end. The flat wash alone leaves light photos unreadable at the top of the
   text block; the gradient alone leaves the heading floating on nothing when the
   photo is busy in the middle. */
.gw-card__bg::after {
  content: '';
  position: absolute;
  inset: 0;
  background:
    linear-gradient(to top, rgb(16 24 32 / 0.75) 0%, rgb(16 24 32 / 0) 60%),
    rgb(0 0 0 / 0.2);
}
.gw-card--bg .gw-card__body { position: relative; }

/* Three nested gaps, all from the comp (244:2370): the card separates its image
   from its text by 48, the body separates the head group from the copy by 24, and
   the head group separates the heading row from the meta line by 12. Nesting them
   is what stops the date drifting into the excerpt's rhythm. */
.gw-card__body { display: flex; flex-direction: column; gap: var(--space-24); }
.gw-card__headgroup { display: flex; flex-direction: column; gap: var(--space-12); }
.gw-card__head { display: flex; gap: var(--space-24); align-items: flex-start; }
.gw-card__heading { flex: 1; min-width: 0; }
.gw-card__text { margin: 0; }

/* The date line. Uppercase is applied here, not in the text style — the same call
   as .gw-statement__text and .gw-hero__heading, and it keeps the <time> element's
   datetime attribute carrying the real value while the caps stay presentational. */
.gw-card__meta {
  margin: 0;
  text-transform: uppercase;
}

/* The comp truncates both heading and body to one clamped block. Merchant copy is
   not length-checked, so without this a long entry silently breaks the grid. */
.gw-card--sm .gw-card__text {
  display: -webkit-box;
  -webkit-line-clamp: 4;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* --------------------------------------------------------------- card grid
   A fixed 4-column track, because the comp mixes a 2-wide card with 1-wide ones
   in the same row. auto-fit cannot express that. */
.gw-card-grid__grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
}
.gw-card-grid__cell { min-width: 0; }
.gw-card-grid__cell--span-1 { grid-column: span 1; }
.gw-card-grid__cell--span-2 { grid-column: span 2; }
.gw-card-grid__cell--span-3 { grid-column: span 3; }
.gw-card-grid__cell--span-4 { grid-column: span 4; }
/* Related mode's product card: two rows tall, so the four post cards form the
   comp's 2x2 beside it (Figma `244:2470`). */
.gw-card-grid__cell--tall { grid-row: span 2; }
.gw-card-grid__cell--tall .gw-showcase-card { block-size: 100%; }

/* Related mode's small post cards — Figma `244:2395`. Square, which is also
   load-bearing: an inset card's image is flex: 1 and needs a definite card
   height to grow into (the same reason as .gw-blog__grid .gw-card). Title on
   one line, excerpt on two, pill full width at the bottom, 16px between the
   body's parts. */
.gw-card-grid .gw-card--sm.gw-card--link { aspect-ratio: 1; }
.gw-card__body--sm { gap: var(--space-16); }
.gw-card__body--sm .gw-card__heading {
  text-transform: uppercase;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.gw-card__body--sm .gw-card__text { -webkit-line-clamp: 2; }
.gw-card__cta--block { width: 100%; justify-content: center; }
.gw-card-grid__header { padding: var(--space-40) var(--space-48); }

/* Spans collapse in two steps rather than one, so a 2-wide card stays 2-wide on a
   tablet-ish width instead of jumping straight to full bleed. There is no 768 comp
   to check this against — every Tablet artboard in the file is empty. */
@media (max-width: 1099px) {
  .gw-card-grid__grid { grid-template-columns: repeat(2, 1fr); }
  .gw-card-grid__cell--span-3,
  .gw-card-grid__cell--span-4 { grid-column: span 2; }
  /* At two columns the product card is full width, so it stops spanning two
     rows and the post cards pair up beneath it. */
  .gw-card-grid__cell--tall { grid-row: auto; }
}
@media (max-width: 749px) {
  .gw-card-grid__grid { grid-template-columns: 1fr; }
  .gw-card-grid__cell { grid-column: span 1 !important; }
  .gw-card-grid__header { padding: var(--space-24); }
  /* The 48 gap is a 720-wide measurement. At 390 the blog cards are the size the
     comp draws as `sm`, so they take the sm rhythm rather than keeping a gap that
     is an eighth of the card's width. There is no 768 artboard to check against. */
  .gw-card--full { padding: var(--space-24); gap: var(--space-24); }
}

/* --------------------------------------------------------- annotated media */
/* Adam's frame `261:2447` (QA 2026-09-29) set the image against the left edge
   with the text taking the rest. On a wide screen that left the ball pinned
   left and a wide empty band to the right of the copy, so the pair read as
   off-centre (Adam, 2026-10-20): it is now one centred unit.

   The two columns keep the comp's image frame (684) beside the text's real
   measure (~600: the heading is capped at 5.8em of 96px = 556, the body at
   32em) with a 48px gap, as fr so they scale together below the cap. Above
   ~1430 — the comp's own width — the unit stops growing and centres, with
   equal margins either side, and a 48px gutter keeps the ball off the edge. */
.gw-annotated__inner {
  display: grid;
  grid-template-columns: minmax(0, 684fr) minmax(0, 600fr);
  column-gap: var(--space-48);
  align-items: center;
  max-inline-size: calc(684px + 600px + 3 * var(--space-48));
  margin-inline: auto;
  padding-inline: var(--space-48);
}
/* Clips the image's 1% overscan so it cannot scroll the page sideways. */
.gw-annotated { overflow: hidden; }

/* `261:2420` fills the 684x691 frame with the image at 101.04% width, pulled
   left 1.08% — the whole ball, not the older instance's 124% edge crop.
   `:has()` so a section with no image does not reserve an empty box; the
   image is absolutely positioned, so without the ratio the frame has no
   height at all. */
.gw-annotated__media { position: relative; }
.gw-annotated__media:has(.gw-annotated__img) { aspect-ratio: 684 / 691; }
.gw-annotated__img {
  position: absolute;
  top: 0;
  left: -1.08%;
  width: 101.04%;
  height: 100%;
  max-width: none;
  object-fit: cover;
}
/* Comp gaps are 21 (heading/body/list) and 22 (to the buttons), measured with
   text-box-trim; CSS keeps each line box's leading, so 16 + 24 lands on the
   same visual rhythm. */
.gw-annotated__text {
  display: flex;
  flex-direction: column;
  gap: var(--space-16);
}
.gw-annotated__actions { margin-block-start: var(--space-8); }
.gw-annotated__heading {
  /* type: .gw-type-display-l (`261:2433`: 96 / 0.81 / -4%). Uppercase is on
     the node in the comp, not in the text style — the same call as
     .gw-statement__text and .gw-hero__heading, and it stays in CSS so the
     merchant's copy is not shouted in the editor. The comp's 556px measure is
     5.8em at this size, which is what wraps it to three lines. */
  text-transform: uppercase;
  max-inline-size: 5.8em;
}
/* The comp's measure is 506.84 (`261:2434`): 32em at this type's own size. */
.gw-annotated__body { max-width: 32em; }
/* Rich text arrives as <p>; the UA's 1em margins stacked on the column gap. */
.gw-annotated__body p { margin: 0; }
.gw-annotated__actions { display: flex; gap: var(--space-16); flex-wrap: wrap; }

/* The callouts, as the updated comp draws them (`I261:2485;513:5182`, QA
   2026-09-29): an icon list between the body and the buttons — a 24px ball
   and a Body/Default label in the accent ink, 10px apart, items 16px apart. */
.gw-annotated__callouts {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-16);
}
.gw-annotated__callout {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  flex: 1 1 14rem;
  max-inline-size: 18rem;
  color: var(--text-accent);
}
.gw-annotated__callout-icon { flex: none; width: var(--size-icon-md); height: var(--size-icon-md); }

/* Phone: the comp's gutter, the ball shown whole, and the column stacked
   heading → body → list → buttons with the image last (Adam, QA 2026-09-29:
   stack the desktop design, no arrows). */
@media (max-width: 749px) {
  .gw-annotated__inner {
    grid-template-columns: minmax(0, 1fr);
    /* The desktop frame sits against the page edge; on a phone the ball takes
       the same gutter as everything else. */
    column-gap: 0;
    padding-inline: var(--space-24);
  }
  .gw-annotated__text { padding-inline: 0; }
  /* On a phone the image sits in the gutter at its natural height. */
  .gw-annotated__media:has(.gw-annotated__img) { aspect-ratio: auto; }
  .gw-annotated__img { position: static; width: 100%; height: auto; left: auto; }
  .gw-annotated__media { order: 1; }
  .gw-annotated__callouts { flex-direction: column; }
  /* In a column the 14rem basis would size height. */
  .gw-annotated__callout { flex: none; }
}

/* ----------------------------------------------------------------- header
   base.css already gives .gw-header a stacking position above the WebGL stage.
   What is added here is the LoFli layout and the mobile disclosure. */
.gw-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-24);
  padding: var(--space-16);
}

/* The comp puts the cart INSIDE its Nav Links group, so the links and the cart
   read as one cluster at the right with the wordmark alone on the left. Here
   they are two elements, so the nav takes the free space as a margin and the
   two sit together — rather than space-between stranding the nav in the
   middle. Left as an auto margin instead of a wrapper element so that a nav
   with no menu still leaves the cart correctly to the right. */
.gw-header__nav { margin-inline-start: auto; }
/* The colourway the section declares in Liquid. It is the whole story before
   the runtime loads, with JavaScript off, and on any page whose sections
   declare no surface — so it has to be correct on its own, not a placeholder
   for the observer to fix. */
   These name their colours outright rather than going through --text-primary
   and --text-brand, because the header no longer carries data-theme: picking
   "Yellow text" is the author saying this nav sits on a dark hero, and it
   should not quietly resolve to the /700 shade meant for yellow type on
   white. Where the surface underneath is known, the observer overrides all
   three anyway. */
.gw-header--light { --gw-nav-ink: var(--color-white);       --gw-nav-ink-contrast: var(--color-neutral-900); }
.gw-header--dark  { --gw-nav-ink: var(--color-neutral-900); --gw-nav-ink-contrast: var(--color-white); }
.gw-header--brand { --gw-nav-ink: var(--color-yellow-500);  --gw-nav-ink-contrast: var(--color-neutral-900); }

/* Pinned and transparent: the nav has no bar of its own, it just carries ink
   over whatever scrolls beneath it. src/runtime/nav.js overwrites --gw-nav-ink
   inline as each surface reaches the nav's centre line. */
.gw-header {
  background: transparent;
  color: var(--gw-nav-ink);
}

/* The WRAPPER is what pins, not the header. `{% sections %}` puts every
   section inside its own div, and a sticky element can only travel within its
   containing block — that wrapper is exactly as tall as the header, so a
   sticky header inside it scrolls away with it and appears not to stick at
   all. The header keeps base.css's position: relative, which is what the mega
   panel positions against.

   Fixed rather than sticky, so the nav takes no space in the page: the first
   section starts at the very top and runs underneath it. A nav in flow has a
   68px band of its own, and being transparent that band shows the page
   background — a white strip above any page that opens on a colour, which is
   never right. It also means there is always a surface under the ink band, so
   the colour is correct at the top of the page and not just after a scroll.

   The trade is that the first 68px of every template is under the nav —
   --gw-nav-height, for a first section that has to clear it exactly. */
:root { --gw-nav-height: 68px; }
.shopify-section-group-header-group {
  position: fixed;
  inset-inline: 0;
  top: 0;
  z-index: 3;
}

/* 120ms is long enough to read as a swap rather than a glitch, short enough
   that it never lags the scroll. Dropped under reduced motion, where the ink
   still has to change — legibility is not decoration — just not gradually. */
@media (prefers-reduced-motion: no-preference) {
  .gw-header { transition: color .12s linear; }
}

.gw-header__logo { display: flex; align-items: center; color: inherit; text-decoration: none; }
.gw-header__logo-img { width: 68px; height: auto; }
/* 68x32 in the comp — the wordmark's own 2.11:1 gives the height. */
.gw-header__wordmark { display: block; width: 68px; height: auto; }

.gw-header__menu,
.gw-header__actions { display: flex; align-items: center; gap: var(--space-24); }
.gw-header__menu { list-style: none; margin: 0; padding: 0; }
.gw-header__link {
  position: relative; /* the hover baseball positions against this */
  display: inline-flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-4) var(--space-8) var(--space-4) var(--gw-nav-link-lead, var(--space-8));
  color: inherit;
  text-decoration: none;
}

/* The ball makes its own room: the link's leading padding grows to hold it,
   which pushes its neighbours aside rather than the ball overlapping them.

   Only links that actually carry a ball — a submenu child would otherwise
   widen on hover for no reason.

   The space comes off the LEFT because the nav is right-aligned by an auto
   margin, so the hovered label and the cart stay put and the links before it
   slide away. Worth keeping that way round: a shift that moved the hovered
   item could slide it out from under the pointer, un-hover it, collapse, and
   oscillate. */
.gw-header__link:has(.gw-header__ball) {
  transition: padding .22s cubic-bezier(.34, 1.56, .64, 1);
}
.gw-header__link:has(.gw-header__ball):hover,
.gw-header__link:has(.gw-header__ball):focus-visible,
.gw-header__group[open] > .gw-header__link:has(.gw-header__ball),
.gw-header__group--mega:has(> details[open]) > .gw-header__link:has(.gw-header__ball) {
  --gw-nav-link-lead: calc(var(--space-8) + var(--size-icon-md) + var(--space-8));
}
.gw-header__link[aria-current='page'] { text-decoration: underline; text-underline-offset: 4px; }

/* A baseball rolls in to the left of the nav item on hover, replacing the
   comp's underline. Absolutely positioned so the default bar stays exactly as
   drawn — nothing is reserved until a link is hovered, and then the link's own
   padding opens up for it (see the rule above). It takes no pointer events, so
   it cannot steal a click from its neighbour.

   Also shown on focus-visible and while the panel is open: the affordance
   should not be mouse-only, and a panel opened by click keeps its ball. */
.gw-header__ball {
  position: absolute;
  /* Inside the link's own leading padding, which grows to reveal it — not
     outside in the gap, where it would sit on the neighbouring link. */
  inset-inline-start: var(--space-8);
  top: 50%;
  width: var(--size-icon-md);
  height: var(--size-icon-md);
  opacity: 0;
  transform: translateY(-50%) scale(0);
  transform-origin: center;
  pointer-events: none;
  /* Overshoots slightly on the way in — a ball that lands dead-on reads as a
     fade rather than a throw. */
  transition: transform .22s cubic-bezier(.34, 1.56, .64, 1), opacity .12s linear;
}
.gw-header__link:hover .gw-header__ball,
.gw-header__link:focus-visible .gw-header__ball,
.gw-header__group[open] > .gw-header__link .gw-header__ball,
.gw-header__group--mega:has(> details[open]) > .gw-header__link .gw-header__ball {
  opacity: 1;
  transform: translateY(-50%) scale(1);
}

.gw-header__icon-link { color: inherit; position: relative; display: inline-flex; }
/* The cart controller's swap regions sit inside flex rows (.gw-header__actions,
   the drawer's link). display: contents keeps the span itself out of the flex
   layout — only its children (the visually-hidden label, the count badge) lay
   out as if it weren't there — so a swap never introduces its own flex item
   box around the icon/count it wraps. */
.gw-header [data-gw-cart-swap] { display: contents; }
.gw-header__cart-count {
  position: absolute;
  top: -6px; inset-inline-end: -8px;
  min-width: 16px;
  padding-inline: 4px;
  border-radius: var(--radius-full);
  /* Flipped against the ink rather than fixed yellow-on-black: a fixed yellow
     pill vanishes the moment the nav crosses a yellow section. Over black this
     resolves to the comp's yellow pill with black digits anyway. */
  background: var(--gw-nav-ink);
  color: var(--gw-nav-ink-contrast);
  text-align: center;
  letter-spacing: 0;
}

/* Disclosures, not JS drawers. Both open states are drawn now (`391:4819`
   desktop, `391:4555` mobile) and both are still <details>: Escape closes them,
   focus order follows the DOM, and they work before JS runs. */
.gw-header__group summary,
.gw-header__drawer-group summary,
.gw-header__mobile summary { cursor: pointer; list-style: none; }
.gw-header__group summary::-webkit-details-marker,
.gw-header__drawer-group summary::-webkit-details-marker,
.gw-header__mobile summary::-webkit-details-marker { display: none; }
.gw-header__group { position: relative; }
/* 24px in both comps — beside a 16px link and beside the overlay's 64px type.
   Sized explicitly because .gw-icon defaults to 1em, which would scale it with
   whatever text it sits next to. */
.gw-header__chev {
  width: var(--size-icon-md);
  height: var(--size-icon-md);
  transition: transform .18s ease;
}
[open] > summary .gw-header__chev { transform: rotate(180deg); }

/* Desktop Shop (QA 2026-09-29): the label is a link and the chevron is the
   disclosure beside it. The details stays static so the panel still positions
   against the header, as .gw-header__group--mega already arranges. */
.gw-header__group--mega { display: inline-flex; align-items: center; }
.gw-header__mega-toggle { position: static; }
.gw-header__chev-toggle { display: inline-flex; align-items: center; padding: var(--space-4); color: inherit; }
/* Shop and its chevron ~half as far apart (QA 2026-10-07): the link's 8px tail
   and the toggle's 4px lead stacked with the glyph's own inset into ~18px. */
.gw-header__group--mega > .gw-header__link { padding-inline-end: 0; }
.gw-header__group--mega .gw-header__chev-toggle { padding-inline-start: 0; }

/* Open, the whole nav is a black block — the comp draws the bar and the panel
   as one surface, not a floating panel under a transparent bar. `:has()` lets
   the bar respond to its own descendant with no JavaScript, which keeps the
   open state working when the runtime has not loaded. Where `:has()` is not
   supported the bar stays transparent and only the panel is black: less
   handsome, still legible, still usable. */
.gw-header:has([data-gw-nav-disclosure][open]) {
  background: var(--color-neutral-900);
  /* !important because nav.js writes the section's ink INLINE on this element,
     and an inline value beats any normal rule — without it the open bar kept
     a black section's black ink, black on black (final review 2026-09-29). */
  --gw-nav-ink: var(--color-white) !important;
  --gw-nav-ink-contrast: var(--color-neutral-900) !important;
}

/* The bar paints above the page and the desktop panels. It does NOT separate the
   bar from the mobile drawer: the drawer lives inside .gw-header__inner, so both
   share this stacking context — the logo and burger are lifted above the drawer
   explicitly in the <750 block (QA 2026-10-07: the drawer covered both and left
   phones with no way out of the menu). */
.gw-header__inner { position: relative; z-index: 1; }

/* The page behind a full-screen overlay must not scroll. Set by
   src/runtime/nav.js and removed on teardown even if the overlay is still
   open, so a torn-down section cannot leave the page stuck. */
html[data-gw-nav-open],
html[data-gw-nav-open] body { overflow: hidden; }
.gw-header__submenu {
  position: absolute;
  inset-inline-start: 0;
  top: 100%;
  z-index: 4;
  min-width: 12rem;
  margin: var(--space-8) 0 0;
  padding: var(--space-8);
  list-style: none;
  background: var(--bg-page);
  border: var(--border-thick) solid var(--border-strong);
  border-radius: var(--radius-md);
}

/* -------------------------------------------- the desktop mega panel
   Figma `391:4819`. Full-bleed across the nav: the panel is positioned against
   the header, which `.gw-header__group--mega { position: static }` allows by
   taking the menu item out of the way as a containing block.

   The comp stacks the bar and the panel in one 96px-gap column. Here the panel
   is absolute instead — in flow it would grow the sticky header and shove the
   page down on open — so that gap becomes the panel's top padding. */
.gw-header__group--mega { position: static; }
.gw-mega {
  position: absolute;
  inset-inline: 0;
  top: 100%;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-48);
  padding: var(--space-80) var(--space-16) var(--space-16);
  background: var(--color-neutral-900);
  color: var(--color-white);
  /* QA 2026-09-29: over a same-coloured section the panel blended in. Figma
     Elevation/2 plus a bottom stroke in the ink of the section under the nav
     (nav.js records it as --gw-nav-section-ink, untouched by the open bar's
     white override) — the colour that already reads against that section. */
  box-shadow: var(--elevation-2);
  border-block-end: var(--border-thick) solid var(--gw-nav-section-ink, var(--gw-nav-ink, currentColor));
}
.gw-mega__heading { color: inherit; }
.gw-mega__heading-link { color: inherit; text-decoration: none; }
.gw-mega__heading-link:hover { text-decoration: underline; }
.gw-mega__cards { display: flex; gap: var(--space-8); }
/* 390px is the comp's card, but it yields rather than overflowing a narrow
   laptop — the panel is full-bleed and cannot scroll sideways. */
.gw-mega .gw-showcase-card { flex: 0 1 390px; max-width: 390px; }

/* ---------------------------------------- nav disclosure motion (2026-09-29)
   Open is pure CSS on [open], so it plays with no JavaScript. Close plays on
   [data-gw-closing], which src/runtime/nav.js holds until the panel's own
   animationend and only then removes `open` — a <details> cannot animate
   after that. The negative bottom inset keeps the panel's drop shadow and
   bottom stroke inside the reveal. Out is quicker than in: a panel you are
   dismissing should get out of the way. */
@keyframes gw-curtain-in  { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 -40px 0); } }
@keyframes gw-curtain-out { from { clip-path: inset(0 0 -40px 0); } to { clip-path: inset(0 0 100% 0); } }
@keyframes gw-rise-in     { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
/* Height to and from the measured panel height nav.js records, for the mobile
   Shop expansion. `auto` is the fallback where the measure is missing, which
   interpolate-size animates in the browsers that support it. The top padding
   folds with it, or a collapsed panel would still hold its 12px. */
@keyframes gw-height-in  { from { height: 0; padding-block-start: 0; } to { height: var(--gw-disclosure-height, auto); } }
@keyframes gw-height-out { from { height: var(--gw-disclosure-height, auto); } to { height: 0; padding-block-start: 0; } }

.gw-header__mega-toggle[open] > .gw-mega { animation: gw-curtain-in .45s cubic-bezier(.22, 1, .36, 1) both; }
.gw-header__mega-toggle[data-gw-closing] > .gw-mega { animation: gw-curtain-out .3s cubic-bezier(.55, 0, .68, .3) both; }
/* The contents follow the curtain down rather than being uncovered by it. */
.gw-header__mega-toggle[open] .gw-mega__heading,
.gw-header__mega-toggle[open] .gw-mega__cards > * { animation: gw-rise-in .4s cubic-bezier(.22, 1, .36, 1) .12s both; }
.gw-header__mega-toggle[open] .gw-mega__cards > :nth-child(2) { animation-delay: .18s; }

.gw-header__mobile { display: none; }
.gw-header__drawer-menu,
.gw-header__drawer-submenu { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-16); }
.gw-header__drawer-submenu { padding-inline-start: var(--space-16); margin-top: var(--space-8); }

/* Both glyphs render; CSS picks one. That is the whole open/close affordance,
   with no JavaScript to swap an icon. */
.gw-header__burger-close { display: none; }
.gw-header__mobile[open] > summary .gw-header__burger-open { display: none; }
.gw-header__mobile[open] > summary .gw-header__burger-close { display: block; }

@media (max-width: 749px) {
  .gw-header__nav { display: none; }
  .gw-header__mobile { display: block; }

  /* The closed mobile bar (`354:2771`) carries no cart icon — the overlay's
     Cart row is the way to the cart on a phone. */
  .gw-header__icon-link--cart { display: none; }

  /* ------------------------------------------ the mobile overlay
     Figma `391:4371` / `391:4555`. Fixed over the viewport, under the bar so
     the close button stays reachable, and scrollable in case the menu plus an
     expanded Shop panel runs past the fold on a short screen. */
  .gw-header__logo,
  .gw-header__burger { position: relative; z-index: 1; }
  .gw-header__drawer {
    position: fixed;
    inset: 0;
    z-index: 0;
    padding: var(--space-96) var(--space-16) var(--space-16);
    background: var(--color-neutral-900);
    color: var(--color-white);
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  .gw-header__drawer-menu { gap: var(--space-24); }
  .gw-header__drawer-link {
    display: flex;
    align-items: center;
    gap: var(--space-8);
    color: inherit;
    text-decoration: none;
  }
  .gw-header__drawer-link[aria-current='page'] { text-decoration: underline; text-underline-offset: 6px; }
  /* The same in the drawer: Shop and its chevron, 8 → 2 (QA 2026-10-07). */
  .gw-header__drawer-group > summary { gap: var(--space-2); }
  .gw-header__drawer-count { font-size: var(--font-size-24); }

  /* Shop expanded inline, between its own label and the next item. */
  .gw-mega--mobile {
    position: static;
    display: grid;
    gap: var(--space-12);
    padding: var(--space-12) 0 0;
    background: none;
    box-shadow: none;
    border-block-end: 0;
  }
  .gw-mega__all { color: inherit; text-decoration: underline; text-underline-offset: 4px; justify-self: start; }
  .gw-mega--mobile .gw-mega__cards { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
  .gw-mega--mobile .gw-showcase-card { flex: initial; max-width: none; padding: var(--space-8); gap: var(--space-4); }
  /* The comp's 19.2px label. gw-type-h2 clamps to 32px here, which is wider
     than half a 390px screen. */
  .gw-mega--mobile .gw-showcase-card__title { font-size: var(--font-size-20); }

  /* The overlay unrolls like the desktop panel; Shop inside it eases its
     height instead, because it opens in the flow and pushes the rows below. */
  .gw-header__mobile[open] > .gw-header__drawer { animation: gw-curtain-in .45s cubic-bezier(.22, 1, .36, 1) both; }
  .gw-header__mobile[data-gw-closing] > .gw-header__drawer { animation: gw-curtain-out .3s cubic-bezier(.55, 0, .68, .3) both; }
  .gw-header__drawer-group { interpolate-size: allow-keywords; }
  .gw-header__drawer-group[open] > .gw-mega--mobile { overflow: hidden; animation: gw-height-in .4s cubic-bezier(.22, 1, .36, 1); }
  .gw-header__drawer-group[data-gw-closing] > .gw-mega--mobile { overflow: hidden; animation: gw-height-out .3s cubic-bezier(.55, 0, .68, .3) both; }
}

@media (prefers-reduced-motion: reduce) {
  .gw-header__chev { transition: none; }

  /* !important because the [open] and [data-gw-closing] rules carry more
     specificity than these, and reduced motion has to win outright. nav.js
     then finds no close animation and shuts the panel at once. */
  .gw-header__mega-toggle > .gw-mega,
  .gw-header__mega-toggle .gw-mega__heading,
  .gw-header__mega-toggle .gw-mega__cards > *,
  .gw-header__mobile > .gw-header__drawer,
  .gw-header__drawer-group > .gw-mega--mobile { animation: none !important; }

  /* The ball still appears — it is the hover affordance, not decoration — it
     just arrives without the throw. And its room is held open permanently
     rather than animating: shifting the neighbouring links is the one part of
     this that is motion for its own sake, so under reduced motion the bar
     simply carries the space all the time and nothing moves. */
  .gw-header__ball { transition: none; }
  .gw-header__link:has(.gw-header__ball) {
    --gw-nav-link-lead: calc(var(--space-8) + var(--size-icon-md) + var(--space-8));
    transition: none;
  }
}

/* ----------------------------------------------------------------- footer */
.gw-footer {
  display: grid;
  /* minmax(0, …): an implicit auto track sizes to min-content, so a row wider
     than the window pushed the whole footer off-screen (QA 2026-09-29). */
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-64);
  padding: var(--space-80) var(--space-48) var(--space-32);
  text-align: center;
}
/* Black carries data-theme="inverse" in the markup, which re-points --bg-page
   to black and --text-brand to the bright yellow-500. Yellow is a real brand
   fill, so its type is --text-on-brand, which is black by definition — this is
   the pairing that keeps white off yellow (docs/BRAND.md). */
.gw-footer--black {
  background: var(--bg-page);
  color: var(--text-brand);
  --gw-field-fill: transparent;
}
.gw-footer--yellow {
  background: var(--bg-brand);
  color: var(--text-on-brand);
  /* The comp fills the field a shade up from the surface so it reads as a well
     rather than a hole. yellow-300 is the drawn value. */
  --gw-field-fill: var(--bg-brand-sunken);
}

/* On black, everything is yellow EXCEPT the legal line, which is white
   (`217:1863`). It is the one piece of the footer that is information rather
   than voice. */
.gw-footer--black .gw-footer__legal { color: var(--text-primary); }

/* Comp gap is 60; --space-64 is the nearest token and this stays on the scale
   rather than hardcoding a one-off 60px. */
.gw-footer__masthead { display: grid; gap: var(--space-64); justify-items: center; }
.gw-footer__logo { color: inherit; text-decoration: none; max-width: 450px; width: 100%; }
.gw-footer__logo-img { width: 100%; height: auto; }
.gw-footer__wordmark { display: block; width: 100%; height: auto; }
.gw-footer__tagline { max-width: 34ch; text-transform: uppercase; }

/* Five columns spread across the full width. The comp's 200 (links) and 323
   (newsletter) are each column's PREFERRED width, not a fixed one: they shrink
   rather than overflow, and below 1320 the row reflows (see the mid query). */
.gw-footer__columns {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-24);
  width: 100%;
}
.gw-footer__column { display: grid; gap: var(--space-24); justify-items: center; flex: 0 1 200px; min-width: 0; }
.gw-footer__column-title {
  width: 100%;
  padding-bottom: var(--space-8);
  border-bottom: var(--border-thick) solid currentColor;
  text-transform: uppercase;
}
.gw-footer__links { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-16); }
.gw-footer__links a { color: inherit; text-decoration: none; }
.gw-footer__links a:hover { text-decoration: underline; }
.gw-footer__legal { margin: 0; }

/* The newsletter column is wider than a link column — it holds a field, not a
   list. 323.305 is the comp's field width. */
.gw-footer__column--newsletter { flex: 0 1 323.305px; min-width: 0; }

.gw-newsletter { display: grid; gap: var(--space-24); width: 100%; }

/* A plain outline pill (QA 2026-10-14, Adam: "just a simple pill instead of
   the svg") — it replaced the comp's metaball outline at every width. Same
   2px ink as the outline buttons, and the colourway's fill so the yellow
   footer still reads it as a well. The arrow sits inside the right end, its
   32px glyph centred in the 52px cap: (52 - 2×2 border - 32) / 2 = 8px. */
.gw-newsletter__field {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-8);
  width: 100%;
  max-width: 323.305px;
  height: 52px;
  padding-inline: var(--space-24) var(--space-8);
  border: var(--border-thick) solid currentColor;
  border-radius: var(--radius-full);
  background: var(--gw-field-fill, transparent);
}
.gw-newsletter__input {
  /* Kept positioned: the field used to paint a shape over an unpositioned
     input (QA 2026-10-07), and nothing is gained by un-lifting it. */
  position: relative;
  flex: 1;
  min-width: 0;
  background: transparent;
  border: 0;
  color: inherit;
}
/* .3 is the comp's value — lighter than the .5 this used to carry. */
.gw-newsletter__input::placeholder { color: inherit; opacity: .3; }
.gw-newsletter__input:focus { outline: none; }
/* Keyboard focus only. `:focus-within` would fire on a click too, which reads
   as a stray ring around a field someone just tapped. The outline follows the
   pill's border-radius. */
.gw-newsletter__field:has(:focus-visible) {
  outline: var(--border-thick) solid var(--border-focus);
  outline-offset: 2px;
}
.gw-newsletter__submit {
  position: relative;
  background: none;
  border: 0;
  padding: 0;
  color: inherit;
  cursor: pointer;
  display: inline-flex;
}
.gw-newsletter__submit-icon { width: var(--size-icon-lg); height: var(--size-icon-lg); }
.gw-newsletter__note { margin: 0; }
.gw-newsletter__note--error { color: var(--feedback-error-border); }
.gw-newsletter__incentive,
.gw-newsletter__terms { margin: 0; opacity: .85; }
/* The terms line is a richtext setting, so it arrives as <p> + <a>: the <p>
   brings the UA's 1em margins and the <a> the UA's link blue, which read as
   default-blue on both footer colourways (QA 2026-09-25). The link takes the
   footer's ink and keeps its underline as the only affordance, as in the comp. */
.gw-newsletter__terms p { margin: 0; }
.gw-newsletter__terms a { color: inherit; text-decoration: underline; }

/* 750–1319px: the comp's single row (4×200 + 323 + gaps + padding) no longer
   fits, and it overflowed the footer (QA 2026-09-29). The newsletter takes its
   own row on top and the four link columns sit two by two beneath it. */
@media (min-width: 750px) and (max-width: 1319px) {
  .gw-footer__columns {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-48) var(--space-24);
    justify-items: center;
  }
  .gw-footer__column--newsletter { grid-column: 1 / -1; order: -1; width: min(323.305px, 100%); }
  /* :not(), because the newsletter carries both classes at equal specificity. */
  .gw-footer__column:not(.gw-footer__column--newsletter) { width: min(200px, 100%); }
}

@media (max-width: 749px) {
  .gw-footer { padding: var(--space-48) var(--space-24) var(--space-24); gap: var(--space-48); }
  .gw-footer__masthead { gap: var(--space-24); }
  .gw-footer__columns { flex-direction: column; gap: var(--space-32); }
  /* Stacked, a flex-basis sizes HEIGHT: reset it, and restore the comp's
     fixed link-column width so the title rules line up. */
  .gw-footer__column { flex: none; width: 200px; }

  /* The newsletter drops below all four link columns (`354:4300`), rather than
     staying third where its block sits in the list. */
  .gw-footer__column--newsletter { order: 1; width: 100%; }
  /* Full width, the column no longer centres the 323px field for it: without
     this the pill sat flush left under centred text (launch, 2026-10-21). */
  .gw-footer__column--newsletter .gw-newsletter { justify-items: center; }
}

/* ------------------------------------------------------------ design mode */
.gw-placeholder {
  display: grid;
  place-items: center;
  aspect-ratio: 1;
  border: var(--border-thick) dashed var(--border-default);
  color: var(--text-muted);
  font-family: var(--font-family-display);
}

/* ------------------------------------------------------------- form field
   Figma 230:637. The label is always present — see the snippet for why a
   placeholder is not a label. */
.gw-field { display: grid; gap: var(--space-8); text-align: start; }
.gw-field__label { display: block; }
.gw-field__control {
  width: 100%;
  padding: var(--space-12) var(--space-16);
  background: var(--bg-surface);
  color: var(--text-primary);
  border: var(--border-thick) solid var(--border-default);
  border-radius: var(--radius-md);
}
.gw-field__control::placeholder { color: var(--text-muted); }
.gw-field__control:focus-visible {
  outline: var(--border-thick) solid var(--border-focus);
  outline-offset: 2px;
  border-color: var(--border-focus);
}
.gw-field--error .gw-field__control { border-color: var(--feedback-error-border); }
.gw-field__error { color: var(--feedback-error-text); margin: 0; }
.gw-field__help { color: var(--text-muted); margin: 0; }
.gw-field--disabled { opacity: .6; }
.gw-field__control:disabled { cursor: not-allowed; background: var(--bg-surface-subtle); }

/* --------------------------------------------------------------- checkbox */
.gw-checkbox__label {
  display: inline-flex;
  align-items: center;
  gap: var(--space-12);
  cursor: pointer;
}
.gw-checkbox__box {
  flex: none;
  display: grid;
  place-items: center;
  width: var(--size-icon-sm);
  height: var(--size-icon-sm);
  border: var(--border-thick) solid var(--border-strong);
  border-radius: var(--radius-xs);
  color: var(--text-on-brand);
}
.gw-checkbox__box .gw-icon { opacity: 0; width: 12px; height: 12px; }
.gw-checkbox__input:checked + .gw-checkbox__label .gw-checkbox__box { background: var(--bg-brand); }
.gw-checkbox__input:checked + .gw-checkbox__label .gw-checkbox__box .gw-icon { opacity: 1; }

/* The input is visually hidden but still focusable, so the ring has to be drawn
   on the box the user can actually see. */
.gw-checkbox__input:focus-visible + .gw-checkbox__label .gw-checkbox__box {
  outline: var(--border-thick) solid var(--border-focus);
  outline-offset: 2px;
}
.gw-checkbox__input:disabled + .gw-checkbox__label { opacity: .6; cursor: not-allowed; }

/* ----------------------------------------------- form components: outline
   The Contact page (`33:32`) draws the field as an outline on a black band
   rather than the filled well on the component sheet (`230:637`): transparent
   fill, a 2px rule in the ink colour, radius-lg, and an uppercase H6 label.

   currentColor keeps the snippet ignorant of what it is sitting on — the same
   discipline the nav ink map uses — so this is not inverse-specific.

   Comp padding is 13.565/18.087 and the field is 52 tall; --space-16 gives 55.
   Taking the token and the 3px follows the footer precedent above (comp gap 60
   → --space-64) and keeps the target comfortably over 44. */
.gw-field--outline .gw-field__control {
  background: transparent;
  border-color: currentColor;
  border-radius: var(--radius-lg);
  padding: var(--space-16);
}

/* The comp's placeholder is the ink at 50%, which --text-muted does not match on
   this surface. Safe to dim: the label is always rendered, so the placeholder is
   supplementary rather than the accessible name. */
.gw-field--outline .gw-field__control::placeholder {
  color: currentColor;
  opacity: .5;
}

.gw-field--outline .gw-field__label { text-transform: uppercase; }

/* The label runs to three lines here, so the box goes to the top of it rather
   than the middle (`253:2079`). */
.gw-checkbox--outline .gw-checkbox__label { align-items: start; }

.gw-checkbox--outline .gw-checkbox__box {
  border-width: var(--border-hairline);
  border-color: var(--text-primary);
}

/* Checked is the ink, not the brand yellow the default carries — `253:2080` is
   white on the black band. Spelled with --text-primary rather than currentColor
   because `background: currentColor` resolves against the *same* element's
   computed colour, and this rule re-points that colour for the tick: the two
   would collapse to one value and the tick would vanish. Both tokens flip with
   data-theme, so this is still correct on a light surface. */
.gw-checkbox--outline .gw-checkbox__input:checked + .gw-checkbox__label .gw-checkbox__box {
  background: var(--text-primary);
  color: var(--bg-page);
}

/* ----------------------------------------------------------------- contact
   Figma `33:32`. Band padding, heading gap and column gap are all comp values.
   The break is at 900 rather than the comp's 768 because the tablet frame
   `33:34` is empty (768x1500, no children) — below 900 a halved 660 column
   leaves the textarea too narrow to type in and wraps the checkbox copy to
   four lines. */
.gw-contact {
  position: relative;
  padding-block: var(--space-96) var(--space-32);
}
.gw-contact__inner {
  display: grid;
  gap: var(--space-96);
}
.gw-contact__title {
  text-transform: uppercase;
}
.gw-contact__intro {
  display: grid;
  gap: var(--space-16);
}

/* Below the break the form is the DOM order, unchanged. */
.gw-contact__form {
  display: grid;
  gap: var(--space-24);
}

/* The message cell spans all three rows above the break — that is what makes
   the textarea stretch to the height of the name/email/checkbox stack, as
   drawn. The areas rearrange one DOM order; nothing is reordered in markup. */
@media (min-width: 900px) {
  .gw-contact__form {
    grid-template-columns: 1fr 1fr;
    grid-template-areas:
      "name    message"
      "email   message"
      "check   message"
      "submit  submit";
    /* No align-items: start. The message cell spans three rows and has to
       stretch across them; start-aligning every cell left it at the textarea's
       natural `rows` height, which is why the box began well below the name
       field instead of level with it. */
  }
  .gw-contact__cell--name    { grid-area: name; }
  .gw-contact__cell--email   { grid-area: email; }
  .gw-contact__cell--message { grid-area: message; }
  .gw-contact__cell--check   { grid-area: check; }
  .gw-contact__cell--submit  { grid-area: submit; }

  /* `auto 1fr` is what puts the box's top level with the name field's: without
     it the field's two rows are both auto, and grid's default align-content
     stretches BOTH of them, pushing the box down by half the spare space.
     min-height rather than height so the box can still grow past the column
     when someone keeps typing. */
  .gw-contact__cell--message .gw-field {
    min-height: 100%;
    grid-template-rows: auto 1fr;
  }
}

/* Hug the text. `field-sizing` does this natively where it is supported
   (Chrome/Edge 123+, Safari 18.4+); the `autogrow` capability is the fallback
   for everywhere else, Firefox most of all. Both are progressive — with
   neither, the box scrolls, which is the browser default and not broken. */
.gw-contact__cell--message .gw-field__control {
  field-sizing: content;
}

.gw-contact__cell--submit {
  display: flex;
  justify-content: flex-end;
}

/* The confirmation replaces the fields, so it owns the grid alone and does not
   need the column rules. */
.gw-contact__posted {
  display: grid;
  gap: var(--space-16);
  max-width: 48ch;
}
.gw-contact__posted:focus-visible {
  outline: var(--border-thick) solid var(--border-focus);
  outline-offset: var(--space-8);
}

/* Off-screen rather than display:none or hidden, both of which a bot skips. It
   is aria-hidden and tabindex="-1" in the markup, so it is out of the
   accessibility tree and out of the tab order regardless. */
.gw-contact__trap {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* ------------------------------------------------------ quantity selector */
.gw-qty {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  height: 32px;
  padding-inline: var(--space-8);
  border: var(--border-thick) solid currentColor;
  border-radius: var(--radius-full);
}
.gw-qty__step {
  display: inline-flex;
  padding: 0;
  background: none;
  border: 0;
  color: inherit;
  cursor: pointer;
}
.gw-qty__step:disabled { opacity: .35; cursor: not-allowed; }
.gw-qty__input {
  width: 2.5ch;
  padding: 0;
  text-align: center;
  background: transparent;
  border: 0;
  color: inherit;
  -moz-appearance: textfield;
}
/* The native spinners duplicate the two buttons either side of the field and
   push the number off centre. */
.gw-qty__input::-webkit-outer-spin-button,
.gw-qty__input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.gw-qty:focus-within { outline: var(--border-thick) solid var(--border-focus); outline-offset: 2px; }

/* The cart's decrement slot at quantity 1 is a link, not a button. */
.gw-qty__step--remove { text-decoration: none; }

/* ---------------------------------------------------------------- cart line
   Figma "Product" row, 266:6016 / 357:4480 / 266:6117. snippets/cart-line.liquid.
   The row reads on the cart's inverse band, so every colour is a semantic token
   that follows data-theme; only the tile takes a surface of its own. */
.gw-cart-line {
  position: relative;
  display: flex;
  gap: var(--space-24);
  align-items: stretch;
  color: var(--text-primary);
}
/* The line's X (QA 2026-10-07): top-right, a 32px target around a 20px glyph.
   The head keeps clear of it. */
.gw-cart-line__remove {
  position: absolute;
  top: 0;
  right: 0;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  color: inherit;
  border-radius: var(--radius-full);
}
.gw-cart-line__remove:hover { opacity: .6; }
.gw-cart-line__remove:focus-visible { outline: var(--border-thick) solid var(--border-focus); outline-offset: 2px; }
.gw-cart-line[data-gw-line-key] .gw-cart-line__head { padding-inline-end: var(--space-32); }
.gw-cart-line__tile {
  flex: none;
  display: grid;
  place-items: center;
  width: 150px;
  aspect-ratio: 1;
  padding: var(--space-24);
  border-radius: var(--radius-lg);
}
.gw-cart-line__img { width: 100%; height: 100%; object-fit: contain; }
.gw-cart-line__info {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--space-24);
}
.gw-cart-line__text { display: grid; gap: var(--space-12); width: 100%; }
.gw-cart-line__head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--space-16);
}
.gw-cart-line__title { margin: 0; text-transform: uppercase; }
.gw-cart-line__title a { color: inherit; text-decoration: none; }
.gw-cart-line__title a:hover { text-decoration: underline; }
.gw-cart-line__price,
.gw-cart-line__variant,
.gw-cart-line__error { margin: 0; }
.gw-cart-line__price { white-space: nowrap; text-align: right; }
.gw-cart-line__price s { margin-inline-end: var(--space-4); color: var(--text-muted); }
.gw-cart-line__variant { text-transform: uppercase; }
.gw-cart-line__add { margin: 0; }
.gw-cart-line__add-btn {
  display: inline-grid;
  place-items: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: var(--border-thick) solid currentColor;
  border-radius: var(--radius-full);
  background: none;
  color: inherit;
  cursor: pointer;
}
.gw-cart-line__add-btn:focus-visible { outline: var(--border-thick) solid var(--border-focus); outline-offset: 2px; }
/* The upgrade card's action (2026-10-19): the same 2px ink pill as the add
   button, stretched to hold a word, because "Upgrade" swaps what is in the bag
   and an icon alone would not say so. 32px tall to sit level with the add
   buttons in the same row of cards. */
.gw-cart-line__upgrade-btn {
  display: inline-flex;
  align-items: center;
  height: 32px;
  padding-inline: var(--space-16);
  border: var(--border-thick) solid currentColor;
  border-radius: var(--radius-full);
  background: none;
  color: inherit;
  cursor: pointer;
}
.gw-cart-line__upgrade-btn:focus-visible { outline: var(--border-thick) solid var(--border-focus); outline-offset: 2px; }
@media (hover: hover) {
  /* Ink fill on hover, page colour for the label — the outline buttons' move. */
  .gw-cart-line__upgrade-btn:hover { color: var(--bg-page); background: var(--text-primary); }
}
/* The saving is the reason to tap, so it takes the brand ink, not the muted grey. */
.gw-cart-line__saving { margin: 0; color: var(--text-brand); }

@media (max-width: 749px) {
  /* 357:4436 — the price drops under the name, 8 apart. */
  .gw-cart-line__head { flex-direction: column; gap: var(--space-8); }
  .gw-cart-line__price { text-align: left; }
}

/* --------------------------------------------------------------------- cart
   Figma 33:28. sections/main-cart.liquid. The nav floats over the band, so the
   top padding clears it: 68 + 64 = the comp's 132 on desktop, 68 + 28 = 96 on
   the phone. */
.gw-cart { padding-block: calc(var(--gw-nav-height) + var(--space-64)) var(--space-64); }
.gw-cart__content { display: grid; gap: var(--space-48); }

.gw-cart__title {
  display: flex;
  align-items: flex-start;
  gap: var(--space-12);
  margin: 0;
  text-transform: uppercase;
  color: var(--text-primary);
}
/* L Condensed is display-xl's face, weight and tracking at the comp's 120. */
.gw-cart__title-main { font-size: clamp(var(--font-size-64), 8.33vw, var(--font-size-120)); }
.gw-cart__title:focus { outline: none; }

.gw-cart__body { display: grid; gap: var(--space-48); align-items: start; margin: 0; }
.gw-cart__form { display: contents; }
@media (min-width: 990px) {
  /* The comp's 1344 content width: 879 of lines, 48 of gap, 417 of summary. */
  .gw-cart__body { grid-template-columns: 879fr 417fr; }
  /* The lines span both rows so the express buttons stack under the summary. */
  .gw-cart__lines-col { grid-row: span 2; }
  .gw-cart__express { grid-column: 2; }
}
/* Express checkout sits 16px under the summary card, not the grid's 48. The
   button look is Shopify's; its documented properties match it to the
   site's pills. */
.gw-cart__express {
  margin-top: calc(var(--space-16) - var(--space-48));
  --shopify-accelerated-checkout-button-block-size: 48px;
  --shopify-accelerated-checkout-button-border-radius: var(--radius-full);
  --shopify-accelerated-checkout-row-gap: var(--space-8);
}
.gw-cart__lines { display: grid; gap: var(--space-24); margin: 0; padding: 0; list-style: none; }
.gw-cart__update { margin-top: var(--space-24); }
[data-gw-cart-ready] .gw-cart__update { display: none; }
[data-gw-cart][aria-busy="true"] .gw-cart__lines { opacity: .6; transition: opacity .15s; }
/* pointer-events: auto overrides .gw-btn's generic aria-disabled rule (which
   sets pointer-events: none) — a real press must still reach the submit
   handler so the checkout deferral (src/runtime/cart.js) can arm. Without
   this, a mousedown-triggered blur/change goes busy before the click lands,
   the button is hit-tested past, no submit fires, and the press is lost. */
[data-gw-cart] [data-gw-cart-checkout][aria-disabled="true"] { pointer-events: auto; cursor: progress; }

.gw-cart__summary {
  display: grid;
  gap: var(--space-48);
  padding: var(--space-24);
  border-radius: var(--radius-xl);
  color: var(--text-primary);
}
.gw-cart__summary-title { margin: 0; text-transform: uppercase; }
.gw-cart__figures,
.gw-cart__rows { display: grid; gap: var(--space-24); margin: 0; }
.gw-cart__row { display: flex; justify-content: space-between; gap: var(--space-16); }
.gw-cart__row dt,
.gw-cart__row dd { margin: 0; }
.gw-cart__row dd { text-align: right; }
.gw-cart__rule { width: 100%; margin: 0; border: 0; border-top: 1px solid currentColor; }

.gw-cart__empty { display: grid; gap: var(--space-24); justify-items: start; }

.gw-cart__recs { display: grid; gap: var(--space-40); margin-top: var(--space-16); }
.gw-cart__recs[hidden] { display: none; }
.gw-cart__recs-title { margin: 0; color: var(--text-primary); }
.gw-cart__recs-list { display: grid; gap: var(--space-32); margin: 0; padding: 0; list-style: none; }
@media (min-width: 750px) {
  .gw-cart__recs-list { grid-template-columns: 1fr 1fr; }
}

@media (max-width: 749px) {
  .gw-cart { padding-top: calc(var(--gw-nav-height) + var(--space-24) + var(--space-4)); }
  .gw-cart__content { gap: var(--space-24); }
  .gw-cart__title-main { font-size: var(--font-size-64); }
  .gw-cart__title-count { font-size: var(--font-size-32); }
  /* 357:4451 draws the summary card edge to edge on the phone. On a real
     iPhone it read as brushing the screen sides (QA 2026-10-19), so it keeps
     the same 24px gutter as the lines above it. */
  .gw-cart__recs { margin-top: var(--space-24); }
}

/* -------------------------------------------------------------------- PDP
   Figma PDP Hero 241:552. Gallery beside the buy box, with a sticky bar across
   the bottom of the hero. The hero fill comes from the product's
   `custom.surface` metafield, not a section setting — one template serves both
   products, so anything that differs between them has to travel with the
   product. See docs/PRODUCT-DATA.md. */
/* Exactly one screen on desktop: the gallery row takes whatever the sticky bar
   leaves. min-height, not height, so a short window grows the hero to fit the
   buy box rather than clipping it. svh after vh, so the mobile-browser toolbar
   does not push the sticky bar below the fold where it is supported. */
.gw-pdp {
  position: relative;
  display: grid;
  grid-template-rows: 1fr auto;
  min-height: 100vh;
  min-height: 100svh;
  padding: var(--space-48);
  padding-top: calc(var(--gw-nav-height) + var(--space-24));
  /* No bottom padding: the buy bar is the hero's bottom edge. */
  padding-bottom: 0;
}
.gw-pdp__inner {
  display: grid;
  grid-template-columns: 1fr minmax(20rem, 26rem);
  gap: var(--space-48);
  align-items: stretch;
}
/* Centred on the gallery row, so the buy box lines up with the arrows and the
   ball rather than hanging from the top of the photos. */
.gw-pdp__inner > .gw-buybox { align-self: center; }

.gw-pdp__gallery {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-16);
  padding-inline: var(--space-24);
}
/* The slides are absolutely positioned, so they size to the gallery and never
   the other way round — an image's intrinsic height cannot push the hero past
   one screen. The gallery's height comes from the grid row. */
.gw-pdp__main { position: relative; flex: 1; align-self: stretch; min-width: 0; }
.gw-pdp__main-empty { aspect-ratio: 1; width: 100%; }

.gw-pdp__slide { position: absolute; inset: 0; display: flex; gap: var(--space-8); }
/* display:flex above would otherwise beat the UA's [hidden] { display: none }. */
.gw-pdp__slide[hidden] { display: none; }

/* The product cutout: whole, centred, never cropped. */
.gw-pdp__slide--product { align-items: center; justify-content: center; }
.gw-pdp__product-img { width: auto; height: 65%; max-width: 100%; object-fit: contain; scale: var(--gw-ball-scale, 1); }

/* Slide changes. gallery.js marks the incoming slide data-gw-slide-enter and
   keeps the outgoing one on screen with data-gw-slide-leave until its animation
   ends — both carry the direction of travel. "next" travels leftward: the new
   slide comes in from the right and the old one leaves to the left; "prev"
   mirrors it. A 10% push with a fade reads as a slide without turning the hero
   into a full-width carousel swipe. The leaving slide keeps a duration, because
   that is what the runtime reads to know how long to keep it. */
.gw-pdp__slide[data-gw-slide-enter] { animation: .45s cubic-bezier(.22, 1, .36, 1) both; }
.gw-pdp__slide[data-gw-slide-leave] { animation: .45s cubic-bezier(.22, 1, .36, 1) both; pointer-events: none; }

@media (prefers-reduced-motion: no-preference) {
  .gw-pdp__slide[data-gw-slide-enter='next'] { animation-name: gw-pdp-in-next; }
  .gw-pdp__slide[data-gw-slide-enter='prev'] { animation-name: gw-pdp-in-prev; }
  .gw-pdp__slide[data-gw-slide-leave='next'] { animation-name: gw-pdp-out-next; }
  .gw-pdp__slide[data-gw-slide-leave='prev'] { animation-name: gw-pdp-out-prev; }
}
/* Reduced motion keeps the crossfade and drops the travel. */
@media (prefers-reduced-motion: reduce) {
  .gw-pdp__slide[data-gw-slide-enter] { animation-name: gw-pdp-fade-in; }
  .gw-pdp__slide[data-gw-slide-leave] { animation-name: gw-pdp-fade-out; }
}

@keyframes gw-pdp-in-next  { from { opacity: 0; transform: translateX(10%); } }
@keyframes gw-pdp-in-prev  { from { opacity: 0; transform: translateX(-10%); } }
@keyframes gw-pdp-out-next { to { opacity: 0; transform: translateX(-10%); } }
@keyframes gw-pdp-out-prev { to { opacity: 0; transform: translateX(10%); } }
@keyframes gw-pdp-fade-in  { from { opacity: 0; } }
@keyframes gw-pdp-fade-out { to { opacity: 0; } }

/* Photos fill their slide; a pair splits it evenly. */
.gw-pdp__photo {
  flex: 1 1 0;
  min-width: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: var(--radius-xl);
}

.gw-pdp__arrow {
  flex: none;
  padding: 0;
  background: none;
  border: 0;
  color: inherit;
  cursor: pointer;
  /* Above the slides: a photo sliding out passes over the arrow's position. */
  position: relative;
  z-index: 1;
}
/* One glyph, rotated, rather than a second asset. The Figma set has no
   arrow-circle-LEFT — the comp rotates the same instance 180deg. */
.gw-pdp__arrow--prev { transform: rotate(180deg); }
/* Grow on hover. The individual `scale` property, not `transform`, so it
   composes with the prev arrow's rotate instead of replacing it. Hover only
   where there is a real pointer — on touch, :hover sticks after a tap. */
@media (hover: hover) {
  .gw-pdp__arrow:hover { scale: 1.15; }
}
.gw-pdp__arrow:focus-visible { scale: 1.15; }
@media (prefers-reduced-motion: no-preference) {
  .gw-pdp__arrow { transition: scale .2s cubic-bezier(.22, 1, .36, 1); }
}
.gw-pdp__arrow:focus-visible { outline: var(--border-thick) solid var(--border-focus); outline-offset: 4px; border-radius: var(--radius-full); }

/* The comp shows arrows and no thumbnail strip. The thumbnails still exist —
   they are the list the arrows step through, and the only way to jump to a
   specific image. Clipped out of sight, NOT display:none, so they stay
   focusable and countable for keyboard and screen-reader users. */
.gw-pdp__thumbs {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

/* -------------------------------------------------------------- buy box */
.gw-buybox {
  display: grid;
  gap: var(--space-16);
  padding: var(--space-24);
  background: var(--bg-brand);
  border: var(--border-thick) solid var(--border-strong);
  border-radius: var(--radius-lg);
  color: var(--text-on-brand);
}
/* On the cyan PDP the box takes the cyan fill, so it reads as a panel on the
   hero rather than a yellow card floating on blue. */
.gw-surface--accent .gw-buybox { background: var(--bg-accent); color: var(--text-on-accent); }

.gw-buybox__head { display: grid; gap: var(--space-12); justify-items: start; }
.gw-buybox__title { margin: 0; }
/* The reviews app injects here. Empty until one is installed, and it must not
   reserve space when empty or the buy box has a hole in it. */
.gw-buybox__rating:empty { display: none; }

.gw-buybox__pricing { display: grid; gap: var(--space-12); justify-items: start; }
.gw-buybox__price { display: flex; align-items: flex-end; gap: var(--space-8); margin: 0; }
.gw-buybox__was { color: var(--text-secondary); }
.gw-buybox__policy { margin: 0; }

.gw-buybox__form { display: grid; gap: var(--space-16); }
.gw-buybox__cta { display: grid; gap: var(--space-8); padding-block: var(--space-8); }
.gw-buybox__cta-row { display: flex; align-items: center; gap: var(--space-8); }
.gw-buybox__restock { display: grid; gap: var(--space-8); }

.gw-buybox__about-summary {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  cursor: pointer;
  list-style: none;
}
.gw-buybox__about-summary::-webkit-details-marker { display: none; }
.gw-buybox__chev { transition: transform .18s ease; }
.gw-buybox__about[open] .gw-buybox__chev { transform: rotate(180deg); }
.gw-buybox__about-body { margin-top: var(--space-8); display: grid; gap: var(--space-8); }

/* ------------------------------------------------------- variant picker */
.gw-variants { display: grid; gap: var(--space-8); }
.gw-variants__group { margin: 0; padding: 0; border: 0; }
.gw-variants__legend { padding: 0; margin-bottom: var(--space-8); }
.gw-variants__options { display: flex; flex-wrap: wrap; gap: var(--space-16); }
.gw-variants__pill {
  display: inline-flex;
  align-items: center;
  height: 32px;
  padding-inline: var(--space-12);
  border: var(--border-thick) solid currentColor;
  border-radius: var(--radius-full);
  cursor: pointer;
}
.gw-variants__input:checked + .gw-variants__pill {
  background: var(--bg-inverse);
  color: var(--text-inverse);
}
/* The input is visually hidden but focusable, so the ring goes on the pill. */
.gw-variants__input:focus-visible + .gw-variants__pill {
  outline: var(--border-thick) solid var(--border-focus);
  outline-offset: 2px;
}
.gw-variants__pill.is-unavailable {
  opacity: .45;
  cursor: not-allowed;
  text-decoration: line-through;
}

/* ------------------------------------------------------------- buy bar
   Fixed to the bottom edge of the screen from the start: at the top of the
   page it is the bottom strip of the hero, where the slot below holds its
   place, and from there it stays as the page scrolls. buy-bar.js reports two
   states — expanded (the buy box has scrolled away) and away (the footer is
   showing) — and everything they look like is here.

   The fill is always the product's own colour. On the hero that is the hero's
   fill, so the bar is invisible there and reads as type on the hero, as the
   comp draws it; once other sections pass underneath it is already a solid
   strip. Only its top rule waits for expanded. */
.gw-pdp__buybar-slot { height: var(--gw-buybar-h, 4rem); margin-top: var(--space-24); }

.gw-buybar {
  --gw-buybar-ease: cubic-bezier(.22, 1, .36, 1);
  position: fixed;
  z-index: 2;
  inset-inline: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-24);
  padding: var(--space-12) var(--space-24);
  background: var(--bg-brand);
  color: var(--text-on-brand);
  border-top: var(--border-thick) solid transparent;
}
.gw-surface--accent .gw-buybar { background: var(--bg-accent); color: var(--text-on-accent); }
.gw-buybar[data-gw-buybar-expanded] { border-top-color: var(--border-strong); }

.gw-buybar__title { margin: 0; }
.gw-buybar__end { display: flex; align-items: center; }

/* The buttons' column opens from zero width, which is what pushes the price
   left, while the buttons themselves scale up into it. visibility, not just
   opacity: collapsed buttons must not be tab stops. It flips at the END of the
   collapse and the START of the expand, so it never cuts an animation short. */
.gw-buybar__actions {
  display: grid;
  grid-template-columns: 0fr;
  visibility: hidden;
}
.gw-buybar__actions-inner {
  display: flex;
  align-items: center;
  gap: var(--space-24);
  min-width: 0;
  overflow: hidden;
  /* Room above and below for the focus ring, which overflow: hidden would
     otherwise clip; the negative margin gives the space back. Block only —
     inline padding would set a floor on the collapsed width and hold the
     price off the edge. The inline room is margin on the buttons instead,
     which is clipped content and collapses to nothing with the column. */
  padding-block: var(--space-8);
  margin-block: calc(-1 * var(--space-8));
}
.gw-buybar__actions-inner > :first-child { margin-left: var(--space-24); }
.gw-buybar__actions-inner > :last-child { margin-right: var(--space-4); }
.gw-buybar__actions .gw-btn { flex: none; scale: .6; opacity: 0; }
.gw-buybar[data-gw-buybar-expanded] .gw-buybar__actions { grid-template-columns: 1fr; visibility: visible; }
.gw-buybar[data-gw-buybar-expanded] .gw-buybar__actions .gw-btn { scale: 1; opacity: 1; }

/* Away: slid off the bottom of the screen while the footer is showing. */
.gw-buybar[data-gw-buybar-away] {
  translate: 0 100%;
  visibility: hidden;
}

@media (prefers-reduced-motion: no-preference) {
  .gw-buybar {
    transition:
      border-top-color .3s var(--gw-buybar-ease),
      translate .4s var(--gw-buybar-ease),
      visibility 0s linear .4s;
  }
  .gw-buybar:not([data-gw-buybar-away]) {
    transition:
      border-top-color .3s var(--gw-buybar-ease),
      translate .4s var(--gw-buybar-ease),
      visibility 0s;
  }
  .gw-buybar__actions {
    transition: grid-template-columns .45s var(--gw-buybar-ease), visibility 0s linear .45s;
  }
  .gw-buybar[data-gw-buybar-expanded] .gw-buybar__actions {
    transition: grid-template-columns .45s var(--gw-buybar-ease), visibility 0s;
  }
  .gw-buybar__actions .gw-btn { transition: scale .45s var(--gw-buybar-ease), opacity .3s linear; }
}

@media (max-width: 999px) {
  .gw-pdp__inner { grid-template-columns: 1fr; }
  .gw-pdp {
    min-height: 0;
    padding: var(--space-24);
    padding-top: calc(var(--gw-nav-height) + var(--space-16));
  }
  /* Single column: a swipe gallery with dots (QA 2026-10-07 — "remove the
     arrows, swipe with dots beneath, so the image takes up more space"). The
     arrows and their 24px gutters go, the image runs the full width as a square,
     and the slides sit side by side in a scroll-snap track. gallery.js reads the
     snap from this CSS (data-gw-gallery-track) and marks the dot in view. */
  .gw-pdp__gallery { padding-inline: 0; flex-direction: column; align-items: stretch; }
  .gw-pdp__arrow { display: none; }
  .gw-pdp__main {
    aspect-ratio: 1;
    display: flex;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
    /* A gutter between photos as one slides out and the next in (QA
       2026-10-14). gallery.js scrolls to each slide's offsetLeft, which
       already counts the gap, so the dots still land square. */
    gap: var(--space-12);
  }
  .gw-pdp__main::-webkit-scrollbar { display: none; }
  .gw-pdp__slide { position: relative; inset: auto; flex: 0 0 100%; scroll-snap-align: center; }
  .gw-pdp__slide[hidden] { display: flex; }
  .gw-pdp__slide[data-gw-slide-enter],
  .gw-pdp__slide[data-gw-slide-leave] { animation: none; }
  .gw-pdp__product-img { height: 78%; }

  /* The thumbnails, un-clipped, as dots: 8px marks in 24px hit areas, filled
     for the slide in view. They keep their "Image n / total" labels. */
  .gw-pdp__thumbs {
    position: static;
    width: auto;
    height: auto;
    overflow: visible;
    clip-path: none;
    display: flex;
    justify-content: center;
  }
  .gw-pdp__thumb {
    display: grid;
    place-items: center;
    width: 24px;
    height: 24px;
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    cursor: pointer;
  }
  .gw-pdp__thumb img { display: none; }
  .gw-pdp__thumb::before {
    content: '';
    width: 8px;
    height: 8px;
    border-radius: var(--radius-full);
    border: var(--border-hairline) solid currentColor;
  }
  .gw-pdp__thumb[aria-current='true']::before { background: currentColor; }
  /* currentColor, not --border-focus: cyan-600 vanishes on the cyan PDP. */
  .gw-pdp__thumb:focus-visible { outline: var(--border-thick) solid currentColor; outline-offset: 2px; border-radius: var(--radius-full); }
  /* One column: the buy box sits directly above where the bar would rest, and
     two copies of the same price stacked is noise. So on a narrow screen the
     bar only exists once the buy box has scrolled away — hidden at rest the
     same way it hides for the footer — and the hero keeps no slot for it. */
  /* Figma 355:5522: title and price on one line, the two buttons full width
     beneath them, Add to Cart first. */
  .gw-pdp__buybar-slot { height: 0; margin: 0; }
  .gw-buybar {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas: "title price" "actions actions";
    align-items: center;
    column-gap: var(--space-16);
    row-gap: var(--space-12);
  }
  .gw-buybar:not([data-gw-buybar-expanded]) {
    translate: 0 100%;
    visibility: hidden;
  }
  .gw-buybar__end { display: contents; }
  /* The H4 treatment — the condensed face — at this size, as the comp sets it. */
  .gw-buybar__title {
    grid-area: title;
    font-family: var(--font-family-condensed);
    font-weight: 800;
    font-size: var(--font-size-32);
    line-height: .95;
    letter-spacing: -0.02em;
  }
  .gw-buybar__price { grid-area: price; }
  /* The bar is only ever seen expanded here, so the buttons need no entrance
     of their own — the bar's slide up is the entrance. */
  .gw-buybar__actions { grid-area: actions; grid-template-columns: 1fr; }
  .gw-buybar__actions-inner { gap: var(--space-8); padding: 0; margin: 0; overflow: visible; }
  .gw-buybar__actions-inner > :first-child,
  .gw-buybar__actions-inner > :last-child { margin: 0; }
  .gw-buybar__actions .gw-btn { flex: 1 1 0; justify-content: center; scale: 1; opacity: 1; }
  .gw-buybar__actions .gw-btn--secondary { order: -1; }
}
/* Collapsing is a slide off the bottom here, so visibility has to wait for it —
   otherwise the bar blinks out instead of leaving. */
@media (max-width: 999px) and (prefers-reduced-motion: no-preference) {
  .gw-buybar:not([data-gw-buybar-expanded]) {
    transition: translate .4s var(--gw-buybar-ease), visibility 0s linear .4s;
  }
}

@media (prefers-reduced-motion: reduce) {
  .gw-buybox__chev { transition: none; }
}

/* ------------------------------------------------------------- testimonials
   Figma 241:1036. Native horizontal scrolling with snap, not a JS carousel: the
   list has to work by touch, trackpad, keyboard and screen reader whatever the
   decoration on top, and the platform gives all four. */
.gw-testimonials { padding-inline: 0; }
.gw-testimonials__heading { text-align: center; padding-inline: var(--space-48); margin-bottom: var(--space-48); }
.gw-testimonials__viewport { position: relative; }
/* Figma 241:1036: 96 above and below, the heading 48 over the row. */
.gw-testimonials { padding-block: var(--space-96); }

/* Items are spaced by their own margin, not the track's gap: the drift moves
   the track by -50%, which only lands on a seam if both halves are the same
   width, and a gap sits only BETWEEN items (docs/MOTION.md). */
.gw-testimonials__track {
  display: flex;
  width: auto;
  margin: 0;
  padding: 0 0 0 var(--space-48);
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
}
.gw-testimonials__track::-webkit-scrollbar { display: none; }
/* Sized by `width` with `flex: none`, NOT by flex-basis. WebKit takes a flex
   item's max-content contribution from its content when only the basis is set
   — the unwrapped quote, ~600px — so the drifting `max-content` track measured
   6430px around 2980px of cards, and the -50% loop ran at double speed into a
   blank stretch before jumping (QA 2026-10-14, iPhone). */
.gw-testimonials__item { flex: none; width: 274px; margin-inline-end: var(--space-48); scroll-snap-align: center; }

/* Drifting: the viewport clips, the track is as wide as its copies, and there
   is nothing to scroll or snap. Everything above is the not-drifting row. */
.gw-testimonials__viewport[data-gw-marquee-running] { overflow: hidden; }
.gw-testimonials__viewport[data-gw-marquee-running] .gw-testimonials__track {
  width: max-content;
  padding: 0;
  overflow: visible;
  scroll-snap-type: none;
}

/* The track is a focus stop (it has tabindex), so it needs a visible ring —
   an unfocusable scroll region cannot be scrolled by keyboard, and an
   unringed one leaves the user with no idea they have landed on it. */
.gw-testimonials__track:focus-visible { outline: var(--border-thick) solid var(--border-focus); outline-offset: -2px; }

/* Edge fades. pointer-events:none so they never swallow a drag on the track
   underneath, which is exactly how a fade breaks touch scrolling. */
.gw-testimonials__viewport::before,
.gw-testimonials__viewport::after {
  content: '';
  position: absolute;
  top: 0; bottom: 0;
  width: var(--space-96);
  pointer-events: none;
  z-index: 1;
}
.gw-testimonials__viewport::before { left: 0; background: linear-gradient(to right, var(--bg-page), transparent); }
.gw-testimonials__viewport::after { right: 0; background: linear-gradient(to left, var(--bg-page), transparent); }

/* ----------------------------------------------------------------- review */
.gw-review { margin: 0; display: grid; gap: var(--space-12); }
.gw-review__media { border-radius: var(--radius-md); overflow: hidden; aspect-ratio: 274 / 202; }
.gw-review__img { width: 100%; height: 100%; object-fit: cover; }
.gw-review__body { display: grid; gap: var(--space-8); }
/* 12 from the stars to the quote (the comp's 11), 8 from quote to name. */
.gw-review__stars { display: flex; gap: 1.5px; margin-bottom: var(--space-4); color: var(--gw-star-fill); }
/* The comp draws the stars in yellow/300, a pale step that no semantic token
   carries — the same case as the form-field fill above. */
.gw-review { --gw-star-fill: var(--bg-brand-sunken); }
.gw-review__star .gw-icon { width: 21px; height: 21px; }
.gw-review__star.is-empty { opacity: .25; }
/* The comp's quote marks hang: the opening mark sits outside the text edge so
   the words line up with the stars and the name. Added here rather than typed
   into each review, so a pasted quote never ends up double-marked. */
.gw-review__quote { margin: 0; text-transform: uppercase; quotes: "“" "”"; text-indent: -0.45em; }
.gw-review__quote::before { content: open-quote; }
.gw-review__quote::after { content: close-quote; }
.gw-review__author { margin: 0; color: var(--text-brand); }

/* -------------------------------------------------------------------- FAQ
   Figma 244:2063. Rows are <details>, so the answers are in the DOM and
   indexable whether or not they are open — the reason this is not a div and a
   click handler. */
/* Two columns only when there IS an image. Keyed off the image rather than
   always reserving it, because an explicit first track is filled by whatever
   comes first — with no image that was the question list, squeezed into the
   narrow image column with the wide one left empty. Proportional tracks from
   the 1440 comp (459 image, 861 list), so the pair scales with the container;
   the image keeps the comp's 459x801 shape, hanging from the top. */
.gw-faq__inner {
  display: grid;
  gap: var(--space-24);
  align-items: start;
}
.gw-faq__inner:has(> .gw-faq__media) { grid-template-columns: minmax(0, 459fr) minmax(0, 861fr); }
.gw-faq__media { aspect-ratio: 459 / 801; border-radius: 30px; overflow: hidden; }
.gw-faq__img { width: 100%; height: 100%; object-fit: cover; }
.gw-faq__list { display: grid; gap: var(--space-24); }
.gw-faq__heading { margin-bottom: var(--space-8); }

/* FAQ colour (2026-09-30, Adam): one of the two brand pairs, never a free hex —
   the same fill + "a shade deeper" pairs the outline buttons hover through. The
   section always carries one of these; the fallbacks below are the yellow pair. */
.gw-faq--brand {
  --gw-faq-ink: var(--text-brand);
  --gw-faq-fill: var(--bg-brand);
  --gw-faq-hover: var(--action-primary-hover);
  --gw-faq-on-fill: var(--text-on-brand);
}
.gw-faq--accent {
  --gw-faq-ink: var(--text-accent);
  --gw-faq-fill: var(--bg-accent);
  --gw-faq-hover: var(--bg-accent-sunken);
  --gw-faq-on-fill: var(--text-on-accent);
}

/* A pair on its own surface (cyan FAQ on the cyan band, yellow on yellow): the
   brand ink is ~3:1 on its own fill and an open row's fill-coloured edge vanishes,
   so the ink goes to the on-fill black and open rows keep an edge in it. */
.gw-faq--brand.gw-surface--brand   { --gw-faq-ink: var(--gw-faq-on-fill); }
.gw-faq--accent.gw-surface--accent { --gw-faq-ink: var(--gw-faq-on-fill); }
.gw-faq--brand.gw-surface--brand .gw-faq__item[open],
.gw-faq--accent.gw-surface--accent .gw-faq__item[open] { border-color: var(--gw-faq-ink); }

.gw-faq__item {
  padding: var(--space-48);
  border: var(--border-hairline) solid var(--gw-faq-ink, var(--text-brand));
  border-radius: 30px;
  transition: background-color .18s ease, border-color .18s ease;
}
/* Open rows invert to the pair's fill, which is the comp — and its on-fill ink
   comes with it so the question does not stay yellow on yellow. */
.gw-faq__item[open] {
  background: var(--gw-faq-fill, var(--bg-brand));
  color: var(--gw-faq-on-fill, var(--text-on-brand));
  border-color: var(--gw-faq-fill, var(--bg-brand));
}
.gw-faq__question {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-24);
  cursor: pointer;
  list-style: none;
  color: var(--gw-faq-ink, var(--text-brand));
  transition: color .18s ease;
}
.gw-faq__item[open] .gw-faq__question { color: inherit; }
/* Hover only where there is a pointer: on a phone a tap would leave the row stuck
   in its hover shade. Closed rows move outline and question to the hover shade
   only on black: yellow/400 and cyan/400 are ~1.3:1 on a light surface, so there
   the row would fade rather than respond. Open rows deepen their fill anywhere. */
@media (hover: hover) {
  [data-theme="inverse"] .gw-faq__item:not([open]):hover { border-color: var(--gw-faq-hover); }
  [data-theme="inverse"] .gw-faq__item:not([open]):hover .gw-faq__question { color: var(--gw-faq-hover); }
  .gw-faq__item[open]:hover { background: var(--gw-faq-hover); border-color: var(--gw-faq-hover); }
}
.gw-faq__question::-webkit-details-marker { display: none; }
.gw-faq__question:focus-visible { outline: var(--border-thick) solid var(--border-focus); outline-offset: 4px; }
.gw-faq__chev {
  flex: none; width: 48px; height: 20px; transition: transform .18s ease;
  /* chevron-wide's glyph spans x 16→48 of its 48-wide box, so its centre is
     x=32 (66.667%), not 50% — rotating about the box centre threw it 16px to
     the side (QA 2026-10-07). The viewBox is generated from Figma
     (scripts/build-icons.mjs), so the fix lives here, not in the icon. */
  transform-origin: 66.6667% 50%;
}
.gw-faq__item[open] .gw-faq__chev { transform: rotate(180deg); }
.gw-faq__answer { margin-top: var(--space-24); max-width: 800px; }

/* ------------------------------------------------------------- app slot */
.gw-app-slot__heading { margin-bottom: var(--space-24); }
.gw-app-slot__empty {
  padding: var(--space-48);
  border: var(--border-thick) dashed var(--border-default);
  border-radius: var(--radius-lg);
  text-align: center;
  color: var(--text-muted);
}

/* Judge.me in the slot (launch, 2026-10-21). LoFli runs Judge.me's v3 widget
   (.jdgm-widget-revamp), which takes its palette from --jm-* custom properties
   that it writes as an INLINE style from the app's settings: teal, dark text,
   for a white page. Inline beats a stylesheet, so these are !important — the
   one way a stylesheet outranks an inline declaration. They point at the
   section's semantic tokens, so the widget follows whichever surface the slot
   is set to. Scoped to the slot: star badges elsewhere keep Judge.me's look,
   and the write-a-review form (a modal mounted outside the slot) keeps its own
   light panel. */
.gw-app-slot [style*="--jm-review-widget-text-color"] {
  --jm-review-widget-text-color: var(--text-primary) !important;
  --jm-review-widget-lighter-text-color: color-mix(in srgb, var(--text-primary) 70%, transparent) !important;
  --jm-review-widget-star-and-histogram-color: var(--text-brand) !important;
  --jm-widget-primary-color: var(--text-brand) !important;
  --jm-review-widget-button-color: var(--text-brand) !important;
  --jm-review-widget-button-text-color: var(--bg-page) !important;
}
.gw-app-slot .jdgm-widget-revamp { color: var(--text-primary); font-family: var(--font-family-display); }
/* Stars are coloured through a hashed per-component variable, not the --jm-*
   set above, so their paths are inked directly: every outline, and only the
   full stars filled, so an empty star stays an outline. A half star is a
   gradient whose first stop carries the colour (the CSS property outranks
   the stop-color attribute), so it keeps its half. */
.gw-app-slot .jm-star-rating svg path { stroke: var(--text-brand) !important; }
.gw-app-slot .jm-star-rating__filled svg path { fill: var(--text-brand) !important; }
.gw-app-slot .jm-star-rating__half stop:first-child { stop-color: var(--text-brand); }
.gw-app-slot .jm-star-rating__font-icon { color: var(--text-brand) !important; }
/* The section prints its own "Reviews" heading in brand type. */
.gw-app-slot .jm-review-widget-header__title { display: none; }

.gw-media-text__specs { display: flex; flex-wrap: wrap; gap: var(--space-24); }

@media (max-width: 999px) {
  .gw-faq__inner:has(> .gw-faq__media) { grid-template-columns: 1fr; }
  .gw-faq__media { aspect-ratio: 4 / 3; }
  .gw-faq__item { padding: var(--space-24); }
}
@media (max-width: 749px) {
  .gw-testimonials__heading { padding-inline: var(--space-24); }
  .gw-testimonials { padding-block: var(--space-64); }
  .gw-testimonials__track { padding-left: var(--space-24); }
  /* Never the full phone width, so a neighbour always peeks in — the cue that
     the still (Reduce Motion) row can be swiped (QA 2026-10-07). */
  .gw-testimonials__item { width: min(274px, 72vw); margin-inline-end: var(--space-24); }
  .gw-testimonials__viewport::before,
  .gw-testimonials__viewport::after { width: var(--space-24); }
}

@media (prefers-reduced-motion: reduce) {
  .gw-faq__chev,
  .gw-faq__item,
  .gw-faq__question { transition: none; }
  /* The testimonial row keeps its snap here: snapping is not animation, and it
     is what makes the still row read as a carousel (QA 2026-10-07). */
}

/* --------------------------------------------------------------- hero
   Figma 244:3664 / 357:3885, the "Hero Banner" component. Full-bleed image, one
   oversized condensed line bottom-left.

   The banner proportions live on --media, not the base class: Home and About run
   this section with a heading and no image, and they should size to their
   content rather than reserve an empty band. The height is the comp's 563/1440
   ratio rather than a pixel value, capped so a short window keeps the headline. */
.gw-hero {
  position: relative;
  display: grid;
  align-items: end;
  overflow: hidden;
}
.gw-hero--media { min-height: min(39vw, 80vh); }
/* Opt-in, so the blog hero stays a banner (About fills a phone screen by its
   own rule in the phone block below). `svh` second: on mobile
   `100vh` is the LARGE viewport, so the hero sits taller than the visible area
   until the address bar retracts and the bottom of the headline is cut off.
   `svh` is the small-viewport height, which is what "fills the screen" means
   to someone looking at it. Ordered so a browser without svh keeps vh. */
.gw-hero--full { min-height: 100vh; min-height: 100svh; }
.gw-hero__media { position: absolute; inset: 0; }
/* The parallax layer starts 20% above the section. motion.js scrubs it down by
   up to 0.15 × its own height, and a hero at the top of the page is already
   halfway through that at scroll 0 — cut flush, the image sits ~8% low and the
   scrim over the bare page shows as a grey band under the nav. 20% covers
   0.15 × 1.2; src/theme/__tests__/hero.test.js keeps the two numbers in step. */
.gw-hero__media[data-gw-parallax] { top: -20%; }
.gw-hero__img { width: 100%; height: 100%; object-fit: cover; }

/* Same two-layer treatment as the bg card: a flat wash over the whole frame and
   a gradient weighted to the headline end. Brand yellow on an uncontrolled
   photograph has no contrast guarantee, and docs/BRAND.md rules out lightening
   the type instead. */
.gw-hero__scrim {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(to top, rgb(16 24 32 / 0.55) 0%, rgb(16 24 32 / 0) 55%),
    rgb(0 0 0 / 0.2);
}
.gw-hero__inner {
  position: relative;
  width: 100%;
  padding-block: var(--space-48);
}
/* Figma `197:93`: the headline sits bottom-left and the support copy + button
   form a narrow column to its RIGHT. Two grid columns rather than flow, so the
   aside can sit beside the heading instead of under it. The 296px is the comp's
   own measure (`261:2569`).

   The column hangs from the TOP of the headline (Adam, 2026-10-20), not from
   the comp's bottom: `align-items: start` keeps it level with the first line
   however many lines the headline runs to. The section itself still sits the
   whole lede at the bottom of the image (`.gw-hero { align-items: end }`).

   Below 750 there is no room for a column beside 260px type, so it stacks. */
.gw-hero__lede {
  display: grid;
  grid-template-columns: auto minmax(0, 296px);
  align-items: start;
  column-gap: var(--space-48);
  row-gap: var(--space-16);
}
.gw-hero__aside {
  display: grid;
  gap: var(--space-24);
  justify-items: start;
}
.gw-hero__heading {
  /* type: .gw-type-display-xl. Uppercase is applied on the node in the comp,
     not by the text style — the same call as .gw-statement__text. */
  text-transform: uppercase;
  color: var(--text-brand);
}
/* White over a photograph, not brand yellow: the comp sets both headline nodes
   (`197:115` / `197:113`) to white.

   The token is `--text-primary`, NOT `--text-inverse`, and the difference is
   the whole bug this replaced. hero.liquid puts `data-theme="inverse"` on the
   section whenever there is an image, and under that theme the semantic pair
   SWAPS: `--text-primary` becomes white and `--text-inverse` becomes
   neutral-900. Reaching for the token with "inverse" in its name on an
   inverted section gets you the dark value — which is exactly what shipped,
   a navy headline over the photo.

   Scoped to `--media` so a hero with no image (About) keeps the brand colour
   it already had, rather than this quietly restyling a page nobody asked
   about. `--media` and `data-theme="inverse"` are both keyed on the same
   `image != blank`, so inside this rule `--text-primary` is always white. */
.gw-hero--media .gw-hero__heading,
.gw-hero--media .gw-hero__body {
  color: var(--text-primary);
}
/* Each authored line is its own block, so the break comes from the copy rather
   than from wherever the measure happens to fall at 260px. */
.gw-hero__line { display: block; }
.gw-statement__line { display: block; }
.gw-hero__body {
  /* type: .gw-type-h6 — Heading/H6 is exactly the comp's support copy
     (`261:2569`): Albert Sans Black 20/1.05/-4%. Uppercase is on the node.
     Colour comes from the `--media` rule above, for the reason given there. */
  text-transform: uppercase;
}
.gw-hero__body > :last-child { margin-bottom: 0; }

@media (max-width: 749px) {
  .gw-hero--media { min-height: min(144vw, 80vh); }
  /* About opens on a full phone screen, so nothing of the next band shows
     under it (QA 2026-10-14). `svh` for the reason given at .gw-hero--full:
     the headline sits at the bottom and must clear Safari's toolbar. */
  .template-page-about .gw-hero { min-height: 100vh; min-height: 100svh; }
  .gw-hero__lede { grid-template-columns: minmax(0, 1fr); }
  .gw-hero__inner { padding-block: var(--space-24); }
}

/* ------------------------------------------------------------ blog listing
   Figma 33:21 (desktop) / 33:23 (mobile). Black filter bar, then a two-up grid
   of article cards on the page surface. */
/* The colour is set on the bar, not the count: the section's brand surface
   makes every <p> inherit (`.gw-surface--brand :is(p)`, which outranks a single
   class), so the count takes whatever the bar says — white, under inverse. */
.gw-blog__bar { background: var(--bg-page); color: var(--text-primary); }
/* A fixed 48 inset at every width — the site has no max-width container, so
   this is deliberately not .gw-container. */
.gw-blog__bar-inner {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-48);
  min-height: 76px;
  padding-inline: var(--space-48);
}
/* 357:3911 centres the pair and closes the gap to 12. */
@media (max-width: 749px) {
  .gw-blog__bar-inner { justify-content: center; gap: var(--space-12); }
}
.gw-blog__count {
  /* type: .gw-type-caption — Body/Caption in 244:2840, full-strength ink/inverse */
  margin: 0;
}
.gw-blog__empty { padding-block: var(--space-96); text-align: center; }

/* The comp is a plain two-up at 1440 and a one-up at 390. There is no 768
   artboard in the file — every Tablet frame is empty — so the middle step is
   this theme's decision, taken to match .gw-card-grid's breakpoints rather than
   invent a third set. */
.gw-blog__grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 749px) {
  .gw-blog__grid { grid-template-columns: 1fr; }
}

/* Every card in the comp is square — 720 at desktop, 390 at mobile. The square
   is also load-bearing: an inset card's image is flex: 1, so without a definite
   card height it has nothing to grow into and collapses to zero. The image
   panel is radius-xl here (244:2329), a step up from the small card's lg. */
.gw-blog__grid .gw-card { aspect-ratio: 1; }

/* Hover sinks the yellow panel to bg/brand-sunken. Scoped to the brand surface:
   the section lets the card colour be changed, and sunken yellow on a cyan or
   black card would be a different colour, not a hover. */
.gw-blog__grid .gw-card--link.gw-surface--brand { transition: background-color .18s ease; }
.gw-blog__grid .gw-card--link.gw-surface--brand:hover { background: var(--bg-brand-sunken); }
@media (prefers-reduced-motion: reduce) {
  .gw-blog__grid .gw-card--link.gw-surface--brand { transition: none; }
}
.gw-blog__grid .gw-card--inset .gw-card__media { border-radius: var(--radius-xl); }

/* Past the comp's 1440 a two-up square gets taller than the viewport, so wide
   screens go three-up — still edge to edge, no gutters. */
@media (min-width: 1441px) {
  .gw-blog__grid { grid-template-columns: repeat(3, 1fr); }
}

/* Titles clamp at two lines, and that forces the line-height off the comp's 0.87.
   The clamp's overflow: hidden cuts at the line box, and Chrome still paints
   the clamped third line — so the cut has to fall between line 2's descenders
   and line 3's tallest glyphs. In Albert Sans 900 (ascent 0.95, descent 0.25,
   g/p/y to -0.2, f to 0.729) those overlap at 0.87 and only separate above
   ~0.93. At 0.95 line 2's descenders end at 1.975em and line 3 starts at
   1.996em; the 0.085em padding puts the cut at 1.985em, between them. The
   negative margin keeps the 12px gap to the date. Agreed with Adam 2026-09-18
   over trimming descenders or truncating in JS. */
.gw-blog__grid .gw-card__heading {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  line-height: 0.95;
  padding-bottom: 0.085em;
  margin-bottom: -0.085em;
}

/* ------------------------------------------------------------ sort control
   Figma 244:2918 (bar) and 244:2899 (menu). A <details> disclosure: the page
   ships no JavaScript, and the native element already gives Escape, focus order
   and the open/closed state for free. */
.gw-sort { position: relative; }
.gw-sort__toggle {
  /* type: .gw-type-button. 244:2897: a fixed 147 pill, content centred, 2px
     ink border — the section puts the details in light tokens. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-width: 147px;
  min-height: 32px;
  padding-inline: var(--space-8);
  background: var(--bg-page);
  color: var(--text-primary);
  border: var(--border-thick) solid var(--border-strong);
  border-radius: var(--radius-lg);
  cursor: pointer;
  list-style: none;
}
.gw-sort__toggle::-webkit-details-marker { display: none; }
.gw-sort__chevron { transition: transform .18s ease; }
.gw-sort[open] .gw-sort__chevron { transform: rotate(180deg); }

.gw-sort__menu {
  position: absolute;
  right: 0;
  z-index: 2;
  min-width: 100%;
  margin: var(--space-8) 0 0;
  padding: var(--space-8);
  list-style: none;
  background: var(--bg-surface);
  color: var(--text-primary);
  border-radius: var(--radius-lg);
  text-align: center;
}
.gw-sort__option {
  display: block;
  padding: var(--space-8) var(--space-16);
  border-radius: var(--radius-sm);
  color: inherit;
  text-decoration: none;
  white-space: nowrap;
}
.gw-sort__option:hover { background: var(--action-secondary-hover); }
.gw-sort__option[aria-current] { font-weight: 700; }

@media (prefers-reduced-motion: reduce) {
  .gw-sort__chevron { transition: none; }
}

/* -------------------------------------------------------------- pagination
   Not in any comp — the blog listing was drawn with eight cards and no
   controls. Built anyway because SITEMAP.md specifies a paginated listing, and
   it renders only once there is a second page. */
.gw-pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-8);
  padding-block: var(--space-48);
}
.gw-pagination__page,
.gw-pagination__step {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 32px;
  min-height: 32px;
  padding-inline: var(--space-8);
  border-radius: var(--radius-full);
  color: inherit;
  text-decoration: none;
}
.gw-pagination__page:hover { background: var(--action-secondary-hover); }
.gw-pagination__page.is-current {
  background: var(--bg-inverse);
  color: var(--text-inverse);
}
.gw-pagination__prev-icon { transform: rotate(90deg); }
.gw-pagination__next-icon { transform: rotate(-90deg); }

/* ------------------------------------------------------------ linked cards
   An article card is a link, so the heading's anchor is stretched over the
   whole card. One tab stop, one accessible name, and the pill stays decorative.
   The focus ring therefore has to be drawn on the card, not the anchor, or it
   would outline the heading text and leave the rest of the target unmarked. */
.gw-card--link .gw-card__link {
  color: inherit;
  text-decoration: none;
}
.gw-card--link .gw-card__link::after {
  content: '';
  position: absolute;
  inset: 0;
}
.gw-card--link:focus-within {
  outline: var(--border-thick) solid var(--border-focus);
  outline-offset: 2px;
}
.gw-card--link .gw-card__link:focus-visible { outline: none; }
.gw-card__cta { flex: none; }

/* The comp clamps the large card's body to three lines. The small card already
   clamps at four; merchant copy is not length-checked, so without this a long
   post intro pushes its card taller than the one beside it. */
.gw-card--full .gw-card__text {
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* The About page's scroll path is one absolutely-positioned layer spanning
   several sections, so it needs a positioning context that outlives any one of
   them. #main is the only element that wraps them all. Setting position on a
   block container changes nothing visually; it only gives absolute descendants
   something to resolve against. */
#main { position: relative; }

.gw-path {
  position: absolute;
  left: 50%;
  /* Inset by the container gutter, so the path is held to the same margins as
     the copy it runs behind — including the 24 phones take. --gw-gutter is
     defined with .gw-container above; it used to be read with a 48px fallback
     and no definition anywhere, so the fallback always won and the path stayed
     48 from the edge at every width. */
  width: min(584.712px, 100% - (2 * var(--gw-gutter)));
  transform: translateX(-50%);
  pointer-events: none;
  z-index: 0;
  /* The line strokes currentColor, so this is "ink on whatever surface the page
     is". --text-primary is that token and follows data-theme, so the path stays
     legible on the inverse About page and would still be legible if the layer
     were ever dropped on a light one. --text-inverse is the opposite of what is
     wanted here: on the inverse page it resolves to near-black, which is the
     page colour. */
  color: var(--text-primary);
}

/* The curve itself stays inside its viewBox: -140 and 616 are control points,
   and the drawn extent is x ∈ [0.5, 584.5] of a 584.712-wide box. What leaves
   the box is the paint — the line is stroked 1 wide and the reveal inside the
   mask 12 wide, both centred on the curve, so the outer swings would be shaved
   flat against the edges if this clipped. */
.gw-path__svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* Figma's asset carries mix-blend-mode: difference. It is deliberately not
   reproduced: .gw-path's own z-index makes the layer a stacking context, and a
   blend mode composites only against the backdrop inside its own context — which
   here is empty, so the declaration did nothing at all. Removing the z-index to
   make it work is not an option; it is what keeps the path behind the content.
   The spec pre-accepted the plain rgba stroke as the fallback (Risk 1), and on a
   single-colour page the two are visually near-identical anyway. */
.gw-path__line {
  fill: none;
  stroke: currentColor;
  stroke-width: 1;
  stroke-dasharray: 22 22;
  opacity: 0.3;
}

/* Inside the <mask>: white paints, black hides. Stroked far wider than the
   dotted line so the reveal edge is square across it. */
.gw-path__reveal {
  fill: none;
  stroke: #fff;
  stroke-width: 12;
}

/* Parked at the path's origin, and that parked position is a shipped state, not
   a placeholder: the path capability is skipWhenReduced, so under
   prefers-reduced-motion or data-gw-motion="none" nothing ever sets a transform
   and this is where the ball stays. The curve starts at user x=305.532 of the
   584.712 viewBox — 52.25% across — and at y=0; preserveAspectRatio="none"
   stretches that box to the layer, so the percentage holds at every width. The
   centring translate matches the xPercent/yPercent the runtime sets, and the
   runtime zeroes left/top when it takes over so the two are never added together. */
.gw-path__ball {
  position: absolute;
  top: 0;
  left: 52.25%;
  transform: translate(-50%, -50%);
  display: block;
  width: 40px;
  height: 40px;
  will-change: transform;
}

/* The ball's two Figma fills (#F4DA40 body, #101820 seams) as custom
   properties rather than the hex itself, so #main.gw-page-brand below can
   re-point them when the page swaps to the brand surface. The defaults still
   land on exactly those literal values — --bg-brand and --text-on-brand
   happen to equal them — because these ARE the tokens for "brand yellow" and
   "ink on brand yellow", which is what the ball already was before any of
   this existed. */
:root {
  --gw-ball-body: var(--bg-brand);
  --gw-ball-seam: var(--text-on-brand);
}

.gw-ball-body { fill: var(--gw-ball-body); transition: fill 600ms ease; }
.gw-ball-seam { fill: var(--gw-ball-seam); transition: fill 600ms ease; }

/* About's sections paint no background of their own — the page colour comes from
   #main, so the path layer can sit between the two. They still need to be above
   it, and to declare their nav ink by hand because they carry no gw-surface class.

   The ink is here for the same reason. A gw-surface--* class always pairs
   background WITH color, and dropping the class dropped both: text fell through
   to base.css's `body { color: var(--color-text) }`, which the layout sets from
   the merchant colour scheme (#101820) and which knows nothing about the surface
   under it. On the inverse page that was near-black on near-black — the whole
   page invisible but the ball and the one <em>. This is the same trap the
   gw-surface--brand/accent inherit rule at the top of this file guards against,
   walked into from the other side. */
.gw-about-section {
  position: relative;
  z-index: 1;
  background: none;
  color: var(--text-primary);
}

/* About's sections are transparent so the scroll path shows between them, which
   means the page colour has to come from their common ancestor instead — and the
   ink with it. A gw-surface--* class pairs background with color for exactly
   this reason; #main is standing in for one here, and without the colour the
   page inherits base.css's `body { color: var(--color-text) }`, which is the
   merchant colour scheme's near-black — on the black surface below it. */
.template-page-about #main {
  background: var(--bg-page);
  color: var(--text-primary);
  transition: background-color 600ms ease, color 600ms ease;
}

/* The surface swap's fade lives on the BASE rules, not on #main.gw-page-brand.
   A CSS transition is taken from the destination style, so declared only on the
   brand state it faded in and SNAPPED back out — "scrolling back up instantly
   changes to black" (QA 2026-10-07). Here it runs both ways. */
.template-page-about .gw-about-section,
.template-page-about .gw-path,
.template-page-about .gw-hero__subhead em,
.template-page-about .gw-media-text__heading {
  transition: color 600ms ease;
}

/* -------------------------------------------------------- surface swap
   src/runtime/surface-swap.js adds this class to #main when the visitor
   scrolls past the midpoint of the story collage's triptych row
   (sections/story-collage.liquid, Figma `266:4824`) and removes it again on
   the way back up. The target state is not a new palette — it is
   .gw-surface--brand's own fill/ink pair, reused here rather than invented,
   because #main is standing in for a surface class the same way the
   .template-page-about rule above already treats it as one. The ball's
   tokens invert alongside it: body goes dark, seams go yellow, so a yellow
   ball does not vanish into a yellow page. */
#main.gw-page-brand {
  background: var(--bg-brand);
  color: var(--text-on-brand);
  --gw-ball-body: var(--text-on-brand);
  --gw-ball-seam: var(--bg-brand);
}

/* #main's own `color` above only reaches elements that INHERIT it. Every
   About section sets its own `color: var(--text-primary)` on
   .gw-about-section (below, line ~2118) — added deliberately, to fix an
   earlier bug where the sections carried no ink at all and fell through to
   the merchant colour scheme's near-black. That rule is correct for the dark
   page and now blocks the swap for exactly the same reason it used to block
   nothing: it is a second, more specific `color` between #main and every
   word on the page. This is the general rule the rest of this block follows
   too — ANYTHING inside #main that sets its own `color` needs its own
   brand-state line; inheritance alone never gets there. */
#main.gw-page-brand .gw-about-section {
  color: var(--text-on-brand);
}

/* .gw-path sets its own `color` above (line ~2019) rather than inheriting
   #main's, so the swap has to reach it explicitly too — without this the
   dotted path stays stroked in the dark page's ink on the yellow page and
   reads as nearly invisible at its 0.3 opacity. */
#main.gw-page-brand .gw-path {
  color: var(--text-on-brand);
}

/* Both of these single out one word/line in brand yellow against the dark
   page — .gw-hero__subhead's "more" and the Evan row's heading. --text-brand
   and --bg-brand are the exact same value under this template's permanent
   data-theme="inverse" (both --color-yellow-500), so left unfixed either one
   reads as brand-yellow text on a brand-yellow page — invisible, not merely
   low-contrast. */
#main.gw-page-brand .gw-hero__subhead em {
  color: var(--text-on-brand);
}

#main.gw-page-brand .gw-media-text__heading {
  color: var(--text-on-brand);
}

@media (prefers-reduced-motion: reduce) {
  /* Registered WITHOUT skipWhenReduced (see the `nav` precedent in
     src/runtime/index.js): which surface the page is showing is information,
     not decoration, so a reduced-motion visitor still gets the correct
     surface at the correct scroll position. Only the crossfade between the
     two is motion, and that is what drops here — the state still changes,
     just instantly. */
  .template-page-about #main,
  .template-page-about .gw-about-section,
  .template-page-about .gw-path,
  .template-page-about .gw-hero__subhead em,
  .template-page-about .gw-media-text__heading,
  .gw-ball-body,
  .gw-ball-seam {
    transition: none;
  }
}

.gw-hero--display {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-48);
  padding-block: var(--space-96);
  text-align: center;
}

/* Set by the scroll-hold capability while an intro sequence plays. The padding
   replaces the scrollbar's width for exactly as long as hiding overflow takes
   it away, so the page does not jump sideways on the way in or out. With the
   page scrollbar hidden site-wide (above) the measured width is 0, so on LoFli
   this is a no-op, kept because scroll-hold.js is shared base code. */
.gw-scroll-held {
  overflow: hidden;
  padding-right: var(--gw-scrollbar-compensation, 0);
}

.gw-hero__display {
  /* type: .gw-type-display-xl. Uppercase is applied on the node in the comp
     (Figma 266:4085), not by the text style, so it lives here — the same
     split as .gw-statement__text. The setting keeps the author's own casing
     so the editor shows readable copy. */
  text-transform: uppercase;
}

.gw-hero__ball-slot {
  display: block;
  width: 40px;
  height: 40px;
}

/* The comp colours one word of the lede brand-yellow. richtext gives authors
   <em>, so that is the hook — and Task 1's markup-preserving split keeps this
   element intact while wrapping the word inside it. */
.gw-hero__subhead em { font-style: normal; color: var(--text-brand); }

/* ------------------------------------------------------------ story collage
   The scattered middle of the About page (see .grounded/design-audit.md). It
   is transparent like every About section — .gw-about-section above already
   supplies the position/z-index/background — so this class only owns the
   collage's own layout. */
.gw-collage { padding-block: var(--space-96); }


.gw-collage__flow {
  position: relative;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-96);
  text-align: center;
}

.gw-collage__triptych {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-96);
}

/* Fixed 350 square. The source is 1024x768, so the box is squared with
   aspect-ratio and the image is contained rather than covered — cover would
   crop the ball's own edges, and stretching it to fill would make it an oval.
   min() keeps it inside the column once the triptych stacks below 1024. */
.gw-collage__ball {
  margin: 0;
  flex: none;
  width: min(350px, 100%);
  aspect-ratio: 1;
}

.gw-collage__ball-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* Photos default to document flow (mobile) and only leave it at 1024px, below. */
.gw-collage__photo { position: relative; z-index: 1; }
.gw-collage__photo-img { display: block; width: 100%; height: auto; border-radius: 25px; }

/* Zero-height: the anchor only needs a position for getBoundingClientRect,
   never visible space of its own. */
/* 25px of clear space under the final paragraph (QA 2026-09-29): the anchor is
   measured at its centre and .gw-path__ball is 40px, so the ball's top sits 25px
   below the text. */
.gw-collage__path-end { display: block; height: 1px; margin-block-start: calc(25px + 40px / 2); }

/* The comp's measure (~400–415px). 72ch from .gw-prose ran the intro under the
   side photos at desktop widths (QA 2026-09-29). */
.gw-collage__para { max-inline-size: 26rem; margin-inline: auto; }

@media (min-width: 1024px) {
  /* The scatter bleeds past this section on both sides by design. Left bleed is
     harmless — a left overflow does not scroll in LTR — but photo 2 starts at
     75.6% and is 38.5% wide, so it ends 14.1% past the right edge and puts a
     horizontal scrollbar on the whole document (~203px at 1440). Figma's frame
     clips that; the browser has to be told to. `clip`, never `hidden`: hidden
     would make this a scroll container, which changes how sticky and fixed
     descendants behave. Only above 1024, where the photographs are absolute —
     in the mobile flow there is nothing to clip. */
  .gw-collage { overflow-x: clip; }

  /* Above 1024 the photographs leave the flow and take their authored
     positions — percentages of this section box, set inline per photo (see
     the comment in story-collage.liquid). Below this the scatter has no room
     to avoid colliding with the copy, so it stays in flow instead; that is a
     deliberate behaviour change for the mobile comp, not a fallback.
     (It must stay in THIS block: on 2026-10-07 the ≥1441 block below was
     inserted mid-block and carried it up to 1441px, so every laptop width got
     the stacked phone layout — QA 2026-10-19.) */
  .gw-collage__photo {
    position: absolute;
    left: var(--gw-collage-x);
    top: var(--gw-collage-y);
    width: var(--gw-collage-w);
  }
}

/* Above the comp's 1440 the photos' percentages kept resolving against an ever
   wider section, so the side bleed grew with the screen and cut the photos off
   on large monitors (QA 2026-10-07). The section stops growing at 1440 — so
   the scatter is exactly the 1440 composition, centred — and the clip moves out
   to #main, where it still stops the horizontal scrollbar without cutting the
   photos at the section's now-inset edges. 1440 and below: unchanged. */
@media (min-width: 1441px) {
  .gw-collage { max-inline-size: 1440px; margin-inline: auto; overflow-x: visible; }
  .template-page-about #main { overflow-x: clip; }
}

@media (max-width: 1023px) {
  .gw-collage__triptych { flex-direction: column; gap: var(--space-48); }
  .gw-collage__photo { margin-block: var(--space-48); }
}

/* ------------------------------------------------------- distance scroll
   Figma `261:2164` (baseball / brand) and `261:2247` (softball / accent).
   This is the band AT REST — the no-JS state and the floor later tasks animate
   away from. Nothing here moves yet. */
.gw-distance {
  position: relative;
  /* `svh` second, same reason as the hero: on mobile `100vh` is the LARGE
     viewport, so the band outgrows the visible area until the address bar
     retracts. */
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  align-content: end;
  justify-items: center;
  gap: var(--space-8);
  padding-block: var(--space-40);
  /* The chart runs slightly wider than the content box in the comp (1359 from
     x=48 against a 1392 right edge) and the ball is centred over everything. */
  overflow: hidden;
}

/* The dim used for the INACTIVE product name. The comp calls this `--text-muted`
   and resolves it to #F5E9A9 — which is yellow/200, the surface's own tint, not
   our `--text-muted` (a neutral). There is no semantic token for "this surface,
   lightened", so the two are named here from primitives, exactly as the nav ink
   map does and for the same reason.

   It MUST be a custom property rather than a literal: the state swap changes
   the surface class, and a hard-coded colour would not follow it. */
.gw-distance.gw-surface--brand  { --gw-distance-dim: var(--color-yellow-200); --gw-icon-knockout: var(--bg-brand); }
.gw-distance.gw-surface--accent { --gw-distance-dim: var(--color-cyan-200); --gw-icon-knockout: var(--bg-accent); }

.gw-distance__inner {
  width: 100%;
  padding-inline: var(--space-48);
  display: grid;
  gap: var(--space-48);
}
.gw-distance__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-48);
}
.gw-distance__copy {
  display: grid;
  gap: var(--space-16);
  justify-items: start;
  /* The comp's 287.71 column. */
  max-width: 288px;
}
/* The two copy states STACK in one grid cell rather than swapping `display`.

   Two reasons, and the second is the one that was visible: `display` cannot be
   transitioned at all, and the two bodies are different lengths — so swapping
   it both snapped the change AND reflowed the column under the button.
   Stacked, the taller state sets the height once and nothing moves.

   `visibility` rather than `display` for the hidden one, because visibility
   also removes the element from the accessibility tree — the state is then
   expressed in exactly one place, instead of a CSS rule and a static
   `aria-hidden` that stops agreeing the moment the class flips. */
.gw-distance__states { display: grid; }
.gw-distance__state {
  grid-area: 1 / 1;
  display: grid;
  gap: var(--space-16);
  align-content: start;
}
.gw-distance__state--softball { opacity: 0; visibility: hidden; }
.gw-distance.is-softball .gw-distance__state--baseball { opacity: 0; visibility: hidden; }
.gw-distance.is-softball .gw-distance__state--softball { opacity: 1; visibility: visible; }

.gw-distance__toggle {
  display: grid;
  gap: var(--space-16);
  justify-items: end;
  margin: 0;
}
.gw-distance__toggle-item {
  display: inline-flex;
  align-items: center;
  gap: var(--space-12);
  /* Dimmed by default; the active one takes the surface's own text colour by
     INHERITING it, which is what lets the state swap reach it at all. */
  color: var(--gw-distance-dim);
}
.gw-distance__toggle-item--baseball { color: inherit; }
.gw-distance.is-softball .gw-distance__toggle-item--baseball { color: var(--gw-distance-dim); }
.gw-distance.is-softball .gw-distance__toggle-item--softball { color: inherit; }
/* Only the ACTIVE product shows its glyph — the comp puts the icon beside
   whichever name is current, not beside both. */
/* The glyph always occupies its space and fades, rather than being added and
   removed. Removing it changed the width of a right-aligned row, so BOTH names
   shifted sideways every time the state changed — which read as the jumpiest
   part of the swap. The cost is that the comp's inactive name sits one icon
   further left than it would if the space collapsed; the comp has it flush,
   and holding still is worth more than that. */
.gw-distance__toggle-item .gw-distance__glyph { width: 48px; height: 48px; opacity: 0; }
.gw-distance__toggle-item--baseball .gw-distance__glyph { opacity: 1; }
.gw-distance.is-softball .gw-distance__toggle-item--baseball .gw-distance__glyph { opacity: 0; }
.gw-distance.is-softball .gw-distance__toggle-item--softball .gw-distance__glyph { opacity: 1; }

/* Everything the threshold changes, eased — and ONLY where motion is welcome.
   Adam's call for reduced motion on this band was "pin, but jump between
   states", so under that preference every one of these stays instantaneous and
   the state change is still fully conveyed. That is the whole reason the
   transitions live in a query rather than on the rules themselves.

   `visibility` is discrete, so it flips at the far end of its own duration:
   the outgoing copy finishes fading before it leaves the a11y tree, and the
   incoming one is present the moment it starts appearing. */
@media (prefers-reduced-motion: no-preference) {
  .gw-distance {
    transition: background-color 400ms ease, color 400ms ease;
  }
  .gw-distance__state {
    transition: opacity 320ms ease, visibility 0s linear 320ms;
  }
  .gw-distance__state--baseball,
  .gw-distance.is-softball .gw-distance__state--softball {
    transition: opacity 320ms ease, visibility 0s linear 0s;
  }
  .gw-distance__toggle-item { transition: color 400ms ease; }
  /* Width and margin are here for mobile, where the icon collapses so the
     dormant name can sit flush with the gutter. Transitioning them is what
     turns that collapse into a slide instead of a jump. Both are inert on
     desktop, where the slot is reserved and neither value ever changes. */
  .gw-distance__glyph {
    transition: opacity 320ms ease, width 320ms ease, margin-inline-end 320ms ease;
  }
}

/* The chart. Every position is a percentage of the comp's own `Group 27` box
   (1359 x 268), so the whole thing stretches with the viewport instead of
   matching at 1440 and drifting either side of it. */
.gw-distance__chart {
  position: relative;
  width: 100%;
  aspect-ratio: 1359 / 268;
}
/* One layer per trajectory. It is positioned where the comp puts the curve,
   and it doubles as the coordinate origin the runtime samples against — so the
   ball inside it is placed in the layer's own space, not the chart's. */
.gw-distance__flight { position: absolute; }
.gw-distance__flight--traditional { left: 3.39%; top: 0;      width: 93.81%; height: 71.32%; }
.gw-distance__flight--lofli       { left: 3.39%; top: 28.81%; width: 57.05%; height: 42.52%; }
.gw-distance__curve { display: block; width: 100%; height: 100%; }

/* Parked at the path's END, not its start — both curves finish at their own
   bottom-right.

   This is the picture every path that never animates should show: the curves
   ship fully drawn (the mask has no dash offset until the runtime sets one), so
   a ball parked at the START would sit beneath a flight it had not made. At the
   end, the no-JS page, the reduced-motion page and a band with Animation set to
   None all read as the finished comparison — which is the information the
   section exists to give.

   The runtime zeroes `left`/`top` and drives x/y from progress, so its first
   update pulls the ball back to the start. That happens on page load, when this
   band is far below the fold, so the correction is never seen. Its teardown
   restores these and the finished picture comes back. */
.gw-distance__ball-marker {
  position: absolute;
  left: 100%;
  top: 100%;
  translate: -50% -50%;
  display: block;
}
/* One ball, one size (QA 2026-10-07): both are the filled glyph, whose ball is
   inset 2 of 24 units — 54px draws a ~45px ball, the old LoFli size. */
.gw-distance__ball-marker { width: 54px; height: 54px; }
.gw-distance__ball-marker .gw-icon { width: 100%; height: 100%; }
.gw-distance__line {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-dasharray: 16 16;
  /* The curves carry `preserveAspectRatio="none"` so they stretch to the box.
     Without this the 2px stroke stretches with them and the two curves end up
     visibly different weights, because they stretch by different amounts. */
  vector-effect: non-scaling-stroke;
}
.gw-distance__reveal {
  fill: none;
  /* White is a MASK LUMINANCE value, not a theme colour: in a luminance mask
     white shows and black hides. The stroke is far wider than the line it
     uncovers so the mask never clips it. Ships fully open — with no JS, or
     under reduced motion, the page shows both complete curves. Task 3 animates
     this path's dash offset to draw them in. */
  stroke: #fff;
  stroke-width: 400;
}

.gw-distance__marker {
  position: absolute;
  display: block;
  width: 3.4%;
  aspect-ratio: 1;
}
.gw-distance__marker .gw-icon { width: 100%; height: 100%; }
/* Only the batter is static now. The comp draws a ball at BOTH ends of each
   curve because it is a storyboard of the whole scroll; live, there is one ball
   per curve and it travels between them. */
.gw-distance__marker--batter { left: 0; top: 65.71%; }

/* The ball names ride the ruler under their balls (QA 2026-09-29). Each sits in
   its own flight layer, so these are in LAYER space: parked centred under the
   landing point (`left: 100%`), which is the no-JS / reduced-motion picture,
   and path-scrub drives x from there. Two rows, because both flights start at
   the batter and one row would stack the names on top of each other:
   Traditional above the ruler where the comp puts it (chart 86.29%, i.e.
   86.29 / 71.32 of this layer), LoFli just beneath it (the chart's bottom edge
   is 100% + 28.67 / 42.52 of this layer). Below the ruler is the one place
   neither ball ever lands, so neither name can collide with it at any width. */
/* Both names on Traditional's line (chart y 86.29%, re-expressed in the LoFli
   layer: (86.29 - 28.81) / 42.52 = 135.18%). The scrub anchors them outward
   (follow-align), so no CSS translate here — GSAP owns the x anchor. They fade
   in between 10% and 15% of the pinned band (QA 2026-10-07); without the pin
   (no JS, Animation: None) --gw-pin-progress is unset and they simply show. */
.gw-distance__tick {
  position: absolute;
  left: 100%;
  translate: none;
  white-space: nowrap;
}
/* The 10% reveal is motion: under reduced motion the pin still writes
   --gw-pin-progress, and the labels would sit invisible until 10% in. */
@media (prefers-reduced-motion: no-preference) {
  .gw-distance__tick { opacity: clamp(0, (var(--gw-pin-progress, 1) - 0.1) * 20, 1); }
}
.gw-distance__tick--traditional { top: 120.99%; }
.gw-distance__tick--lofli       { top: 135.18%; }
/* Room under the ruler for the LoFli row, so it clears the note below. */
/* (The chart's bottom margin made room for LoFli's second label row — gone.) */

/* 33 ticks over a baseline — 32 equal gaps, so the spacing is a fraction of the
   width and survives any viewport rather than being 33 authored elements. */
.gw-distance__ruler {
  position: absolute;
  left: 1.39%;
  top: 95.15%;
  width: 97.5%;
  height: 4.85%;
  border-bottom: var(--border-thick) solid currentColor;
  background-image: repeating-linear-gradient(
    to right,
    currentColor 0 2px,
    transparent 2px calc(100% / 32)
  );
  background-repeat: no-repeat;
  background-size: 100% calc(100% - 2px);
}

.gw-distance__note { margin: 0; text-align: center; }

/* The 3D ball's slot, centred over the whole band. Empty until Task 4 puts two
   model proxies in it; reserved now so the band's height settles first. */
.gw-distance__ball {
  position: absolute;
  left: 50%;
  top: 50%;
  translate: -50% -50%;
  width: min(26.8vw, 386px);
  aspect-ratio: 1;
}

/* The exchange. Both proxies sit in the same slot; the baseball leaves
   downward and the softball arrives from above, both travelling -Y.

   This moves DIVS, not the scene. dom-sync re-reads every bound proxy's rect on
   each rendered frame, so a transform here carries the model with it — which is
   why swapping two 3D models needs no 3D code at all.

   `translate` rather than `top`, so it composites instead of reflowing, and so
   it cannot fight the slot's own centring translate above.

   An off-stage ball has to be GONE, not parked somewhere.

   `overflow: hidden` cannot help: the models are painted by the shared stage, a
   `position: fixed` fullscreen canvas that is not a descendant of this section,
   so there is nothing for the section to clip. A model also cannot be faded —
   opacity on the proxy does not reach it.

   Parking it off-slot does not work either, and this was the second attempt:
   the slot TRAVELS THROUGH the viewport, so any fixed offset — whether measured
   in the ball's height or the viewport's — is on camera at some scroll
   position. Half a viewport above the slot is off screen once the band is
   pinned, and squarely in the middle of the section ABOVE it on the way there.

   What does work is a zero rect. `display: none` makes
   getBoundingClientRect() return all zeros, dom-sync derives sizePx 0, fitScale
   returns 0 and the model renders at true zero scale — the same mechanism that
   silently hid the ball back when a proxy had no height, used deliberately.

   So the ball is `display: none` whenever it is neither active nor moving, and
   `allow-discrete` holds the display through the transition so the exit is
   still seen. `@starting-style` gives the arriving ball somewhere to come FROM,
   since an element leaving `display: none` has no previous computed value to
   transition out of. Without it the arrival would pop into the middle while the
   exit slid out properly. */
.gw-distance { --gw-distance-offstage: calc(50vh + 100%); }
@supports (height: 100svh) {
  .gw-distance { --gw-distance-offstage: calc(50svh + 100%); }
}
.gw-distance__model {
  position: absolute;
  inset: 0;
  /* Default: waiting above, and not rendered at all. */
  display: none;
  translate: 0 calc(-1 * var(--gw-distance-offstage));
}
.gw-distance__model--baseball { display: block; translate: 0 0; }
.gw-distance.is-softball .gw-distance__model--baseball {
  display: none;
  translate: 0 var(--gw-distance-offstage);
}
.gw-distance.is-softball .gw-distance__model--softball { display: block; translate: 0 0; }

@media (prefers-reduced-motion: no-preference) {
  /* Slower than the copy cross-fade (320ms) on purpose: the copy is a change of
     information and wants to be quick, the ball is an object moving through
     space and reads wrong if it snaps. Both still start on the same frame.

     `display` is in the list so `allow-discrete` can hold it through the exit;
     without that the leaving ball vanishes on frame one and never slides. */
  .gw-distance__model {
    transition:
      translate 560ms cubic-bezier(0.33, 1, 0.68, 1),
      display 560ms allow-discrete;
  }

  /* …but only while the pin holds the band. Scrolled straight through, the pin
     let go mid-slide and the leaving ball was painted over the next section
     (the stage is a fixed canvas the band cannot clip). Unpinned, it goes at
     once (QA 2026-10-07; is-pin-active from scroll-pin.js). */
  .gw-distance[data-gw-pin]:not(.is-pin-active) .gw-distance__model { transition: none; }

  /* Where the arriving ball comes FROM. An element leaving `display: none` has
     no previous computed style to transition out of, so without this it would
     appear at the centre while its counterpart slid away — half an exchange.

     Both directions are declared: softball arriving from above on the way down,
     baseball returning from below when the visitor scrolls back up. */
  @starting-style {
    .gw-distance.is-softball .gw-distance__model--softball {
      translate: 0 calc(-1 * var(--gw-distance-offstage));
    }
    .gw-distance__model--baseball { translate: 0 var(--gw-distance-offstage); }
  }
}

@media (max-width: 749px) {
  /* Mobile is a DIFFERENT composition, not a squeezed one (`355:5273`, placed as `355:5355`).

     The distance chart is gone entirely — no curves, no travelling balls, no
     ruler. What is left is the indicator at the top, the ball filling the
     middle, and the copy and button at the bottom. That is not a compromise
     forced by width: the chart's whole argument is one arc being shorter than
     another, and at 390px the two would be close enough to read as the same
     line. Dropping it is also the cheapest outcome on the device least able to
     afford a scrubbed SVG and two 3D models.

     TWO deliberate beats rather than a scrub: one scroll changes the product,
     a second releases the page. The band is short here and a continuous scrub
     over it reads as jitter. The runtime reads this number, so the breakpoint
     stays in CSS where it belongs. */
  .gw-distance {
    --gw-pin-steps: 2;
    /* QA 2026-10-14 (Adam, iPhone): two screens of scrolling to change the
       ball "feels broken". One screen, and any swipe inside it glides to the
       other end (scroll-pin.js), so one swipe = the next ball. */
    --gw-pin-length: 1;
    --gw-pin-snap: 1;
    /* The comp gives ALL the spare height to the content column and leaves the
       footnote at its natural size against the bottom edge.

       `align-content: stretch` divided it EQUALLY between the two auto rows
       instead — which is what stretch means on a grid — so the footnote sat in
       mid-air with a third of the band empty beneath it, and the copy was
       dragged up underneath the ball. `1fr auto` is the shape `.gw-pdp` already
       uses for a full-height band with a fixed thing at the bottom. */
    /* QA 2026-10-07: the ball used to float absolutely over this at 43.2% of the
       band — tuned to the 810px comp. A real iPhone's visible viewport is ~664
       (less on an SE), and in WebKit the ball covered the indicator and the copy
       at every real size. The band is now one grid with the ball in a row of its
       own: it takes whatever height is left and can never sit on the words.
       `display: contents` on inner/head lifts copy and toggle into this grid
       without moving them in the source, so reading order is unchanged.
       The ball row never drops below 96px: on an SE (~548 visible) the band
       grows a little past the screen rather than shrinking the ball to a dot,
       and the only thing pushed below the fold is the footnote. */
    grid-template-areas: "toggle" "ball" "copy" "note";
    grid-template-rows: auto minmax(96px, 1fr) auto auto;
    justify-items: stretch;
    gap: var(--space-24);
    padding-inline: var(--space-48);
    /* The comp's top padding is 64, but the comp draws no nav. The real one is
       fixed, transparent and 68 tall, so 64 puts the indicator under the logo —
       which is exactly what it was doing. Clear it the way the other
       full-height templates here do. */
    padding-block: calc(var(--gw-nav-height) + var(--space-24)) var(--space-24);
  }
  /* 48, not 24: the comp's mobile gutter is the same as the desktop one, and
     its copy column (287.71) is measured against 390 - 96. This is the band's
     own inner, not `.gw-container`, so the 24px phone rule does not apply. */
  .gw-distance__inner,
  .gw-distance__head { display: contents; }
  .gw-distance__toggle { grid-area: toggle; }
  .gw-distance__copy { grid-area: copy; }
  .gw-distance__note { grid-area: note; justify-self: center; }
  .gw-distance__toggle { justify-items: start; }
  .gw-distance__toggle-item { flex-direction: row-reverse; justify-content: flex-end; }
  .gw-distance__chart { display: none; }
  .gw-distance__copy { max-width: none; }

  /* The comp puts the icon beside the CURRENT product only: the dormant name
     sits flush with the gutter and the active one is indented by the icon.

     Desktop reserves the slot instead (see the base rule and its note) because
     the row is right-aligned there, so collapsing the icon moved BOTH names
     sideways at once. Here the rows are left-aligned, so only the active name
     moves — and it slides rather than jumps, because the width is transitioned.

     The negative inline-end margin cancels the flex `gap` as the icon
     collapses; without it the dormant name would still sit 12px in. */
  .gw-distance__toggle-item .gw-distance__glyph {
    width: 0;
    margin-inline-end: calc(-1 * var(--space-12));
  }
  .gw-distance__toggle-item--baseball .gw-distance__glyph,
  .gw-distance.is-softball .gw-distance__toggle-item--softball .gw-distance__glyph {
    width: 48px;
    margin-inline-end: 0;
  }
  .gw-distance.is-softball .gw-distance__toggle-item--baseball .gw-distance__glyph {
    width: 0;
    margin-inline-end: calc(-1 * var(--space-12));
  }

  /* In flow, in its own row, square, at most 180px (Adam 2026-10-07: keep 3D on
     phones but scale it down significantly — the comp's 278 dominated a real
     screen). dom-sync sizes the model from this box's live rect, so the model
     follows whatever height the row gets. */
  .gw-distance__ball {
    grid-area: ball;
    position: relative;
    inset: auto;
    translate: none;
    width: auto;
    height: min(100%, 180px);
    aspect-ratio: 1;
    justify-self: center;
    align-self: center;
  }

  /* The comp wraps the footnote to two short lines rather than running it
     across the full width. */
  .gw-distance__note { max-width: 214px; }

  /* Pinned, the band is fixed at its own height. At 100svh a strip of page shows
     beneath it once Safari's toolbar collapses; at 100lvh it always covers the
     screen, and the extra bottom padding keeps the content inside the SMALL
     viewport, so nothing is under the toolbar when it is showing. */
  @supports (height: 100lvh) {
    /* Pinned only — an unpinned band just scrolls, and the padding would be
       ~80px of empty yellow at its foot on iOS. */
    .gw-distance[data-gw-pin] {
      min-height: 100lvh;
      padding-block-end: calc(var(--space-24) + 100lvh - 100svh);
    }
  }
}

/* ------------------------------------------------------------------ article
   Figma `33:24` — sections/main-article.liquid. The hero reuses .gw-hero--media;
   the body band is a black surface with a 960 column, 128 from either end. */
.gw-article { padding-block: var(--space-128); }
/* No image, no hero: the fixed nav sits straight over the black band. */
.gw-article--no-hero { padding-block-start: calc(var(--gw-nav-height) + var(--space-96)); }
.gw-article__inner {
  max-inline-size: 960px;
  margin-inline: auto;
  display: grid;
  gap: var(--space-40);
}
.gw-article__header { display: grid; gap: var(--space-12); }
.gw-article__title { text-transform: uppercase; overflow-wrap: break-word; }
.gw-article__meta { display: flex; flex-wrap: wrap; gap: var(--space-12); margin: 0; }
.gw-article__lede { text-transform: uppercase; }

/* The article's own rich text (comp 293:7043): Albert Sans Regular 16/1.1, a
   blank line between paragraphs, subheads in black uppercase flush on the
   paragraph they head, images the full column. */
.gw-article__body {
  font-family: var(--font-family-display);
  font-weight: 400;
  font-size: var(--font-size-16);
  line-height: 1.1;
  letter-spacing: -0.04em;
}
.gw-article__body > * { margin: 0; }
.gw-article__body > * + * { margin-block-start: 1.1em; }
.gw-article__body :is(h2, h3, h4) {
  font-size: var(--font-size-16);
  font-weight: 900;
  line-height: 0.87;
  text-transform: uppercase;
  margin-block-start: var(--space-40);
}
.gw-article__body :is(h2, h3, h4) + * { margin-block-start: var(--space-4); }
/* The column's own 40 gap already sits above the body; a leading subhead's
   margin would double it. */
.gw-article__body > :first-child { margin-block-start: 0; }
.gw-article__body strong, .gw-article__body b { font-weight: 600; }
.gw-article__body a { color: inherit; text-underline-offset: 3px; }
.gw-article__body :is(ul, ol) { padding-inline-start: 1.25em; }
.gw-article__body img { display: block; inline-size: 100%; block-size: auto; }
.gw-article__body :is(img, figure, p:has(> img:only-child)) { margin-block: var(--space-40); }

/* ------------------------------------------------------------------ policy
   Figma `33:36` ("09 · Policies"), component `266:6678` / `357:4849`.
   snippets/policy-page.liquid, rendered from the layout for /policies/*.

   One DOM order — head, nav, body — stacked below 900. At 900 and above the
   grid puts the nav beside the body, in the body's row, top-aligned to the
   hairline as drawn; the columns hold the comp's 448:870 split of its 1318
   content width. The break is 900 rather than 768 because the tablet frame
   `33:38` is empty — the same call as Contact.

   The title is H3 on phones (the mobile comp) and the "L Condensed" style at
   desktop — Alumni Sans Bold, 120 at 1440 — which the type scale does not
   carry, so its size, weight, leading and tracking are set here. Body h2s are
   H4 on phones and H3 at desktop, matching "Policy Title" in each comp. Body
   h1 and h4 take the same treatment as h2 and h3 respectively — base.css
   pulls every h1–h4 into --font-heading, so an editor-authored h1 or h4 needs
   the same override or it renders in the wrong family.

   The nav is position: fixed (68px), so the policy band's top padding accounts
   for it: 68 + 28 = 96 on phones (comp frame 33:39), 68 + 96 = 164 on desktop
   (comp frame 33:37). */
.gw-policy {
  padding-block: calc(var(--gw-nav-height) + var(--space-24) + var(--space-4)) var(--space-96);
  color: var(--text-primary);
}
.gw-policy__inner {
  display: grid;
  grid-template-areas: "head" "nav" "body";
  row-gap: var(--space-24);
}
.gw-policy__head { grid-area: head; }
.gw-policy__head > .gw-policy__swap {
  display: grid;
  gap: var(--space-24);
}
/* The switch slides these regions 64px sideways; clip that on the x axis only,
   so the title's 0.62 leading still paints above its box. overflow-clip-margin
   widens the clip box past the padding edge so the h1's :focus-visible ring
   and body link rings aren't cut off on their sides. */
.gw-policy__head,
.gw-policy__body {
  overflow-x: clip;
  overflow-clip-margin: 4px;
}
.gw-policy__title:focus:not(:focus-visible) { outline: none; }
.gw-policy__title {
  font-size: clamp(var(--font-size-64), 8.33vw, var(--font-size-120));
  text-transform: uppercase;
}
.gw-policy__date { margin: 0; }

/* 24 of row-gap plus 24 here is the mobile comp's 48 between nav and body. */
.gw-policy__nav {
  grid-area: nav;
  display: grid;
  gap: var(--space-24);
  align-content: start;
  margin-block-end: var(--space-24);
}
.gw-policy__eyebrow {
  margin: 0;
  text-transform: uppercase;
}
.gw-policy__links {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-12);
}
.gw-policy__link {
  color: inherit;
  text-decoration: none;
  text-transform: uppercase;
}
.gw-policy__link:hover,
.gw-policy__link[aria-current="page"] {
  text-decoration: underline;
}
/* The phone dropdown — a <details>, the sort control's pattern (gw-sort):
   the current policy in the sidebar-link style, the same chevron, and the list
   in flow beneath it when open. */
.gw-policy__toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  text-transform: uppercase;
  text-decoration: underline;
  cursor: pointer;
  list-style: none;
}
.gw-policy__toggle::-webkit-details-marker { display: none; }
.gw-policy__chevron { transition: transform .18s ease; }
.gw-policy__picker[open] .gw-policy__chevron { transform: rotate(180deg); }
.gw-policy__picker[open] > .gw-policy__links { margin-top: var(--space-12); }
@media (prefers-reduced-motion: reduce) {
  .gw-policy__chevron { transition: none; }
}

.gw-policy__body {
  grid-area: body;
  border-top: var(--border-hairline) solid var(--border-strong);
  padding-top: var(--space-24);
}
/* Shopify wraps generated policy bodies in a <div>, so target content by
   type at any depth rather than by direct-child position. The 24px and 12px
   rules are both (0,1,2) specificity — the 12px heading rule wins the tie
   only because it comes after the 24px rule in source order; keep that
   order. */
.gw-policy__body :is(p, ul, ol, h1, h2, h3, h4) { margin: 0; }
.gw-policy__body :is(p, ul, ol, h1, h2, h3, h4) + :is(p, ul, ol, h1, h2, h3, h4) { margin-top: var(--space-24); }
.gw-policy__body :is(h1, h2, h3, h4) + :is(p, ul, ol, h1, h2, h3, h4) { margin-top: var(--space-12); }
.gw-policy__body :is(p, ul, ol) + :is(p, ul, ol) { margin-top: 1em; }
.gw-policy__body :is(h1, h2, h3, h4) {
  font-family: var(--font-family-condensed);
  font-weight: 800;
  font-size: var(--font-size-32);
  line-height: 0.95;
  letter-spacing: -0.02em;
}
.gw-policy__body :is(ul, ol) { padding-inline-start: 1.25em; }
.gw-policy__body a { color: inherit; text-decoration: underline; }

@media (min-width: 900px) {
  .gw-policy {
    padding-top: calc(var(--gw-nav-height) + var(--space-96));
  }
  .gw-policy__inner {
    grid-template-columns: 448fr 870fr;
    grid-template-areas: ". head" "nav body";
  }
  .gw-policy__nav { margin-block-end: 0; }
  .gw-policy__toggle { display: none; }
  .gw-policy__picker[open] > .gw-policy__links { margin-top: 0; }
  .gw-policy__title {
    font-weight: 700;
    line-height: 0.65;
    letter-spacing: -0.03em;
  }
  .gw-policy__body :is(h1, h2) {
    font-size: clamp(var(--font-size-40), 4.4vw, var(--font-size-64));
    line-height: 0.62;
  }
}
/* Its own block: a browser without ::details-content drops only this, and the
   controller's `open` at 900+ covers it. */
@media (min-width: 900px) {
  .gw-policy__picker::details-content { content-visibility: visible; }
}

/* ------------------------------------------------------------------- 404
   Figma `33:40`. The band is the pile's physics box (src/runtime/pile.js), so
   its box IS the behaviour: its height is the floor, its sides are the walls.
   Exactly one viewport tall on every size (Adam, 2026-09-28 — the comp drew
   phones content-tall at 546, but a band taller than the screen sinks the
   pile's floor below the fold). The nav floats over it (68px), so the
   content offsets start from the top of the page. */
.gw-404 {
  position: relative;
  overflow: hidden;          /* balls leave through the open top; never draw over the footer */
  /* Exactly one screen, on every size: the band's bottom edge is the pile's
     floor, so a band taller than the viewport drops the pile below the fold.
     `vh` first as the fallback for browsers without `svh`. The headline is
     capped by viewport height below so the content always fits inside. */
  height: 100vh;
  height: 100svh;
  /* Comp: the headline starts 196px down an 898px band (≈22%), leaving the
     lower part of the screen to the pile. Never closer than clear of the nav. */
  padding-block: max(22svh, 96px) 0;
  touch-action: pan-y;       /* horizontal drags push balls; vertical ones scroll */
  -webkit-tap-highlight-color: transparent;
}
.gw-404__content {
  position: relative;
  z-index: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-48);
  text-align: center;
}
/* type: .gw-type-display-xl — Alumni Sans Bold 260/0.65/-3%, uppercase on the node. */
.gw-404__title {
  text-transform: uppercase;
  color: var(--text-primary);
  /* The display-xl clamp scales with width only; on a wide, short window its
     three lines outgrow a one-screen band. 28svh is the comp's 260px at 898. */
  font-size: min(clamp(var(--font-size-96), 18vw, var(--font-size-260)), 28svh);
}
.gw-404__line { display: block; }
.gw-404__link {
  color: var(--text-brand);
  text-decoration: none;
}
.gw-404__link:hover { text-decoration: underline; text-underline-offset: 0.12em; }
.gw-404__link:focus-visible {
  text-decoration: underline;
  text-underline-offset: 0.12em;
  outline: var(--border-thick) solid var(--border-focus);
  outline-offset: 2px;
}

.gw-pile__layer {
  position: absolute;
  inset: 0;
  z-index: 1;                /* in front of the headline, as the comp draws them */
  pointer-events: none;
}
/* These nodes exist only once the 2D renderer creates them (one clone of the
   <template> per ball, appended by createDomRenderer in pile-dom.js). In 3D
   the layer stays empty — the stage canvas draws the balls — and the
   <template> itself is inert markup a browser never renders on its own. */
.gw-pile__ball {
  position: absolute;
  top: 0;
  left: 0;
  color: var(--text-brand);
  will-change: transform;
}
/* The icon's circle is 20 of its 24 units; draw the svg at 120% so the CIRCLE
   fills the ball's box and the physics radius is the visible one. */
.gw-pile__ball .gw-icon {
  display: block;
  width: 120%;
  height: 120%;
  margin: -10%;
}

@media (max-width: 749px) {
  .gw-404__title { font-size: min(var(--font-size-120), 18svh); }
  /* type: .gw-type-body — Albert Sans SemiBold 16/1.1/-4% on the mobile comp. */
  .gw-404__link {
    font-weight: 600;
    font-size: var(--font-size-16);
    line-height: 1.1;
  }
}

/* ── Coming soon (password page) — Figma 613:3993 ──────────────────────────
   Exactly one screen at every width: the photo and wash fill it, the wordmark
   sits in the 16px bar, and the ball, headline and fields stack from the
   bottom. Nothing scrolls unless the screen is shorter than the content. */
.gw-soon {
  position: relative;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  overflow: hidden;
  color: var(--text-primary);
}
.gw-soon__bg { position: absolute; inset: 0; z-index: -1; overflow: hidden; }
/* The comp crops the photo to ~1.39x, centred on the batter. */
.gw-soon__photo {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 50%;
  transform: scale(1.39);
}
.gw-soon__bg::after {
  content: "";
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--bg-brand) 90%, transparent);
}

.gw-soon__bar { padding: var(--space-16); }
.gw-soon__wordmark { display: block; width: 68px; height: auto; }

.gw-soon__body {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: flex-start;
  gap: 44px;
  padding: var(--space-48) var(--space-24);
}
/* 171 x 173 in the comp: the 4:3 photo is drawn into a near-square frame, and
   that stretch is what rounds the ball, so it is kept. */
.gw-soon__ball {
  width: clamp(96px, 11.9vw, 171px);
  height: auto;
  aspect-ratio: 171 / 173;
  filter: drop-shadow(0 26px 51px rgba(0, 0, 0, .25));
}

/* Display/XL, sized so "BATTING NEXT." (about 5.1em wide) always fits the
   column: 260px at 1440, scaling down with the viewport on a phone. */
.gw-soon__title {
  font-size: min(var(--font-size-260), calc((100vw - 2 * var(--space-24)) / 5.2));
  text-transform: uppercase;
  white-space: nowrap;
  display: grid;
  gap: 0.09em;
}
.gw-soon__line { display: block; }

/* The comp spaces the fields 24px under the headline, inside a 44px stack. */
.gw-soon__forms {
  display: flex;
  flex-wrap: wrap;
  gap: 15px;
  margin-top: calc(var(--space-24) - 44px);
}
.gw-soon__form {
  display: grid;
  gap: var(--space-12);
  width: 400px;
  max-width: 100%;
  padding-inline: 11px;
}
.gw-soon__label { text-transform: uppercase; }
.gw-soon__field {
  display: flex;
  align-items: center;
  gap: var(--space-8);
  height: 52px;
  padding-inline: 18px var(--space-8);
  border: var(--border-thick) solid currentColor;
  border-radius: var(--radius-lg);
}
.gw-soon__field:has(:focus-visible) {
  outline: var(--border-thick) solid var(--border-focus);
  outline-offset: 2px;
}
.gw-soon__input {
  flex: 1;
  min-width: 0;
  height: 100%;
  padding: 0;
  border: 0;
  background: transparent;
  color: inherit;
}
.gw-soon__input::placeholder { color: inherit; opacity: .5; }
.gw-soon__input:focus { outline: none; }
.gw-soon__submit {
  display: inline-flex;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  cursor: pointer;
}
.gw-soon__submit-icon { width: var(--size-icon-lg); height: var(--size-icon-lg); }
.gw-soon__note { margin: 0; }
.gw-soon__note--error { color: var(--feedback-error-border); }

@media (max-width: 749px) {
  .gw-soon__body { gap: var(--space-32); padding-block: var(--space-32); }
  .gw-soon__forms { width: 100%; margin-top: 0; gap: var(--space-24); }
  .gw-soon__form { width: 100%; padding-inline: 0; }
}
