/* POST UI System — single-file bundle. GENERATED by build-tokens.py.
   Paste into Webflow: Project Settings -> Custom Code -> Head, inside <style>.
   Order is load-bearing: fonts -> tokens -> type -> components. */

/* ===================== fonts ===================== */
/* POST UI System — fonts. Hand-written SOURCE (like components.css), copied to dist/.
   Must load BEFORE tokens/type/components: --font-post and --mono are useless if the
   faces behind them never arrive.

   THE BUG THIS FIXES: until now nothing here was loaded at all. --font-post named
   'Neue Haas Grotesk Display Pro' and --mono named 'SFMono-Regular', both of which are
   LOCALLY INSTALLED fonts. The guide looked right on the designer's Mac and fell through
   to Arial/Consolas on every other machine. A design system that only renders correctly
   on the machine that authored it is not a design system. */

/* ---- Neue Haas Grotesk Display — the brand face -----------------------------------
   Served from the same Adobe Fonts kit posthtx.com already uses (kit wnx0ooa), so the
   guide and production render from one licensed source. NOT self-hosted on purpose:
   Neue Haas is a licensed Monotype face and the .ttf files in foundation/fonts/ are a
   DESKTOP license — putting them on a public CDN would breach it. Adobe's kit is the
   web license. Weights available: 300/400/500/600/700/900 + italics 300-600, which
   covers the 500 (body, headings) and 600 (strong, brand) this system actually uses.

   @import so a consumer gets the face by loading post-ui.css alone. In HTML you control,
   prefer <link rel="stylesheet" href="https://use.typekit.net/wnx0ooa.css"> in <head> —
   it loads in parallel instead of waiting on the CSS parser. */
@import url("https://use.typekit.net/wnx0ooa.css");

/* ---- JetBrains Mono — code, token names, raw values -------------------------------
   Self-hosted because the old stack was Mac-only: 'SFMono-Regular' and 'Menlo' do not
   exist on Windows, so Windows silently landed on Consolas — different width, different
   x-height, visibly worse. Shipping the face means a token table measures the same on
   every machine, which is the whole point of a spec.

   SIL Open Font License 1.1 (see fonts/OFL.txt) — free to self-host and redistribute.
   Subset to Latin-1 + typographic punctuation + the arrows/checks the guide draws with;
   full JetBrains Mono carries Cyrillic and Greek this system never sets (276KB -> 107KB).

   font-display:swap — text paints immediately in the fallback and reflows when the face
   lands. Correct trade for a doc site: readable beats pixel-perfect for ~100ms. */
