/* Shared styles: the page, header, tabs, search, footer, cards, and everything not owned by a single tab. Loaded in the order base, weather, sky, about. */
:root{ --picture-filter:contrast(1.18) saturate(1.12) brightness(1.06); --ink:#f1edff; --muted:#c2b9d8; --line:rgba(222,208,255,.18); --panel:rgba(25,27,65,.61); --accent-rgb:200,160,255; --yellow:#dfc3ff; --sky:#c69cff; --danger:#f5d0ff; }
/* Colour tokens (light theme VIS-06, phase 1): every colour in the parts of the page that follow the theme. The values here are the dark theme, exactly the literals they replaced; phase 2 adds the light values. Rules inside photo tiles, the radar picture and the Sky visuals keep their own literals: those parts stay dark in both themes. */
:root{ --page-bg:#061629; --link:#dbc5ff; --brand-em:#d9bdff; --brand-ink:#ffffff; --brand-divider:rgba(198,156,255,.6); --ink-strong:#fff; --ink-soft:#e9e4f7; --ink-sub:#a9c9dd; --eyebrow:#b8d7ea; --summary-ink:#d1e4f2; --footer-ink:#bdb4d3; --placeholder:#b8b0d4; --focus:#fff; --unit-bg:#201f53; --nav-bg:rgba(13,14,47,.45); --field-bg:rgba(13,14,47,.64); --tip-bg:rgba(13,14,47,.96); --hover-bg:rgba(255,255,255,.1); --hover-bg-strong:rgba(255,255,255,.12); --chip-bg:rgba(255,255,255,.06); --chip-on-bg:#f1edff; --chip-on-ink:#241a42; --on-accent:#21133f; --on-accent-nav:#25133e; --backtop-bg:rgba(25,27,65,.88); --backtop-bg-hover:rgba(25,27,65,1); --sun-down-fill:#3b3f6b; --sun-down-stroke:rgba(255,255,255,.4); --warn-line:rgba(255,196,126,.52); --warn-bg:rgba(127,76,29,.23); --warn-title:#ffe0bb; --warn-text:#f4d2ab; --warn-dot:#ffbd70; --warn-ink:#ffd9a8; --quiet-line:rgba(194,185,216,.4); --quiet-bg:rgba(194,185,216,.06); --rain-ink:#92dfff; --track:rgba(255,255,255,.14); --track-soft:rgba(255,255,255,.1); --track-mark:rgba(255,255,255,.4); --knob:#fff; --knob-ring:rgba(6,22,41,.85); --wind-arrow:#9fe3ff; --chart-faint:rgba(255,255,255,.22); --chart-horizon:rgba(255,255,255,.25); --arc-text:#f0ebff; --moon-dark:#2a2e58; --hint-line:rgba(233,228,247,.7); --hint-bg:rgba(255,255,255,.08); --rain-bar:#9fd0ff; --rain-bar-edge:rgba(255,255,255,.55); --bar-none:rgba(194,185,216,.6); --bar-trace:rgba(159,208,255,.8); --snow-light:#eaf4ff; --snow-dark:#8fb7dc; --freeze-light:#e9f6ff; --pollen-edge:#8f89b8; --sun-rise-ink:#ffe2a8; --sun-set-ink:#ffc4a8; --about-hero-bg:linear-gradient(120deg,rgba(42,23,83,.88),rgba(15,27,64,.82)); --hero-ink:#ddd4ee; --source-line:rgba(201,167,255,.2); --source-bg:linear-gradient(145deg,rgba(35,30,85,.76),rgba(13,18,57,.88)); --source-kicker:#cbb7ff; --source-note-bg:linear-gradient(145deg,rgba(77,40,115,.62),rgba(19,19,57,.9)); }
*{box-sizing:border-box}
body{margin:0;min-width:320px;color:var(--ink);font-family:"Exo 2",Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--page-bg)}
body:before{content:"";position:fixed;inset:0;z-index:-2;background:radial-gradient(circle at 73% 5%,#5a3288 0,transparent 32%),radial-gradient(circle at 8% 25%,#1b315e 0,transparent 35%),linear-gradient(135deg,#090a23 0%,#17224f 58%,#3d2268 100%)}
/* Star field. Seven layers, each one dot per tile at its own offset inside the tile, with tile sizes that share no common factor (223/197, 181/239, 211/173, 139/151, 157/127, 97/109, 71/83) so the combined pattern's repeat period is far larger than any viewport and never reads as a grid. Size and brightness vary per layer, which is what the old single 36px grid of identical 1px dots lacked. Marcus, 2026-09-23: opacity raised from .18 to .198, a deliberate 10% brightness increase on every star. That makes this the first version whose brightest pixel is brighter than the original single-grid design (46 -> 51 of 255), so the "contrast cannot regress" argument the previous revision rested on no longer applies and the numbers are re-measured instead. dotgrid.test.mjs's forced worst-case dot is raised to match: if that injection is left at .18 while the page draws .198, the contrast guard measures a dimmer star than the page actually paints and can pass while the real page fails. Keep the two values equal. Every star is still dimmest-to-brightest within the layer; alpha 1 remains the ceiling. */
body:after{content:"";position:fixed;inset:0;z-index:-1;opacity:.198;background-image:radial-gradient(circle at 21% 38%,rgba(255,255,255,1) 0,rgba(255,255,255,1) 1.5px,transparent 2.2px),radial-gradient(circle at 67% 11%,rgba(255,255,255,.85) 0,rgba(255,255,255,.85) 1.2px,transparent 1.9px),radial-gradient(circle at 34% 7%,rgba(198,156,255,.75) 0,rgba(198,156,255,.75) 1.1px,transparent 1.8px),radial-gradient(circle at 43% 74%,rgba(255,255,255,.7) 0,rgba(255,255,255,.7) 1px,transparent 1.7px),radial-gradient(circle at 86% 57%,rgba(255,255,255,.55) 0,rgba(255,255,255,.55) .9px,transparent 1.6px),radial-gradient(circle at 13% 87%,rgba(255,255,255,.45) 0,rgba(255,255,255,.45) .75px,transparent 1.4px),radial-gradient(circle at 58% 28%,rgba(255,255,255,.35) 0,rgba(255,255,255,.35) .65px,transparent 1.3px);background-size:223px 197px,181px 239px,211px 173px,139px 151px,157px 127px,97px 109px,71px 83px;mask-image:linear-gradient(to bottom,#000,transparent 70%)}
button,input{font:inherit}
button{color:inherit;cursor:pointer}
/* UI-01, Marcus's site audit 2026-09-23: every bare <a> (the About tab's 17 source/attribution links, including
   the Google Pollen terms and privacy links, plus the Windy link) had no site color and fell back to the
   browser's UA default #0000EE, which measures 1.94:1 on this page's dark background -- far under the 4.5:1 WCAG
   AA text minimum, and a Google Pollen API terms compliance risk since those attribution links must be shown to
   users. The same lavender already used for footer links (#dbc5ff, footer a below) measures 11.6:1 here, so it
   is reused rather than a new color introduced. a:visited is (0,1,1) specificity, so it beats a plain class rule
   like .skip-link's (0,1,0) regardless of file order -- .skip-link:visited below fixes the one place that bit.
   a.brand (weather.css's warn-panel link rule and allergens.css's credit-link rule are the same case) is also (0,1,1),
   a tie broken only by coming later in the file than this rule -- not by being "more specific" -- so keep it
   below this rule if it moves. test/link-color.test.mjs asserts no visible link's computed color is ever the UA
   default, but per PLATFORM's finding this cannot observe :visited state at all: getComputedStyle never reports a
   visited link's real style, by browser-enforced design (privacy), so the :visited cases above are a static
   specificity argument, not something a rendered test can confirm. */
a,a:visited{color:var(--link)}
.shell{max-width:1180px;margin:auto;padding:24px 24px 48px}
header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:32px}
.brand{font-weight:780;letter-spacing:-.04em;font-size:1.3rem;display:flex;align-items:center;gap:12px}
.brand img{width:58px;height:58px;object-fit:contain;filter:drop-shadow(0 0 12px rgba(200,126,255,.62))}
.brand em{font-style:normal;color:var(--brand-em);font-weight:620}
.unit{border:1px solid var(--line);background:var(--unit-bg);border-radius:99px;padding:5px;display:flex;flex:none}
/* The unit switch is a row of two buttons that never shrinks or wraps (it was a shrinkable block, so on a narrow or large-text phone its two buttons wrapped into a round blob with the selected one hanging out of the border, 2026-09-21); the brand gives way instead: it may shrink, its logo and gaps get smaller on narrow screens. */
header>.brand{min-width:0;flex:0 1 auto}.brand .stack{min-width:0}
@media(max-width:400px){header{gap:10px}.brand{gap:8px}header .brand img{width:46px;height:46px}header .brand .divider{height:34px}}
/* The right side of the header is now two groups, not one: the install controls (a reserved slot, so the
   unit toggle never shifts when one appears -- see pwa.js) and the unit toggle itself. Wrapped together so
   header's own justify-content:space-between still only ever balances two things (the brand and this group),
   not four. */
.header-tools{display:flex;align-items:center;gap:10px;flex:none}
@media(max-width:400px){.header-tools{gap:6px}}
/* Marcus, 2026-09-23: "Install Nalssi" and the iOS "Add to Home Screen" tip moved here from a row in the
   weather tab's body, so they show on every tab, next to the °F/°C toggle. Only one of the two slots below is
   ever in the header on a given device (pwa.js removes the other one entirely, decided once at load) -- the
   one that stays reserves a fixed 44x44 box regardless of whether its content is showing yet, so the unit
   toggle and the brand never shift position when it later appears. */
.install-slot{position:relative;flex:none}
.install-btn{width:44px;height:44px;min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center;padding:0;border:1px solid var(--line);border-radius:12px;background:transparent;color:var(--ink);cursor:pointer}
.install-btn:hover{background:var(--hover-bg)}
.install-btn:focus-visible{outline:3px solid var(--focus);outline-offset:2px}
/* #installNalssi alone gets the reserve-then-reveal treatment: beforeinstallprompt is asynchronous (it may
   fire seconds after load, or never), so it starts invisible-but-present, and pwa.js only adds .ready once
   the event actually fires. #installIosInfo has no such wait -- iOS eligibility is already known by the time
   either button exists -- so it needs no separate hidden state of its own here. */
#installNalssi{visibility:hidden}
#installNalssi.ready{visibility:visible}
/* Manager, 2026-09-23: a395b61 (already on batch28) raised the sitewide font-size floor to .9rem; this was
   .82rem when first written, before that floor landed. */
.install-tip{position:absolute;top:52px;right:0;z-index:10;display:flex;align-items:center;gap:8px;width:max-content;max-width:min(260px,calc(100vw - 32px));padding:10px 12px;border:1px solid var(--line);border-radius:12px;background:var(--tip-bg);color:var(--ink);font-size:1rem;line-height:1.4;box-shadow:0 12px 28px rgba(0,0,0,.35)}
.install-tip button{flex:none;width:44px;height:44px;min-width:44px;min-height:44px;margin:-10px -8px -10px 0;display:inline-flex;align-items:center;justify-content:center;padding:0;border:0;border-radius:50%;background:transparent;color:var(--muted);cursor:pointer}
.install-tip button:hover{background:var(--hover-bg-strong)}
.install-tip button:focus-visible{outline:3px solid var(--focus);outline-offset:-3px}
.unit button{border:0;border-radius:99px;background:transparent;padding:7px 11px;font-weight:700;font-size:1rem}
.unit button.active{background:var(--chip-on-bg);color:var(--chip-on-ink)}
.search{display:flex;flex-wrap:wrap;align-items:center;gap:10px;background:var(--field-bg);border:1px solid var(--line);border-radius:18px;padding:7px 8px 7px 17px;max-width:560px;box-shadow:0 16px 35px rgba(0,0,0,.16)}
.search svg{opacity:.7;flex:none}
.search input{flex:1 1 60px;min-width:0;border:0;outline:0;background:transparent;color:var(--ink);font-size:1rem;min-height:40px}
.search input::placeholder{color:var(--placeholder)}
.search button{border:0;border-radius:13px;background:var(--sky);color:var(--on-accent);font-weight:750;padding:11px 16px;min-height:42px}
/* UI-02, Marcus's site audit 2026-09-23: on a phone the search row (magnifier, input, Search, Use my location)
   wrapped "Use my location" onto its own line, since its full text is too wide to share the row. Below 720px
   it collapses to an icon-only round button, the same 44px target and treatment as the clear (X) control, so
   it fits back on the row; its text stays for every other width and for screen readers at every width (the
   button keeps an aria-label with the same words, so shrinking it never changes what it announces). */
.search button.use-location{display:inline-flex;align-items:center;gap:8px}
.search button.use-location svg{opacity:1}
/* The clear (X) control: shown only while the box has text, a 44px target, empties the box and keeps the cursor in it */
.search .clear-x{display:none;flex:none;width:44px;height:44px;min-height:44px;margin-right:-4px;padding:0;border:0;border-radius:50%;background:transparent;color:var(--ink);cursor:pointer;align-items:center;justify-content:center}.search .clear-x:hover{background:var(--hover-bg-strong)}.search .clear-x:focus-visible{outline:3px solid var(--focus);outline-offset:-3px}.search input:not(:placeholder-shown)~.clear-x{display:inline-flex}
.eyebrow{color:var(--eyebrow);font-size:1rem;font-weight:750;text-transform:uppercase;letter-spacing:.12em;margin:28px 0 8px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:24px;box-shadow:0 18px 35px rgba(0,0,0,.13);backdrop-filter:blur(16px)}
.summary{padding:26px;display:flex;flex-direction:column;justify-content:space-between}
.summary h2,.section-title h2,.radar-title{margin:0;font-size:1.05rem;letter-spacing:-.02em}
.summary p{color:var(--summary-ink);line-height:1.55;margin:16px 0;font-size:1rem}
.section-title{display:flex;align-items:end;justify-content:space-between;margin:34px 0 13px}
.section-title p{margin:0;color:var(--muted);font-size:1rem}
.hour{flex:0 0 88px;min-width:88px;padding:16px 6px 26px;text-align:center;position:relative;z-index:1}
.hour.now-hour{background:rgba(var(--accent-rgb),.16);box-shadow:inset 0 0 0 1px rgba(var(--accent-rgb),.45);border-radius:18px}
.hour.now-hour time{color:var(--ink-strong);font-weight:700}
.sun-marker{position:absolute;left:4px;right:4px;bottom:6px;font-size:1rem;line-height:1.2;color:var(--sun-rise-ink)}
.sun-marker.sunset{color:var(--sun-set-ink)}
.hour time{font-size:1rem;color:var(--muted)}
.hour .icon{font-size:1.65rem;display:block;margin:12px 0}
.hour b{font-size:1rem}
@media(max-width:720px){.card.sunmoon{min-height:460px;display:flex;flex-direction:column;justify-content:space-between}}
.sun-dot.down{fill:var(--sun-down-fill);stroke:var(--sun-down-stroke);stroke-width:2}
.card.sunmoon{background:transparent;-webkit-backdrop-filter:none;backdrop-filter:none;--panel-alpha:.75}
footer{margin-top:25px;color:var(--footer-ink);font-size:1rem;display:flex;justify-content:space-between;gap:18px;flex-wrap:wrap}
footer a{color:var(--link)}
.wx-disclaimer-wrap{flex:1 0 100%}
.wx-disclaimer-wrap summary{cursor:pointer;font-size:1rem;display:inline-block;padding:15px 10px;margin:-15px -10px}
.wx-disclaimer{margin:.5em 0 0;font-size:1rem;line-height:1.5;color:var(--ink-soft)}
.sky-hero.space-image{position:relative;overflow:hidden;isolation:isolate;background-color:#0d0c31;background-image:none!important}
.sky-hero.space-image::before{content:"";position:absolute;inset:0;z-index:-1;background-repeat:no-repeat;background-position:center;background-size:cover;transform:scale(1.06);filter:var(--picture-filter)}
.sky-hero.space-image::before{background-image:url('/api/sky-image?name=hero&v=3')}
/* The hidden attribute always wins: a class that sets display (.planet-image, .recent-places ...) must not show an element the page has hidden (an empty picture box, a "Recent places" label with no places). test/planet-pictures.test.mjs walks every tab for a displayed [hidden] element. */
[hidden]{display:none!important}
.view-nav{display:flex;gap:6px;margin:0 0 22px;padding:5px;width:max-content;border:1px solid var(--line);border-radius:14px;background:var(--nav-bg)}
.view-nav button{border:0;border-radius:10px;background:transparent;color:var(--muted);font-weight:700;padding:9px 15px}
.view-nav button.active{background:#d3b1ff;color:var(--on-accent-nav)}
.data-tile .eyebrow{margin:0 0 8px}
.brand,.eyebrow,.view-nav button{font-family:Orbitron,"Segoe UI",sans-serif}
.brand{letter-spacing:.035em;font-size:1.12rem}
.eyebrow{font-size:1rem;font-weight:600}
.view-nav button{font-size:1rem;font-weight:600}
.sky-hero.space-image{background-image:linear-gradient(90deg,rgba(20,13,57,.94),rgba(35,17,77,.72) 58%,rgba(15,22,62,.68));background-size:cover;background-position:center}
.planet-image{display:block;width:100%;height:82px;object-fit:cover;border-radius:12px;margin-bottom:13px;background:#17173e;border:1px solid rgba(221,196,255,.14)}
@media(max-width:720px){.shell{padding:17px 14px 35px}header{margin-bottom:25px}.brand{font-size:1.05rem}.search{border-radius:15px}.search button{padding:10px 12px}.summary{padding:22px}.search button.use-location{gap:0;width:44px;height:44px;min-width:44px;min-height:44px;padding:0;border-radius:50%;justify-content:center}.search button.use-location span{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}}
/* D10 follow-up, 2026-09-23 (PLATFORM's option 2): a viewport-based cap on the figure (94vw minus some fixed
   margin, or that margin scoped to a max-height media query) kept reopening the same overlap at a size it wasn't
   tuned for -- a landscape phone at 844x390, then a half-screen/short-laptop window at 1100x540. Reserving the
   close button's own footprint (top:12px+height:44px=56px, right:12px+width:44px=56px) as padding on the lightbox
   itself instead structurally excludes it from the centered content box at every size: the figure is a grid item
   centered within padding: 56px 56px 28px 28px (top/right 56px matches the button's reach; bottom/left need only
   clear the backdrop, 28px as before), so its top-right corner can never reach past the content box's own edge
   into the button's corner, regardless of the figure's own width or the viewport's aspect ratio. */
.photo-lightbox{position:fixed;z-index:20;inset:0;display:grid;place-items:center;padding:56px 56px 28px 28px;background:rgba(3,5,20,.86);backdrop-filter:blur(10px);opacity:0;pointer-events:none;transition:opacity .2s ease}
.photo-lightbox.open{opacity:1;pointer-events:auto}
.photo-lightbox figure{max-width:min(1100px,94vw);max-height:90vh;margin:0;transform:scale(.96);transition:transform .2s ease}
.photo-lightbox.open figure{transform:scale(1)}
.photo-lightbox img{display:block;max-width:100%;max-height:82vh;object-fit:contain;border:1px solid rgba(220,196,255,.42);border-radius:18px;box-shadow:0 28px 90px rgba(0,0,0,.55)}
.photo-lightbox figcaption{padding-top:10px;color:#e6dcff;text-align:center;font-family:Orbitron,"Segoe UI",sans-serif;font-size:1rem;letter-spacing:.04em}
.photo-lightbox[hidden]{display:none}
/* External review, D10, 2026-09-23: the lightbox had no visible close control -- Escape and a click on the
   backdrop both worked, but nothing on screen said so. Fixed top-right, above the picture at every width the
   figure's own max-width allows, a 44px target matching every other icon button on the page (.install-tip
   button's own dismiss-X shape, reused rather than a new icon). */
.photo-lightbox .lightbox-close{position:absolute;top:12px;right:12px;z-index:1;width:44px;height:44px;min-width:44px;min-height:44px;display:inline-flex;align-items:center;justify-content:center;padding:0;border:1px solid rgba(220,196,255,.42);border-radius:50%;background:rgba(3,5,20,.7);color:#e6dcff;cursor:pointer}
.photo-lightbox .lightbox-close:hover{background:rgba(3,5,20,.9)}
.photo-lightbox .lightbox-close:focus-visible{outline:3px solid #fff;outline-offset:2px}
/* Text on pictures has no box: legibility comes from an outline and a soft shadow on the text, with no fade behind it (test/sky-ink.test.mjs measures 4.5:1 behind the glyphs at four widths, on the real pictures and on a plain white one). */
:root{--ink-shadow:0 0 1px #000,0 0 2px #000,0 1px 2px rgba(0,0,0,.95),0 0 6px rgba(0,0,0,.9),0 0 14px rgba(0,0,0,.75),0 0 26px rgba(0,0,0,.5)}
/* Design 6: shared frosted-glass tiles; the accent follows the conditions (see accentFor) */
.card{border-color:rgba(var(--accent-rgb),.3);box-shadow:0 18px 35px rgba(0,0,0,.16),inset 0 1px 0 rgba(255,255,255,.09);-webkit-backdrop-filter:blur(18px) saturate(1.25);backdrop-filter:blur(18px) saturate(1.25)}
.search,.view-nav,.unit{border-color:rgba(var(--accent-rgb),.3)}
.view-nav button.active,.search button{background:rgb(var(--accent-rgb));color:var(--on-accent)}
.section-title h2:before,.radar-title:before{content:"";display:inline-block;width:4px;height:1em;margin-right:8px;border-radius:2px;background:rgb(var(--accent-rgb));vertical-align:-2px}
.sky-hero.space-image{min-height:clamp(170px,20vw,240px)}
.highlow .low-na{font-size:1rem}
.hour .rain{min-height:2.6em;line-height:1.3}
.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
/* Accessibility: a visible keyboard focus ring everywhere (white, 2px clear of the control so it reads on light and dark), a skip link, and 44px touch targets (test/a11y.test.mjs). */
:focus-visible{outline:3px solid var(--focus);outline-offset:2px}
.search:focus-within{outline:3px solid var(--focus);outline-offset:2px}
.skip-link{position:absolute;left:12px;top:-64px;z-index:100;background:var(--chip-on-bg);color:var(--chip-on-ink);padding:14px 16px;border-radius:10px;font-weight:700}
/* PLATFORM, 2026-09-23: a:visited above is (0,1,1) specificity, which beats plain .skip-link's (0,1,0) regardless
   of source order -- a visited skip link would have turned #dbc5ff on its own near-white #f1edff background,
   1.36:1, worse than the 1.94:1 this file's link-color fix exists to repair. .skip-link:visited matches a:visited's
   specificity, so it wins on the tie by coming later in the file (test/link-color.test.mjs cannot observe this:
   PLATFORM found getComputedStyle never reports :visited styles, by browser design, so this is a static check on
   the rule itself, not a rendered one). */
.skip-link:visited{color:var(--chip-on-ink)}
.skip-link:focus{top:12px}
#main:focus{outline:0}
.unit button,.view-nav button,.search button,.search input,.choices button{min-height:44px}
.unit button{min-width:44px}
footer a{display:inline-block;padding:15px 10px;margin:-15px -10px}
a.brand{color:inherit;text-decoration:none}
a.brand:focus-visible{outline:2px solid rgb(var(--accent-rgb));outline-offset:4px;border-radius:14px}
/* Back to top: fixed, bottom-right, on every tab (it sits outside the four view containers). Shown once the page has scrolled a ways (app.js), never removed from the DOM -- disabled while hidden so it takes no click and no keyboard tab stop, rather than toggling aria-hidden/tabindex by hand. */
.back-to-top{position:fixed;right:20px;bottom:20px;z-index:5;width:48px;height:48px;min-width:44px;min-height:44px;border:1px solid var(--line);border-radius:50%;background:var(--backtop-bg);color:var(--ink);display:flex;align-items:center;justify-content:center;box-shadow:0 10px 24px rgba(0,0,0,.35);opacity:0;transform:translateY(8px);pointer-events:none;transition:opacity .2s,transform .2s}
.back-to-top.shown{opacity:1;transform:none;pointer-events:auto}
.back-to-top:hover{background:var(--backtop-bg-hover)}
.back-to-top:focus-visible{outline:3px solid var(--focus);outline-offset:2px}
@media(max-width:480px){.back-to-top{right:14px;bottom:14px}}
@media(prefers-reduced-motion:reduce){.back-to-top{transition:none}}
/* Wordmark: Orbitron capitals, thin divider, sub-line. The glow matches the shield's drop-shadow colour and softness. */
.brand{--wordmark-glow:0 0 6px rgba(200,126,255,.75),0 0 18px rgba(200,126,255,.55),0 0 34px rgba(200,126,255,.30)}
.brand .divider{width:1px;height:40px;background:var(--brand-divider)}
.brand .stack{display:flex;flex-direction:column;gap:4px;line-height:1}
.brand .wm{font-family:Orbitron,"Segoe UI",sans-serif;font-weight:700;font-size:1.5rem;letter-spacing:.14em;text-transform:uppercase;color:var(--brand-ink);text-shadow:var(--wordmark-glow)}
.brand .sub{font-family:"Exo 2","Segoe UI",sans-serif;font-size:1rem;font-weight:500;letter-spacing:.06em;text-transform:none;color:var(--brand-ink);text-shadow:var(--wordmark-glow)}
@media(max-width:480px){.brand{gap:10px}.brand .divider{height:34px}.brand .wm{font-size:1.15rem}.brand .sub{font-size:1rem}}
/* PLATFORM's HOLD, 2026-09-23: NalssiSky (9 glyphs) is wider than the old Nalssi (6), and below 375px it did
   not fit -- header>.brand{min-width:0} let the wordmark's box shrink below what its glyphs (at the
   max-width:480px font-size above) need, so the text wrapped, the header grew from 56px to 102px, and the
   glyphs overflowed their own shrunk box by 82px. This block must come AFTER the max-width:480px rule above:
   same selector, same specificity, so source order decides, and max-width:400px is still true whenever
   max-width:480px is (any width <=400 is also <=480) -- placed earlier, this rule's own font-size was silently
   losing to the 480px one's 1.15rem. Below 400px the wordmark stops wrapping.
   `header>.brand{flex:0 0 auto}` was my first attempt and is WRONG: forcing a flex item's basis to auto makes
   the browser size it by max-content, which (per the flex/intrinsic-sizing spec) computes every descendant's
   width as if nothing wrapped -- including the .sub subline, which is meant to keep wrapping. That inflated
   .brand to the subline's full unwrapped width (~297px measured) and blew the header past 375px on any page
   with real content loaded (caught by test/a11y.test.mjs's unrelated D10 lightbox test, which is the only one
   that loads fixture data before measuring; the page's own skeleton state before data loads never showed it).
   Restoring `min-width:auto` (the browser default, not the `0` the un-narrowed rule sets) is the correct fix:
   it makes flex-shrink stop at each descendant's own automatic minimum size, computed per descendant's own
   line-breaking behavior -- the nowrap wordmark's minimum is its full single-line width (what we want as the
   floor), while the subline's minimum stays its longest single word (much narrower), so the subline is still
   free to wrap across up to 2 lines within that floor. */
@media(max-width:400px){.brand .wm{font-size:16px;letter-spacing:.08em;white-space:nowrap}header>.brand{min-width:auto}.brand .stack{min-width:auto}}
/* PLATFORM's second HOLD, 2026-09-23: even with the fix above, at 320/340 the header is wider than the real
   screen, not just the (mobile-emulation-widened) layout viewport used to measure it before -- brand 186 +
   gap 10 + tools 150 = 346px against 292px available at 320. Manager's choice was shield 36px, wordmark
   .85rem/.04em letter-spacing -- but .85rem is 13.6px, under this site's 16px text-size floor
   (test/text-sizing.test.mjs), and even with it the subline still needed enough per-line width to push the
   header to 82px (over the 72px ceiling) at 320. Adjusted from that starting point rather than following it
   to the letter: the wordmark stays at 1rem (16px, on the floor, not under it) with tighter letter-spacing to
   compensate for the width that costs; the subline is hidden below 360px entirely (its wrapping was the
   header-height driver, and PLATFORM's own HOLD-1 instructions named this as the fallback if the fix alone
   didn't fit); the shield shrinks a little further and the one remaining gap is minimal, so the numbers add
   up within the 132px the brand actually has once the fixed-width install button and unit toggle (150px) and
   header's own 10px gap are subtracted from the 292px available at 320. white-space:nowrap and min-width:auto
   from the max-width:400px rule above still apply, since 360 is inside 400.
   Manager's BATCH32 blocker, same day: at 320px with Android's font-scaling accessibility setting on (root
   font-size raised to 115% or 130%), the header still overflowed the real screen, by 9px and 25px -- the
   wordmark's own floor (min-width:auto, set above) is its rendered single-line width, and that width grows
   with the scaled root font-size because its font-size was in rem. There is no room left to give at 320px even
   before scaling (132px total for the whole brand once the fixed-width install button, unit toggle and gaps
   are subtracted), so the wordmark cannot also keep growing with a user's text-scale setting at this width
   without overflowing. Below 400px specifically -- not at wider widths, where there is room to spare and the
   wordmark keeps scaling normally for accessibility -- both the max-width:400px and max-width:360px rules now
   pin it to 16px instead of 1rem: the same px value 1rem already rendered at the default scale, so nothing
   changes for a user who has not turned on text scaling. */
@media(max-width:360px){header .brand img{width:30px;height:30px}.brand .wm{font-size:16px;letter-spacing:-.02em}.brand{gap:4px}.brand .divider{display:none}.brand .sub{display:none}}
.recent-places{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:10px 0}.recent-places>span{color:var(--muted);font-size:1rem}.recent-places button{min-height:44px;min-width:44px;padding:0 14px;border:1px solid var(--line);border-radius:999px;background:var(--chip-bg);color:var(--ink);font-size:1rem;font-weight:600}.recent-places .recent-clear{background:transparent;text-decoration:underline}.choices{margin:12px 0;padding:14px 16px;background:var(--panel);border:1px solid rgba(var(--accent-rgb),.3);border-radius:18px}
.choices p{margin:0 0 8px;color:var(--muted);font-size:1rem}
.choices ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}
.choices button{min-height:44px;padding:9px 13px;border:1px solid rgba(var(--accent-rgb),.85);border-radius:12px;background:rgba(var(--accent-rgb),.12);color:var(--ink);font:inherit;font-size:1rem;text-align:left;cursor:pointer}
.choices button:focus-visible{outline:2px solid rgb(var(--accent-rgb));outline-offset:2px}
:root{--ink-shadow:0 0 1px #000,0 0 2px #000,0 1px 2px rgba(0,0,0,.95),0 0 6px rgba(0,0,0,.9),0 0 14px rgba(0,0,0,.75),0 0 26px rgba(0,0,0,.5)}
.card.sunmoon{text-shadow:var(--ink-shadow);-webkit-text-stroke:3px rgba(0,0,0,.6);paint-order:stroke fill}
:root{--ink-glow:drop-shadow(0 0 1px #000) drop-shadow(0 0 2px #000) drop-shadow(0 1px 2px rgba(0,0,0,.95)) drop-shadow(0 0 6px rgba(0,0,0,.9))}
/* Star dots behind page-level text (option 5): the dots stay, and a dark halo under each glyph keeps a dot from ever sitting directly behind ink. No box, no change to the grey text hierarchy. Card and picture text has its own rules. */
.section-title p,.place p,.choices p,.recent-places>span,.eyebrow,footer,footer span,footer a,.wx-disclaimer{text-shadow:0 0 1px rgba(6,8,30,.95),0 0 3px rgba(6,8,30,.9),0 0 6px rgba(6,8,30,.75)}
/* Picture text keeps its own six-stop ink shadow: a rule that names .eyebrow directly would otherwise beat the inherited value inside a picture tile. */
.on-picture .eyebrow{text-shadow:var(--ink-shadow)}
/* The tab bar is as wide as its four tabs (width:max-content), which is wider than a phone under about 380 px: the page scrolled sideways and About was cut off (Marcus and Helper, 2026-09-21). On a phone it takes the screen's width, the tabs share it. Last in the file so it wins over the tab rules above. */
/* PLATFORM/Manager, 2026-09-23: a viewport-tuned margin (padding/gap/letter-spacing alone, sized to fit one
   measured width) came within 1.41px of overflowing at 320px/text-1 -- close enough that an Edge-vs-Chrome glyph
   difference could flip it intermittently. Structural instead: each tab is flex:1 1 auto (basis = its own content,
   so it only shrinks, proportionally to its own size, once the row truly can't fit -- not flex:1 1 0's always-
   equal-quarters split, which forced "Weather" and "Air quality" to truncate even at 360px/text-1 where there was
   slack to spare) with min-width:0 so flex-shrink can actually bite, and overflow:hidden;text-overflow:ellipsis as
   the fallback if a shrunk label's text no longer fits its own box. Below about 400px the bar can never grow past
   the screen -- .view-nav is a fixed width:100%, not the old max-content -- so this replaces the old horizontal-
   scroll fallback entirely: at every width and text size, the four tabs sit flush, at 1rem and 44px, and only the
   text of a label can compress (never the bar itself). */
@media(max-width:600px){.view-nav{width:100%;box-sizing:border-box;justify-content:space-between;gap:0}.view-nav button{flex:1 1 auto;min-width:0;padding:9px 2px;font-size:1rem;letter-spacing:-.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
/* Marcus, 2026-09-23 (option A over PLATFORM's option B): the flex fix above still left "Air quality" -- the
   longest label -- a sub-pixel short of its own box below 360px (PLATFORM's HOLD on 37524f7: -1px at 320px/text-1).
   Below 360px only, the tab reads "Air" instead: the real text node is made invisible (font-size:0, so it still
   exists for anything that reads DOM text) and a ::before pseudo-element shows "Air" at the normal 1rem size in
   its place -- no markup change, so test/airquality-rename.test.mjs's regex-based reader (which requires the raw
   `>Air quality<` with no nested tags) still finds the label it expects. The button's own aria-label keeps the
   accessible name "Air quality" regardless of what's visually shown -- once an element has an aria-label, ARIA
   ignores its visible content entirely, so a screen reader announces the same name at every width. "Air" is short
   enough that it keeps well over 1px of margin at every width and text size down to 320px (measured; the fourth
   tab's ::before content is what actually renders, not the hidden real text, so this is checked separately from
   the Range-based per-label margin test above, which can only measure real DOM text nodes). */
@media(max-width:359.98px){#allergensTab{font-size:0}#allergensTab::before{content:"Air";font-size:1rem}}
/* Light theme (VIS-06, phase 2): the light values, used when the system asks for light. Photo-backed and night-sky parts re-declare the dark values and stay dark. */
@media (prefers-color-scheme:light){
:root:not([data-theme=dark]){color-scheme:light; --ink:#1b1740; --muted:#4f4870; --line:rgba(60,48,120,.2); --panel:rgba(255,255,255,.74); --accent-rgb:106,62,190; --yellow:#e0a526; --sky:#6a3ebe; --danger:#8a1f6a; --page-bg:#eef3fb; --link:#5a2fb0; --brand-em:#6a3ebe; --brand-ink:#1b1740; --brand-divider:rgba(106,62,190,.5); --ink-strong:#0f0c2e; --ink-soft:#2d2750; --ink-sub:#2f5470; --eyebrow:#2d5a78; --summary-ink:#24435a; --footer-ink:#4a4368; --placeholder:#5e5880; --focus:#1b1740; --unit-bg:#e6def7; --nav-bg:rgba(255,255,255,.6); --field-bg:rgba(255,255,255,.85); --tip-bg:rgba(255,255,255,.98); --hover-bg:rgba(40,30,90,.08); --hover-bg-strong:rgba(40,30,90,.1); --chip-bg:rgba(40,30,90,.05); --chip-on-bg:#2d2360; --chip-on-ink:#fff; --on-accent:#fff; --on-accent-nav:#fff; --backtop-bg:rgba(255,255,255,.92); --backtop-bg-hover:#fff; --sun-down-fill:#c9c6dd; --sun-down-stroke:rgba(40,30,90,.45); --warn-line:rgba(170,95,20,.5); --warn-bg:rgba(255,170,80,.16); --warn-title:#6e3900; --warn-text:#633405; --warn-dot:#c26a00; --warn-ink:#6e3900; --quiet-line:rgba(80,70,120,.35); --quiet-bg:rgba(80,70,120,.05); --rain-ink:#0b5f8a; --track:rgba(40,30,90,.14); --track-soft:rgba(40,30,90,.08); --track-mark:rgba(40,30,90,.45); --knob:#fff; --knob-ring:rgba(40,30,90,.7); --wind-arrow:#0e6a96; --chart-faint:rgba(40,30,90,.25); --chart-horizon:rgba(40,30,90,.3); --arc-text:#1b1740; --moon-dark:#c9c6dd; --hint-line:rgba(40,30,90,.55); --hint-bg:rgba(40,30,90,.05); --rain-bar:#2f86d6; --rain-bar-edge:rgba(20,60,120,.55); --bar-none:rgba(80,70,120,.45); --bar-trace:rgba(47,134,214,.7); --snow-light:#ffffff; --snow-dark:#6f9cc8; --freeze-light:#d4ecff; --pollen-edge:#6e6896; --sun-rise-ink:#7a4a00; --sun-set-ink:#8f3a12; --about-hero-bg:linear-gradient(120deg,rgba(236,228,252,.94),rgba(222,232,248,.92)); --hero-ink:#2d2750; --source-line:rgba(106,62,190,.25); --source-bg:linear-gradient(145deg,rgba(255,255,255,.84),rgba(240,236,252,.92)); --source-kicker:#5a2fb0; --source-note-bg:linear-gradient(145deg,rgba(236,226,252,.92),rgba(248,246,255,.96)); }
:root:not([data-theme=dark]) :where(.now,#weatherSummary,.card.sunmoon,.sky-hero,.sky-score,.iss-card,#visibleTonightCard,.tile-text,.on-picture,.condition-visual,.radar-picture,.photo-lightbox,.about-tile,.about-pets-hero,.allergen-hero,.planet-image,#skyDome){color-scheme:dark; --ink:#f1edff; --muted:#c2b9d8; --line:rgba(222,208,255,.18); --panel:rgba(25,27,65,.61); --accent-rgb:200,160,255; --yellow:#dfc3ff; --sky:#c69cff; --danger:#f5d0ff; --page-bg:#061629; --link:#dbc5ff; --brand-em:#d9bdff; --brand-ink:#ffffff; --brand-divider:rgba(198,156,255,.6); --ink-strong:#fff; --ink-soft:#e9e4f7; --ink-sub:#a9c9dd; --eyebrow:#b8d7ea; --summary-ink:#d1e4f2; --footer-ink:#bdb4d3; --placeholder:#b8b0d4; --focus:#fff; --unit-bg:#201f53; --nav-bg:rgba(13,14,47,.45); --field-bg:rgba(13,14,47,.64); --tip-bg:rgba(13,14,47,.96); --hover-bg:rgba(255,255,255,.1); --hover-bg-strong:rgba(255,255,255,.12); --chip-bg:rgba(255,255,255,.06); --chip-on-bg:#f1edff; --chip-on-ink:#241a42; --on-accent:#21133f; --on-accent-nav:#25133e; --backtop-bg:rgba(25,27,65,.88); --backtop-bg-hover:rgba(25,27,65,1); --sun-down-fill:#3b3f6b; --sun-down-stroke:rgba(255,255,255,.4); --warn-line:rgba(255,196,126,.52); --warn-bg:rgba(127,76,29,.23); --warn-title:#ffe0bb; --warn-text:#f4d2ab; --warn-dot:#ffbd70; --warn-ink:#ffd9a8; --quiet-line:rgba(194,185,216,.4); --quiet-bg:rgba(194,185,216,.06); --rain-ink:#92dfff; --track:rgba(255,255,255,.14); --track-soft:rgba(255,255,255,.1); --track-mark:rgba(255,255,255,.4); --knob:#fff; --knob-ring:rgba(6,22,41,.85); --wind-arrow:#9fe3ff; --chart-faint:rgba(255,255,255,.22); --chart-horizon:rgba(255,255,255,.25); --arc-text:#f0ebff; --moon-dark:#2a2e58; --hint-line:rgba(233,228,247,.7); --hint-bg:rgba(255,255,255,.08); --rain-bar:#9fd0ff; --rain-bar-edge:rgba(255,255,255,.55); --bar-none:rgba(194,185,216,.6); --bar-trace:rgba(159,208,255,.8); --snow-light:#eaf4ff; --snow-dark:#8fb7dc; --freeze-light:#e9f6ff; --pollen-edge:#8f89b8; --sun-rise-ink:#ffe2a8; --sun-set-ink:#ffc4a8; --about-hero-bg:linear-gradient(120deg,rgba(42,23,83,.88),rgba(15,27,64,.82)); --hero-ink:#ddd4ee; --source-line:rgba(201,167,255,.2); --source-bg:linear-gradient(145deg,rgba(35,30,85,.76),rgba(13,18,57,.88)); --source-kicker:#cbb7ff; --source-note-bg:linear-gradient(145deg,rgba(77,40,115,.62),rgba(19,19,57,.9)); }
/* colour is inherited as the parent already computed it, so the stay-dark parts also take --ink again; zero specificity, so a part that sets its own colour keeps it */
:where(:root:not([data-theme=dark])) :where(.now,#weatherSummary,.card.sunmoon,.sky-hero,.sky-score,.iss-card,#visibleTonightCard,.tile-text,.on-picture,.condition-visual,.radar-picture,.photo-lightbox,.about-tile,.about-pets-hero,.allergen-hero,.planet-image,#skyDome){color:var(--ink)}
/* the photo cards before their photo arrives: as dark as they look on the dark page, not grey */
:root:not([data-theme=dark]) :is(.now,.card.sunmoon){--panel:rgba(14,20,50,.94)}
/* the Sky dome draws on the card itself, so on a light card it gets its own night-sky backing (PLATFORM, 2026-09-23: its labels were 1.12:1) */
:root:not([data-theme=dark]) #skyDome .viz-svg{background:#0d0c31;border-radius:16px}
/* 4) radar legend swatches are data colours: an edge that is 3:1 on the light card (5 of 6 were under it) */
:root:not([data-theme=dark]) .radar-legend i{outline:1px solid rgba(0,0,0,.45)}
/* 5) the Show live radar button edge, solid accent (2.84:1 at .6 alpha on the light card) -- OBSOLETE,
   2026-09-24: the button no longer sits on a plain light card. .radar-preview-body is now also .on-picture
   (the card's own background is a full photo, one of 5 AI-generated scenes, in every theme), which already
   forces the dark-scheme --accent-rgb onto this button regardless of the page's own light/dark theme, so
   this override's border-color:rgb(var(--accent-rgb)) resolved to the DARK scheme's lighter accent value --
   correct for a dark backdrop, but low-contrast (2.12:1) against a plain white one, which is no longer the
   background this button is ever measured against. Removed; the plain rgba(var(--accent-rgb),.6) border
   (below, shared with dark theme) is what actually renders now, verified against the real photos in
   test/radar-preview-pictures.test.mjs / the contrast measurement in this branch's commit message.
:root:not([data-theme=dark]) body:before{background:radial-gradient(circle at 73% 5%,#f1e4ff 0,transparent 32%),radial-gradient(circle at 8% 25%,#dcebff 0,transparent 35%),linear-gradient(135deg,#f3f6fc 0%,#e5ecf7 58%,#ede5f8 100%)}
:root:not([data-theme=dark]) body:after{display:none}
:root:not([data-theme=dark]) :is(.section-title p,.place p,.choices p,.recent-places>span,.eyebrow,footer,footer span,footer a,.wx-disclaimer):not(:where(.now,#weatherSummary,.card.sunmoon,.sky-hero,.sky-score,.iss-card,#visibleTonightCard,.tile-text,.on-picture,.condition-visual,.radar-picture,.photo-lightbox,.about-tile,.about-pets-hero,.allergen-hero,.planet-image,#skyDome) *){text-shadow:none}
:root:not([data-theme=dark]) .card{box-shadow:0 12px 28px rgba(40,30,90,.1)}
}
