/* WildfireWindow design system — "Smoke Slate + Signal Teal", the Sky Strip.
   The page atmosphere IS the reading: the header gradient + haze density are
   driven by the live local AQI band via [data-band] on <html>. EPA band hexes
   are DATA-ONLY tokens (dials/badges), never decorative. Not a HurriTrack clone
   and deliberately not the AI-default cream+serif+terracotta / black+acid-green.

   ⚠️ THIS IS NOT A FORK OF engine/scaffold/assets/sky.css. DO NOT SYNC IT.
   It shares that file's NAME and nothing else. The scaffold's sky.css is the
   gen-#2 four-phase time-of-day system keyed to [data-theme] (night/dawn/day/
   dusk, toggled by the visitor via theme.js) and it ships alongside
   components.css, which supplies .card/.btn/.wrap/etc. WildfireWindow is a
   custom-script site: scripts/build.mjs never copies engine/scaffold/assets,
   and the site loads neither components.css nor theme.js. So THIS file is the
   only stylesheet on all 182 pages and it owns every component class itself.

   The two files share 0 class selectors and 7 custom-property NAMES, all with
   different values (--radius 14px vs 16px, --maxw 1080px vs 760px, --surface
   opaque white vs a translucent dark panel, the three --font-* tokens).
   Replacing this file with the canonical one would leave every page unstyled,
   delete the six --aqi-* EPA band tokens the dials and badges read, and orphan
   the [data-band] attribute that functions/_middleware.js and _ue/app.js both
   write. If you need the shared design system here, that is a deliberate
   re-platform onto createSite() — not a file copy. */

/* --- issue 10: self-hosted typefaces ---------------------------------------
   These six faces were previously fetched from fonts.googleapis.com as a
   RENDER-BLOCKING stylesheet in <head> — the site's only third-party request on
   the critical path, and the only third party that saw an ordinary page view
   (Google receives the visitor's IP with every font request). Self-hosting
   removes one DNS lookup, one TLS handshake and one blocking round trip, and
   removes a processor from the privacy policy at the same time.

   Latin subset only. IBM Plex Mono 700 was in the old Google URL but no rule in
   this file requests it, so it is not shipped. font-display:swap keeps text
   visible during load, matching the previous &display=swap behaviour.
   public/_headers caches /fonts/* immutable for a year. */
@font-face { font-family:"Bricolage Grotesque"; src:url(/fonts/bricolage-700.woff2) format("woff2");
  font-weight:700; font-style:normal; font-display:swap; }
@font-face { font-family:"Public Sans"; src:url(/fonts/public-sans-400.woff2) format("woff2");
  font-weight:400; font-style:normal; font-display:swap; }
@font-face { font-family:"Public Sans"; src:url(/fonts/public-sans-600.woff2) format("woff2");
  font-weight:600; font-style:normal; font-display:swap; }
@font-face { font-family:"Public Sans"; src:url(/fonts/public-sans-700.woff2) format("woff2");
  font-weight:700; font-style:normal; font-display:swap; }
@font-face { font-family:"IBM Plex Mono"; src:url(/fonts/plex-mono-400.woff2) format("woff2");
  font-weight:400; font-style:normal; font-display:swap; }
@font-face { font-family:"IBM Plex Mono"; src:url(/fonts/plex-mono-600.woff2) format("woff2");
  font-weight:600; font-style:normal; font-display:swap; }