@font-face {
  font-family: 'JetBrains Mono';
  src: url('./fonts/JetBrainsMono-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'JetBrains Mono';
  src: url('./fonts/JetBrainsMono-Medium.woff2') format('woff2');
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'JetBrains Mono';
  src: url('./fonts/JetBrainsMono-Bold.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

/* ---- POST Sans Fallback — the offline / blocked-kit safety net --------------------
   THE GAP THIS CLOSES: Neue Haas is the one face we cannot ship. It is a licensed
   Monotype design and the .ttf files in foundation/fonts/ are a DESKTOP license, so
   self-hosting them would breach it; Adobe's kit IS our web license. That leaves one
   failure mode the mono face does not have — no network, or a firewall that blocks
   use.typekit.net, and the kit never arrives. The stack then falls to Arial and the
   guide is visibly wrong again, at different measurements, exactly like the bug the
   rest of this file fixes.

   We cannot make Arial BE Neue Haas. We can make it occupy the same space. These
   overrides re-scale the local grotesque so its line boxes and advance widths match
   Neue Haas: text stays on the same lines, headings keep their depth, and nothing
   reflows. The page degrades quietly instead of breaking.

   MEASURED, not guessed — read out of Chrome on 2026-08-17 by rendering both faces
   at 1000px and comparing advance width over a mixed alpha/prose/numeral sample, then
   fontBoundingBoxAscent/Descent for the vertical metrics:

     weight  NHG width/em   Arial width/em   size-adjust
     500     63.841         67.863           94.07%
     600     65.754         71.681           91.73%

   Two faces, not one, because the two weights differ enough to matter (a shared
   93% would leave 600 about 1.4% wide, which shows up as a reflowed heading).
   ascent/descent are divided by size-adjust so the override lands in the ADJUSTED em,
   not the nominal one — get that wrong and the line height is off by the same 6%.

   src is local() only: this face must never cost a request. If none of the three
   resolve the face simply fails and the browser moves down the stack, which is the
   old behaviour — this can degrade, it cannot break.

   Arial and Helvetica measured IDENTICALLY here (metric-compatible by design, which
   is what Arial was drawn for); Liberation Sans is the Linux equivalent and is listed
   for that reason, not because it was measured on this machine — it is not installed
   on Windows, so its numbers came back as the browser default and were discarded.

   TO RE-MEASURE after a kit or weight change: render the sample in both faces at a
   large size, take the advance-width ratio for size-adjust, then divide the target's
   fontBoundingBox ascent/descent by it. */
@font-face {
  font-family: 'POST Sans Fallback';
  src: local('Arial'), local('Helvetica'), local('Liberation Sans');
  font-weight: 500;
  font-style: normal;
  size-adjust: 94.07%;
  ascent-override: 97.37%;
  descent-override: 24.24%;
  line-gap-override: 0%;
}
/* The 600 face points at Arial BOLD, and that is not a detail — it is the whole reason
   this weight measures correctly. size-adjust 91.73% was derived by measuring Arial at
   weight 600, which Chrome resolves to the BOLD file. But local('Arial') hands back Arial
   REGULAR: CSS weight matching picks the file, the local() name does not. Backing a
   bold-derived ratio with the regular file rendered headings 4.19% narrow AND too light.
   Naming the bold file explicitly makes the shipped face the same one the number came
   from. Both names are listed because Windows matches the full name and macOS the
   PostScript name. */
@font-face {
  font-family: 'POST Sans Fallback';
  src: local('Arial Bold'), local('Arial-BoldMT'), local('Helvetica Bold'),
       local('Liberation Sans Bold');
  font-weight: 600;
  font-style: normal;
  /* 90.73%, not the 91.73% the canvas sample gave. Measuring a string in isolation and
     laying it out in a real line box are not quite the same thing; the shipped face came
     out +1.10% wide, so this is the canvas figure corrected by that residual and checked
     back against a rendered heading. ascent/descent are re-divided by the NEW adjust. */
  size-adjust: 90.73%;
  ascent-override: 102.50%;
  descent-override: 28.99%;
  line-gap-override: 0%;
}

/* ===================== tokens ===================== */
/* POST UI System — generated by build-tokens.py. Do not edit by hand. */
/* Usage:  <body data-brand="eat">  …  <section data-theme="brand"> */

/* ---------- primitives ---------- */
:root{
  --color-brand-blue-100:#D9EEFF;
  --color-brand-blue-200:#C6E6FF;
  --color-brand-blue-300:#7DC1FF;
  --color-brand-blue-400:#67ACF7;
  --color-brand-blue-50:#EDF7FF;
  --color-brand-blue-500:#498FEC;
  --color-brand-blue-600:#2A71E0;
  --color-brand-blue-700:#094FD2;
  --color-brand-blue-800:#0016C1;
  --color-brand-blue-900:#000D8C;
  --color-brand-blue-950:#000766;
  --color-functional-amber-100:#FFE4DA;
  --color-functional-amber-200:#FFB89E;
  --color-functional-amber-300:#FF8C64;
  --color-functional-amber-400:#F7825A;
  --color-functional-amber-50:#FFF3EE;
  --color-functional-amber-500:#DF653C;
  --color-functional-amber-600:#C2410C;
  --color-functional-amber-700:#983E11;
  --color-functional-amber-800:#7A3B12;
  --color-functional-amber-900:#492107;
  --color-functional-amber-950:#331503;
  --color-functional-green-100:#C5E4CF;
  --color-functional-green-200:#C2E1CC;
  --color-functional-green-300:#A5CCAE;
  --color-functional-green-400:#88B58F;
  --color-functional-green-50:#F0F8F3;
  --color-functional-green-500:#6A9E71;
  --color-functional-green-600:#3C7C43;
  --color-functional-green-700:#2E6D3A;
  --color-functional-green-800:#13532A;
  --color-functional-green-900:#0B4D26;
  --color-functional-green-950:#032610;
  --color-functional-red-100:#FFD4D0;
  --color-functional-red-200:#FFB5B0;
  --color-functional-red-300:#FF8F8D;
  --color-functional-red-400:#FF696C;
  --color-functional-red-50:#FFE7E4;
  --color-functional-red-500:#F54550;
  --color-functional-red-600:#C8102E;
  --color-functional-red-700:#AE0F28;
  --color-functional-red-800:#84081B;
  --color-functional-red-900:#5A0610;
  --color-functional-red-950:#3F0409;
  --color-neutral-0:#FFFFFF;
  --color-neutral-100:#EBEBEB;
  --color-neutral-1000:#000000;
  --color-neutral-200:#D2D2D2;
  --color-neutral-300:#C0C0C0;
  --color-neutral-400:#A0A0A0;
  --color-neutral-50:#F2F2F2;
  --color-neutral-500:#8F8F8F;
  --color-neutral-600:#767676;
  --color-neutral-700:#676767;
  --color-neutral-800:#454545;
  --color-neutral-900:#2E2E2E;
  --color-neutral-950:#1F1F1F;
  --color-state-base:#B8C0E6;
  --color-subbrand-culture-100:#D3F5E0;
  --color-subbrand-culture-200:#B4E6C7;
  --color-subbrand-culture-300:#90D2AB;
  --color-subbrand-culture-400:#6DBB8F;
  --color-subbrand-culture-50:#E6FCEE;
  --color-subbrand-culture-500:#4FA375;
  --color-subbrand-culture-600:#34885D;
  --color-subbrand-culture-700:#236D48;
  --color-subbrand-culture-800:#185235;
  --color-subbrand-culture-900:#0E3622;
  --color-subbrand-culture-950:#082516;
  --color-subbrand-eat-100:#FFD5CC;
  --color-subbrand-eat-200:#FFB5AB;
  --color-subbrand-eat-300:#FF9086;
  --color-subbrand-eat-400:#FF6962;
  --color-subbrand-eat-50:#FFE7E1;
  --color-subbrand-eat-500:#F54544;
  --color-subbrand-eat-600:#E11A2C;
  --color-subbrand-eat-700:#AF101C;
  --color-subbrand-eat-800:#850913;
  --color-subbrand-eat-900:#5A060B;
  --color-subbrand-eat-950:#400406;
  --color-subbrand-shop-100:#FFD0FF;
  --color-subbrand-shop-200:#FFAEFF;
  --color-subbrand-shop-300:#FF85FD;
  --color-subbrand-shop-400:#FB5BED;
  --color-subbrand-shop-50:#FFE4FF;
  --color-subbrand-shop-500:#EA3ADD;
  --color-subbrand-shop-600:#C400B9;
  --color-subbrand-shop-700:#A00097;
  --color-subbrand-shop-800:#790072;
  --color-subbrand-shop-900:#52004D;
  --color-subbrand-shop-950:#390036;
  --color-subbrand-skylawn-100:#C3F3FF;
  --color-subbrand-skylawn-200:#9BE3FF;
  --color-subbrand-skylawn-300:#6BCDFE;
  --color-subbrand-skylawn-400:#2DB2EB;
  --color-subbrand-skylawn-50:#DCFBFF;
  --color-subbrand-skylawn-500:#009CD8;
  --color-subbrand-skylawn-600:#0082BB;
  --color-subbrand-skylawn-700:#006799;
  --color-subbrand-skylawn-800:#004D73;
  --color-subbrand-skylawn-900:#00334E;
  --color-subbrand-skylawn-950:#002336;
  --color-subbrand-work-100:#FFE1C6;
  --color-subbrand-work-200:#FFC9A2;
  --color-subbrand-work-300:#FAAB79;
  --color-subbrand-work-400:#E98E51;
  --color-subbrand-work-50:#FFEFDD;
  --color-subbrand-work-500:#D1732D;
  --color-subbrand-work-600:#B45A0A;
  --color-subbrand-work-700:#994B03;
  --color-subbrand-work-800:#6E3200;
  --color-subbrand-work-900:#4B2000;
  --color-subbrand-work-950:#341500;
  --color-utility-clay-100:#FAEFDE;
  --color-utility-clay-200:#E9D3C9;
  --color-utility-clay-300:#E3CBC2;
  --color-utility-clay-400:#BBA199;
  --color-utility-clay-50:#FCF5EA;
  --color-utility-clay-500:#A4887F;
  --color-utility-clay-600:#8E6D64;
  --color-utility-clay-700:#795348;
  --color-utility-clay-800:#64382C;
  --color-utility-clay-900:#4E1C10;
  --color-utility-clay-950:#3E0900;
  --font-line-height-body:1.2;
  --font-line-height-caps:0.9;
  --font-line-height-solid:1;
  --font-size-12:12px;
  --font-size-144:144px;
  --font-size-16:16px;
  --font-size-18:18px;
  --font-size-20:20px;
  --font-size-24:24px;
  --font-size-28:28px;
  --font-size-32:32px;
  --font-size-40:40px;
  --font-size-48:48px;
  --font-size-60:60px;
  --font-size-80:80px;
  --font-size-96:96px;
  --font-tracking-display:-0.005em;
  --font-tracking-normal:0em;
  --font-weight-bold:600;
  --font-weight-default:500;
  --icon-size-16:16px;
  --icon-size-24:24px;
  --icon-size-32:32px;
  --icon-size-48:48px;
  --size-content-max:1800px;
  --space-12:12px;
  --space-128:128px;
  --space-16:16px;
  --space-20:20px;
  --space-24:24px;
  --space-32:32px;
  --space-4:4px;
  --space-40:40px;
  --space-48:48px;
  --space-64:64px;
  --space-8:8px;
  --space-80:80px;
  --space-96:96px;
  --stroke-1:1px;
  --stroke-2:2px;
  --stroke-3:3px;
  --space-6:6px;
  --opacity-20:0.2;
  --opacity-50:0.5;
  --opacity-85:0.85;
}

/* ---------- brand axis · set on the page wrapper ---------- */
:root,[data-brand="post"]{
  --brand-base:var(--color-brand-blue-800);
  --brand-deep:var(--color-brand-blue-900);
  --brand-hover:var(--color-brand-blue-900);
  --brand-muted:var(--color-brand-blue-50);
  --brand-tint-100:var(--color-brand-blue-100);
  --brand-tint-50:var(--color-brand-blue-50);
  --brand-tint-200:var(--color-brand-blue-200);
}
[data-brand="eat"]{
  --brand-base:var(--color-subbrand-eat-600);
  --brand-deep:var(--color-subbrand-eat-900);
  --brand-hover:var(--color-subbrand-eat-700);
  --brand-muted:var(--color-neutral-0);
  --brand-tint-100:var(--color-subbrand-eat-100);
  --brand-tint-50:var(--color-subbrand-eat-50);
  --brand-tint-200:var(--color-subbrand-eat-200);
}
[data-brand="work"]{
  --brand-base:var(--color-subbrand-work-700);
  --brand-deep:var(--color-subbrand-work-900);
  --brand-hover:var(--color-subbrand-work-800);
  --brand-muted:var(--color-subbrand-work-50);
  --brand-tint-100:var(--color-subbrand-work-100);
  --brand-tint-50:var(--color-subbrand-work-50);
  --brand-tint-200:var(--color-subbrand-work-200);
}
[data-brand="culture"]{
  --brand-base:var(--color-subbrand-culture-700);
  --brand-deep:var(--color-subbrand-culture-900);
  --brand-hover:var(--color-subbrand-culture-800);
  --brand-muted:var(--color-subbrand-culture-50);
  --brand-tint-100:var(--color-subbrand-culture-100);
  --brand-tint-50:var(--color-subbrand-culture-50);
  --brand-tint-200:var(--color-subbrand-culture-200);
}
[data-brand="skylawn"]{
  --brand-base:var(--color-subbrand-skylawn-500);
  --brand-deep:var(--color-subbrand-skylawn-900);
  --brand-hover:var(--color-subbrand-skylawn-600);
  --brand-muted:var(--color-neutral-0);
  --brand-tint-100:var(--color-subbrand-skylawn-100);
  --brand-tint-50:var(--color-subbrand-skylawn-50);
  --brand-tint-200:var(--color-subbrand-skylawn-200);
}
[data-brand="shop"]{
  --brand-base:var(--color-subbrand-shop-600);
  --brand-deep:var(--color-subbrand-shop-900);
  --brand-hover:var(--color-subbrand-shop-700);
  --brand-muted:var(--color-neutral-0);
  --brand-tint-100:var(--color-subbrand-shop-100);
  --brand-tint-50:var(--color-subbrand-shop-50);
  --brand-tint-200:var(--color-subbrand-shop-200);
}
[data-brand="music"]{
  --brand-base:var(--color-neutral-700);
  --brand-deep:var(--color-neutral-900);
  --brand-hover:var(--color-neutral-800);
  --brand-muted:var(--color-neutral-50);
  --brand-tint-100:var(--color-neutral-100);
  --brand-tint-50:var(--color-neutral-50);
  --brand-tint-200:var(--color-neutral-200);
}

/* ---------- theme axis · set on each section ---------- */
[data-theme="light"]{
  --action:var(--brand-base);
  --action-hover:var(--brand-hover);
  --border:var(--text);
  --feedback-error-inline:var(--color-functional-red-800);
  --feedback-error-surface:var(--color-functional-red-100);
  --feedback-error-text:var(--color-functional-red-800);
  --feedback-info-surface:var(--color-neutral-50);
  --feedback-info-text:var(--color-neutral-900);
  --feedback-success-surface:var(--color-functional-green-50);
  --feedback-success-text:var(--color-functional-green-800);
  --feedback-warning-surface:var(--color-functional-amber-50);
  --feedback-warning-text:var(--color-functional-amber-800);
  --hover-ink:var(--brand-deep);
  --hover-surface:var(--brand-tint-50);
  --muted:var(--color-neutral-700);
  --on-action:var(--color-neutral-0);
  --surface:var(--color-neutral-0);
  --text:var(--color-neutral-950);
  --hover-surface-nested:var(--brand-tint-200);
  --scrim-color:var(--brand-base);
  --scrim-media:var(--color-neutral-0);
}
[data-theme="warm"]{
  --action:var(--brand-base);
  --action-hover:var(--brand-hover);
  --border:var(--text);
  --feedback-error-inline:var(--color-functional-red-800);
  --feedback-error-surface:var(--color-functional-red-100);
  --feedback-error-text:var(--color-functional-red-800);
  --feedback-info-surface:var(--color-neutral-50);
  --feedback-info-text:var(--color-neutral-900);
  --feedback-success-surface:var(--color-functional-green-50);
  --feedback-success-text:var(--color-functional-green-800);
  --feedback-warning-surface:var(--color-functional-amber-50);
  --feedback-warning-text:var(--color-functional-amber-800);
  --hover-ink:var(--brand-deep);
  --hover-surface:var(--brand-tint-100);
  --muted:var(--color-neutral-700);
  --on-action:var(--color-neutral-0);
  --surface:var(--color-utility-clay-100);
  --text:var(--color-neutral-950);
  --hover-surface-nested:var(--brand-tint-200);
  --scrim-color:var(--brand-base);
  --scrim-media:var(--color-neutral-0);
}
[data-theme="brand"]{
  --action:var(--color-neutral-0);
  --action-hover:var(--brand-tint-100);
  --border:var(--text);
  --feedback-error-inline:var(--color-functional-red-200);
  --feedback-error-surface:var(--color-functional-red-100);
  --feedback-error-text:var(--color-functional-red-800);
  --feedback-info-surface:var(--color-neutral-50);
  --feedback-info-text:var(--color-neutral-900);
  --feedback-success-surface:var(--color-functional-green-50);
  --feedback-success-text:var(--color-functional-green-800);
  --feedback-warning-surface:var(--color-functional-amber-50);
  --feedback-warning-text:var(--color-functional-amber-800);
  --hover-ink:var(--brand-tint-100);
  --hover-surface:var(--brand-hover);
  --muted:var(--brand-muted);
  --on-action:var(--brand-deep);
  --surface:var(--brand-base);
  --text:var(--color-neutral-0);
  --hover-surface-nested:var(--brand-deep);
  --scrim-color:var(--brand-base);
  --scrim-media:var(--color-neutral-1000);
}
[data-theme="deep"]{
  --action:var(--color-neutral-0);
  --action-hover:var(--color-utility-clay-50);
  --border:var(--text);
  --feedback-error-inline:var(--color-functional-red-200);
  --feedback-error-surface:var(--color-functional-red-100);
  --feedback-error-text:var(--color-functional-red-800);
  --feedback-info-surface:var(--color-neutral-50);
  --feedback-info-text:var(--color-neutral-900);
  --feedback-success-surface:var(--color-functional-green-50);
  --feedback-success-text:var(--color-functional-green-800);
  --feedback-warning-surface:var(--color-functional-amber-50);
  --feedback-warning-text:var(--color-functional-amber-800);
  --hover-ink:var(--color-utility-clay-50);
  --hover-surface:var(--color-utility-clay-900);
  --muted:var(--color-utility-clay-300);
  --on-action:var(--color-utility-clay-950);
  --surface:var(--color-utility-clay-950);
  --text:var(--color-neutral-0);
  --hover-surface-nested:var(--color-utility-clay-800);
  --scrim-color:var(--brand-base);
  --scrim-media:var(--color-neutral-1000);
}

/* ---------- breakpoint axis · viewport, mobile-first ---------- */
/* Mobile — the default */
:root{
  --dot-size:var(--space-8);
  --icon-size-l:var(--icon-size-32);
  --icon-size-m:var(--icon-size-24);
  --icon-size-s:var(--icon-size-16);
  --layout-gutter:var(--space-16);
  --layout-max-width:var(--size-content-max);
  --layout-page:var(--space-16);
  --layout-section:var(--space-64);
  --space-l:var(--space-12);
  --space-m:var(--space-8);
  --space-s:var(--space-6);
  --space-xs:var(--space-4);
  --stroke-l:var(--stroke-2);
  --stroke-m:var(--stroke-1);
  --stroke-s:var(--stroke-1);
  --text-2xs:var(--font-size-12);
  --text-display:var(--font-size-48);
  --text-display-inset:-3.7px;
  --text-hero:var(--font-size-40);
  --text-lg:var(--font-size-24);
  --text-md:var(--font-size-18);
  --text-sm:var(--font-size-16);
  --text-xl:var(--font-size-28);
  --text-xs:var(--font-size-16);
  --text-xxl:var(--font-size-32);
  --space-xl:var(--space-16);
  --circle-size-s:24px;
  --circle-size-m:36px;
  --circle-size-l:48px;
}
@media (min-width:768px){ /* Base */
  :root{
    --dot-size:var(--space-12);
    --icon-size-l:var(--icon-size-32);
    --icon-size-m:var(--icon-size-24);
    --icon-size-s:var(--icon-size-16);
    --layout-gutter:var(--space-24);
    --layout-max-width:var(--size-content-max);
    --layout-page:var(--space-24);
    --layout-section:var(--space-80);
    --space-l:var(--space-12);
    --space-m:var(--space-8);
    --space-s:var(--space-6);
    --space-xs:var(--space-4);
    --stroke-l:var(--stroke-2);
    --stroke-m:var(--stroke-2);
    --stroke-s:var(--stroke-1);
    --text-2xs:var(--font-size-12);
    --text-display:var(--font-size-96);
    --text-display-inset:-7.5px;
    --text-hero:var(--font-size-60);
    --text-lg:var(--font-size-32);
    --text-md:var(--font-size-24);
    --text-sm:var(--font-size-20);
    --text-xl:var(--font-size-40);
    --text-xs:var(--font-size-16);
    --text-xxl:var(--font-size-48);
    --space-xl:var(--space-20);
    --circle-size-s:24px;
    --circle-size-m:36px;
    --circle-size-l:48px;
  }
}
@media (min-width:1280px){ /* Large */
  :root{
    --dot-size:var(--space-12);
    --icon-size-l:var(--icon-size-48);
    --icon-size-m:var(--icon-size-32);
    --icon-size-s:var(--icon-size-24);
    --layout-gutter:var(--space-32);
    --layout-max-width:var(--size-content-max);
    --layout-page:var(--space-32);
    --layout-section:var(--space-128);
    --space-l:var(--space-16);
    --space-m:var(--space-12);
    --space-s:var(--space-8);
    --space-xs:var(--space-4);
    --stroke-l:var(--stroke-3);
    --stroke-m:var(--stroke-2);
    --stroke-s:var(--stroke-1);
    --text-2xs:var(--font-size-16);
    --text-display:var(--font-size-144);
    --text-display-inset:-11.2px;
    --text-hero:var(--font-size-80);
    --text-lg:var(--font-size-40);
    --text-md:var(--font-size-28);
    --text-sm:var(--font-size-24);
    --text-xl:var(--font-size-48);
    --text-xs:var(--font-size-20);
    --text-xxl:var(--font-size-60);
    --space-xl:var(--space-24);
    --circle-size-s:36px;
    --circle-size-m:48px;
    --circle-size-l:64px;
  }
}

/* ---------- shadcn/ui bridge ---------- */
/* shadcn reads its own names; point them at ours so components inherit themes. */
:root{
  --background:var(--surface);
  --foreground:var(--text);
  --muted-foreground:var(--muted);
  --primary:var(--action);
  --primary-foreground:var(--on-action);
  --input:var(--border);
  --accent:var(--hover-surface);
  --accent-foreground:var(--hover-ink);
  --ring:var(--action);
  --destructive:var(--feedback-error-text);
  --destructive-foreground:var(--feedback-error-surface);
}

/* POST is a sharp-corner brand. */
:root{--radius:0}

/* Component rules live in components.css — this file is TOKENS only. They were
   here briefly on 2026-08-05 as unprefixed .control/.field/.label, which would
   collide with almost anything in a Webflow project. */

/* ===================== type ===================== */
/* POST UI System — type. GENERATED by build-tokens.py. Do not edit by hand. */
/* One class per Figma Text Style. Import AFTER tokens.css — the sizes are its vars. */

/* The face itself is loaded by fonts.css — this only names it. */
:root{--font-post:'neue-haas-grotesk-display','Neue Haas Grotesk Display Pro','Neue Haas Grotesk Display','POST Sans Fallback','Helvetica Neue',Arial,sans-serif}

/* Display — text/display */
.post-text-display{
  font-family:var(--font-post);
  font-size:var(--text-display);
  font-weight:500;
  line-height:0.9;
  letter-spacing:-0.005em;
  text-transform:uppercase;
}
/* Heading/H1 — text/hero */
.post-text-h1{
  font-family:var(--font-post);
  font-size:var(--text-hero);
  font-weight:500;
  line-height:0.9;
  letter-spacing:-0.005em;
  text-transform:uppercase;
}
/* Heading/H2 — text/xxl */
.post-text-h2{
  font-family:var(--font-post);
  font-size:var(--text-xxl);
  font-weight:500;
  line-height:1;
}
/* Heading/H3 — text/xl */
.post-text-h3{
  font-family:var(--font-post);
  font-size:var(--text-xl);
  font-weight:500;
  line-height:1;
}
/* Heading/H4 — text/lg */
.post-text-h4{
  font-family:var(--font-post);
  font-size:var(--text-lg);
  font-weight:500;
  line-height:1;
}
/* Text/Body — text/md */
.post-text-body{
  font-family:var(--font-post);
  font-size:var(--text-md);
  font-weight:500;
  line-height:1.2;
}
/* Text/Body Link — text/md */
.post-text-body-link{
  font-family:var(--font-post);
  font-size:var(--text-md);
  font-weight:500;
  line-height:1.2;
  text-decoration:underline;
}
/* Text/Caption — text/xs */
.post-text-caption{
  font-family:var(--font-post);
  font-size:var(--text-xs);
  font-weight:500;
  line-height:1.2;
}
/* Text/Small — text/sm */
.post-text-small{
  font-family:var(--font-post);
  font-size:var(--text-sm);
  font-weight:500;
  line-height:1.2;
}
/* Text/Small Link — text/sm */
.post-text-small-link{
  font-family:var(--font-post);
  font-size:var(--text-sm);
  font-weight:500;
  line-height:1.2;
  text-decoration:underline;
}
/* Utility/Button L — text/md */
.post-text-button-l{
  font-family:var(--font-post);
  font-size:var(--text-md);
  font-weight:500;
  line-height:1;
}
/* Utility/Button S — text/sm */
.post-text-button-s{
  font-family:var(--font-post);
  font-size:var(--text-sm);
  font-weight:500;
  line-height:1;
}
/* Utility/Tag — text/2xs */
.post-text-tag{
  font-family:var(--font-post);
  font-size:var(--text-2xs);
  font-weight:600;
  line-height:1;
  letter-spacing:0.04em;
  text-transform:uppercase;
}

/* The display inset: the huge brand word sits LEFT of its box so it aligns
   optically under the logo. Figma auto-layout padding cannot go negative, so on
   canvas each breakpoint frame fakes it with a hand-set X offset. Here it is a
   real negative margin — the sign is the whole point of the token. */
.post-display-inset{margin-left:var(--text-display-inset)}

/* ===================== components ===================== */
/* ============================================================================
   POST UI System — components
   HAND-WRITTEN. Not generated: this file encodes the RULES the components follow,
   which is not the same thing as whatever the Figma canvas currently looks like.
   The contract it implements (variant axes + reasoning) is synced in components.json.

   Import order:  tokens.css  ->  type.css  ->  components.css

   NAMING. Every class is `post-` prefixed. This ships into Webflow projects where
   `.button`, `.field`, `.label` or `.text-body` are exactly the classes a site
   already has, and an unprefixed collision would silently restyle real content.
   Block `post-x` · modifier `post-x--y` · state `is-*`.

   THE FOUR RULES EVERYTHING BELOW OBEYS
   1. Tokens are named by scale, never by consumer — space/m, stroke/m, icon-size/m
      and text/md all mean "the middle step".
   2. Strokes are altitude, not component — s detail · m component · l section.
   3. Optical padding — outer padding is uniform, the LABEL carries space/xs left and
      right, and the gap is 0. Text ends up inset by outer+xs while an icon sits at
      outer, which is correct: a glyph carries internal whitespace and text does not.
      One recipe covers icon-left, icon-right, both, or neither.
   4. Control height comes from the ICON TIER, never from text. Every control label
      carries min-height = its icon-size token. Without it the icon becomes the
      tallest child and toggling an optional icon grows the control — a chip jumped
      28 -> 32px exactly this way. With it, toggling an icon changes width only.

   currentColor is load-bearing. Outline/ghost controls bind their label fill AND
   their stroke to the same `action` token, so the border is written as
   `border-color: currentColor` and one colour change moves both.
   ============================================================================ */

/* @component _base ---------------------------------------------------------- */
/* Shared by every control. `gap:0` is rule 3 — spacing comes from the label's own
   padding, never from the flex gap. */
.post-control,
.post-field {
  display: inline-flex;
  align-items: center;
  gap: 0;
  border: var(--stroke-m) solid transparent;
  font-family: var(--font-post);
  font-weight: 500;
  line-height: 1;
  color: var(--action);
  background: none;
  cursor: pointer;
}
.post-control { padding: var(--space-s); }
.post-field   { padding: var(--space-m); }

/* The label owns its inset AND its height. Both halves of rules 3 and 4. */
.post-label {
  display: inline-flex;
  align-items: center;
  padding-inline: var(--space-xs);
  min-height: var(--icon-size-s);
}
/* The class sits ON the <svg>, not on a wrapper — `<svg class="post-icon"><use
   href="#post-icon-search"/></svg>`. So `fill` belongs here. It used to live only on the
   descendant rule below, which never matched that markup and left every glyph black. */
.post-icon {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--icon-size-s);
  height: var(--icon-size-s);
  fill: currentColor;
}
.post-icon svg { width: 100%; height: 100%; display: block; fill: currentColor; }

/* Focus is one ring everywhere: 2px action, 2px offset. No dedicated token — a state
   that can be expressed with an existing colour does not earn one. */
.post-control:focus-visible,
.post-field:focus-visible,
.post-control.is-focus,
.post-field.is-focus {
  outline: 2px solid var(--action);
  outline-offset: 2px;
}
/* Disabled is 40% opacity, also tokenless, for the same reason. */
.post-control.is-disabled,
.post-field.is-disabled,
.post-control:disabled,
.post-field:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

/* MEDIA PLACEHOLDER — the code half of the Figma paint style every card media frame and
   Media Thumb already fills with (color/neutral/200 + a FIT image). Ported as a real
   exported asset rather than re-invented, so the guide and the canvas show the SAME plate
   instead of two different greys.

   `contain`, not `cover`, because the Figma fill is scaleMode FIT: the illustration is
   centred at its own size with the neutral plane around it, and cropping it would make a
   16:9 card show a detail of the mark rather than the mark.

   It is an OPT-IN class, never part of .post-card__media itself. A consumer passing a real
   <img> must not pay for a 10kB background they cannot see, and must not 404 on an asset
   they never copied out of dist/. */
.post-media-placeholder {
  background-color: var(--color-neutral-200);
  background-image: url("media-placeholder.png");
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
}
/* @endcomponent */

/* @component button --------------------------------------------------------- */
/* Style filled·outline·ghost × Size l·s × State default/hover/focus/active/disabled
   = 30 variants in Figma. Icons are BOOLEANS + instance-swaps, never variants — that
   is what keeps it at 30 instead of 120. */
.post-btn {
  display: inline-flex;
  align-items: center;
  gap: 0;
  padding: var(--space-s);
  border: var(--stroke-m) solid transparent;
  font-family: var(--font-post);
  font-weight: 500;
  line-height: 1;
  color: var(--action);
  background: none;
  cursor: pointer;
}

/* Size sets the type role AND the icon tier together — they are one decision.
   l = text/md + icon-size/m · s = text/sm + icon-size/s */
.post-btn--l { font-size: var(--text-md); }
.post-btn--l .post-label { min-height: var(--icon-size-m); }
.post-btn--l .post-icon { width: var(--icon-size-m); height: var(--icon-size-m); }
.post-btn--s { font-size: var(--text-sm); }
.post-btn--s .post-label { min-height: var(--icon-size-s); }
.post-btn--s .post-icon { width: var(--icon-size-s); height: var(--icon-size-s); }

.post-btn--filled {
  background: var(--action);
  color: var(--on-action);
  border-color: var(--action);
}
.post-btn--outline { border-color: currentColor; }
/* Ghost is flush-aligned by pulling the padding back out, so its label lines up with
   body text on the same grid — but the padding stays part of the tap target. */
.post-btn--ghost {
  border-color: transparent;
  margin-inline: calc(-1 * var(--space-s));
}

.post-btn--filled:hover,
.post-btn--filled.is-hover,
.post-btn--filled.is-active {
  background: var(--action-hover);
  border-color: var(--action-hover);
}
.post-btn--outline:hover,
.post-btn--outline.is-hover,
.post-btn--outline.is-active,
.post-btn--ghost:hover,
.post-btn--ghost.is-hover,
.post-btn--ghost.is-active {
  background: var(--hover-surface);
  color: var(--hover-ink);
}
/* Active adds a 1px press on top of the hover colours. */
.post-btn:active,
.post-btn.is-active { transform: translateY(1px); }
.post-btn.is-focus,
.post-btn:focus-visible { outline: 2px solid var(--action); outline-offset: 2px; }
.post-btn.is-disabled,
.post-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
/* @endcomponent */

/* @component icon-button ---------------------------------------------------- */
/* Standalone icons ONLY. An icon beside a label lives inside that label's component.
   Circle corners are knowingly round — the wayfinding map-pin heritage, and one of
   the two sanctioned exceptions to POST's sharp corners (Radio is the other). */
.post-iconbtn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: var(--stroke-m) solid transparent;
  background: none;
  color: var(--action);
  cursor: pointer;
  padding: 0;
}
.post-iconbtn .post-icon { width: 100%; height: 100%; }

/* Ghost: the BOX IS THE ICON, not the circle, so the glyph aligns optically with text
   and with grids. The hover halo overflows the bounds via padding + a matching
   negative margin — so the tap target is circle-sized even at rest, while layout
   still sees only the icon. */
.post-iconbtn--ghost {
  border-radius: 50%;
  /* Both of these are load-bearing, and both were missing. Under the border-box default
     the width token is the WHOLE box, so padding + border ate the glyph from the inside:
     at size s that is 16 - 2*8 - 2*2 = a 0px icon, and s/m/l rendered 0/4/12px instead of
     16/24/32. content-box makes the width token mean the ICON, which is what the tier
     table says it means; border-width:0 then keeps the negative margin exact, so layout
     sees the glyph and nothing else. */
  box-sizing: content-box;
  border-width: 0;
  padding: var(--space-s);
  margin: calc(-1 * var(--space-s));
}
.post-iconbtn--ghost.post-iconbtn--s { width: var(--icon-size-s); height: var(--icon-size-s); }
.post-iconbtn--ghost.post-iconbtn--m { width: var(--icon-size-m); height: var(--icon-size-m); }
.post-iconbtn--ghost.post-iconbtn--l { width: var(--icon-size-l); height: var(--icon-size-l); }
.post-iconbtn--ghost:hover,
.post-iconbtn--ghost.is-hover { background: var(--hover-surface); color: var(--hover-ink); }

/* Circle: the diameter still DERIVES from the icon rather than being hand-set —
   circle = icon + 2 x padding, so the tier table (24/32/48/64 holding 16/24/32/48)
   falls out of the tokens instead of being maintained separately.

   Written as an explicit border-box diameter rather than content-box + padding, because
   under content-box the BORDER is added on top of the circle. That made outline (which
   sets stroke/s at size s and stroke/l at size l) and filled (which keeps the base
   stroke/m) render two different diameters at the same tier — 34 vs 36 at s — and pushed
   size l to 52px against a documented 48. The diameter now includes its own border, so
   the two styles agree and the tier table is true.

   Sizing the glyph explicitly matters for the same reason: the shared 100%/100% rule
   would fill the whole circle now that the box is the circle rather than the icon. */
.post-iconbtn--circle-outline,
.post-iconbtn--circle-filled {
  border-radius: 50%;
  box-sizing: border-box;
  /* Size m is NOT a Figma variant — circle is s and l only (14 variants = 3 styles x 3
     sizes x 2 states, minus circle x m). This is the guard rail: without a declared
     width, `width:100%` on the glyph resolved against an indeterminate parent and blew
     the button up to a ~300x150 ellipse. An out-of-spec combination should look wrong,
     not detonate. */
  width:  calc(var(--icon-size-m) + 2 * var(--space-m));
  height: calc(var(--icon-size-m) + 2 * var(--space-m));
}
.post-iconbtn--circle-outline .post-icon,
.post-iconbtn--circle-filled .post-icon {
  width: var(--icon-size-m); height: var(--icon-size-m);
}
.post-iconbtn--circle-outline.post-iconbtn--s,
.post-iconbtn--circle-filled.post-iconbtn--s {
  width:  calc(var(--icon-size-s) + 2 * var(--space-s));
  height: calc(var(--icon-size-s) + 2 * var(--space-s));
}
.post-iconbtn--circle-outline.post-iconbtn--s .post-icon,
.post-iconbtn--circle-filled.post-iconbtn--s .post-icon {
  width: var(--icon-size-s); height: var(--icon-size-s);
}
.post-iconbtn--circle-outline.post-iconbtn--l,
.post-iconbtn--circle-filled.post-iconbtn--l {
  width:  calc(var(--icon-size-l) + 2 * var(--space-m));
  height: calc(var(--icon-size-l) + 2 * var(--space-m));
}
.post-iconbtn--circle-outline.post-iconbtn--l .post-icon,
.post-iconbtn--circle-filled.post-iconbtn--l .post-icon {
  width: var(--icon-size-l); height: var(--icon-size-l);
}
/* Outline weight follows ALTITUDE (rule 2), not the component: the small circle is a
   detail (stroke/s), the large one is section-scale (stroke/l). Buttons sit between
   at stroke/m. */
.post-iconbtn--circle-outline { border-color: currentColor; }
.post-iconbtn--circle-outline.post-iconbtn--s { border-width: var(--stroke-s); }
.post-iconbtn--circle-outline.post-iconbtn--l { border-width: var(--stroke-l); }
.post-iconbtn--circle-filled {
  background: var(--action);
  color: var(--on-action);
  border-color: var(--action);
}
.post-iconbtn--circle-outline:hover,
.post-iconbtn--circle-outline.is-hover { background: var(--hover-surface); color: var(--hover-ink); }
.post-iconbtn--circle-filled:hover,
.post-iconbtn--circle-filled.is-hover { background: var(--action-hover); border-color: var(--action-hover); }
.post-iconbtn:focus-visible,
.post-iconbtn.is-focus { outline: 2px solid var(--action); outline-offset: 2px; }
.post-iconbtn.is-disabled,
.post-iconbtn:disabled { opacity: 0.4; cursor: not-allowed; }
/* @endcomponent */

/* @component icon-circle ---------------------------------------------------- */
/* DISPLAY TREATMENT, not a control — the circled icon used as a graphic (wayfinding
   heritage). Anything clickable uses post-iconbtn instead. */
.post-iconcircle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  box-sizing: content-box;
  color: var(--action);
}
.post-iconcircle--s { width: var(--icon-size-s); height: var(--icon-size-s); padding: var(--space-s); }
.post-iconcircle--m { width: var(--icon-size-m); height: var(--icon-size-m); padding: var(--space-s); }
.post-iconcircle--l { width: var(--icon-size-l); height: var(--icon-size-l); padding: var(--space-m); }
.post-iconcircle--filled { background: var(--action); color: var(--on-action); }
.post-iconcircle--outline { border: var(--stroke-m) solid currentColor; }
/* @endcomponent */

/* @component tag ------------------------------------------------------------ */
/* NON-interactive info marker — category, status, a short piece of text.
   RENAMED from Badge 2026-08-19: `Badge` now means the small number/dot/icon marker.

   HOW YOU TELL IT IS NOT CLICKABLE: all caps, weight 600, muted info ink. Chip is
   sentence case in action blue. Typography AND colour encode interactivity, so a
   static marker never reads like a control even at a glance.

   THE BORDER IS ALWAYS DECLARED and only its COLOUR changes — the same mechanism
   .post-btn uses. There is no global border-box reset in this file, so a style that
   simply dropped its border would be 2 x stroke smaller than its siblings. Every
   style here is the same box. */
.post-tag {
  display: inline-flex;
  align-items: center;
  gap: 0;
  padding: var(--space-xs);
  border: var(--stroke-m) solid transparent;
  color: var(--feedback-info-text);
  background: none;
  font-family: var(--font-post);
  font-size: var(--text-2xs);
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.post-tag .post-label { min-height: var(--icon-size-s); }

.post-tag--outline { border-color: var(--feedback-info-text); }
.post-tag--filled {
  background: var(--feedback-info-text);
  border-color: var(--feedback-info-text);
  color: var(--feedback-info-surface);
}
/* ON MEDIA. An outline tag disappears against a photograph; this is the treatment
   that survives one — a light plate with a dark edge, readable over any image. */
.post-tag--filled-outline {
  background: var(--feedback-info-surface);
  border-color: var(--feedback-info-text);
  color: var(--feedback-info-text);
}

/* Deprecated alias. .post-badge is the old name and still ships so live pages do not
   break; it will be removed once consumers are migrated. Do not use it in new work. */
.post-badge { }
/* @endcomponent */

/* @component chip ----------------------------------------------------------- */
/* INTERACTIVE filter toggle — the counterpart to Badge. Ink and stroke bind the same
   token, so the border is currentColor and selected inverts cleanly. */
.post-chip {
  display: inline-flex;
  align-items: center;
  gap: 0;
  padding: var(--space-s);
  border: var(--stroke-m) solid currentColor;
  color: var(--action);
  background: none;
  cursor: pointer;
  font-family: var(--font-post);
  font-size: var(--text-2xs);
  font-weight: 600;
  line-height: 1;
  letter-spacing: normal;
  text-transform: none;
}
/* Rule 4 again: the label carries the icon tier, so toggling the dismiss x changes
   width only — never height. */
.post-chip .post-label { min-height: var(--icon-size-s); }
.post-chip .post-icon { width: var(--icon-size-s); height: var(--icon-size-s); }

/* The dismiss x appears ONLY on a selected chip. An unselected filter has nothing to
   clear, so an x on it offers an action that does not exist — and next to the chip's
   own toggle it reads as two ways to do one thing. Selecting is what creates something
   to dismiss, so that is when the affordance arrives.

   display:none rather than visibility:hidden, and the difference is deliberate. Search
   Bar's clear button uses visibility because it sits INSIDE a fixed-width field where a
   reserved slot stops the text reflowing as you type. A chip has no fixed width: a
   reserved slot would just be a dead gap on the right of every unselected chip, so the
   padding would look wrong on the common case to protect the rare one. The cost is that
   a chip GROWS when selected and nudges its neighbours — acceptable for a filter row,
   and the standard behaviour for this control. Do not "fix" that by reserving the slot
   without re-reading this.

   HEIGHT: the wrapper must not build a text line box. Left as a default block box around
   an inline <svg> it was 18px for a 16px glyph — the extra 2px is descender leading below
   the baseline — which made a dismissible chip 28px against a text-only chip's 26px, i.e.
   exactly the height drift Rule 4 exists to prevent. flex + the icon-tier height pins it
   to the same tier the label uses, so the two agree by construction. */
.post-chip [data-post-dismiss] {
  display: none;
  align-items: center;
  height: var(--icon-size-s);
}
/* DISMISSIBLE decides whether selection produces a removable affordance. Renamed from
   Select (single|multi) on 2026-08-25 — it named an exclusivity this component never
   enforced, while what actually differs is whether an x appears.
     no   nothing to individually clear, so it NEVER shows a dismiss. This is the
          DEFAULT — no modifier needed.
     yes  each one separately clearable, so selection brings the x. */
.post-chip--dismissible.is-selected [data-post-dismiss] { display: flex; }
.post-chip:hover,
.post-chip.is-hover { background: var(--hover-surface); color: var(--hover-ink); }
.post-chip.is-selected { background: var(--action); color: var(--on-action); border-color: var(--action); }
.post-chip.is-selected:hover,
.post-chip.is-selected.is-hover { background: var(--action-hover); border-color: var(--action-hover); }
.post-chip:focus-visible { outline: 2px solid var(--action); outline-offset: 2px; }
/* @endcomponent */

/* @component filter-tag ----------------------------------------------------- */
/* Applied-filter tag. Pins an EXPLICIT deep theme, so it stays clay on every brand
   page — the same brand-independence rule as sidebars and modals. Removing is its
   only action; the interactive filter CHOICE is Chip.
   Set data-theme="deep" on the element in markup; that is what pins it. */
.post-filtertag {
  display: inline-flex;
  align-items: center;
  gap: 0;
  padding: var(--space-s);
  background: var(--surface);
  color: var(--text);
  border: none;
  cursor: pointer;
  font-family: var(--font-post);
  font-size: var(--text-2xs);
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.post-filtertag .post-label { min-height: var(--icon-size-s); }
/* The remove glyph is deliberately FIXED at the 16px primitive, not the responsive
   role — it is a constant-size affordance, not part of the sizing ladder. */
.post-filtertag .post-icon { width: var(--icon-size-16); height: var(--icon-size-16); }
.post-filtertag:hover,
.post-filtertag.is-hover { background: var(--hover-surface); color: var(--hover-ink); }
/* @endcomponent */

/* @component input ---------------------------------------------------------- */
/* Neutral until interaction: the resting border is the `border` token (ink), not the
   action colour — POST borders are a strong tone, not a grey hairline. Error
   MESSAGING lives in Field, not here. */
.post-input {
  display: block;
  width: 100%;
  box-sizing: border-box;
  padding: var(--space-m);
  border: var(--stroke-m) solid var(--border);
  background: var(--surface);
  color: var(--text);
  font-family: var(--font-post);
  font-size: var(--text-sm);
  font-weight: 500;
  line-height: var(--icon-size-s);
}
.post-input::placeholder { color: var(--muted); }
/* HOVER IS A FILL AND AN EDGE, on every control that has an edge. Checkbox and Radio always
   did both; Input and Select changed only their border, and Search Bar and Level Filter did
   nothing at all - so the same gesture produced four different amounts of feedback depending
   on which control you were over. Same pair everywhere now: hover-surface behind,
   hover-ink on the edge.

   Deliberately BEFORE the focus/error/disabled rules: those are more important states and
   must win on a control that is also being hovered. An errored input stays red under the
   pointer, it does not turn blue. */
.post-input:hover,
.post-input.is-hover {
  border-color: var(--hover-ink);
  background: var(--hover-surface);
}
.post-input:focus,
.post-input.is-focus {
  border-color: var(--action);
  outline: 2px solid var(--action);
  outline-offset: 2px;
}
.post-input.is-error { border-color: var(--feedback-error-text); background: var(--feedback-error-surface); }
.post-input:disabled,
.post-input.is-disabled { opacity: 0.4; cursor: not-allowed; }
textarea.post-input { line-height: 1.2; resize: vertical; min-height: 6em; }
/* @endcomponent */

/* @component field ---------------------------------------------------------- */
/* Label + Input + helper as ONE unit — this is what a form actually consumes.
   The helper flips to the error colour with the input. */
.post-fieldset { display: flex; flex-direction: column; gap: var(--space-s); }
.post-fieldset > .post-fieldlabel {
  font-family: var(--font-post);
  font-size: var(--text-xs);
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text);
}
/* /inline, not /text. The chip ink is theme-invariant by design and only works ON the
   chip: red/800 reads 1.08:1 on a brand plane and 1.63:1 on deep — invisible. Anything
   error-coloured that sits on the SECTION plane needs the theme-aware token. */
.post-fieldset > .post-fieldlabel .post-req { color: var(--feedback-error-inline); }
.post-fieldset > .post-helper {
  font-family: var(--font-post);
  font-size: var(--text-xs);
  font-weight: 500;
  line-height: 1.2;
  color: var(--muted);
}
.post-fieldset.is-error > .post-helper { color: var(--feedback-error-inline); }

/* HOVERING ANY PART OF THE FIELD HOVERS ITS INPUT. The label and the helper belong to the
   control, exactly as a checkbox's label belongs to its box - reaching for the word
   "Email" should light the box you are about to type in.

   Guarded with :not(.is-error). The descendant selector outranks `.post-input.is-error`, so
   without it an errored field would turn blue the moment the pointer crossed its label -
   hover quietly overruling a state that matters far more. */
.post-fieldset:hover .post-input:not(.is-error):not(:disabled):not(.is-disabled) {
  border-color: var(--hover-ink);
  background: var(--hover-surface);
}
/* @endcomponent */

/* @component select --------------------------------------------------------- */
/* Form-scale select; Level Filter is its nav-scale sibling. Display rule: single
   select shows the chosen item, multi shows a COUNT ("3 spaces selected") — the
   count is content, so the component does not need a variant for it. */
/* Figma pins every Select variant to a FIXED width with the trigger set to FILL — the
   box is a stable slot and the VALUE flexes inside it. Without a declared width the
   component shrink-wraps wherever it is a flex/grid child, so the box resized on every
   pick: "Select a space" -> "The Hall" -> "3 selected" each produced a different width
   and shifted whatever sat beside it. The container owns the width; the value never does. */
.post-select { position: relative; width: 100%; }
/* The value is the flexible part, so it truncates rather than pushing the box open.
   min-width:0 is required — a flex item's default min-width:auto refuses to shrink
   below its content and would defeat the ellipsis. */
.post-select__value {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.post-select__control {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-m);
  width: 100%;
  box-sizing: border-box;
  padding: var(--space-m);
  border: var(--stroke-m) solid var(--border);
  background: var(--surface);
  color: var(--text);
  font-family: var(--font-post);
  font-size: var(--text-sm);
  font-weight: 500;
  line-height: var(--icon-size-s);
  cursor: pointer;
  text-align: left;
}
.post-select__control .post-icon { width: var(--icon-size-s); height: var(--icon-size-s); color: var(--action); }
/* One chevron glyph, rotated — not two icons. The icon describes what happens to the
   page, not what the widget is called. */
.post-select__control .post-icon { transition: transform 0.15s ease; }
.post-select.is-open .post-select__control .post-icon { transform: rotate(180deg); }
.post-select__control:hover,
.post-select.is-hover .post-select__control {
  border-color: var(--hover-ink);
  background: var(--hover-surface);
}
.post-select.is-open .post-select__control,
.post-select.is-focus .post-select__control { border-color: var(--action); }
.post-select.is-focus .post-select__control { outline: 2px solid var(--action); outline-offset: 2px; }
.post-select.is-error .post-select__control { border-color: var(--feedback-error-text); background: var(--feedback-error-surface); }
.post-select.is-disabled { opacity: 0.4; pointer-events: none; }
.post-select__panel {
  position: absolute;
  z-index: 20;
  left: 0; right: 0;
  margin-top: calc(-1 * var(--stroke-m));
  border: var(--stroke-m) solid var(--border);
  background: var(--surface);
  display: none;
  max-height: 16em;
  overflow-y: auto;
}
.post-select.is-open .post-select__panel { display: block; }
.post-select__option {
  display: block;
  width: 100%;
  box-sizing: border-box;
  padding: var(--space-m);
  border: none;
  background: none;
  text-align: left;
  color: var(--text);
  font-family: var(--font-post);
  font-size: var(--text-sm);
  font-weight: 500;
  line-height: var(--icon-size-s);
  cursor: pointer;
}
.post-select__option:hover,
.post-select__option.is-hover { background: var(--hover-surface); color: var(--hover-ink); }
.post-select__option[aria-selected="true"] { background: var(--action); color: var(--on-action); }
/* @endcomponent */

/* @component checkbox ------------------------------------------------------- */
/* A SHARP square — this is where the brand's sharp-corner rule pays off, because a
   square checkbox is the conventional form anyway. Size m is the form-primary one,
   sized to sit beside Input fields. */
.post-check { display: inline-flex; align-items: flex-start; gap: var(--space-s); cursor: pointer; }
.post-check__box {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--icon-size-s);
  height: var(--icon-size-s);
  border: var(--stroke-m) solid var(--border);
  background: var(--surface);
  color: transparent;
}
.post-check__box svg { width: 100%; height: 100%; fill: currentColor; }
.post-check__label {
  font-family: var(--font-post);
  font-size: var(--text-sm);
  font-weight: 500;
  line-height: var(--icon-size-s);
  color: var(--text);
}
.post-check--m .post-check__box { width: var(--icon-size-m); height: var(--icon-size-m); }
.post-check--m .post-check__label { font-size: var(--text-md); line-height: var(--icon-size-m); }
.post-check:hover .post-check__box,
.post-check.is-hover .post-check__box { background: var(--hover-surface); border-color: var(--hover-ink); }
.post-check.is-checked .post-check__box {
  background: var(--action);
  border-color: var(--action);
  color: var(--on-action);
}
.post-check.is-error .post-check__box { border-color: var(--feedback-error-text); background: var(--feedback-error-surface); }
.post-check.is-disabled { opacity: 0.4; cursor: not-allowed; }
.post-check input { position: absolute; opacity: 0; width: 0; height: 0; }
.post-check input:focus-visible + .post-check__box { outline: 2px solid var(--action); outline-offset: 2px; }
/* @endcomponent */