:root {
  /* smoke-slate neutral scale */
  --ink: #14181D;
  --slate-900: #1C222A;
  --slate-700: #2E3945;
  --slate-500: #5A6B7B;
  --slate-300: #9FB0BC;
  --slate-100: #D9E1E5;
  --clear-air: #F2F5F6;
  --surface: #FFFFFF;

  /* brand — Signal Teal sits OUTSIDE the AQI hue range on purpose */
  --teal: #12808C;
  --teal-dim: #0E626B;
  --teal-text: #0E626B;   /* AA-passing link colour on --clear-air */
  --warn: #8A4200;        /* staleness/warning — NOT an AQI band colour */

  /* EPA AQI band colors — data-only */
  --aqi-good: #00E400;
  --aqi-moderate: #FFFF00;
  --aqi-usg: #FF7E00;
  --aqi-unhealthy: #FF0000;
  --aqi-veryUnhealthy: #8F3F97;
  --aqi-hazardous: #7E0023;

  /* Sky Strip: defaults to a clear day; [data-band] overrides these two stops. */
  --sky-1: #7FB6D6;
  --sky-2: #C9DCE6;
  --sky-ink: #0d2233;
  --haze: 0;              /* 0..1 milky overlay opacity */

  --radius: 14px;
  --radius-sm: 9px;
  --shadow: 0 1px 2px rgba(20,24,29,.06), 0 6px 20px rgba(20,24,29,.06);
  --maxw: 1080px;

  --font-display: "Bricolage Grotesque", "Public Sans", system-ui, sans-serif;
  --font-body: "Public Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
}