/* @component radio ---------------------------------------------------------- */
/* Inherently ROUND — documented alongside the icon-button circle as the two
   sanctioned exceptions to sharp corners. A square radio would read as a checkbox. */
.post-radio { display: inline-flex; align-items: flex-start; gap: var(--space-s); cursor: pointer; }
.post-radio__ring {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--icon-size-s);
  height: var(--icon-size-s);
  border: var(--stroke-m) solid var(--border);
  border-radius: 50%;
  background: var(--surface);
}
.post-radio__dot { width: 50%; height: 50%; border-radius: 50%; background: transparent; }
.post-radio__label {
  font-family: var(--font-post);
  font-size: var(--text-sm);
  font-weight: 500;
  line-height: var(--icon-size-s);
  color: var(--text);
}
.post-radio:hover .post-radio__ring,
.post-radio.is-hover .post-radio__ring { background: var(--hover-surface); border-color: var(--hover-ink); }
/* Selected keeps the RING in the action colour and fills the dot — it does not invert
   the whole control, which would read as a checkbox. */
.post-radio.is-selected .post-radio__ring { border-color: var(--action); }
.post-radio.is-selected .post-radio__dot { background: var(--action); }
.post-radio.is-error .post-radio__ring { border-color: var(--feedback-error-text); background: var(--feedback-error-surface); }
.post-radio.is-disabled { opacity: 0.4; cursor: not-allowed; }
.post-radio input { position: absolute; opacity: 0; width: 0; height: 0; }
.post-radio input:focus-visible + .post-radio__ring { outline: 2px solid var(--action); outline-offset: 2px; }
/* @endcomponent */

/* @component search-bar ----------------------------------------------------- */
/* Section-scale, so stroke/l (rule 2) — this is a big surface, not a component-scale
   control. One component across all three breakpoints; the text fills. */
.post-search {
  display: flex;
  align-items: center;
  gap: var(--space-m);
  padding: var(--space-m);
  border: var(--stroke-l) solid currentColor;
  color: var(--action);
  background: var(--surface);
  /* "Width is instance-resizable; the text fills" — so the bar takes its container and
     the input absorbs the slack. Stated rather than left to `display:flex` defaulting to
     auto, because the common case is a flex/grid parent where an auto-width child
     shrink-wraps instead. box-sizing is declared for the same reason the icon button
     needed it: with width + padding + border we cannot inherit the consumer's reset. */
  width: 100%;
  box-sizing: border-box;
}
.post-search .post-icon { width: var(--icon-size-m); height: var(--icon-size-m); }
.post-search input {
  flex: 1;
  min-width: 0;
  border: none;
  background: none;
  outline: none;
  color: var(--text);
  font-family: var(--font-post);
  font-size: var(--text-md);
  font-weight: 500;
  line-height: var(--icon-size-m);
}
.post-search input::placeholder { color: var(--muted); }
.post-search:hover,
.post-search.is-hover {
  border-color: var(--hover-ink);
  background: var(--hover-surface);
}
/* After the hover rule on purpose: an ACTIVE search bar outranks a hovered one, so it keeps
   its action colour while the pointer is over it instead of flickering between the two. */