/* Sky Strip band mapping — clear cerulean → milky amber → smoke orange → maroon dusk */
html[data-band="good"]          { --sky-1:#5AA0CF; --sky-2:#BCD9E8; --sky-ink:#0d2233; --haze:0;   }
html[data-band="moderate"]      { --sky-1:#A9BCae; --sky-2:#EFE6C4; --sky-ink:#33320d; --haze:.10; }
html[data-band="usg"]           { --sky-1:#D79A5B; --sky-2:#F0D9B8; --sky-ink:#3a1c00; --haze:.24; }
html[data-band="unhealthy"]     { --sky-1:#C6663B; --sky-2:#E7A882; --sky-ink:#2A0C00; --haze:.40; }
html[data-band="veryUnhealthy"] { --sky-1:#7E4A63; --sky-2:#B78BA0; --sky-ink:#F7E9EF; --haze:.52; }
html[data-band="hazardous"]     { --sky-1:#5A2634; --sky-2:#8F5763; --sky-ink:#f6dce3; --haze:.62; }

* { box-sizing: border-box; }
.skip { position:absolute; left:-9999px; top:0; z-index:100; background:var(--surface); color:var(--ink);
        padding:.7rem 1rem; border-radius:0 0 10px 0; font-weight:700; box-shadow:var(--shadow); }
.skip:focus { left:0; }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
           clip:rect(0 0 0 0); white-space:nowrap; border:0; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
:root { color-scheme: light dark; }
body {
  margin: 0; background: var(--clear-air); color: var(--ink);
  font-family: var(--font-body); line-height: 1.55; font-size: 17px;
  text-rendering: optimizeLegibility;
}
h1,h2,h3 { font-family: var(--font-display); line-height: 1.12; font-weight: 700; letter-spacing: -.01em; }
h1 { font-size: clamp(1.7rem, 4.5vw, 2.7rem); margin: .2em 0 .3em; }
h2 { font-size: clamp(1.3rem, 3vw, 1.75rem); margin: 1.6em 0 .5em; }
h3 { font-size: 1.12rem; margin: 1.3em 0 .4em; }
a { color: var(--teal-text); text-decoration: none; }
a:hover { text-decoration: underline; }
p { margin: .6em 0; }
.mono { font-family: var(--font-mono); }
/* The widget page ships a copy-paste embed snippet in a <pre>. Preformatted text
   has no break opportunities, so it dragged the document ~150px wider than a
   phone. Give the block its own scroller. */
pre.mono { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.muted { color: var(--slate-500); }
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 clamp(1rem, 4vw, 2rem); }
.narrow { max-width: 720px; }

/* --- Sky Strip header --- */
.sky-strip {
  position: relative;
  background: linear-gradient(160deg, var(--sky-1), var(--sky-2));
  color: var(--sky-ink);
  border-bottom: 1px solid rgba(20,24,29,.08);
}
.sky-strip::after { /* milky haze overlay keyed to band */
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(120% 140% at 70% -20%, rgba(255,252,245,var(--haze)), transparent 60%);
}
.topbar { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding-block: .7rem; position: relative; z-index: 1; }
.brand { display: flex; align-items: center; gap: .5rem; font-family: var(--font-display); font-weight: 700; font-size: 1.15rem; color: inherit; }
.brand .mark { width: 22px; height: 22px; border-radius: 6px; background: var(--teal); box-shadow: inset 0 0 0 3px rgba(255,255,255,.5); }
nav.main { margin-left: auto; display: flex; gap: .4rem; flex-wrap: wrap; }
nav.main a { color: inherit; padding: .6rem .7rem; border-radius: 8px; font-size: .95rem; font-weight: 600; }
nav.main a:hover { background: rgba(255,255,255,.35); text-decoration: none; }

/* --- AQI dial + badges (DATA color tokens) --- */
.dial {
  width: 128px; height: 128px; border-radius: 50%; display: grid; place-items: center;
  font-family: var(--font-mono); font-weight: 700; font-size: 2.9rem; color: var(--ink);
  background: var(--slate-100); box-shadow: inset 0 0 0 6px rgba(255,255,255,.55), var(--shadow);
}
.dial.small { width: 62px; height: 62px; font-size: 1.4rem; box-shadow: inset 0 0 0 4px rgba(255,255,255,.55); }
.dial.aqi-good,.dial.aqi-moderate,.dial.aqi-usg { color: #14181D; }
.dial.aqi-unhealthy,.dial.aqi-veryUnhealthy,.dial.aqi-hazardous { color: #fff; }
[data-band="unhealthy"] .dial:not([class*="aqi-"]),
[data-band="veryUnhealthy"] .dial:not([class*="aqi-"]),
[data-band="hazardous"] .dial:not([class*="aqi-"]) { color: #fff; }
.badge {
  white-space: nowrap;
  display: inline-flex; align-items: center; gap: .35rem; padding: .2rem .55rem; border-radius: 999px;
  font-family: var(--font-mono); font-size: .82rem; font-weight: 600; background: var(--slate-100); color: var(--ink);
}
.aqi-good{background:var(--aqi-good);color:#0B2E0B}.aqi-moderate{background:var(--aqi-moderate);color:#3A3A00}
.aqi-usg{background:var(--aqi-usg);color:#3A1C00}.aqi-unhealthy{background:var(--aqi-unhealthy);color:#fff}
.aqi-veryUnhealthy{background:var(--aqi-veryUnhealthy);color:#fff}.aqi-hazardous{background:var(--aqi-hazardous);color:#fff}

/* --- cards, chips, buttons --- */
.card { background: var(--surface); border: 1px solid var(--slate-100); border-radius: var(--radius); padding: 1.1rem 1.2rem; box-shadow: var(--shadow); }
.grid { display: grid; gap: 1rem; }
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(260px,1fr)); }
.cols-3 { grid-template-columns: repeat(auto-fit, minmax(210px,1fr)); }
.chip { display:inline-block; padding:.3rem .6rem; border-radius:999px; background:var(--surface); border:1px solid var(--slate-100); font-size:.85rem; font-family:var(--font-mono); }
.btn { display:inline-block; background:var(--teal); color:#fff; padding:.6rem 1rem; border-radius:10px; font-weight:700; border:0; cursor:pointer; font-family:var(--font-body); }
.btn:hover { background:var(--teal-dim); text-decoration:none; }
.btn.ghost { background:transparent; color:var(--teal-text); box-shadow:inset 0 0 0 1.5px var(--teal); }

/* --- hero / provenance line --- */
.hero { padding-block: 1.6rem 1.2rem; }
.prov { font-family: var(--font-mono); font-size: .8rem; color: var(--slate-500); }
.prov [data-stale="1"], .prov[data-stale="1"] { color: var(--warn); font-weight: 700; }
.prov [data-stale="1"]::before, .prov[data-stale="1"]::before { content: "\26A0\FE0E "; }
.sparkline { width: 100%; height: 46px; display: block; }

/* --- banners --- */
.banner { border-radius: var(--radius); padding: .7rem 1rem; margin: .8rem 0; font-weight: 600; }
.banner.alert { background: #FFF4E5; border: 1px solid #F0C27A; color: #6b3d00; }
.banner.event { background: #2E1116; border: 1px solid #7E0023; color: #F6DCE3; }
body:not([data-event-mode]) .event-only { display: none; }

/* --- ad containers ---------------------------------------------------------
   These rules only ever apply to a container holding a REAL <ins
   class="adsbygoogle"> unit. adSlot() in scripts/lib/layout.mjs emits nothing
   at all until site.json carries an approved slot id, so when no ads are sold
   there is no .ad element on the page and none of this matches.

   The striped placeholder background that used to live here is deliberately
   gone: it painted a box labelled "advertisement" on all 182 pages for ad
   units that did not exist. Reserve space for a real unit, never for a fake
   one. The label below is the disclosure a real unit needs — it must never
   appear over empty space.

   One more case the note above does not cover: an APPROVED slot can still come
   back unfilled (no demand), and the AdSense script may never report at all when
   it is blocked or offline. Reserving height up front for either of those paints
   the same empty labelled box the striped placeholder used to. So the container
   takes zero space and shows no label until a fill is confirmed — app.js sets
   [data-filled] from data-ad-status. */
.ad { display:block; margin:0; min-height:0; text-align:center; overflow:hidden; border-radius:10px; }
.ad::before { content:""; display:none; }
.ad[data-filled] { margin:1.2rem auto; }
.ad[data-filled]::before { content:"advertisement"; font-size:.62rem; letter-spacing:.08em; text-transform:uppercase; color:var(--slate-300); display:block; padding-top:.3rem; }
.ad-leader { max-width: 728px; }
.ad-leader[data-filled] { min-height: 100px; }
.ad-incontent { max-width: 336px; }
.ad-incontent[data-filled] { min-height: 250px; }
.ad-side[data-filled] { min-height: 600px; width: 300px; }
.ad-anchor { position: sticky; bottom: 0; z-index: 40; }
.ad-anchor[data-filled] { min-height: 50px; }
@media (min-width: 900px){ .ad-anchor { display: none; } }

/* --- commerce module (conditional, keyed to AQI band) --- */
.commerce { border:1px solid var(--slate-100); border-left:4px solid var(--teal); border-radius:var(--radius); padding:1rem 1.1rem; background:var(--surface); }

/* --- forecast chips / tables / forms --- */
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.table-scroll > table { min-width: 30rem; }
table { border-collapse: collapse; width: 100%; font-size: .95rem; }
th,td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--slate-100); }
th { font-family: var(--font-mono); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--slate-500); }
input:not([type=checkbox]):not([type=radio]), select { font: inherit; padding: .55rem .7rem; border: 1px solid var(--slate-300); border-radius: 10px; background: var(--surface); color: var(--ink); }
label { font-weight: 600; font-size: .92rem; }

/* --- footer + network block --- */
footer.site { margin-top: 3rem; background: var(--slate-900); color: #D9E1E5; }
iframe { max-width: 100%; }
footer.site a { color: #BFE6EA; }
footer.site .wrap { padding-top: 2rem; padding-bottom: 2rem; }
.network { display:flex; gap:.6rem; flex-wrap:wrap; margin:.6rem 0 1rem; }
.network a { background: rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.12); padding:.4rem .7rem; border-radius:10px; font-size:.9rem; }
.foot-links { display:flex; gap:1rem; flex-wrap:wrap; font-size:.9rem; }
.disclosure { font-size:.82rem; color: var(--slate-500); margin-top:1rem; }
footer.site .disclosure { color: #B7C4CD; }

@media (prefers-reduced-motion: no-preference) {
  .dial, .badge { transition: background .5s ease, color .5s ease; }
  /* No transition on .sky-strip: the band change mutates --haze inside an rgba()
     in the ::after gradient, and custom properties do not interpolate without
     @property — so the old `transition: background .6s` animated a full-width
     gradient repaint for no visual benefit, in the same frame as hydration's DOM
     writes. The band swap is instant now, which is both cheaper and honest. */
}
@media (prefers-color-scheme: dark) {
  :root { --clear-air:#0f1216; --surface:#161b21; --ink:#EAF0F2; --slate-100:#2A333C; --slate-300:#6a7c88; --slate-500:#9FB0BC; }
  .card, .commerce { border-color:#232c34; }
  .ad { background:repeating-linear-gradient(45deg,#1b2128,#1b2128 10px,#20272f 10px,#20272f 20px); }
  .banner.alert { background:#2A1D08; border-color:#7A5A1E; color:#FFE0B0; }
  .skip { background:var(--surface); color:var(--ink); }
  :root { --teal-text:#4FBECB; --warn:#FFB067; }
}

@media print {
  .ad, nav.main, .network, .banner.event, .commerce, .skip { display: none !important; }
  .sky-strip { background: none !important; color: #14181D !important; }
  .badge, .dial { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
  footer.site { background: none !important; color: #14181D !important; }
  footer.site a, a { color: #14181D !important; }
  main a[href^="http"]::after { content: " (" attr(href) ")"; font-size: .85em; word-break: break-all; }
  body { background: #fff !important; font-size: 12pt; }
}

/* ==========================================================================
   Audit remediation, 2026-08. Each block below closes a numbered finding from
   reports/wildfirewindow-audit-2026-08/. Appended rather than interleaved so
   the original design system stays readable as one piece.
   ========================================================================== */

/* --- issue 11: neutral band ------------------------------------------------
   The stylesheet's own rule is "the page atmosphere IS the reading". But every
   page shipped a hardcoded [data-band] as its build-time default, so the home
   page rendered an amber "unhealthy for sensitive groups" sky while the dial
   still read "AQI unavailable right now". A page with no reading must look like
   a page with no reading. functions/_middleware.js overwrites this server-side
   the moment a real observation exists, and app.js does the same after hydration.
   On pages that are not a reading of anywhere (/privacy/, /terms/, /guides/)
   this neutral state is correct permanently. */
html:not([data-band]), html[data-band="unknown"] {
  --sky-1:#B9C6CE; --sky-2:#DDE5E9; --sky-ink:#22303A; --haze:0;
}

/* --- issue 12: the commerce reserve was inert -------------------------------
   The block was authored `hidden` with `min-height:230px`. `hidden` resolves to
   display:none, under which min-height reserves exactly nothing — so revealing
   the block at AQI 101+ shifted every element below it, during precisely the
   smoke events this site exists for. visibility:hidden keeps the box in flow,
   so the space is genuinely reserved and the reveal costs zero CLS. */
.commerce[data-commerce-state="idle"] {
  visibility: hidden;
  min-height: 230px;
  pointer-events: none;
}
@media (max-width: 560px) {
  .commerce[data-commerce-state="idle"] { min-height: 290px; }
}
/* "off" = we have a definitive reading BELOW the USG threshold, so the block is
   not merely hidden, it is not applicable. Reserving space for it would leave a
   230px hole on every page on a clean-air day — which is most days. The state is
   set server-side by functions/_middleware.js whenever the edge already knows
   the band, so the correct layout is present at first paint and nothing shifts.
   "idle" (reserve) is only for the case where nobody knows the band yet. */
.commerce[data-commerce-state="off"] { display: none; }

/* --- issue 14: focus-visible ------------------------------------------------
   The only focus rule in this file was `.skip:focus`. Keyboard users got the UA
   default ring over a teal button and over a gradient header — the exact case
   WCAG 2.2 Focus Appearance exists for. Teal-on-amber has too little contrast on
   the Sky Strip, so the header inverts to ink plus a white halo that works on
   all six band gradients. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: 3px solid var(--teal);
  outline-offset: 2px;
  border-radius: 4px;
}
.sky-strip :where(a, button):focus-visible {
  outline-color: var(--ink);
  box-shadow: 0 0 0 5px rgba(255,255,255,.75);
}
nav.main a:focus-visible { background: rgba(255,255,255,.45); }
main :where(a, button):focus-visible { outline-color: var(--teal-text); }

/* --- issue 15: mobile nav ---------------------------------------------------
   Seven links at flex-wrap occupied two full rows — roughly 38% of a 390x844
   viewport before any content — and each link computed to ~38px tall, under the
   44x44 floor in WCAG 2.5.8. A checkbox disclosure needs no JavaScript, so the
   nav still works before (and without) hydration. */
.nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
.nav-label { display: none; }
@media (max-width: 720px) {
  .topbar { flex-wrap: nowrap; }
  .nav-label {
    display: flex; margin-left: auto; padding: .7rem;
    min-height: 44px; min-width: 44px; align-items: center; justify-content: center;
    cursor: pointer; border-radius: 8px; font-size: 1.25rem; line-height: 1;
  }
  .nav-label:hover { background: rgba(255,255,255,.35); }
  nav.main {
    position: absolute; inset-inline: 0; top: 100%; z-index: 30;
    flex-direction: column; gap: 0; margin-left: 0;
    background: var(--surface); border-bottom: 1px solid var(--slate-100);
    box-shadow: var(--shadow); padding: .4rem 0;
  }
  nav.main a {
    padding: .85rem 1.1rem; min-height: 44px; display: flex; align-items: center;
    border-radius: 0; color: var(--ink);
  }
  .nav-toggle:not(:checked) ~ nav.main { display: none; }
}

/* --- issue 16: mobile source order -----------------------------------------
   The two-column hero collapses so a phone reader met "AQI unavailable right
   now" before "Live wildfire smoke & air quality" — the page announced a
   failure before it introduced itself. Safe to reorder visually here because
   the two children are independent blocks and the resulting tab order still
   reads top to bottom. Do not extend this pattern to interactive lists. */
@media (max-width: 720px) {
  .hero .grid.cols-2 { display: flex; flex-direction: column; }
  .hero .grid.cols-2 > :last-child  { order: 1; }
  .hero .grid.cols-2 > :first-child { order: 2; }
}

/* --- issue 26: alerts container reserve ------------------------------------
   [data-alerts] carried min-height:0, so an injected alert shifted the page.
   One alert row is ~76px. */
[data-alerts] { min-height: 76px; }
[data-alerts]:empty { min-height: 0; }

/* --- issue 27: ad label contrast -------------------------------------------
   --slate-300 measures 2.04:1 on --clear-air and 4.00:1 in dark: it fails AA as
   text in BOTH themes. It is currently latent (no approved ad slots, so the
   label never renders) and would have become a live failure on the day AdSense
   was switched on. --slate-500 passes at 5.01:1 light / 8.41:1 dark. Reserve
   --slate-300 for borders and dividers only. */
.ad[data-filled]::before { color: var(--slate-500); }

/* --- issue 6: consent gate --------------------------------------------------
   Shown only where functions/_middleware.js stamped data-consent-required, i.e.
   EEA/UK/CH. position:fixed so it overlays rather than occupying flow space —
   it can never move content (WS-4). Accept and Reject are deliberately the same
   size and one click each: a refusal that costs more than acceptance is not a
   free choice. */
.consent {
  position: fixed; inset-inline: 0; bottom: 0; z-index: 60;
  background: var(--surface); border-top: 1px solid var(--slate-100);
  box-shadow: 0 -6px 24px rgba(20,24,29,.14);
  padding: .9rem clamp(1rem, 4vw, 2rem);
  display: flex; flex-wrap: wrap; align-items: center; gap: .75rem 1.2rem;
  max-width: 100%;
}
.consent-copy { margin: 0; flex: 1 1 22rem; font-size: .92rem; color: var(--ink); }
.consent-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.consent-row .btn { min-height: 44px; display: inline-flex; align-items: center; }
.consent-more { font-size: .85rem; color: var(--teal-text); }
@media (max-width: 560px) {
  .consent { flex-direction: column; align-items: stretch; }
  .consent-row { justify-content: stretch; }
  .consent-row .btn { flex: 1 1 auto; justify-content: center; }
}
@media print { .consent { display: none !important; } }

/* ==========================================================================
   Review remediation, 2026-09. Appended like the 2026-08 block; nothing above
   it changed. Findings and measurements:
   reports/wildfirewindow-review-2026-09.md
   ========================================================================== */

/* R1 — the primary dial had THEME-VARIANT ink over a THEME-INVARIANT band. Its
   background is an inline style carrying the EPA hex and it never gets an
   .aqi-* class, so its ink fell through to var(--ink), which flips to #EAF0F2
   in dark while the hex does not. Measured in dark: good 1.51:1, moderate
   1.07:1, usg 2.22:1 — the number this site exists to deliver, invisible on
   Moderate. Theme-invariant now; worst case 4.00:1 against a 3:1 floor. */
html[data-band="good"]     .dial:not([class*="aqi-"]),
html[data-band="moderate"] .dial:not([class*="aqi-"]),
html[data-band="usg"]      .dial:not([class*="aqi-"]) { color: #14181D; }

/* R2 — WCAG 1.4.11 on the band chips. Text ON the bands already passed (4.76:1
   worst case); no EPA hex changes. The EDGE failed — the hexes are fixed while
   the surface flips, so fill alone cannot reach 3:1 in both themes:
     light good 1.74 moderate 1.07 usg 2.55 | dark vUnh 2.74 haz 1.58
     both themes, resting --slate-100 dial vs --surface 1.32 / 1.35
   currentColor is the per-band ink, already clearing 4.76:1 on its own fill, so
   a ring in it covers the surface wherever the fill cannot. After: 6.31:1
   light, 4.33:1 dark, every band. */
.badge[class*="aqi-"] { box-shadow: inset 0 0 0 1px currentColor; }
.dial { box-shadow: inset 0 0 0 1px currentColor,
                    inset 0 0 0 6px rgba(255,255,255,.55), var(--shadow); }
.dial.small { box-shadow: inset 0 0 0 1px currentColor,
                          inset 0 0 0 4px rgba(255,255,255,.55); }

/* R3 — visible dial state. The copy was already honest and no path renders the
   dial as a zero, but loading, no-monitor-data and fetch-failure all painted
   the same empty grey circle. Told apart by SHAPE as well as colour: pending =
   solid ring, teal cap | unavailable = DASHED, --warn | resolved = no ring.
   Driven by attributes the markup already writes, so no template or app.js
   change. :has() is progressive. */
.dial[data-ue-inject='dial'] { position: relative; }
/* The dial was `flex: 0 1 auto`, so it SHRANK to 70x128 at 390px — an ellipse,
   not the designed circle — then GREW to 84px when a value arrived. Pinned. */
.dial { flex: 0 0 auto; }
.dial[data-ue-inject='dial']:not([data-band])::after {
  content: ""; position: absolute; inset: 13px; border-radius: 50%;
  border: 3px solid var(--slate-300); pointer-events: none;
}
[data-aqi-widget]:not(:has([data-ue-inject='updated'][data-stale]))
  .dial[data-ue-inject='dial']:not([data-band])::after { border-top-color: var(--teal); }
/* no monitor data, or AirNow unreachable: app.js sets data-stale="1" for both */
[data-aqi-widget]:has([data-ue-inject='updated'][data-stale='1'])
  .dial[data-ue-inject='dial']:not([data-band])::after {
  border-style: dashed; border-color: var(--warn);
}
/* While there is no number, the line naming WHAT is loading and FROM WHERE is
   the most useful thing on the page. Colour only: size or weight would re-wrap
   and shift the page when the reading lands. */
[data-aqi-widget]:not(:has([data-ue-inject='updated'][data-stale])) .dial + div .prov { color: var(--ink); }

/* WS-4: the text column must reserve its TERMINAL height, not its loading one.
   At 390px it grew 100.7 -> 175.1px (page down 27.3px, CLS 0.0994) as the
   category and provenance lines filled in. The content is genuinely variable,
   so the only honest reserve is the measured worst case. It costs air on a
   clean-air day and nothing during a smoke event. Above 720px the dial already
   dominates the row. */
@media (max-width: 720px) { [data-aqi-widget] .dial + div { min-height: 176px; } }
@media (max-width: 380px) { [data-aqi-widget] .dial + div { min-height: 237px; } }
@media (prefers-reduced-motion: no-preference) {
  [data-aqi-widget]:not(:has([data-ue-inject='updated'][data-stale]))
    .dial[data-ue-inject='dial']:not([data-band])::after {
    animation: ww-dial-wait 1.4s linear infinite;
  }
}
@keyframes ww-dial-wait { to { transform: rotate(360deg); } }

/* R4 — the guidance/commerce boundary. The disclosure was already correct and
   STAYS; it just sat last, below body size, after the links a scanner hits
   first. No copy touched, no DOM reordered. --slate-500 throughout, not
   --slate-100 (1.32:1) or --slate-300 (2.23:1): a separator you cannot see is
   not a boundary. */
.commerce [data-commerce-line] + p {
  margin-top: .9rem; padding-top: .9rem; border-top: 2px solid var(--slate-500);
}
/* `inherit`, NOT 1rem: body is 17px while root stays 16px, so 1rem renders the
   disclosure at 94% of body — under the size it must never go. */
.commerce .disclosure { font-size: inherit; color: var(--ink); }
.commerce .disclosure a { text-decoration: underline; }
/* The links that earn nothing keep full button weight; rel="sponsored" drops
   to a quiet outline, so a new affiliate link inherits the treatment. */
.commerce a[rel~="sponsored"].btn {
  background: transparent; color: var(--teal-text);
  box-shadow: inset 0 0 0 1.5px var(--slate-500); font-weight: 600;
}
.commerce a[rel~="sponsored"].btn:hover { box-shadow: inset 0 0 0 1.5px var(--teal); }
/* the disclosure's promotion grew the block; the reserve grows too */
.commerce[data-commerce-state="idle"] { min-height: 248px; }
@media (max-width: 560px) { .commerce[data-commerce-state="idle"] { min-height: 310px; } }

/* R5 — 36 user-visible links ended in " →", the only template tell on the site.
   On a card it was decoration doing an affordance's job. Now part of the
   component, not the copy. */
/* WCAG 1.4.1: --teal-text on --ink measures 2.52:1, under the 3:1 that lets
   colour be a link's ONLY distinguisher, and `a{text-decoration:none}` left
   prose links no other at-rest cue. 13 of the 36 arrows were such links, so
   removing them without this would have made it worse. */
main :where(p, li, dd, td) > a:not(.btn):not(.card),
nav.prov a, .banner a {
  text-decoration: underline; text-underline-offset: .15em;
}
a.card:hover, a.card:focus-visible { border-color: var(--teal); }
a.card:hover strong, a.card:focus-visible strong { text-decoration: underline; }
@media (prefers-reduced-motion: no-preference) { a.card { transition: border-color .15s ease; } }