.post-search.is-active { color: var(--action); }
/* type="search" ships a NATIVE clear affordance — a grey UA-styled x that ignores every
   token in this file and differs per browser. Suppress it and use the real ghost icon
   button instead, so clear is the same close glyph, colour and hover halo as everywhere
   else. Kept on type="search" rather than switching to type="text" because the input
   type is what gives us Escape-to-clear and the right mobile keyboard. */
.post-search input::-webkit-search-cancel-button,
.post-search input::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
/* Both affordances are REAL ghost icon buttons, not decoration: search submits, clear
   empties. The ghost's negative margin is what keeps them honest here — it cancels the
   bar's own padding, so each glyph lands exactly where a plain 24px icon sat, while the
   hover halo expands outward into the padding instead of pushing the layout around. */

/* Clear toggles VISIBILITY, never display. Under display:none the button leaves the flex
   flow, the input reclaims its width, and the text reflows the moment you type the first
   character — the whole bar twitches. visibility keeps the slot reserved, so nothing
   moves; it also drops the button out of the tab order and off the hit-test while empty,
   which is the reason not to just fade it with opacity. */
.post-search__clear { visibility: hidden; }
.post-search.is-filled .post-search__clear { visibility: visible; }
/* @endcomponent */

/* @component accordion ------------------------------------------------------ */
/* ghost = transparent with a section rule, content added IN PLACE.
   panel = inverted: an action-coloured plane with on-action ink.
   The + rotates 45deg to an x — one glyph, not two, because the icon describes what
   happens to the page. */
/* Same fixed-slot rule as Select and Search Bar: Figma pins every variant to a FIXED
   width with the rule, header, title and body all set to FILL. The parent decides the
   width; the TITLE takes up the slack inside it. Without a declared width the accordion
   shrink-wrapped to its own heading wherever it was a flex/grid child, so a row of them
   came out ragged — each one only as wide as its title — and the top rule, which is
   supposed to read as a section divider, stopped short. */
.post-accordion {
  width: 100%;
  box-sizing: border-box;
  /* width:100% alone is not enough in a flex/grid parent: a flex item defaults to
     min-width:auto, which refuses to shrink below its own content, so a long heading
     still widened the column and a row of accordions came out uneven (190/194/210 for
     three different titles). min-width:0 lets the slot win and the title wrap. */
  min-width: 0;
  border-top: var(--stroke-l) solid var(--border);
}
.post-accordion__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-m);
  width: 100%;
  box-sizing: border-box;
  padding: var(--space-m);
  border: none;
  background: none;
  cursor: pointer;
  text-align: left;
  font-family: var(--font-post);
  font-size: var(--text-lg);
  font-weight: 500;
  line-height: 1;
  color: var(--text);
}
/* Title and disclosure icon share ONE ink: text at rest, hover-ink on hover — they
   are one affordance, so they must never diverge. Figma binds the ghost disclosure
   glyph to `text`, NOT `action`; it was pinned to action here, which rendered a brand
   blue + on a near-black title and split the one affordance in two.
   The icon now sets no colour of its own — it inherits the head's, which is the
   mechanism that makes "one ink" structurally true instead of two rules kept in sync. */
/* Title FILLs, glyph is FIXED — Figma's sizing modes, expressed as flex. min-width:0
   lets a long title wrap instead of forcing the head wider than its slot; flex:0 0 auto
   stops the glyph being squeezed when it does. */
.post-accordion__title { flex: 1 1 auto; min-width: 0; }
.post-accordion__head .post-icon {
  flex: 0 0 auto;
  width: var(--icon-size-l);
  height: var(--icon-size-l);
  transition: transform 0.15s ease;
}
/* Hover means two DIFFERENT things per style, which is why this is scoped rather than
   global. On ghost, hover moves the INK (there is no plane to move). On panel the plane
   darkens and the ink stays inverted — see the panel block below.
   Unscoped, this rule was `.post-accordion.is-hover .post-accordion__head` at (0,3,0) and
   out-specified the panel's own (0,2,0) ink rule, so a hovered panel painted its title in
   hover-ink on an action-hover plane — the same colour, i.e. an invisible heading. */
.post-accordion:not(.post-accordion--panel) .post-accordion__head:hover,
.post-accordion:not(.post-accordion--panel).is-hover .post-accordion__head { color: var(--hover-ink); }
.post-accordion.is-open .post-accordion__head .post-icon { transform: rotate(45deg); }
.post-accordion__body { display: none; padding: 0 var(--space-m) var(--space-m); font-size: var(--text-md); color: var(--text); line-height: 1.2; }
.post-accordion.is-open .post-accordion__body { display: block; }
/* panel is the inverted plane. Its outline is bound to on-action in Figma, not action —
   an action-on-action border is invisible, which is why the panel read as a flat blue
   block with no edge. currentColor expresses it: the plane sets color:on-action once and
   the border follows, which is the same rule outline buttons use. */
.post-accordion--panel {
  background: var(--action);
  color: var(--on-action);
  border: var(--stroke-m) solid currentColor;
}
.post-accordion--panel .post-accordion__head,
.post-accordion--panel .post-accordion__body { color: var(--on-action); }
/* Hover darkens the plane in BOTH states. This was gated on .is-open, so a closed panel
   did not respond to the pointer at all — Figma has Style=panel/State=hover on
   action-hover with no open requirement. */
.post-accordion--panel:hover,
.post-accordion--panel.is-hover { background: var(--action-hover); }
/* @endcomponent */

/* @component alert ---------------------------------------------------------- */
/* Sits on the theme-INVARIANT feedback tokens, so it renders identically in all four
   themes. That is deliberate: an error must not restyle itself per section. Doubles
   as toast styling. */
.post-alert {
  display: flex;
  align-items: flex-start;
  gap: var(--space-m);
  padding: var(--space-m);
  font-family: var(--font-post);
  font-size: var(--text-xs);
  font-weight: 500;
  line-height: 1.2;
}
.post-alert .post-icon { width: var(--icon-size-s); height: var(--icon-size-s); }
.post-alert--error   { background: var(--feedback-error-surface);   color: var(--feedback-error-text); }
.post-alert--warning { background: var(--feedback-warning-surface); color: var(--feedback-warning-text); }
.post-alert--success { background: var(--feedback-success-surface); color: var(--feedback-success-text); }
.post-alert--info    { background: var(--feedback-info-surface);    color: var(--feedback-info-text); }
/* @endcomponent */

/* @component note-link ------------------------------------------------------ */
/* A clickable NOTE, not a CTA — info icon + underlined text + arrow. Sits under
   content blocks (policy notes). Underline is the distinguisher, which survives the
   brand/deep themes where action == text. */
/* INLINE, not flex. `inline-flex` + `align-items:center` treats the icons as siblings of
   the whole text block, so the moment a note wrapped to two lines both glyphs floated to
   the vertical middle of the paragraph — the leading icon detached from the first line and
   the arrow detached from the last word. Icons inside a sentence are part of the sentence:
   inline flow puts the leading glyph on the FIRST line's baseline and lets the arrow
   follow the final word wherever it lands, including across a wrap. */
/* Two parts, not one run of text: a SEPARATE leading icon, then the copy with its arrow
   riding the last word. Figma models it exactly this way — [info-icon][text][arrow] with
   the arrow inside the text run — and it is what makes wrapping behave: the icon hangs in
   its own column so wrapped lines indent past it instead of running underneath. */
.post-notelink {
  display: inline-flex;
  align-items: flex-start;
  gap: var(--space-xs);                    /* Figma itemSpacing = 4 */
  color: var(--action);
  cursor: pointer;
  background: none;
  border: none;
  font-family: var(--font-post);
  font-size: var(--text-xs);
  font-weight: 500;
  line-height: 1.2;
  /* Figma grows the box on hover (0 padding -> 8/4/8/4, h 16 -> 32). Reserving that
     padding at rest and cancelling it with a matching negative margin gets the same halo
     WITHOUT the reflow — the ghost icon button's mechanism. The vertical value is space/s
     against space/xs horizontal: a halo needs more room above and below a line of text
     than beside it, or it reads as a highlighter stripe rather than a surface. */
  padding: var(--space-s) var(--space-xs);
  margin: calc(-1 * var(--space-s)) calc(-1 * var(--space-xs));
  text-decoration: none;                   /* the underline belongs to the copy, not the icon */
}
/* A one-line-tall box, so the circle centres on the FIRST line and stays there when the
   copy wraps. The circle is larger than a line box and simply overflows it symmetrically,
   which is what keeps it optically centred instead of pinned to the top. */
.post-notelink__icon {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: calc(var(--text-xs) * 1.2);
}
.post-notelink__icon .post-icon {
  width: var(--icon-size-s);
  height: var(--icon-size-s);
}
/* BOTH glyphs take the link's ink, never their own. Figma binds all three fills — text and
   the two icon vectors — to `action` at rest and `hover-ink` on hover, which in code means
   one `color` on the anchor and `fill:currentColor` underneath. Anything that pins a colour
   on an icon (the icon-circle sets `color:var(--action)`, for instance) breaks the hover:
   the copy moves to hover-ink and the glyph is left behind on the rest colour. */
.post-notelink .post-icon { color: inherit; }
.post-notelink__body {
  flex: 1 1 auto;
  min-width: 0;
  text-decoration: underline;
}
/* The arrow rides the copy: inline, on the baseline, bound to the last word by an &nbsp;
   so it can never orphan onto a line of its own. */
.post-notelink__body .post-icon {
  display: inline-block;
  width: var(--icon-size-s);
  height: var(--icon-size-s);
  vertical-align: -0.18em;
}
.post-notelink:hover,
.post-notelink.is-hover {
  color: var(--hover-ink);
  background: var(--hover-surface);
}
/* @endcomponent */

/* @component progress ------------------------------------------------------- */
.post-progress {
  display: block;
  width: 100%;
  height: var(--stroke-l);
  background: var(--hover-surface);
  overflow: hidden;
}
.post-progress__bar { display: block; height: 100%; background: var(--action); transition: width 0.2s ease; }
/* @endcomponent */

/* @component scroll-dots ---------------------------------------------------- */
/* The gap is space/s — the CONTROL step. This is the token that used to be called
   "control padding" while actually being a carousel dot gap; naming by scale instead
   of by consumer is what removed that confusion. */
.post-dots { display: inline-flex; align-items: center; gap: var(--space-s); }
/* An inactive dot is OUTLINED, not greyed. Grey-and-faded read as disabled — as if
   those slides could not be reached — when the whole point of the control is that they
   can. Outline says "a slide lives here"; fill says "you are on it".
   box-sizing is declared for the same reason it is on Icon Button: there is no global
   border-box reset here, so without it the bordered dot would be 2 x stroke bigger than
   dot-size and the outlined and filled dots would not match. */
.post-dots__dot {
  width: var(--dot-size);
  height: var(--dot-size);
  box-sizing: border-box;
  border-radius: 50%;
  background: none;
  border: var(--stroke-m) solid var(--action);
  padding: 0;
  cursor: pointer;
}
.post-dots__dot.is-active { background: var(--action); border-color: var(--action); }
/* @endcomponent */

/* @component level-filter --------------------------------------------------- */
/* Map level select — the nav-scale sibling of Select. The selected level is listed
   first when open, matching the live map's behaviour. */
/* Same fixed-slot rule as Select — Figma pins it to a FIXED width with the level label
   set to FILL. inline-block made it hug, so the box jumped every time the level changed
   (LEVEL 1 -> SKYLAWN) and dragged the map controls beside it along with it. */
.post-levelfilter { position: relative; display: block; width: 100%; }
.post-levelfilter__control {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  box-sizing: border-box;
  gap: var(--space-m);
  padding: var(--space-m);
  border: var(--stroke-m) solid currentColor;
  background: var(--surface);
  color: var(--action);
  cursor: pointer;
  font-family: var(--font-post);
  font-size: var(--text-md);
  font-weight: 500;
  line-height: var(--icon-size-m);
}
.post-levelfilter__control .post-icon { width: var(--icon-size-m); height: var(--icon-size-m); transition: transform 0.15s ease; }
.post-levelfilter__control:hover,
.post-levelfilter.is-hover .post-levelfilter__control {
  border-color: var(--hover-ink);
  background: var(--hover-surface);
}
.post-levelfilter.is-open .post-levelfilter__control .post-icon { transform: rotate(180deg); }
.post-levelfilter__list { display: none; flex-direction: column; gap: var(--space-l); padding: var(--space-m); border: var(--stroke-m) solid var(--border); background: var(--surface); }
.post-levelfilter.is-open .post-levelfilter__list { display: flex; }
/* @endcomponent */

/* @component chip-group ------------------------------------------------------ */
/* A wrapping row of Chips, or of Tags. Fixed width, wrap on, the SAME gap in both
   directions so the grid reads evenly however it reflows.

   NEVER MIX CHIPS AND TAGS IN ONE GROUP. A mixed row is one where some items respond
   to a pointer and some do not, with only weight and letter-case to tell them apart.
   Chips group with Chips; Tags group with Tags; the two sit in separate rows.

   STYLE APPLIES TO THE WHOLE TAG GROUP, not to individual tags — a row of tags is one
   visual set, and mixing treatments inside it makes some labels look more important
   than others when they are all just labels. */
.post-chipgroup,
.post-taggroup {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-s);
}

/* WHICH ROW THIS IS lives on the GROUP, never on the chip — the chip markup is
   identical in both, which is what lets one component serve both roles.

     data-post-chips="filter"    the control row. The x DESELECTS and the chip stays
                                 put; it is one of a fixed list of filters.
     data-post-chips="applied"   the applied-filter readout, rendered elsewhere on the
                                 page. The x deselects the SOURCE chip, and the page
                                 removes this one in response to that state change.

   This replaces the old Filter Tag component (retired 2026-08-19): an applied filter
   and a selected multi-chip are the same object, differing only in where they render
   and what removing them means. That is an engineering difference, not a visual one. */
/* @endcomponent */

/* @component card ------------------------------------------------------------ */
/* THREE components, one stylesheet. Stacked / Horizontal / Horizontal Small differ in
   the media RELATIONSHIP, not in their mechanics, so the base carries everything and
   each modifier changes only direction, media size and the padding rung.

   ONE TOKEN DRIVES BOTH STYLES. `--card-pad` is the outline's padding AND the ghost's
   hover bleed, because they are the same measurement seen twice: the outline pays for
   that space permanently, the ghost borrows it only while the pointer is down on it.
   Writing it once is what stops the two drifting apart — which is exactly what happened
   in Figma, where the bleed was hand-set to 8 against an outline padding of 16 and 12.

   Figma CANNOT hold this rule: x/y are not bindable to variables, so the plate's inset
   is a literal on canvas and goes stale at every breakpoint. This file is authoritative
   for the bleed, the same way it is for text/display-inset. */
.post-card {
  --card-pad: var(--space-l);
  position: relative;                      /* the ghost plate anchors to this */
  /* AND the card must be its own stacking context, or the ghost plate DISAPPEARS. The plate
     is `z-index:-1` so it sits behind the card's content; but `position:relative` with
     `z-index:auto` does NOT create a stacking context, so that -1 escaped the card entirely
     and painted in the ROOT's negative layer - underneath the background of any ancestor
     that had one. On a bare page it looked fine. Inside anything with a surface - the
     guide's own matrix cell, or any real page section - the hover state vanished and only
     the ink changed. `isolation` creates the context without touching layout. */
  isolation: isolate;
  display: flex;
  flex-direction: column;
  gap: var(--space-m);
  color: var(--text);
  text-decoration: none;
  /* NO border-radius. POST is a sharp-corner brand; the only sanctioned round corners
     in this system are Icon Button's circle and Radio. */
}
/* THE CARD'S MEDIA IS A MEDIA THUMB with its slots switched off - not a second image
   implementation living in the card. That is why this rule sets LAYOUT ONLY: no ratio, no
   background, no object-fit. The plane, the ratio, the placeholder and the element-level
   light/dark all come from `.post-mediathumb`, which already owns them.

     <div class="post-card__media post-mediathumb post-mediathumb--landscape"></div>

   Before this, the card drew its own 16:9 box with a flat `hover-surface` fill, which is
   how the guide ended up showing a solid blue rectangle where the placeholder should be. */
.post-card__media { display: block; width: 100%; }

/* BODY holds everything that is not the media: text, then tags, then actions. The card's
   own axis flips between stacked and horizontal, but the body's never does - it is always a
   column. Without it, a horizontal card's tags and buttons would land in the ROW, beside the
   media, instead of under the title where they belong. */
.post-card__body {
  display: flex;
  flex-direction: column;
  gap: var(--space-m);
  min-width: 0;
  flex: 1 1 auto;
}
.post-card__text { display: flex; flex-direction: column; gap: var(--space-xs); min-width: 0; }

/* TAGS SIT UNDER THE TEXT COMBO, never above it. A tag is metadata ABOUT the thing the
   title names, so it cannot be read before the title without asking someone to hold a
   category in mind for something they have not been told about yet. */
.post-card__tags { display: flex; flex-wrap: wrap; gap: var(--space-s); }

/* ACTIONS LAST, and FILLED + OUTLINE.
   REVERSED 2026-08-25. This row was outline + ghost, on the argument that the card is
   already the primary target and a filled button inside it competes with the thing that
   contains it. The call now is the other way: outline + ghost read as two secondary
   actions with no clear first move, and a card whose whole point is to be acted on should
   say which action that is. Filled leads, outline follows.

   What that costs, recorded so it is a known trade and not a surprise: `filled` no longer
   means "page-level action" exclusively, so the Button hierarchy is now carried by SIZE
   and CONTEXT rather than by style alone - card actions are size s, page actions size l.
   On a media card the solid brand block does sit directly under the photograph; if that
   reads badly on a real image, this is the decision to revisit first. */
/* ACTIONS SIT FURTHER DOWN THAN THE REST OF THE STACK. The body gaps everything at
   space/m, which is right between text and tags - they are both description of the same
   thing. Actions are not description, they are the thing you DO, and at the same gap they
   read as one more line of metadata. The extra space/s on top is what separates "what this
   is" from "what you can do with it" without needing a rule or a heading to say so. */
.post-card__actions {
  display: flex;
  align-items: center;
  gap: var(--space-s);
  margin-top: var(--space-s);
}

/* NOTE: as of 2026-08-25 this row is filled + outline, so no ghost button appears in it and
   the rule below no longer fires here. It is kept because it is the reasoning for ghost's
   negative margin ANYWHERE, and because reverting the action styles would make it live
   again. If ghost ever returns to a card row, this is why the margin stays.

   LEAVE GHOST'S NEGATIVE MARGIN ALONE in this row. It looks like it should be cancelled -
   a -space/s pull on the second button reads like it will eat the gap - and cancelling it is
   wrong. The gap is measured between BOXES, but what a person sees is the distance to the
   LABEL, and the label sits space/s inside its own box. The pull removes exactly that
   doubling:

     with the pull      Action-1 edge -> Action-2 label = space/s   (measured 7px at 6)
     cancelled          Action-1 edge -> Action-2 label = 2x        (measured 13px at 6)

   Same optical-padding rule the whole system runs on: the gap is 0 and the LABEL carries the
   space. Do not "fix" this by zeroing the margin. */

/* CLICKABLE INSIDE CLICKABLE, again - the same shape as Media Thumb. Once the card carries
   its own buttons it cannot BE an <a>, because a button inside an anchor is invalid HTML and
   gives two overlapping targets. The container is an <article>, the link is a stretched
   overlay carrying the accessible name, and the actions sit above it. */
.post-card__link {
  position: absolute;
  inset: 0;
  z-index: 1;
}
.post-card__actions { position: relative; z-index: 2; }

/* ONE AFFORDANCE AT A TIME, the same rule Media Thumb states and this file only claimed to.
   BOTH STAY LIT deliberately: hovering an action does not stop the card being hovered - the
   pointer IS still over the card, and a card that goes flat the moment you reach for its
   button reads as though you have left it.

   What separates them is the SURFACE, not the presence of a state. A nested control takes
   `hover-surface-nested` so it sits ON the card's hover instead of competing with it. Give
   it the container's own `hover-surface` and the two paint the same colour, which is a
   button that vanishes into the thing it sits on at the exact moment you reach for it.

   NOT FILLED. This rule exists so a control with NO background of its own does not paint
   the same colour as the plane it sits on. A filled button already carries an opaque
   background and an opaque hover (`action-hover`), neither of which can be confused with
   the card's tint - it never needed the treatment.

   Applying it anyway is a real bug and it shipped: this selector is (0,3,0) and
   `.post-btn--filled:hover` is (0,2,0), so it won, replaced the solid fill with a pale
   tint and left the label at `on-action` white. A filled primary action turned into
   near-invisible white-on-pale at the exact moment you reached for it. The `:not()` is
   load-bearing - do not simplify it away. */
.post-card__actions .post-btn:not(.post-btn--filled):hover,
.post-card__actions .post-btn:not(.post-btn--filled).is-hover {
  background: var(--hover-surface-nested);
}

/* TYPE COMES FROM THE FIGMA TEXT STYLES, applied as the generated classes in the markup -
   `post-text-small` on the eyebrow and meta, `post-text-h4` on the title. Nothing here
   re-declares a size or a weight, because a second copy of a text style is a second thing
   to keep in sync, and this file lost that argument once already.

     eyebrow  Text/Small   text/sm  500     meta  Text/Small  text/sm  500
     title    Heading/H4   text/lg  500

   Colour is the card's to set: the two supporting lines are `muted` while the title takes
   the full `text`. */
.post-card__eyebrow,
.post-card__meta { color: var(--muted); }

/* OUTLINE — a real surface. Its padding is the space the ghost only borrows. */
.post-card--outline {
  padding: var(--card-pad);
  border: var(--stroke-m) solid var(--border);
  background: var(--surface);
}
/* THE BORDER FOLLOWS THE INK ON HOVER. A hovered card whose edge stays at the resting
   `border` colour looks half-lit - the inside responded and the outline did not. One ink
   for the whole card. */
.post-card--outline:hover,
.post-card--outline.is-hover {
  background: var(--hover-surface);
  color: var(--hover-ink);
  border-color: var(--hover-ink);
}

/* THE MEDIA FOLLOWS THE CARD'S HOVER, NOT ITS OWN. The card is ONE link, so every part of
   it has to respond together. Left to its own `:hover`, the Media Thumb lit up only when the
   pointer was over the image: hovering the title changed the text but left the picture at
   rest, which reads as two separate targets sitting next to each other rather than one card.
   Hovering the image still works - it is inside the card, so the card is hovered too, and
   the two stay in step. */
.post-card:hover .post-mediathumb::after,
.post-card.is-hover .post-mediathumb::after { opacity: var(--opacity-20); }

/* The supporting lines are muted AT REST only. Left as `muted` they would win over the
   inherited hover ink and stay grey on a card whose title had already changed colour -
   the card would hover in two halves. Figma takes all three lines to hover-ink together. */
.post-card:hover .post-card__eyebrow,
.post-card:hover .post-card__meta,
.post-card.is-hover .post-card__eyebrow,
.post-card.is-hover .post-card__meta { color: inherit; }

/* GHOST — no surface at rest, and NO transparent border either. This is the one place
   Card deliberately parts company with Button: a ghost button keeps the border so it
   matches its outline sibling's box, but a ghost card is supposed to measure ONLY its
   content so a grid of them aligns on the media edge. The hover surface is therefore an
   absolutely-positioned plate that bleeds OUTSIDE the box and never touches layout. */
.post-card--ghost::before {
  content: "";
  position: absolute;
  inset: calc(-1 * var(--card-pad));
  background: var(--hover-surface);
  opacity: 0;
  pointer-events: none;
  z-index: -1;                             /* behind the content, not over it */
}
.post-card--ghost:hover::before,
.post-card--ghost.is-hover::before { opacity: 1; }
.post-card--ghost:hover,
.post-card--ghost.is-hover { color: var(--hover-ink); }

/* STACKED — media on top. The default, and the only one that survives a narrow column. */
.post-card--stacked { flex-direction: column; }

/* HORIZONTAL — media left, text right. The media is a FIXED rail and the text absorbs
   the slack, so the title wraps instead of the image shrinking to a stamp. */
.post-card--horizontal { flex-direction: row; align-items: flex-start; }
.post-card--horizontal > .post-card__media { width: var(--card-media-w, 160px); flex: 0 0 auto; }

.post-card:focus-visible { outline: var(--stroke-m) solid var(--action); outline-offset: var(--stroke-m); }
/* @endcomponent */

/* @component media-thumb ----------------------------------------------------- */
/* THE MEDIA ELEMENT - not a card. An image plane plus the chrome that sits on it,
   composed INTO cards rather than being one.

   FOUR SLOTS, pinned to the four corners of the plane:

     top-left      identifying metadata - a Tag Group. Reads first.
     top-right     a save/pin control. Where a thumb reaches on a phone.
     bottom-left   title + meta.
     bottom-right  the primary action - open, play, next.

   The rule behind the arrangement: WHAT IT IS reads left, WHAT YOU DO sits right, and
   that holds on both rows. Anything else and a person has to re-learn the layout between
   the top of the card and the bottom of it.

   THE SIZE NEVER CHANGES when a slot or a row is switched off. The plane is an aspect
   ratio, not a sum of its contents, so a grid of thumbs with different chrome still lines
   up. Verified in Figma too: 320x400 with both rows on, top only, and both off.

   EACH ROW OWNS ITS SCRIM, so a row switched off takes its fade with it - a plane with no
   bottom chrome must not keep a bottom fade. */
.post-mediathumb {
  /* Follows the page theme by default. See the element-theme block below. */
  --media-scrim: var(--scrim-media);
  --media-ink: var(--text);

  /* NESTED CONTROLS FOLLOW THIS ELEMENT'S THEME, NOT THE PAGE'S. This is the one place the
     two can disagree: the plane carries its own light/dark, so a control sitting ON it must
     take its hover colours from the plane. Left on the page tokens, a brand page resolved
     `hover-surface-nested` to brand-deep and put a NAVY disc on a light card - a control
     themed for a surface it is not on.

     The nested surface is therefore a STEP TOWARD THIS ELEMENT'S OWN INK rather than a
     named colour: ink at opacity/20, the same weight as the container's hover wash. On a
     light plane that is a soft dark disc, on a dark plane a soft light one, and it can
     never fight the plane because it is made of the plane's own ink.

     Re-declared as the STANDARD token names, so every nested component picks them up
     through the cascade without needing a media-thumb-specific rule of its own. If
     color-mix is unsupported the declaration is simply invalid and the page-theme value is
     inherited, which is the old behaviour rather than a broken one. */
  --hover-ink: var(--media-ink);
  --hover-surface-nested: color-mix(in srgb, var(--media-ink) calc(var(--opacity-20) * 100%), transparent);
  --hover-surface: var(--hover-surface-nested);

  position: relative;
  display: flex;
  flex-direction: column;
  aspect-ratio: var(--media-ratio, 2 / 3);
  overflow: hidden;
  color: var(--media-ink);
  text-decoration: none;
}

/* ELEMENT-LEVEL LIGHT / DARK - the one component that needs it.
   Every other component takes its ink from the SECTION theme, because what sits behind it
   is a surface this system controls. A media thumb sits on a PHOTOGRAPH, which it does
   not control: the same thumb on the same page can need dark ink over a pale sky and light
   ink over a night shot. Left to the page theme it would be right half the time.

   Unset, it still follows the page - nothing about the existing behaviour changes. Set it
   only when you know the image. */
/* A real photograph COVERS; the placeholder plate CONTAINS, because the Figma fill it is
   ported from is scaleMode FIT - the mark sits centred at its own size with the neutral
   plane around it, and cropping it would show a detail of the mark instead of the mark.
   Written as :not() rather than relying on source order, which would decide this silently
   between two single-class selectors. */
.post-mediathumb:not(.post-media-placeholder) {
  background-size: cover;
  background-position: center;
}

.post-mediathumb[data-media="light"] {
  --media-scrim: var(--color-neutral-0);
  --media-ink: var(--color-neutral-1000);
}
.post-mediathumb[data-media="dark"] {
  --media-scrim: var(--color-neutral-1000);
  --media-ink: var(--color-neutral-0);
}

/* RATIO — three, and only three. The plane is a frame for a photograph, and a system that
   accepts any ratio stops being able to lay anything out: a grid of thumbs only aligns if
   the thumbs agree. These are the three a photograph actually arrives in.

     portrait   2:3   the default. Vertical media, and what a phone camera produces.
     landscape  3:2   the same frame turned - the classic 35mm horizontal.
     square     1:1   grids and avatars, where neither dimension may win.

   2:3 and 3:2 are deliberately the SAME ratio inverted, not 4:5 and 16:9. One number
   describes both orientations, so a portrait and a landscape thumb in one row are
   recognisably the same component rather than two unrelated boxes.

   Set as a custom property rather than `aspect-ratio` directly, so a consumer with a genuine
   one-off can override `--media-ratio` without having to beat a modifier's specificity. */
.post-mediathumb--portrait  { --media-ratio: 2 / 3; }
.post-mediathumb--landscape { --media-ratio: 3 / 2; }
.post-mediathumb--square    { --media-ratio: 1 / 1; }

/* HOVER IS THE WHOLE PLANE. A single brand plate at opacity/20 across the entire element,
   under the chrome. The alternative - recolouring only the row gradients so the photograph
   stayed clean - was tried and rejected: the response reads as a property of the CARD, and
   lighting only its top and bottom edges makes the middle look like it is not part of the
   same target. Brand appears on touch and it covers what you are touching.

   z-index 0 keeps it above the media and BELOW the rows, so the scrims and the labels stay
   legible through it instead of being tinted twice. */
.post-mediathumb::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  background: var(--scrim-color);
  opacity: 0;
  pointer-events: none;
  transition: opacity 120ms ease-out;
}
.post-mediathumb:hover::after,
.post-mediathumb.is-hover::after { opacity: var(--opacity-20); }

.post-mediathumb__top,
.post-mediathumb__bottom {
  position: relative;
  z-index: 1;                               /* above the media, below the hover wash */
  display: flex;
  align-items: flex-start;
  gap: var(--space-m);
  padding: var(--space-m);
}
.post-mediathumb__top::before,
.post-mediathumb__bottom::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  /* 0.5, not 0.85: at 0.85 the dense end read as a painted BAR with a visible edge rather
     than a scrim, and the top of the image was gone. The scrim stays NEUTRAL on hover -
     the brand comes from the full-plane plate above, not from recolouring this. */
  background-image: linear-gradient(var(--media-scrim), transparent);
  opacity: var(--opacity-50);
  pointer-events: none;
}
.post-mediathumb__bottom::before { background-image: linear-gradient(transparent, var(--media-scrim)); }

/* BOTTOM IS PINNED BY margin-top:auto, and the RIGHT SLOT BY margin-left:auto - NOT by
   space-between. Figma's SPACE_BETWEEN distributes only what is VISIBLE, so hiding one
   slot lets the other slide into its place; on canvas each row needs an always-present
   locked spacer to prevent it. CSS does not: an auto margin resolves against the free
   space unconditionally, so -right holds its edge whether or not -left is rendered. Do
   NOT add spacer elements here to mirror Figma - the constraint does not exist in CSS. */
.post-mediathumb__bottom { margin-top: auto; align-items: flex-end; }
/* Explicit, not `:last-child`. A last-child heuristic gets the single-slot case backwards:
   with only the pin rendered it is BOTH first and last, and a rule keyed on position would
   let it fall to the left edge. The slot names its own side, so it holds that side alone. */
.post-mediathumb__slot--right { margin-left: auto; }

.post-mediathumb__body { display: flex; flex-direction: column; gap: var(--space-xs); min-width: 0; }
.post-mediathumb__title { font-size: var(--text-md); font-weight: 500; }
.post-mediathumb__meta  { font-size: var(--text-xs); opacity: var(--opacity-85); }

/* CONTROLS ON THE PLANE TAKE THE MEDIA'S OWN INK, not action blue. An icon button
   defaults to `color: var(--action)`, which put a blue pin on a photograph - brand colour
   appearing at rest, which is the exact thing the scrim model rules out. Over an image the
   only ink that stays legible is the one the scrim was built for, so the controls inherit
   it. currentColor carries it into the glyph. */
.post-mediathumb .post-iconbtn { color: inherit; }
.post-mediathumb .post-icon { fill: currentColor; }

/* ---- CLICKABLE INSIDE CLICKABLE -------------------------------------------------
   The plane is a link AND it carries its own controls. Wrapping the whole thing in an
   <a> put a <button> inside an <a>, which is invalid HTML - interactive content cannot
   nest - and gave a person two overlapping targets with no way to tell which one a click
   would hit.

   The fix is that the CONTAINER IS NOT THE LINK. The container is an <article>; the link
   is a stretched overlay that covers the plane and carries the accessible name. The
   controls sit ABOVE that overlay and take their clicks back. Nothing nests. */
.post-mediathumb__link {
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
}

/* The rows draw the chrome but must not intercept clicks meant for the overlay beneath
   them, so they are transparent to the pointer and each real control opts back in. This
   is what lets the title area still open the item while the pin stays its own target. */
.post-mediathumb__top,
.post-mediathumb__bottom { pointer-events: none; }
.post-mediathumb__top a, .post-mediathumb__top button,
.post-mediathumb__bottom a, .post-mediathumb__bottom button {
  pointer-events: auto;
  position: relative;
  z-index: 2;                               /* above the stretched link */
}

/* ONE AFFORDANCE AT A TIME. A nested control gets the NESTED hover surface, never the
   container's - `hover-surface-nested` exists for exactly this and Card already documents
   it. And while a control is hovered the CONTAINER stops advertising itself, because the
   click is going to the control, not to the link. Without this both lit at once and the
   card looked like it was about to open while your pointer was on the save button. */
/* BOTH STAY LIT, and that is deliberate. Hovering the pin does not stop the card being
   hovered - the pointer IS still over the card, and a card that goes dark the moment you
   reach for its save button reads as though you have left it. This is the YouTube video-card
   model: the whole tile keeps its hover while the inner control shows its own.

   What separates them is the SURFACE, not the presence of a state. The nested control takes
   `hover-surface-nested` - the token exists for exactly this, and Card already documents it -
   so it sits ON the container's hover rather than competing with it. */
.post-mediathumb .post-iconbtn:hover,
.post-mediathumb .post-iconbtn.is-hover { background: var(--hover-surface-nested); }

.post-mediathumb:focus-visible { outline: var(--stroke-m) solid var(--action); outline-offset: var(--stroke-m); }
/* @endcomponent */
