/* Kiri app styles — extracted from index.html (was an inline <style> block).
   Loaded via <link> in the head; precached in sw.js CORE. */
  :root{
    --cream:#FBF6EC; --cream-100:#F6EDD8;
    --moon-glow:#FBEBB0; --moon:#F2D06B; --moon-deep:#D9AE3E; --gold-text:#85651D;
    /* ---- accent as TEXT, which is not the same job as accent as brand ----
       `--moon-deep` is furniture: 43 borders, focus rings, chart strokes, the dot in
       the wordmark. Contrast rules do not govern those, and darkening the token to
       satisfy text would have repainted the whole brand. Where the accent is *text on
       a page* it needs to clear WCAG, so it gets its own value.

       This one is tuned to the LARGE-text threshold (3:1) because that is all it is
       used for — the bird's name at 38px on Home and 52px on About, which measured
       1.94 against cream. Small accent text uses `--gold-text` instead; the "· yours"
       marker on a mutation card was the one place that had reached for `--moon-deep`
       at 13px. Measured across all 8 themes on 2026-08-07. */
    --accent-text:#AB8931;
    --sky-light:#CBE1E8; --sky-deep:#1C3A44;
    --lime-pale:#E4EBA8; --lime:#BCCB4E; --lime-deep:#43490F;
    --peach:#EBA07A;
    --night:#1C2432; --night-deep:#111722;
    --ink:#1C2432; --muted:#626774;
    --line:rgba(28,36,50,0.14); --line-soft:rgba(28,36,50,0.08);
    --white:#FFFDF8;
    /* #4D772D until 2026-08-16, where it measured 4.487 on --safe-bg — under AA by
       0.013, in SIX components (.q-badge.done, .callout.safe .cap, .explain.ok .h,
       .ans.correct, .food-card.safe and the builder's AA badge). The 2026-08-07 sweep
       recorded --safe's worst surface as #FFFDF8 and missed this, because every one of
       those six needs a state to render — a finished chapter, a correct answer — and
       none of them was on screen while it ran. The badge that says "AA" was the first
       to render unconditionally, and it was the one below AA. */
    --safe:#4A7228; --safe-bg:#EAF0D4; --safe-line:#BCCB4E;
    --warn:#8A6517; --warn-bg:#F6EDD8; --warn-line:#D9AE3E;
    --danger:#B23A22; --danger-bg:#F7E2DB; --danger-line:#EBA07A;
    /* The CJK families are APPENDED, not switched in under :lang(ja). Font fallback
       is per character: Latin glyphs keep coming from Fraunces/Nunito — the brand
       wordmark included — and only the characters those fonts lack fall through.
       A :lang() override would have swapped the whole stack and taken the wordmark
       with it, for no benefit.

       They are SYSTEM fonts on purpose (docs/DECISIONS.md, "Fonts / scripts"): the
       bundled woff2 files in fonts/ are the `latin` and `latin-ext` subsets only, and
       a CJK web-font subset runs to megabytes — it would break the offline budget
       this app is built around. Every OS that matters ships a Japanese system font,
       and the stack degrades to the generic family if one is missing.
       Mincho (serif) for display, Gothic (sans) for body, mirroring the Latin pair.

       Korean follows the Japanese families rather than replacing them, and the order
       is load-bearing in a way the Japanese line alone did not reveal: a Japanese font
       contains no Hangul and a Korean font contains no kana, so per-character fallback
       walks past whichever one lacks the glyph and both locales get their own shapes
       from one stack. Myeongjo (serif) for display, Gothic (sans) for body — the same
       serif/sans split as the Latin and Japanese pairs. Apple family first, then
       Windows, then Noto, matching the ordering of the Japanese entries.

       CYRILLIC IS DELIBERATELY ABSENT FROM THIS COMMENT'S LIST AND NEEDS NO ENTRY.
       docs/DECISIONS.md files Russian under "Cyrillic font" with CJK and Thai, and
       that turned out to be wrong: `Georgia` and `system-ui` both carry full Cyrillic
       on every OS that matters, and they sit AHEAD of every script family here. So
       Russian text falls through the latin-subset webfonts into a real, intended face
       before reaching any of the appended names. Do not add a Cyrillic family "for
       symmetry" — it would only ever shadow Georgia/system-ui with a worse match.

       THAI IS APPENDED LAST AND NEEDS NO SCOPING, which is the Chinese case in
       reverse. The Thai block (U+0E00–U+0E7F) is unique to Thai — no other script
       shares a code point with it — so there is nothing for a Japanese, Korean or
       Chinese font to satisfy on Thai's behalf, and per-character fallback simply
       walks past all of them to the first face that has the glyph. Appending works
       here for exactly the reason it does not work for Chinese. */
    --font-display:'Fraunces',Georgia,'Hiragino Mincho ProN','Yu Mincho',YuMincho,'Noto Serif JP','Noto Serif CJK JP',AppleMyungjo,Batang,'Noto Serif KR','Nanum Myeongjo','Angsana New','Noto Serif Thai',serif;
    --font-body:'Nunito',system-ui,'Hiragino Kaku Gothic ProN','Hiragino Sans','Yu Gothic UI','Yu Gothic',Meiryo,'Noto Sans JP','Noto Sans CJK JP','Apple SD Gothic Neo','Malgun Gothic','Noto Sans KR','Nanum Gothic','Sukhumvit Set',Thonburi,'Leelawadee UI',Leelawadee,'Noto Sans Thai',Tahoma,sans-serif;

    /* THE TWO UI EMPHASIS WEIGHTS, AS VARIABLES SO A SCRIPT CAN RETUNE THEM.
       `--w-strong` is the chrome weight — buttons, pills, filters, uppercase
       kickers, tab labels. `--w-med` is the quieter one — inline links, source
       links, alert bodies.

       They are variables because these values were chosen against NUNITO, and
       Nunito is a rounded, open face that carries 800 lightly. A script the
       bundled webfonts do not cover never gets Nunito: `fonts/` ships the latin
       and latin-ext subsets only, so Cyrillic, Greek, CJK and Thai all fall
       through to `system-ui` — Segoe UI, Roboto, SF — which are far denser at the
       same number. 800 that reads as confident in Nunito reads as shouting in
       Segoe UI. See the html:lang(ru) override below. */
    --w-strong:800;
    --w-med:700;

    /* ---- THEME CHARACTER: the four tokens that carry gradient and motion ----
       Added 2026-08-16 because the supporter palettes were only a set of swapped
       colours, and a swapped colour is not a theme — night has stars and they had
       nothing, so they read as flatter than the free pair they were meant to reward.

       Every component below reads these instead of naming a colour, so a theme
       becomes interesting by declaring four values rather than by adding rules:

         --grad-a / --grad-b  the two ends of every accent gradient (primary
                              buttons, the mascot moment's wash, the progress
                              fills). They DEFAULT TO --moon, both of them, so a
                              theme that declares nothing renders a flat --moon
                              exactly as before.
         --aura-1 / --aura-2  two soft radial washes drifting behind the page.
         --aura-opacity       how present that is; 0 switches the layer off.
         --decor-ink          the colour of the floating glyphs (#decor).

       LIGHT AND DARK DELIBERATELY DECLARE THE NEUTRAL VALUES and are unchanged on
       screen. The owner's brief was that the OTHER themes should catch up with
       night, not that night should become something else — and the free pair is
       what every screenshot, every store listing and the contrast sweep are of.

       `var(--moon)` inside a custom property is substituted at USE time, not here,
       so `--grad-a` picks up whichever --moon the active theme defined. That is why
       a theme overriding only --moon still gets a matching button. */
    --grad-a:var(--moon); --grad-b:var(--moon);
    --aura-1:transparent; --aura-2:transparent; --aura-opacity:0;
    --decor-ink:var(--moon-deep);
    /* ---- the ink written ON the accent (owner, 2026-08-16) ----
       Every surface painted `--moon` or the accent gradient carries dark text: the
       primary button, the ticked checkbox, the read marker, the mascot moment. That
       text was `--night` — ONE COOL NAVY FOR NINE PALETTES — and the owner's report was
       that Pineapple's button looked wrong: navy on orange.

       THE OBVIOUS FIX IS WHITE AND IT IS MEASURABLY WORSE. White on Pineapple's red end
       is 2.64:1 where AA needs 4.5, and it fails on eight of the nine themes (the best
       is Turquoise at 2.23). Navy is 5.91 there and 5.28 at its worst anywhere. So the
       problem is not the darkness, it is the HUE: a cool navy sitting on a warm orange.

       This token is what lets a warm palette answer in its own ink while staying just
       as dark. It defaults to `var(--night)`, so every theme that declares nothing is
       pixel-identical to before — the same property that let --grad-a default to
       --moon. Only the three warm light themes override it, each with its own --ink.

       IT IS NOT A FREE CHOICE. tests/contrast.test.js sweeps both gradient ends against
       whatever a theme puts here, so an --on-accent that is too light fails the same
       way a bad gradient end does. Do not point it at a light colour to "match the
       page": this is text on the accent, not text on the page. */
    --on-accent:var(--night);
  }
  /* Japanese does not put spaces between words, so a browser may break a line
     anywhere — including before a closing bracket or a small kana, which reads as a
     typo to a native reader. `line-break:strict` turns on the kinsoku rules that
     forbid those positions. Scoped by :lang() because it is the one thing here that
     is genuinely about the language rather than about the glyphs. */
  html:lang(ja){ line-break:strict; }
  /* Korean is the mirror image of the Japanese rule, and it is easy to assume it needs
     nothing because Korean DOES put spaces between words. That assumption is what makes
     it look wrong: the CSS initial `word-break:normal` treats every Hangul syllable
     block as a break opportunity, so a browser will split a word down the middle even
     when there is a space three characters later. `keep-all` restricts breaks to the
     spaces, which is the Korean typographic convention.

     `overflow-wrap:anywhere` is not decoration — `keep-all` on its own turns a long
     unbroken run (a URL, or a compound noun in a 320 px card) into horizontal overflow
     rather than an ugly break. It applies only when a line cannot fit at all, so the
     ordinary case still breaks on spaces. */
  html:lang(ko){ word-break:keep-all; overflow-wrap:anywhere; }
  /* Chinese is the ONE locale where "appended, never switched" cannot hold, and the
     reason is glyphs rather than layout. Han unification gives 直, 骨, 门, today's 今
     and hundreds more ONE code point with different regional shapes, so a Japanese
     font renders Chinese text in Japanese forms: legible, and wrong the way a Cyrillic
     `а` drawn as a Latin `a` is wrong. The stack above reaches Hiragino/Yu Mincho long
     before any Chinese family, so appending would change nothing — the Japanese face
     already has the character and fallback stops at the first font that does.

     So this scopes the two variables, and the scoping is what keeps the rule's INTENT:
     Fraunces/Georgia and Nunito/system-ui stay at the head of both stacks, exactly
     where they are in :root. The brand wordmark, the Latin numerals in the weight
     chart and every ASCII string still come from the bundled webfonts; only the
     characters those lack fall through, and now they fall into a Chinese face instead
     of a Japanese one. This is a substitution of the TAIL, not a switch of the stack.

     Still system fonts, for the reason in docs/DECISIONS.md "Fonts / scripts": a
     Simplified subset is megabytes against a fully precached shell. Song (serif) for
     display, Hei (sans) for body — the same serif/sans split as Latin, Japanese and
     Korean. Apple first, then Windows, then Noto/Source Han.

     No line-break rule here, deliberately. `line-break:strict` exists for Japanese
     small kana, which Chinese does not have, and browsers already apply Chinese
     line-breaking to :lang(zh-*) text. Chinese breaks between Han characters by
     design, so it needs neither the Japanese rule nor the Korean one. */
  /* Thai puts NO spaces between words — like Japanese — but unlike Japanese it cannot
     break between any two characters either: a break is only legal at a word boundary,
     and finding one needs a dictionary. Browsers ship that dictionary and apply it to
     :lang(th) text, so the correct CSS here is to LEAVE the defaults alone; forcing
     `word-break:break-all` would split words mid-syllable and `keep-all` would leave
     Thai with no break opportunity at all.

     `overflow-wrap:anywhere` is the safety net, and it is the same reasoning as the
     Korean rule rather than decoration: when the dictionary finds no boundary inside a
     long run — a compound, a URL, a transliterated name — there is no space to fall
     back to, so without this the run overflows sideways out of a 320 px card instead of
     breaking. It applies only when a line cannot fit at all.

     LINE-HEIGHT IS DELIBERATELY NOT CHANGED, and that is worth stating because Thai is
     the one script here that stacks marks two levels above the base letter (สั้น, ปั๊ก)
     and below it. Clipping starts at roughly line-height 1.4; `body` is already 1.6, so
     there is room. The browser smoke checks this on a real device — if marks do clip,
     the fix is `html:lang(th) body{line-height:1.75}`, scoped, not a global change. */
  html:lang(th){ overflow-wrap:anywhere; }
  /* Russian is the first locale to retune the emphasis weights, and the reason is
     the one stated at `--w-strong`: it is NOT that Cyrillic is intrinsically heavy.
     It is that Russian text never reaches Nunito at all. The comment at the top of
     this file explains why that is deliberate and correct — Georgia and system-ui
     both carry full Cyrillic and sit ahead of every appended script family, so
     Russian falls into a real, intended face rather than a bad match.

     The consequence was missed: the 83 rules that say 800 and the 23 that say 700
     were all tuned by eye against Nunito. Handed to Segoe UI or Roboto the same
     numbers render visibly heavier, and because it is EVERY button and link at
     once there is nothing on screen to compare against — it just reads as a
     shouty app rather than as a bug.

     Two steps down each, not one: 800 to 600 and 700 to 500. One step (700/600)
     still leaves Segoe UI Semibold heavier than Nunito ExtraBold looks, because
     the two families disagree about where their own weight axis sits, not by a
     fixed offset.

     The values live here rather than in a `html:lang(ru) button, html:lang(ru) a`
     block because that block would have to list about sixty selectors and would
     lose to every later rule that sets a weight inline. Any future locale that
     needs this gets one line. */
  html:lang(ru){ --w-strong:600; --w-med:500; }
  html:lang(zh-Hans){
    --font-display:'Fraunces',Georgia,'Songti SC','Noto Serif SC','Noto Serif CJK SC','Source Han Serif SC',SimSun,STSong,serif;
    --font-body:'Nunito',system-ui,'PingFang SC','Hiragino Sans GB','Microsoft YaHei','Noto Sans SC','Noto Sans CJK SC','Source Han Sans SC',sans-serif;
  }
  body.dark{
    --cream:#141C2A; --cream-100:#1B2434; --white:#1E2838;
    --ink:#F1EAD9; --muted:#98A2B4;
    --line:rgba(255,255,255,0.14); --line-soft:rgba(255,255,255,0.07);
    --gold-text:#E4C674; --moon-deep:#E0BE5E; --accent-text:#E0BE5E;
    --safe:#8fbf5a; --safe-bg:#1d2b1f; --safe-line:#3d5730;
    --warn:#e2ba52; --warn-bg:#2a2517; --warn-line:#5a4b20;
    --danger:#ec8e6e; --danger-bg:#31201b; --danger-line:#6b3c2c;
    /* Night keeps its stars and nothing else changes — see the note in :root. */
    --decor-ink:var(--moon-glow);
  }
  *{box-sizing:border-box;margin:0;padding:0;}
  html{scroll-behavior:smooth;color-scheme:light dark;}
  /* ---- Supporter themes (ROADMAP 5.2) ----
     Each is built ON TOP of light or dark — `body.dark` is still applied for the
     dark-based ones — so they only override the variables they actually change and
     the ~30 existing `body.dark …` rules keep working untouched. Purely cosmetic:
     no care, safety, learning or family function depends on the theme.
     Contrast checked against --ink on --cream and --white at AA for body text. */
  /* Every block below now also declares the four character tokens from :root. The
     rule for --grad-a / --grad-b is that BOTH ends must clear 4.5:1 against --night,
     because that pair is the primary button and the mascot moment's wash;
     tests/contrast.test.js §gradients proves it and fails on a new theme that skips
     it. Measured values are in the comment on each theme. */
  /* Sunset — REBUILT 2026-08-16 on the owner's call, and the reason is that it was not
     a sunset. It was a dark PLUM page under amber-into-rose, which reads as mauve; a
     real sunset is a blue sky with a gold sun in it. Suncheek was wearing exactly that
     pairing — a cool dark page under warm gold — so the owner moved those accents here
     and Suncheek was rebuilt as the light theme its mutation actually describes.

     THE ACCENTS ARE SUNCHEEK'S VERBATIM. The PAGE is not, and could not be: Suncheek's
     #16242A sits 2.1 dE from Turquoise's #13262B, i.e. the same swatch, which was
     defensible while the two shared a genetic base and is not once one of them is a
     time of day. So the page moves to a saturated dusk blue — 14.4 dE from `dark`'s
     desaturated navy and 20.4 from Turquoise, both wider than any same-kind pair the
     app already ships (the closest shipped is dark/turquoise at 9.4).

     Gold holding on against the blue going out. 7.74 and 10.59 on --night. */
  body.theme-sunset{
    --cream:#16294A; --cream-100:#1E3352; --white:#22395C;
    --line:#33507A; --line-soft:#284166;
    --ink:#F3E8DC; --muted:#AFB4C8;
    --moon-glow:#FBD9A8; --moon:#F0A855; --moon-deep:#DE8A34; --accent-text:#DE8A34;
    --gold-text:#F5B96B;
    --grad-a:#F0A855; --grad-b:#F5D08A;
    --aura-1:rgba(240,168,85,.24); --aura-2:rgba(245,208,138,.20); --aura-opacity:1;
    --decor-ink:#FBD9A8;
  }
  body.theme-tropical{
    --cream:#F1F7EA; --cream-100:#E4EFD8; --white:#FBFDF7;
    --line:#C6D9B4; --line-soft:#DCE9CE;
    --ink:#1F3324; --muted:#566D51;
    --moon-glow:#DCEFC0; --moon:#A8D27B; --moon-deep:#6FA544; --accent-text:#689B40;
    --gold-text:#3F6B29;
    /* Leaf into new growth. 9.03 and 10.83 on --night. */
    --grad-a:#A8D27B; --grad-b:#CFE07A;
    --aura-1:rgba(111,165,68,.20); --aura-2:rgba(207,224,122,.24); --aura-opacity:1;
    --decor-ink:#6FA544;
  }
  /* ---- Mutation themes (ROADMAP 5.2) ----
     Named after the green-cheeked mutations in data/mutations.js, and coloured from
     what that mutation actually looks like — so the accent is the bird's feature, not
     a decoration. Each keeps the same AA floor as the pair above: --ink on --cream and
     on --white, and --muted on --cream, for body text. */

  /* Turquoise: blue-green body, blue tail, "cooler tone than the normal". The cool
     dark, where sunset is the warm one.

     THE GRADIENT IS THE MUTATION, not a decoration: the catalog entry says
     "blue-green body, BLUE tail", and until 2026-08-16 the theme was one teal and
     nothing else, so the feature that names the bird was the one thing missing. The
     accent now runs body-teal into tail-blue. 7.86 and 6.99 on --night. */
  body.theme-turquoise{
    --cream:#13262B; --cream-100:#1A323A; --white:#1E3841;
    --line:#2F5560; --line-soft:#254550;
    --ink:#E6F2F1; --muted:#9FC0C2;
    --moon-glow:#BFE6E1; --moon:#6FC7BE; --moon-deep:#4FB3A8; --accent-text:#4FB3A8;
    --gold-text:#8FD8CF;
    --grad-a:#6FC7BE; --grad-b:#6FB6DE;
    --aura-1:rgba(111,199,190,.24); --aura-2:rgba(111,182,222,.22); --aura-opacity:1;
    --decor-ink:#BFE6E1;
  }
  /* Suncheek — REBUILT LIGHT 2026-08-16 (owner). The catalog entry is
     "dilute with pineapple: a warm yellow-PEACH bird with tan flights and VERY LITTLE
     CONTRAST", which is a pale bird; rendering it as a near-black page was the theme
     describing the opposite of its own mutation. Its cool-dark-plus-gold palette went
     to Sunset, where that pairing means something.

     THE HARD PART IS THAT IT IS GENETICALLY CINNAMON + YELLOW-SIDED — the two light
     themes it must not look like — so hue alone cannot separate it. The catalog does
     though: Pineapple is a yellow-to-RED journey, Cinnamon is earthy brown, and
     Suncheek is one soft golden colour that barely travels. So the accent takes the
     GOLD side of that space rather than the red, and the gradient is a single colour
     moving, which is the "very little contrast" the entry describes.

     The accent was swept rather than picked: #E39A3B sits 22.9 dE from the nearest
     shipped light accent, where the closest pair already shipping (cinnamon/pineapple)
     is 17.4. The page is 5.7 dE from Cinnamon's, which is wide for this app — light
     and cinnamon ship at 2.2. Pages barely separate warm light themes; accents do.

     6.64 and 9.87 on --night. */
  /* YELLOWER, 2026-08-16 (owner). It was built warm-ORANGE — accent #E39A3B — and the
     mutation it is named after is a yellow cheek on a peachy bird, so the accent was the
     part that was wrong. It is #F0BB35 now: the same warmth, a clear step round the wheel
     toward yellow, and 58 RGB units from the free gold it might have collided with.

     THE PAGE ONLY MOVES A LITTLE, and that is deliberate rather than timid. Pineapple is
     the yellow-PAGE theme; walking this one to #FFF1D6 put the two pages 8.4 apart, which
     is one theme with two names. The block above already says why that is the right way
     round — "pages barely separate warm light themes; accents do" — so the page keeps its
     peach and the accent carries the change.

     8.80 and 11.42 on --night, 7.74 and 10.04 on its own --on-accent. */
  body.theme-suncheek{
    --cream:#FFEED6; --cream-100:#FBDFB8; --white:#FFFAF2;
    --line:#E8C596; --line-soft:#F5DFBA;
    --ink:#3B2A1B; --muted:#75603F;
    --moon-glow:#FCE7C0; --moon:#F0BB35; --moon-deep:#AC7712; --accent-text:#AC7712;
    --gold-text:#8C5C10;
    --grad-a:#F0BB35; --grad-b:#FBD98A;
    --aura-1:rgba(240,187,53,.22); --aura-2:rgba(251,217,138,.24); --aura-opacity:1;
    --decor-ink:#AC7712;
    /* Its own ink on the accent rather than the app navy — see --on-accent in :root. */
    --on-accent:#3B2A1B;
  }
  /* Cinnamon: warm-brown flights and tail instead of gray/black, lighter overall. */
  body.theme-cinnamon{
    --cream:#F8F0E6; --cream-100:#F0E3D2; --white:#FDF9F3;
    --line:#DBC6AC; --line-soft:#EADCC9;
    --ink:#3A2A1C; --muted:#735F4D;
    --moon-glow:#F3DCC0; --moon:#D7A46B; --moon-deep:#B67C42; --accent-text:#B67C42;
    --gold-text:#7A4E1E;
    /* The brown flight feather against the lighter body — which IS the mutation, and
       is why the gradient runs deep-to-pale rather than between two hues.
       5.87 and 9.18 on --night. */
    --grad-a:#CB9455; --grad-b:#E9C08A;
    --aura-1:rgba(182,124,66,.16); --aura-2:rgba(233,192,138,.24); --aura-opacity:1;
    --decor-ink:#B67C42;
    /* Its own ink on the accent, not the app navy — see --on-accent in :root. */
    --on-accent:#3A2A1C;
  }
  /* Pineapple: bright yellow body with an extensive RED breast. The red is what keeps
     it from collapsing into the default light theme, which is already cream-and-gold —
     so the page is yellow and every accent is the breast patch. */
  body.theme-pineapple{
    --cream:#FCF6DC; --cream-100:#F7EDC0; --white:#FFFDF2;
    --line:#E0CE94; --line-soft:#EFE4BE;
    --ink:#331F14; --muted:#7C5A44;
    --moon-glow:#F6D9C6; --moon:#E08A5E; --moon-deep:#B85A32; --accent-text:#B85A32;
    --gold-text:#8E3F1D;
    /* The bird in one sweep: yellow body into red breast. This is the theme the
       gradient tokens were worth adding for — pineapple is DEFINED by carrying two
       loud colours at once, and a single flat accent could only ever pick one of
       them. 9.09 and 5.91 on --night. */
    --grad-a:#E8C24E; --grad-b:#E08A5E;
    --aura-1:rgba(232,194,78,.26); --aura-2:rgba(184,90,50,.16); --aura-opacity:1;
    --decor-ink:#B85A32;
    /* Its own ink on the accent, not the app navy — see --on-accent in :root. */
    --on-accent:#331F14;
  }
  /* Opaline (owner, 2026-08-16) — the intense green off a standard conure's back, with
     yellow. "Opaline" is the mutation name for what the catalog files as `yellowsided`:
     bright yellow flanks over a green body. That entry was one of the four deliberately
     NOT made themes, on the grounds that it lands too close to Tropical — and it would
     have, as another light green. As a DARK theme it does not: a deep forest page 20.2
     dE from `dark` and 12.0 from Turquoise, both wider than the closest shipped dark
     pair. It is also the only strong green in the set, where Tropical is a pale sage.

     --moon is the owner's #40AB41 unchanged; it clears --night at 5.28 on its own, so
     there was no reason to brighten the colour they actually asked for. The gradient is
     the bird in one sweep, green body into yellow flank.

     --accent-text does NOT equal --moon-deep here, which breaks the pattern the other
     dark themes follow, and the page is why: on a GREEN page a green accent at
     moon-deep's darkness is the same hue as its own background, so the bird's name at
     38 px would technically pass 3:1 and still read as murk. Same split, and the same
     reason, as --accent-text versus --moon-deep in :root. 5.28 and 10.27 on --night. */
  body.theme-opaline{
    --cream:#10251A; --cream-100:#183324; --white:#1C3A29;
    --line:#2E573C; --line-soft:#244630;
    --ink:#E9F3E4; --muted:#A3BCA6;
    --moon-glow:#CDE9C4; --moon:#40AB41; --moon-deep:#2F9430; --accent-text:#6CC96D;
    --gold-text:#A8DE8A;
    --grad-a:#40AB41; --grad-b:#E3D45C;
    --aura-1:rgba(64,171,65,.24); --aura-2:rgba(227,212,92,.20); --aura-opacity:1;
    --decor-ink:#CDE9C4;
  }
  /* ---- Custom (ROADMAP 5.2c, 2026-08-16) ----
     The palette a supporter builds themselves. The values here are only the
     FALLBACK — applyCustomTheme() in app/theme.js writes the real ones as inline
     custom properties on <body>, which win over this block, and the fallback is what
     renders in the gap before that runs (or if the saved config is unreadable).
     It is a real, distinct palette rather than a copy of light, because a person who
     has chosen `custom` and lost their settings should land somewhere deliberate. */
  body.theme-custom{
    --cream:#F4F1FA; --cream-100:#E9E4F4; --white:#FDFCFF;
    --line:#CFC6E4; --line-soft:#E2DCF0;
    --ink:#241E33; --muted:#615A75;
    --moon-glow:#DED3F4; --moon:#A48BD8; --moon-deep:#7B5FB8; --accent-text:#6D51AB;
    --gold-text:#5B4194;
    --grad-a:#A48BD8; --grad-b:#C9A8E8;
    --aura-1:rgba(164,139,216,.22); --aura-2:rgba(201,168,232,.20); --aura-opacity:1;
    --decor-ink:#7B5FB8;
  }
  body{font-family:var(--font-body);color:var(--ink);background:var(--cream);line-height:1.6;-webkit-font-smoothing:antialiased;transition:background .3s,color .3s;}
  a{color:var(--gold-text);font-weight:var(--w-med);text-decoration:none;}
  a:hover{text-decoration:none;}
  body.dark .stat{background:rgba(255,255,255,0.05);border-color:rgba(255,255,255,0.07);}
  body.dark .q-bar,body.dark .fc-bar,body.dark .bar{background:rgba(255,255,255,0.12);}
  body.dark .btn-ghost:hover,body.dark .home-btn:hover,body.dark .back-btn:hover{background:rgba(255,255,255,0.06);}
  .wrap{max-width:760px;margin:0 auto;padding:0 20px;}
  main.wrap,header.top,footer{position:relative;z-index:1;}
  button{font-family:var(--font-body);cursor:pointer;border:none;color:inherit;}
  /* background decor (stars at night / sun sparkles by day / its own set per theme) */
  #decor{position:fixed;inset:0;overflow:hidden;pointer-events:none;z-index:0;}
  /* THE AURA: two soft washes drifting behind everything, off unless a theme asks.
     It lives on #decor rather than on `body` because body's `background` is already
     the flat page colour that the theme wipe and the meta theme-color must agree
     with — painting a gradient there would make the wipe land on a different colour
     than the page it reveals. This is a separate fixed layer at the same z-index 0,
     so it composites under `main`, `header` and `footer` (all z-index 1) exactly as
     the glyphs already did.

     `inset:-25%` is not slack: the layer TRANSLATES, and a 0-inset layer would drag
     its own transparent edge across the corner of the viewport. */
  /* TWO LAYERS, NOT ONE, and that is the whole reason this reads as moving at all.
     Until 2026-08-16 both washes lived in a single ::before running one 26 s keyframe
     that translated 2.6 % and scaled 1.07 — the owner's report was simply "the gradient
     is static", and they were right: a rigid pair of blobs sliding a fortieth of the
     screen over half a minute is not perceptible motion. Moving them TOGETHER was the
     mistake rather than the amounts. Two washes on independent, mutually prime-ish
     durations drift in and out of phase, so the light never repeats the same shape and
     a much smaller displacement reads as alive.

     `inset:-30%` is not slack: these layers TRANSLATE and SCALE DOWN, and an inset that
     only covered the translation would drag a transparent edge across the corner at the
     bottom of the scale curve. */
  /* TWICE THE TRAVEL, THE SAME CLOCK (owner, 2026-08-16: "move more, not necessarily
     more often"). The 5.2e pass fixed the phase — two layers instead of one rigid pair —
     but kept the small displacements the single-layer version had, and 5.5 % of a layer
     is 3.4 % of the viewport: a thing you can only see by staring at one corner. The
     amplitudes are roughly doubled and the durations are untouched, so the light travels
     visibly without ever becoming a thing that flickers at the edge of your attention.

     `inset:-45%` IS THE COST OF THAT AND IT IS NOT OPTIONAL. These layers translate AND
     scale down, so the inset has to cover the worst case of both at once: the layer is
     100 % + 2*inset wide, a translate of t % moves it 1.9t % of the viewport, and at
     scale s it only overhangs the viewport by (95s - 50) %. auraB's worst moment is
     t = 15.6 % diagonal at s = 0.88, which needs 29.6 % of overhang and has 33.6 %.
     Shrink this inset and a transparent edge sweeps across the corner of the screen. */
  #decor::before,#decor::after{content:'';position:absolute;inset:-45%;
    opacity:var(--aura-opacity);will-change:transform;}
  #decor::before{background:radial-gradient(38% 33% at 22% 16%, var(--aura-1), transparent 68%);
    animation:auraA 19s ease-in-out infinite;}
  #decor::after{background:radial-gradient(44% 37% at 80% 78%, var(--aura-2), transparent 70%);
    animation:auraB 24s ease-in-out infinite;}
  @keyframes auraA{
    0%,100%{transform:translate3d(0,0,0) scale(1);}
    34%{transform:translate3d(11%,-9%,0) scale(1.30);}
    67%{transform:translate3d(-8%,7%,0) scale(0.90);}
  }
  @keyframes auraB{
    0%,100%{transform:translate3d(0,0,0) scale(1.10);}
    50%{transform:translate3d(-12%,10%,0) scale(0.88);}
  }
  /* The glyphs breathe. `--o` carries the intended peak opacity as a CUSTOM PROPERTY
     rather than as an inline `opacity`, because an inline opacity and a keyframe that
     animates opacity are the same declaration and the keyframe wins outright — the
     per-glyph variation renderDecor() spent its whole existence producing would have
     been flattened to one value the moment this animation was added. */
  #decor .d{position:absolute;line-height:1;user-select:none;color:var(--decor-ink);
    opacity:var(--o,.3);animation:decorTwinkle var(--dur,9s) ease-in-out infinite;
    animation-delay:var(--delay,0s);}
  @keyframes decorTwinkle{
    0%,100%{opacity:calc(var(--o,.3) * .45);transform:scale(.93);}
    50%{opacity:var(--o,.3);transform:scale(1.07);}
  }
  /* ---- stillness, from two independent sources ----
     `body.still` is the reader's own switch (applyDecorMotion() in app/theme.js). The
     media query is the
     operating system's. They are written as two blocks rather than one selector list
     because they must not be able to cancel each other: the OS setting is an
     accessibility declaration and the in-app toggle may only ever ADD stillness to it,
     never take it away. Turning motion "on" in the app simply leaves no rule behind,
     so the media query still applies with nothing to lose to.

     `#decor .d` needs its opacity restored in both, and that is not tidy-up. The
     keyframe animates opacity; killing the animation without this leaves every glyph
     at its authored `--o`, which is what we want — but the twinkle's 45 % trough is
     what the values were tuned against, so stating it keeps the still version at the
     same weight as the moving one rather than 2.2x brighter. */
  body.still #decor::before,body.still #decor::after,
  body.still #decor .d{animation:none;}
  body.still #decor .d{opacity:var(--o,.3);}
  @media(prefers-reduced-motion:reduce){
    #decor::before,#decor::after,#decor .d{animation:none;}
    #decor .d{opacity:var(--o,.3);}
  }
  /* .btn-primary's own two stillness rules are NOT here, they are next to .btn-primary.
     Not tidiness: `tests/contrast.test.js` locates that rule with
     `css.indexOf('.btn-primary{')`, and `body.still .btn-primary{` contains that string.
     Declared up here it would be found first and the test would read the button's body
     as `animation:none` — reporting that the accent gradient had been deleted. */
  .theme-btn{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;background:var(--white);border:1px solid var(--line);border-radius:9px;color:var(--gold-text);}
  .theme-btn:hover{border-color:var(--moon-deep);}
  .theme-btn svg{width:19px;height:19px;}

  /* THE HEADER IS THE PAGE COLOUR, not a literal. It carried
     `rgba(251,246,236,0.92)` with a `body.dark` override at `rgba(20,28,42,0.92)`
     until 2026-08-16 — the two BASE creams, hard-coded — so every supporter palette
     got one of exactly two headers. Tropical's page is #F1F7EA under a #FBF6EC bar,
     Sunset's #241A22 page under a #141C2A one, and `custom` never matched at all.

     The seam was visible outside the page too: `applyTheme()` sets the meta
     theme-color from `def.bg`, which IS the theme's `--cream`, so on a phone the OS
     status bar was already the right colour and the header 4 px below it was not.

     Two declarations, and the first is not redundant: `color-mix()` is what the
     mascot moment already uses (four rules, v0.61), but a browser that cannot parse
     it drops that line and keeps the opaque page colour — right hue, no blur —
     instead of falling all the way back to a transparent bar with the page scrolling
     through the text. Same shape as `.btn-primary`'s background/background-image pair.

     92 % of `--cream` reproduces both old literals exactly, so light and dark are
     unchanged on screen (docs/HANDOFF.md v0.62 §0). */
  header.top{position:sticky;top:0;z-index:20;
    background:var(--cream);
    background:color-mix(in srgb, var(--cream) 92%, transparent);
    backdrop-filter:blur(8px);border-bottom:1px solid var(--line-soft);}
  .top-in{max-width:760px;margin:0 auto;padding:14px 20px;display:flex;align-items:center;gap:10px;}
  /* `flex-shrink:0` because it is now a direct flex item of `.top-in` rather than a
     wrapper's child: the wordmark is the identity of the page and must never squash.
     The `.brand-sub` that used to sit under it is gone — see renderChrome(). */
  .brand{flex-shrink:0;display:flex;align-items:center;font-family:var(--font-display);font-weight:600;font-size:26px;letter-spacing:-0.02em;color:var(--ink);line-height:1;text-decoration:none;}
  .brand .dot{color:var(--moon-deep);}
  /* THE DRAWN LOGO and its fallback, on the mascot's mechanism exactly: the <img>
     carries `data-error="removeSelf"`, so if /media/ is unreachable — which offline
     it always is, nothing under /media/ is precached — it deletes itself, `:has()`
     stops matching, and the `kiri.` type underneath is what shows. Hiding the type
     rather than covering it is what makes that work; a background image would have
     no error to react to and would leave the header empty.
     38px tall against the header's 26px type: the bird is only ~29% of the pill's
     width, so at type height it would be a smudge rather than a bird. */
  .brand-logo{display:block;height:38px;width:auto;}
  .brand:has(.brand-logo) .brand-type,
  .foot-kiri:has(.brand-logo) .brand-type{display:none;}
  .foot-kiri .brand-logo{height:44px;}
  /* BIGGER ON A PHONE, which is the opposite of the usual instinct and is right here
     (owner, 2026-08-15). On a wide screen the footer mark shares its row with three
     link columns and 44px holds its own; stacked on a phone it is alone at the top of
     a long single column, where the same 44px reads as a leftover from the layout
     above rather than as the sign-off it is. */
  @media(max-width:720px){ .foot-kiri .brand-logo{height:64px;} }
  .top-spacer{flex:1;}
  .home-btn{background:transparent;color:var(--gold-text);font-weight:var(--w-strong);font-size:15px;padding:10px 14px;border-radius:9px;}
  .home-btn:hover{background:var(--cream-100);}
  /* Language picker — a custom listbox, not a <select>, so each locale can carry
     its flag (see app/chrome.js and data/flags.js). The chevron is our own SVG in
     the flex flow rather than the browser's arrow: a native <select> paints its
     arrow hard against the right border and ignores padding-right, which is what
     made the old control look lopsided. Here the same 10px padding applies on both
     sides and `gap` does the rest, so it is symmetric by construction. */
  /* Theme dropdown (supporters only; everyone else keeps the .theme-btn toggle).
     Mirrors the language picker's listbox so the two controls beside each other in
     the header behave identically. The trigger is the same 38 px square as the old
     button plus a chevron, so becoming a supporter changes the control's affordance
     without moving it. */
  .theme-picker{position:relative;}
  .theme-trigger{display:flex;align-items:center;gap:3px;height:38px;padding:0 7px;background:var(--white);border:1px solid var(--line);border-radius:9px;color:var(--gold-text);cursor:pointer;}
  .theme-trigger:hover{border-color:var(--moon-deep);}
  .theme-trigger:focus-visible{outline:2px solid var(--moon-deep);outline-offset:2px;}
  .theme-trigger svg:first-child{width:19px;height:19px;flex-shrink:0;}
  .theme-trigger.with-name{gap:7px;padding:0 9px;font-family:inherit;font-size:13px;font-weight:var(--w-strong);}
  .theme-trigger-name{max-width:124px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ink);text-transform:uppercase;letter-spacing:.04em;}
  .theme-trigger[aria-expanded="true"] .lang-chev{transform:rotate(180deg);}
  /* `max-height` here is only the no-JS floor; fitDropdown() in app/chrome.js
     replaces it with the real distance to the bottom of the viewport on open.
     `overscroll-behavior:contain` keeps a flick at the end of the list from
     scrolling the page underneath the open menu. */
  .theme-menu{position:absolute;top:calc(100% + 6px);right:0;z-index:40;min-width:172px;max-height:70vh;max-height:70dvh;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-width:thin;margin:0;padding:5px;list-style:none;background:var(--white);border:1px solid var(--line);border-radius:11px;box-shadow:0 10px 28px rgba(28,36,50,0.16);}
  .theme-menu[hidden]{display:none;}
  /* Opened upwards when there is not enough room below the trigger — see the flip
     in fitDropdown(). The 6px must stay equal to the `top` offset above, because
     that function measures the gap from the anchor rather than from the menu. */
  .theme-menu.up{top:auto;bottom:calc(100% + 6px);}
  body.dark .theme-menu{box-shadow:0 10px 28px rgba(0,0,0,0.45);}
  .theme-opt{display:flex;align-items:center;gap:9px;min-height:38px;padding:0 10px;border-radius:7px;font-weight:var(--w-strong);font-size:13px;line-height:1;color:var(--ink);white-space:nowrap;cursor:pointer;}
  .theme-opt:hover,.theme-opt.act{background:var(--cream-100);}
  .theme-opt.sel{color:var(--gold-text);}
  /* Same dot the account picker uses, so a theme looks the same in both places. */
  .theme-dot{width:16px;height:16px;border-radius:50%;flex-shrink:0;border:1px solid rgba(0,0,0,0.18);}
  body.dark .theme-dot{border-color:rgba(255,255,255,0.24);}

  .lang-picker{position:relative;}
  .lang-trigger{display:flex;align-items:center;gap:8px;height:38px;padding:0 10px;background:var(--white);border:1px solid var(--line);border-radius:9px;font-family:inherit;font-weight:var(--w-strong);font-size:13px;line-height:1;color:var(--ink);cursor:pointer;}
  .lang-trigger:hover{border-color:var(--moon-deep);}
  .lang-trigger:focus-visible{outline:2px solid var(--moon-deep);outline-offset:2px;}
  .lang-chev{width:14px;height:14px;flex-shrink:0;color:var(--muted);transition:transform .15s;}
  .lang-trigger[aria-expanded="true"] .lang-chev{transform:rotate(180deg);}
  /* 3:2, matching every flag's viewBox. The hairline keeps the white and yellow
     bands from bleeding into the cream card behind them. */
  .lang-flag{width:20px;height:13.33px;flex-shrink:0;display:block;border-radius:2px;box-shadow:0 0 0 1px rgba(28,36,50,0.14);}
  body.dark .lang-flag{box-shadow:0 0 0 1px rgba(255,255,255,0.22);}
  /* Same as .theme-menu, and this is the one that forced the change: 18 locales is
     roughly 700px of list, which ran straight off the bottom of every phone. */
  .lang-menu{position:absolute;top:calc(100% + 6px);right:0;z-index:40;min-width:100%;max-height:70vh;max-height:70dvh;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-width:thin;margin:0;padding:5px;list-style:none;background:var(--white);border:1px solid var(--line);border-radius:11px;box-shadow:0 10px 28px rgba(28,36,50,0.16);}
  .lang-menu[hidden]{display:none;}
  .lang-menu.up{top:auto;bottom:calc(100% + 6px);}
  body.dark .lang-menu{box-shadow:0 10px 28px rgba(0,0,0,0.45);}
  .lang-opt{display:flex;align-items:center;gap:9px;min-height:38px;padding:0 10px;border-radius:7px;font-weight:var(--w-strong);font-size:13px;line-height:1;color:var(--ink);white-space:nowrap;cursor:pointer;}
  .lang-opt:hover,.lang-opt.act{background:var(--cream-100);}
  .lang-opt.sel{color:var(--gold-text);}
  /* The trigger names the selected language in caps, matching the uppercase row
     labels it sits beside in the account panel. Casing is language-sensitive, which
     is why the span carries `lang=` — see langPickerHTML() in app/chrome.js. */
  .lang-name{text-transform:uppercase;letter-spacing:.04em;}
  /* `.top-in` is one non-wrapping flex row and it is already tight at 375px; caps add
     roughly another 13px to the longest names. Truncate the trigger rather than let
     "PORTUGUÊS (BRASIL)" push the wordmark — the flag beside it still identifies the
     language and the open menu still spells it out. The account panel is a fixed
     344px, so its own more specific max-width below keeps winning inside it. */
  @media(max-width:430px){
    .lang-name{max-width:104px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
    /* The cap above truncates the TEXT, and on its own it did not stop the row
       overflowing: 104px of caps plus flag, chevron, gaps and padding is a 176px
       trigger. A flex item defaults to `min-width:auto`, so the picker went on
       demanding its full min-content and pushed the page sideways in pt-BR and id-ID.
       Measured in Chrome, signed out, 2026-08-07.

       Two things were being squashed silently to keep that overflow down to 5px, and
       both become visible as soon as the picker is allowed to shrink instead: the
       wordmark's old "CARE GUIDE" sub-line wrapped to two lines (three in pt-BR), and
       the ⏱ button was down to 21px from its natural 38 — a clipped glyph, not a
       smaller one. The sub-line has since been removed outright, which is what bought
       the row its room back; these three rules keep the rest honest.

       The icon controls and the wordmark are pinned because none of them degrades
       gracefully. The language trigger is the only item here that does — it has an
       ellipsis — so it is the one that absorbs whatever is left over. */
    .top-in>.theme-btn,.top-in>.theme-picker{flex-shrink:0;}
    .top-in>.lang-picker{min-width:0;}
    /* And the trigger has to be told to follow its picker. `width:auto` on a BUTTON is
       shrink-to-fit, not stretch — so the picker obediently shrank to 117px while the
       button inside it stayed at its 176px max-content and hung 59px out of its own
       parent. THAT was what widened the document; the header row itself had already
       fitted, which is why measuring the row kept exonerating it. A block-level flex
       container would have stretched on its own. */
    .top-in>.lang-picker>.lang-trigger{max-width:100%;}
  }
  /* Cyrillic in this stack has a heavier fitted weight than Latin, so "Русский" at
     the shared 800 reads as bolder than every row around it. Set explicitly rather
     than lowering the shared weight: only this script needs it.

     A LITERAL VALUE, NOT `var(--w-strong)`, and that is the whole point of the rule:
     the picker shows every language in its OWN name in EVERY app locale, so the
     Русский row is Cyrillic even while the app is in English — where `--w-strong` is
     correctly still 800. The `html:lang(ru)` override below cannot reach this row.
     The two land on the same 600 so the row does not change weight when a reader
     switches into Russian. */
  .lang-picker [lang="ru"]{font-weight:600;}

  /* Signed-in account dropdown. It replaces the separate theme/language controls
     in the header, then reuses those controls inside the panel. */
  .account-picker{position:relative;}
  .account-trigger{display:flex;align-items:center;gap:7px;height:38px;padding:0 10px;
    background:var(--white);border:1px solid var(--line);border-radius:11px;
    color:var(--ink);font-size:13.5px;font-weight:var(--w-strong);line-height:1;}
  .account-trigger:hover{border-color:var(--moon-deep);background:var(--cream-100);}
  .account-trigger:focus-visible{outline:2px solid var(--moon-deep);outline-offset:2px;}
  .account-trigger .account-icon{width:20px;height:20px;color:var(--gold-text);}
  .account-trigger[aria-expanded="true"] .lang-chev{transform:rotate(180deg);}
  .account-menu{position:absolute;top:calc(100% + 8px);right:0;z-index:45;
    width:min(344px,calc(100vw - 24px));padding:14px;background:var(--white);
    border:1px solid var(--line);border-radius:16px;box-shadow:0 16px 42px rgba(28,36,50,.2);}
  .account-menu[hidden]{display:none;}
  body.dark .account-menu{box-shadow:0 18px 46px rgba(0,0,0,.5);}
  .account-menu-links{display:flex;flex-direction:column;padding:0 0 7px;border-bottom:1px solid var(--line-soft);}
  .account-menu-links a{display:flex;align-items:center;justify-content:space-between;min-height:44px;
    padding:8px 10px;border-radius:9px;color:var(--ink);font-size:14px;font-weight:var(--w-strong);text-decoration:none;}
  .account-menu-links a:hover{background:var(--cream-100);color:var(--gold-text);}
  .account-menu-links a span{font-size:20px;line-height:1;color:var(--muted);}
  .account-preferences{display:flex;flex-direction:column;gap:8px;padding:12px 0;}
  .account-pref-row{display:flex;align-items:center;justify-content:space-between;gap:12px;
    min-height:42px;color:var(--muted);font-size:12px;font-weight:var(--w-strong);letter-spacing:.05em;text-transform:uppercase;}
  .account-pref-row>.theme-picker,.account-pref-row>.lang-picker{flex-shrink:0;}
  /* Widened with the uppercase change: caps are wider than the mixed case this was
     measured against, and "BAHASA INDONESIA" started truncating at the old 105px. */
  .account-pref-row .lang-name{max-width:118px;overflow:hidden;text-overflow:ellipsis;}
  .account-signout{margin-top:2px;}

  main.wrap{padding-top:25px;padding-bottom:50px;}
  /* A pill, not a full-bleed bar. It used to span the viewport (width:100% + 40px
     with -20px margins to escape .wrap's padding), so its hover darkened a whole
     strip instead of a control. `width:fit-content` keeps it a block-level flex
     box — sticky behaves exactly as before — while sizing it to its own label.
     Now that it no longer covers the full width, the content scrolling underneath
     shows beside it, so it needs an opaque background and a border of its own. */
  /* Rounded like every other button (`.btn` is 12px), not a pill — one control
     shape across the app. */
  .back-btn{position:sticky;top:calc(var(--hh,60px) + 8px);z-index:15;display:flex;align-items:center;gap:7px;width:fit-content;margin:0 0 14px;padding:9px 15px 9px 12px;background:var(--white);border:1px solid var(--line);border-radius:12px;color:var(--gold-text);font-weight:var(--w-strong);font-size:14.5px;line-height:1;transition:background .12s,border-color .12s;}
  .back-btn .ba-ic{width:17px;height:17px;display:block;flex-shrink:0;}
  .back-btn:hover{background:var(--cream-100);border-color:var(--moon-deep);}
  .fc-list{display:flex;flex-direction:column;gap:11px;}
  .fc-row{display:flex;align-items:stretch;gap:8px;}
  .fc-go{flex:1;background:var(--white);border:1px solid var(--line-soft);border-radius:14px;padding:14px 16px;text-align:left;transition:border-color .12s;}
  .fc-go:hover{border-color:var(--moon);}
  .fc-top{display:flex;align-items:center;gap:10px;margin-bottom:9px;}
  .fc-top .n{font-family:var(--font-display);font-weight:600;color:var(--gold-text);font-size:13px;flex-shrink:0;}
  .fc-title{flex:1;font-family:var(--font-display);font-weight:500;font-size:16px;line-height:1.1;}
  .fc-count{font-size:12px;font-weight:var(--w-strong);color:var(--muted);flex-shrink:0;}
  .fc-bar{height:7px;background:rgba(28,36,50,0.08);border-radius:5px;overflow:hidden;}
  .fc-bar > i{display:block;height:100%;background:var(--moon);border-radius:5px;transition:width .4s;}
  .fc-reset{width:50px;flex-shrink:0;background:var(--white);border:1px solid var(--line);border-radius:14px;color:var(--gold-text);font-size:19px;transition:border-color .12s,color .12s;}
  .fc-reset:hover{border-color:var(--danger);color:var(--danger);}
  .reset-all{display:inline-flex;align-items:center;gap:8px;background:var(--danger-bg);border:1.5px solid var(--danger-line);color:var(--danger);font-weight:var(--w-strong);font-size:13.5px;padding:10px 18px;border-radius:11px;transition:filter .12s;}
  .reset-all:hover{filter:brightness(0.97);}
  .reset-all-wrap{text-align:center;}
  .fade{animation:fade .35s ease;}
  @keyframes fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}

  .hero{padding:8px 0;}
  .hero-main{width:100%;}
  .hero h1{font-family:var(--font-display);font-weight:500;font-size:46px;line-height:1;letter-spacing:-0.02em;margin-bottom:12px;}
  .hero .sub{font-size:15px;color:var(--muted);max-width:520px;}

  .mode-grid{display:flex;flex-direction:column;gap:14px;margin-top:26px;}
  .mode-card{background:var(--white);border:1px solid var(--line-soft);border-radius:18px;padding:20px 22px;text-align:left;display:flex;align-items:center;gap:18px;width:100%;transition:transform .15s,box-shadow .15s,border-color .15s;}
  .mode-card:hover{transform:translateY(-2px);box-shadow:0 10px 26px rgba(28,36,50,0.09);border-color:var(--moon);}
  .mode-card .mc-ic{width:52px;height:52px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-size:26px;flex-shrink:0;}
  .mode-card.ghid .mc-ic{background:var(--lime-pale);}
  .mode-card.learn .mc-ic{background:var(--moon-glow);}
  .mode-card.quiz .mc-ic{background:var(--sky-light);}
  .mode-card.care .mc-ic{background:#F3DDD1;}
  .mode-card.trouble .mc-ic{background:#EAF0D4;}
  body.dark .mode-card.care .mc-ic{background:rgba(235,160,122,0.18);}
  .mode-card .mc-body{flex:1;}
  .mode-card h2{font-family:var(--font-display);font-weight:500;font-size:22px;margin-bottom:4px;}
  .mode-card p{font-size:13.5px;color:var(--muted);line-height:1.45;}
  .mode-card .mc-arrow{font-size:20px;color:var(--gold-text);flex-shrink:0;font-weight:var(--w-strong);}

  /* Home customization: ordered units + edit panel */
  .home-units{display:flex;flex-direction:column;gap:14px;margin-top:24px;}
  /* The three Home actions (customize / family / backup) were `·`-separated 12px
     links. auto-fit wraps them to one column on a phone instead of shrinking the
     labels below a readable size. */
  .home-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:10px;margin-top:18px;}
  .home-unit>.emg-home,.home-unit>.tools-row,.home-unit>.prog-summary{margin-top:0;}
  .home-edit{margin-top:24px;background:var(--white);border:1px solid var(--line-soft);border-radius:16px;padding:16px 16px 18px;}
  .home-edit .he-head{display:flex;align-items:center;justify-content:space-between;gap:12px;}
  .home-edit .he-head h2{font-family:var(--font-display);font-weight:500;font-size:20px;}
  .home-edit .he-done{background:var(--moon);color:#fff;border:none;border-radius:10px;padding:7px 16px;font-weight:var(--w-med);font-size:13.5px;cursor:pointer;}
  .home-edit .he-hint{font-size:12.5px;color:var(--muted);line-height:1.5;margin:8px 0 14px;}
  .home-edit .he-list{display:flex;flex-direction:column;gap:8px;}
  .hu-row{display:flex;align-items:center;gap:10px;background:var(--cream-100);border:1px solid var(--line-soft);border-radius:12px;padding:8px 10px;transition:opacity .15s,box-shadow .15s,border-color .15s;}
  .hu-row.off{opacity:.5;}
  .hu-row.hu-chosen{box-shadow:0 8px 22px rgba(28,36,50,0.16);border-color:var(--moon);background:var(--white);}
  .hu-row.hu-ghost{opacity:.4;}
  .hu-handle{cursor:grab;touch-action:none;font-size:19px;color:var(--gold-text);background:none;border:none;padding:8px 8px;line-height:1;flex-shrink:0;}
  .hu-handle:active{cursor:grabbing;}
  .hu-ic{font-size:17px;flex-shrink:0;}
  /* Kiri's own row in the Home customize panel. 44 px wide is 32 tall, which the 56 px row
     carries without growing — bigger than the `.hu-ic` emoji above it on purpose, because
     it is a drawing and 17 px of watercolour is a smudge. */
  .hu-row .mascot-art{width:44px;}
  .hu-row .ma-emoji{font-size:22px;}
  .hu-name{flex:1;font-weight:600;font-size:13.5px;color:var(--ink);}
  .hu-ctrls{display:flex;gap:5px;flex-shrink:0;}
  /* Icon-only actions: 38px so a thumb can hit them, and never grey — an icon
     with no label has nothing else to say it is live. */
  .hu-btn{width:38px;height:38px;border:1px solid var(--line);background:var(--white);border-radius:10px;font-size:17px;line-height:1;cursor:pointer;color:var(--gold-text);display:flex;align-items:center;justify-content:center;flex-shrink:0;}
  .hu-btn:hover{border-color:var(--moon-deep);}
  .hu-btn:disabled{opacity:.35;cursor:default;}
  .hu-btn svg{width:19px;height:19px;display:block;}
  .hu-btn.eye.off{color:var(--muted);background:var(--cream-100);}
  .hu-btn.danger{color:var(--danger);border-color:var(--danger-line);}
  .hu-btn.danger:hover{background:var(--danger-bg);border-color:var(--danger);}
  .home-edit .he-list{user-select:none;-webkit-user-select:none;}
  body.hu-dragging,body.hu-dragging .hu-handle{cursor:grabbing;}
  .home-edit .he-reset{margin-top:14px;}

  .prog-summary{margin-top:22px;background:var(--cream-100);border:1px solid var(--line-soft);border-radius:16px;padding:18px 20px;}
  .stats-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;}
  @media(max-width:520px){.stats-grid{grid-template-columns:repeat(2,1fr);}}
  .stat{background:rgba(255,255,255,0.5);border:1px solid rgba(28,36,50,0.05);border-radius:13px;padding:13px 14px;}
  .stat .n{font-family:var(--font-display);font-weight:600;font-size:24px;color:var(--ink);line-height:1;}
  .stat .l{font-size:11.5px;color:var(--muted);margin-top:5px;line-height:1.25;}
  .q-badge{font-size:12px;font-weight:var(--w-strong);padding:3px 10px;border-radius:20px;flex-shrink:0;}
  .q-badge.done{background:var(--safe-bg);color:var(--safe);border:1px solid var(--safe-line);}
  .q-badge.part{background:var(--moon-glow);color:var(--gold-text);}
  /* The partial-score badge had the same pale-on-pale fault as the flashcard back:
     light --moon-glow tint under light --gold-text on every dark theme. `.done`
     above escaped it only because the --safe palette IS overridden for dark. */
  body.dark .q-badge.part{background:rgba(255,255,255,0.10);}
  /* ---- Action controls (2026-07-23) ----
     Every action in the app is a real target: at least 44px tall, real padding,
     and a theme colour — never grey, because grey reads as disabled and an
     underlined 12px label was a ~90x16px tap target the owner reported as
     unclickable. This replaces `.reset-link`, whose name described a reset it
     had long stopped being. Important, destructive and primary actions use a
     real `.btn` instead (see the `.btn` block); `.action-link` is the SECONDARY
     tier — exits, "all chapters", "add a bird".
     Transparent + gold border rather than a filled pill so it needs no per-theme
     override: --moon-deep and --gold-text are defined in light, dark, Sunset and
     Tropical alike, while --moon-glow is not overridden for dark. */
  .action-link{display:inline-flex;align-items:center;justify-content:center;gap:8px;
    min-height:44px;margin-top:12px;padding:11px 18px;background:transparent;
    border:1.5px solid var(--moon-deep);border-radius:12px;color:var(--gold-text);
    font-family:var(--font-body);font-size:15px;font-weight:var(--w-strong);line-height:1.15;
    text-decoration:none;transition:background .12s,border-color .12s;}
  .action-link:hover{background:rgba(217,174,62,0.12);}
  .action-link.danger{border-color:var(--danger-line);color:var(--danger);}
  .action-link.danger:hover{background:var(--danger-bg);}
  .bird-switch{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 14px;}
  .bird-pill{background:var(--white);color:var(--ink);border:1px solid var(--line);border-radius:999px;padding:12px 18px;font-family:var(--font-body);font-size:15px;font-weight:var(--w-med);line-height:1.15;}
  .bird-pill.on{background:var(--moon-deep);color:#fff;border-color:var(--moon-deep);}
  .about-danger{text-align:center;margin-top:18px;}
  .mut-grid{display:grid;grid-template-columns:1fr;gap:14px;margin-top:6px;}
  @media(min-width:560px){.mut-grid{grid-template-columns:1fr 1fr;}}
  .mut-card{border:1px solid var(--line);border-radius:16px;overflow:hidden;background:var(--white);}
  .mut-card.on{border-color:var(--moon-deep);box-shadow:0 0 0 2px var(--moon-deep) inset;}
  /* A mutation whose photographs have not been taken yet. Deliberately a thin strip and
     not a photo-sized placeholder: an empty frame the height of a real picture reads as a
     failed image load, and a grid where half the cards are grey rectangles looks broken
     rather than incomplete. The card is still worth showing — it names the mutation and
     describes it, which is most of why the catalog is read. */
  .mut-photo-pending{
    padding:9px 14px;font-size:12.5px;color:var(--muted);
    background:var(--cream-100);border-bottom:1px solid var(--line);
  }
  .mut-photo-carousel{position:relative;}
  .mut-photo-open{cursor:zoom-in;display:block;width:100%;padding:0;text-align:left;}
  .mut-photo{position:relative;width:100%;aspect-ratio:4/3;background:var(--cream);display:flex;align-items:center;justify-content:center;overflow:hidden;}
  .mut-photo-ph{position:absolute;font-size:40px;opacity:.35;}
  .mut-photo img{position:relative;width:100%;height:100%;object-fit:cover;}
  .mut-photo-arrow{position:absolute;z-index:2;top:50%;transform:translateY(-50%);display:grid;place-items:center;width:44px;height:44px;padding:0;border:1px solid rgba(255,255,255,.58);border-radius:50%;background:rgba(17,23,34,.48);color:#fff;line-height:0;box-shadow:0 3px 12px rgba(17,23,34,.16);-webkit-backdrop-filter:blur(7px);backdrop-filter:blur(7px);}
  .mut-photo-arrow:hover{background:rgba(17,23,34,.66);}
  .mut-carousel-arrow-icon{display:block;width:19px;height:19px;}
  .mut-photo-arrow.prev{left:9px;}
  .mut-photo-arrow.next{right:9px;}
  /* The scrim is .59, not .48, because this is white 11px text over an ARBITRARY
     photo. Its worst case is a bright one, and at .48 white came out at 3.22:1 there
     — below AA with no way for the reader to do anything about it. .59 is the point
     where a pure-white photo still clears 4.5:1, so every photo does.
     (An earlier sweep scored this pill 1.02 and it was wrong: the probe walked past
     a translucent layer to the opaque card behind it and compared white to white.
     Compositing the stack is what turned a false alarm into the real number.) */
  .mut-photo-count{position:absolute;right:8px;bottom:8px;padding:4px 8px;border:1px solid rgba(255,255,255,.34);border-radius:999px;background:rgba(17,23,34,.59);color:#fff;font-size:11px;font-weight:var(--w-strong);line-height:1.2;-webkit-backdrop-filter:blur(7px);backdrop-filter:blur(7px);}
  .mutation-lightbox-body{padding-top:12px;overflow-y:auto;}
  .mutation-lightbox-image .mut-photo{height:min(62vh,620px);height:min(62dvh,620px);aspect-ratio:auto;border-radius:12px;}
  .mutation-lightbox-image .mut-photo img{object-fit:contain;}
  .mutation-lightbox-body>p{margin-top:12px;color:var(--muted);font-size:14px;line-height:1.5;}
  .mutation-catalog-state{text-align:center;color:var(--muted);font-weight:var(--w-med);}
  .mut-body{padding:12px 14px 14px;}
  .mut-body h3{font-family:var(--font-display);font-size:19px;margin:0 0 4px;}
  .mut-body p{font-size:13.5px;color:var(--muted);margin:0 0 12px;line-height:1.45;}
  /* `--gold-text`, not `--moon-deep`: this is 13px text, so it answers to the 4.5:1
     rule, and --gold-text is each theme's accent-as-text. --moon-deep is the brand
     furniture value and measured 1.94 against a card. */
  .mut-yours{color:var(--gold-text);font-size:13px;font-weight:var(--w-strong);}
  /* The English mutation name beside the translated one. Body font on purpose: the
     size drop alone is not enough separation from the display-font title next to it. */
  .mut-en{font-family:var(--font-body);font-size:13px;font-weight:600;color:var(--muted);white-space:nowrap;}

  .screen-head{margin-bottom:22px;}
  .screen-head h1{font-family:var(--font-display);font-weight:500;font-size:32px;letter-spacing:-0.01em;}
  .screen-head p{font-size:14px;color:var(--muted);margin-top:6px;}

  .search{width:100%;padding:13px 16px;border:1px solid var(--line);border-radius:12px;font-family:var(--font-body);font-size:15px;background:var(--white);color:var(--ink);margin-bottom:18px;}
  .search:focus{outline:none;border-color:var(--moon-deep);}

  .ch-list{display:flex;flex-direction:column;gap:11px;}
  .ch-item{background:var(--white);border:1px solid var(--line-soft);border-radius:15px;padding:16px 18px;display:flex;align-items:center;gap:15px;text-align:left;width:100%;transition:transform .12s,border-color .12s;}
  .ch-item:hover{transform:translateX(3px);border-color:var(--moon);}
  .ch-num{font-family:var(--font-display);font-weight:600;font-size:15px;color:var(--gold-text);width:30px;flex-shrink:0;font-variant-numeric:tabular-nums;}
  .ch-body{flex:1;}
  .ch-body .t{display:block;font-family:var(--font-display);font-weight:500;font-size:18px;line-height:1.2;}
  .ch-body .d{display:block;font-size:13px;color:var(--muted);margin-top:4px;}
  .ch-check{width:22px;height:22px;border-radius:50%;border:2px solid var(--line);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:13px;color:transparent;}
  .ch-item.read .ch-check{background:var(--moon);border-color:var(--moon);color:var(--on-accent);}

  .reader h3.title{font-family:var(--font-display);font-weight:500;font-size:30px;line-height:1.08;letter-spacing:-0.01em;margin-bottom:6px;}
  .reader .kicker{font-size:12px;font-weight:var(--w-strong);letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-text);margin-bottom:16px;}
  .block{background:var(--white);border:1px solid var(--line-soft);border-radius:16px;padding:20px 22px;margin-bottom:14px;}
  .block h4{font-family:var(--font-display);font-weight:500;font-size:19px;margin-bottom:8px;}
  .block p{font-size:15.5px;color:var(--ink);margin-bottom:10px;}
  .block p:last-child{margin-bottom:0;}
  .block ul{margin:6px 0 10px 2px;list-style:none;}
  .block li{font-size:15px;padding:5px 0 5px 22px;position:relative;color:var(--ink);}
  .block li::before{content:'';position:absolute;left:4px;top:12px;width:6px;height:6px;border-radius:50%;background:var(--moon-deep);}
  .block b{font-weight:var(--w-strong);}
  .chapter-videos{margin:18px 0 14px;}
  .chapter-videos>h4{font-family:var(--font-display);font-weight:500;font-size:21px;margin:0 0 10px;}
  .chapter-video{background:var(--white);border:1px solid var(--line-soft);border-radius:16px;padding:14px;margin-bottom:14px;}
  .chapter-video-title{font-weight:var(--w-strong);font-size:15px;margin-bottom:10px;}
  .video-frame{position:relative;overflow:hidden;aspect-ratio:16/9;border-radius:12px;background:linear-gradient(145deg,var(--night),#33425d);color:#fff;}
  .video-frame video{position:relative;z-index:2;display:block;width:100%;height:100%;object-fit:contain;background:#0d1119;}
  .video-placeholder{position:absolute;inset:0;z-index:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:20px;text-align:center;color:#fff;}
  .video-placeholder .vp-ic{font-size:34px;line-height:1;}
  .video-placeholder .vp-title{font-weight:var(--w-strong);font-size:14px;}
  .video-placeholder .vp-copy{max-width:360px;font-size:12.5px;color:rgba(255,255,255,.72);}
  .video-frame:not(.is-unavailable) .video-placeholder{visibility:hidden;pointer-events:none;}
  .video-frame.is-unavailable .video-placeholder{visibility:visible;pointer-events:auto;}
  .video-frame.is-unavailable video{display:none;}
  .video-retry{margin-top:5px;padding:7px 12px;border:1px solid rgba(255,255,255,.35);border-radius:9px;color:#fff;background:rgba(255,255,255,.08);font-weight:var(--w-strong);font-size:12px;}
  .video-note{margin:9px 2px 0;font-size:12.5px;color:var(--muted);}
  .callout{border-radius:16px;padding:18px 20px;margin-bottom:14px;border:1px solid;}
  .callout .cap{font-size:12px;font-weight:var(--w-strong);letter-spacing:0.08em;text-transform:uppercase;margin-bottom:8px;display:flex;align-items:center;gap:7px;}
  .callout p, .callout li{font-size:15px;}
  .callout.safe{background:var(--safe-bg);border-color:var(--safe-line);} .callout.safe .cap{color:var(--safe);}
  .callout.warn{background:var(--warn-bg);border-color:var(--warn-line);} .callout.warn .cap{color:var(--warn);}
  .callout.danger{background:var(--danger-bg);border-color:var(--danger-line);} .callout.danger .cap{color:var(--danger);}
  .callout ul{list-style:none;margin-top:4px;}
  /* A paragraph that closes a callout after its list. Chapter 12's "Give him space"
     line carried `style="margin-top:8px;"` inline in the base and in all seventeen
     overlays until 2026-08-08; it was the only attribute other than `class` anywhere
     in the chapter HTML, and it made the public generator's markup allowlist
     (docs/PHASE12B_M0.md §7.1 refusal 7) refuse to build on unmodified data. The
     spacing is real — the global reset zeroes every margin — so it moves here rather
     than disappearing. `assets/site.css` carries the same rule for the public pages,
     copied in spirit: the two stylesheets share no file across the /app/ boundary. */
  .callout ul + p{margin-top:8px;}
  .callout li{padding:4px 0 4px 20px;position:relative;}
  .callout li::before{content:'';position:absolute;left:2px;top:11px;width:6px;height:6px;border-radius:50%;}
  .callout.safe li::before{background:var(--safe);}
  .callout.warn li::before{background:var(--warn);}
  .callout.danger li::before{background:var(--danger);}

  .reader-nav{display:flex;gap:10px;margin-top:22px;}
  .btn{font-weight:var(--w-strong);font-size:15px;padding:13px 22px;border-radius:12px;transition:transform .1s,filter .1s;}
  /* THE BLACK RECTANGLE ON THE MODAL'S CLOSE BUTTON WAS THIS, and it was never a border:
     `.btn` had no focus style, so the browser's own 1px `auto` outline was what showed —
     and it showed on every modal open, because openDayPhotoInfo() (and every dialog like
     it) moves focus into the dialog on purpose, which is required for a keyboard or screen
     reader user to be anywhere near it.
     So the ring is RESTYLED, not removed: same `--moon-deep` at 2px with a 2px offset that
     every other focusable control in this file already uses, and on `:focus-visible` so a
     tap or a click does not paint one. Deleting the indicator instead would have made the
     dialog impossible to navigate by keyboard, which is a real regression bought for a
     cosmetic one. */
  .btn:focus-visible{outline:2px solid var(--moon-deep);outline-offset:2px;}
  .btn:active{transform:scale(0.97);}
  /* `background` twice on purpose. The shorthand sets the flat --moon and resets
     background-image; the second declaration then paints the gradient over it. A
     browser that cannot resolve --grad-a/--grad-b for any reason keeps the accent
     it always had rather than falling through to transparent. Both ends are proven
     against --night in tests/contrast.test.js. */
  /* The accent gradient breathes. `background-size` above 100 % is what gives
     `background-position` somewhere to travel — at the default 100 % the position has
     no range and the animation is a no-op, which is why the gradient looked frozen even
     where one was declared.

     210 % -> 250 % on 2026-08-16 (owner: "move more, not more often"), and the number is
     a TRADE rather than a maximum. The size decides two things at once and they pull
     against each other: the button shows 100/size of the colour ramp at any instant, and
     a fixed point on it traverses roughly (size-100)/size of the ramp over one cycle. So
     290 % moves 26 % more colour past your eye than 210 % and leaves the button looking
     nearly flat while it does it. 250 % buys most of the extra travel and still shows
     40 % of the ramp across the button, which is what makes it read as a gradient when
     it is standing still. The duration is untouched — the ask was distance, not speed.

     IT COSTS NOTHING ON LIGHT AND DARK. Both declare `--grad-a` and `--grad-b` as
     `var(--moon)`, so the gradient is one colour to itself and sliding it is invisible
     — the free pair stays pixel-identical without needing a rule of its own, which is
     the same property that let these tokens be added at all (docs/HANDOFF.md §0). */
  .btn-primary{background:var(--moon);
    background-image:linear-gradient(135deg,var(--grad-a),var(--grad-b));
    background-size:250% 250%;background-position:0% 50%;
    animation:gradBreathe 11s ease-in-out infinite;
    color:var(--on-accent);}
  @keyframes gradBreathe{
    0%,100%{background-position:0% 50%;}
    50%{background-position:100% 50%;}
  }
  /* Both stillness sources for the button, kept below its own rule — see the note in
     the #decor block for why placing them above it breaks the contrast test. */
  body.still .btn-primary{animation:none;}
  @media(prefers-reduced-motion:reduce){ .btn-primary{animation:none;} }
  .btn-primary:hover{filter:brightness(1.04);}
  .btn-ghost{background:var(--white);color:var(--ink);border:1px solid var(--line);}
  .btn-ghost:hover{background:var(--cream-100);}
  .btn-night{background:var(--night);color:var(--moon);}
  /* Destructive actions (delete a bird, remove a vet, leave the family) are
     buttons rather than small links: they are important enough to be deliberate,
     and the danger palette keeps them distinguishable from the gold ones. */
  .btn-danger{background:var(--danger-bg);color:var(--danger);border:1.5px solid var(--danger-line);}
  .btn-danger:hover{filter:brightness(0.97);}
  .btn-logout,.btn-danger-outline{display:flex;align-items:center;justify-content:center;gap:8px;background:transparent;color:var(--danger);border:1.5px solid var(--danger-line);}
  .btn-logout:hover,.btn-danger-outline:hover{background:var(--danger-bg);}
  .btn-icon{display:inline-flex;width:18px;height:18px;flex-shrink:0;}
  .btn-icon svg{width:100%;height:100%;}
  /* A button whose label follows an icon: `.btn` alone leaves them touching. */
  .btn-icon-label{display:inline-flex;align-items:center;justify-content:center;gap:9px;}
  /* Still 44px tall — only the horizontal padding and label shrink, for buttons
     that share a row with an input or another control. */
  .btn-sm{font-size:14px;padding:11px 16px;}
  .btn-full{width:100%;}
  .btn:disabled{opacity:0.4;cursor:default;}
  .reader-nav .grow{flex:1;}
  .read-toggle{width:100%;margin-top:10px;}

  .quiz-opts{display:flex;flex-direction:column;gap:11px;margin-bottom:8px;}
  .quiz-opt{background:var(--white);border:1px solid var(--line-soft);border-radius:15px;padding:16px 18px;text-align:left;display:flex;align-items:center;gap:14px;transition:border-color .12s,transform .12s;}
  .quiz-opt:hover{border-color:var(--moon);transform:translateX(3px);}
  .quiz-opt .ic{font-size:22px;width:26px;text-align:center;flex-shrink:0;}
  .quiz-opt .t{display:block;font-family:var(--font-display);font-weight:500;font-size:18px;line-height:1.2;}
  .quiz-opt .d{display:block;font-size:13px;color:var(--muted);margin-top:3px;}
  .quiz-opt:disabled{opacity:0.45;cursor:default;}
  .quiz-opt:disabled:hover{border-color:var(--line-soft);transform:none;}
  .divider{font-size:12px;font-weight:var(--w-strong);letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-text);margin:20px 0 12px;text-align:center;}

  .q-top{display:flex;align-items:center;gap:12px;margin-bottom:18px;}
  .q-count{font-size:13px;font-weight:var(--w-strong);color:var(--muted);white-space:nowrap;}
  .q-bar{flex:1;height:8px;background:rgba(28,36,50,0.08);border-radius:5px;overflow:hidden;}
  .q-bar > i{display:block;height:100%;background:var(--moon);transition:width .35s;}
  .q-chapter{font-size:12px;font-weight:var(--w-strong);letter-spacing:0.1em;text-transform:uppercase;color:var(--gold-text);margin-bottom:8px;}
  /* An `h1`, not a div: on a quiz or triage step the question IS the page heading,
     and those steps had no h1 at all. Styling is entirely by class and `*{margin:0}`
     resets the UA heading margin, so the element change is invisible. The step-1
     troubleshooter intro deliberately stays an h2 — promoting it too would put two
     h1s on that one step. */
  .q-text{font-family:var(--font-display);font-weight:500;font-size:24px;line-height:1.22;margin-bottom:20px;}
  .answers{display:flex;flex-direction:column;gap:10px;}
  .ans{background:var(--white);border:1.5px solid var(--line);border-radius:13px;padding:15px 18px;text-align:left;font-size:15.5px;font-weight:600;color:var(--ink);display:flex;align-items:center;gap:12px;transition:border-color .12s,background .12s;}
  .ans:hover:not(:disabled){border-color:var(--moon-deep);}
  .ans .mk{width:24px;height:24px;border-radius:50%;border:2px solid var(--line);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:var(--w-strong);color:var(--muted);}
  .ans.correct{border-color:var(--safe);background:var(--safe-bg);}
  .ans.correct .mk{background:var(--safe);border-color:var(--safe);color:#fff;}
  .ans.wrong{border-color:var(--danger);background:var(--danger-bg);}
  .ans.wrong .mk{background:var(--danger);border-color:var(--danger);color:#fff;}
  .ans:disabled{cursor:default;}
  .ans.dim{opacity:0.55;}
  .explain{margin-top:16px;border-radius:14px;padding:16px 18px;font-size:15px;border:1px solid;animation:fade .3s ease;}
  .explain.ok{background:var(--safe-bg);border-color:var(--safe-line);}
  .explain.no{background:var(--danger-bg);border-color:var(--danger-line);}
  .explain .h{font-weight:var(--w-strong);margin-bottom:5px;display:flex;align-items:center;gap:7px;}
  .explain.ok .h{color:var(--safe);}
  .explain.no .h{color:var(--danger);}

  .flip{position:relative;perspective:1400px;cursor:pointer;margin-bottom:16px;display:grid;}
  .flip-face{grid-area:1/1;backface-visibility:hidden;-webkit-backface-visibility:hidden;border-radius:20px;padding:34px 26px;min-height:240px;display:flex;flex-direction:column;justify-content:center;transition:transform .55s;}
  .flip-front{background:var(--white);border:1px solid var(--line-soft);transform:rotateY(0deg);}
  .flip-back{background:var(--moon-glow);border:1px solid var(--moon);transform:rotateY(180deg);}
  /* The same trap the theme swatch hit. --moon-glow stays a LIGHT tint on every dark
     theme — it is the accent surface for light-based components and no dark palette
     overrides it — while --ink flips to near-white. So the answer face rendered
     near-white body text on pale mint, and its --gold-text label disappeared into the
     same tint. Every other --moon-glow surface (.nudge, .streak-badge, .cal-cell.done,
     .supporter-badge, .badge .bic, .mc-ic) already carried its dark override; this one
     was missed. A translucent white tint is theme-agnostic, so the supporter palettes
     and any added later are readable without a rule each; the --moon border and the
     --gold-text label still carry the accent. */
  body.dark .flip-back{background:rgba(255,255,255,0.10);}
  .flip.flipped .flip-front{transform:rotateY(180deg);}
  .flip.flipped .flip-back{transform:rotateY(360deg);}
  .flip-hint{font-size:11px;font-weight:var(--w-strong);letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-text);margin-bottom:16px;}
  .flip-q{font-family:var(--font-display);font-weight:500;font-size:25px;line-height:1.25;}
  .flip-a{font-size:17px;line-height:1.55;color:var(--ink);}
  .card-actions{display:flex;gap:10px;}
  .card-actions .btn{flex:1;}

  .result{text-align:center;padding:10px 0;}
  .result .ring{width:150px;height:150px;margin:6px auto 20px;position:relative;}
  .result .ring .pct{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;}
  .result .ring .pct b{font-family:var(--font-display);font-weight:600;font-size:40px;line-height:1;}
  .result .ring .pct span{font-size:12px;color:var(--muted);margin-top:3px;}
  .result h2{font-family:var(--font-display);font-weight:500;font-size:30px;margin-bottom:8px;}
  .result .msg{font-size:15px;color:var(--muted);max-width:440px;margin:0 auto 24px;}
  .result-actions{display:flex;flex-direction:column;gap:10px;max-width:360px;margin:0 auto;}
  .trouble-result{text-align:left;}
  .trouble-result .result-icon{text-align:center;font-size:58px;line-height:1;margin:8px 0 14px;}
  .trouble-result>h2{text-align:center;}
  .trouble-result>.msg{text-align:center;}
  .trouble-kicker{text-align:center;font-size:12px;font-weight:var(--w-strong);letter-spacing:.1em;text-transform:uppercase;color:var(--gold-text);margin-bottom:7px;}
  .trouble-nav{margin:-4px 0 14px;}
  .trouble-back{margin-top:0;}
  .trouble-disclaimer{font-size:12.5px;color:var(--muted);padding:12px 14px;border:1px solid var(--line);border-radius:12px;margin:18px 0;}
  .trouble-sources{margin:18px 0 22px;padding-top:14px;border-top:1px solid var(--line-soft);}
  .trouble-sources h3{font-family:var(--font-display);font-weight:500;font-size:18px;margin-bottom:7px;}
  .trouble-source{display:block;color:var(--gold-text);font-size:13.5px;font-weight:var(--w-med);padding:9px 0;text-decoration:none;}
  .trouble-source:hover{text-decoration:none;}

  .hero-actions{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-top:16px;margin-bottom:16px;}
  .hero-actions .hero-about,.hero-actions .home-add-bird{margin:0;}
  .hero-about{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 18px;line-height:1.2;text-decoration:none;}

  .about{padding-top:8px;}
  .about-bird-switch .bird-switch{margin:22px 0 30px;justify-content:center;}
  .about-hero{text-align:center;margin-bottom:20px;}
  .about-hero h1{font-family:var(--font-display);font-weight:500;font-size:52px;line-height:1;color:var(--accent-text);letter-spacing:-0.02em;}
  .about-sex{display:inline-flex;align-items:center;gap:6px;margin-top:9px;padding:4px 11px;border-radius:20px;background:var(--cream-100);border:1px solid var(--line-soft);color:var(--muted);font-size:12px;font-weight:var(--w-strong);}
  .about-actions{display:flex;justify-content:center;align-items:center;flex-wrap:wrap;gap:12px;margin:16px 0 20px;}
  .about-gallery{margin:4px 0 24px;}
  .about-gallery-head{display:flex;flex-direction:column;align-items:flex-start;gap:10px;margin:0 2px 12px;color:var(--muted);font-size:12.5px;font-weight:var(--w-med);line-height:1.4;}
  .about-gallery-head .action-link{flex-shrink:0;margin-top:0;}
  .about-photo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding:2px;}
  .about-photo-card{min-width:0;}
  .about-photo-card>.ph,.about-photo-card>.profile-photo-slot{width:100%;}
  .about-photo-empty{aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;border:1px dashed var(--line);border-radius:14px;background:var(--cream-100);color:var(--muted);text-align:center;padding:12px;}
  .about-photo-empty span{font-size:30px;}
  .about-photo-empty p{font-size:13px;font-weight:var(--w-med);line-height:1.35;}
  @media(max-width:650px){.about-photo-grid{grid-template-columns:repeat(2,minmax(0,1fr));}}
  .ph{position:relative;aspect-ratio:1;border-radius:14px;overflow:hidden;background:var(--moon-glow);border:1px solid var(--line-soft);}
  .profile-photo-view{display:block;width:100%;padding:0;cursor:zoom-in;text-align:left;}
  .profile-photo-view:focus-visible{outline:2px solid var(--moon-deep);outline-offset:2px;}
  .ph-ph{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;font-size:30px;color:var(--moon-deep);}
  .ph-ph small{font-size:10px;font-weight:var(--w-strong);letter-spacing:0.08em;text-transform:uppercase;color:var(--gold-text);}
  .ph img{position:relative;width:100%;height:100%;object-fit:cover;display:block;}
  .about-story .block p{font-size:16px;}
  .about-empty{color:var(--muted) !important;font-style:italic;}
  .profile-form{display:flex;flex-direction:column;gap:18px;}
  .profile-field{display:flex;flex-direction:column;gap:6px;}
  .profile-field > label,.profile-legend{font-family:var(--font-display);font-size:18px;font-weight:500;}
  .profile-input,.profile-textarea{width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:12px;background:var(--cream);color:var(--ink);font:inherit;}
  .profile-input:focus,.profile-textarea:focus{outline:none;border-color:var(--moon-deep);}
  .profile-textarea{min-height:132px;resize:vertical;line-height:1.5;}
  .profile-sex-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
  .profile-sex-grid .wide{grid-column:1/-1;}
  .profile-photo-slot{position:relative;min-width:0;}
  .profile-photo-pick{display:block;cursor:pointer;}
  .profile-photo-pick:focus-within{outline:2px solid var(--moon-deep);outline-offset:2px;}
  .profile-photo-pick .photo-overlay{position:absolute;inset:auto 6px 6px 6px;z-index:2;padding:5px 4px;border-radius:8px;background:rgba(17,23,34,.78);color:#fff;text-align:center;font-size:10px;font-weight:var(--w-strong);line-height:1.2;}
  .profile-cover-badge{position:absolute;z-index:2;top:7px;left:7px;padding:4px 7px;border-radius:999px;background:rgba(17,23,34,.82);color:#fff;font-size:10px;font-weight:var(--w-strong);line-height:1.2;}
  .photo-replace-control{position:absolute;z-index:2;inset:auto 7px 7px 7px;display:flex;align-items:center;justify-content:center;min-height:34px;padding:5px 8px;border-radius:9px;background:rgba(17,23,34,.82);color:#fff;font-family:var(--font-body);font-size:11px;font-weight:var(--w-strong);cursor:pointer;}
  .profile-photo-input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;}
  .profile-photo-remove{position:absolute;z-index:3;top:6px;right:6px;width:34px;height:34px;border-radius:50%;background:var(--danger-bg);border:1.5px solid var(--danger-line);color:var(--danger);font-size:18px;font-weight:var(--w-strong);line-height:1;}
  .profile-order-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;}
  .profile-order-card{min-width:0;padding:7px;border:1px solid var(--line);border-radius:16px;background:var(--white);}
  .profile-order-controls{display:grid;grid-template-columns:44px 1fr 44px;gap:6px;margin-top:7px;}
  .profile-order-controls button{display:flex;align-items:center;justify-content:center;min-height:44px;border-radius:9px;background:var(--cream-100);color:var(--gold-text);font-size:18px;font-weight:var(--w-strong);}
  .profile-order-controls button:disabled{opacity:.35;cursor:not-allowed;}
  .profile-order-handle{cursor:grab;touch-action:none;}
  .profile-order-handle:active{cursor:grabbing;}
  .profile-order-ghost{opacity:.35;}
  .profile-order-done{margin-top:12px;}
  .profile-lightbox{max-width:860px;}
  /* THE PICTURE YIELDS, THE CONTROLS DO NOT — and that is the fix for the ← → arrows
     that were cut off at the bottom of this dialog.
     It was never only the phone's browser bar. This body had no `overflow` of its own,
     unlike `.modal-body`, and the image was sized against the VIEWPORT (`70vh`) rather
     than against the space left inside the card. Head + 70vh + controls simply exceeds
     the card's own `max-height`, `.modal-card` is `overflow:hidden`, and whatever did
     not fit was clipped rather than scrolled: the arrows, and the last strip of the
     photo above them. A short desktop window did it too; a mobile browser did it always,
     because its bar takes the height that `vh` pretends is still there.
     So the image box is the only flexible child (`flex:1 1 auto; min-height:0` — the
     min-height is what actually permits a flex item to shrink below its content), and
     the controls are `flex:0 0 auto`. The picture now shrinks to whatever is left and
     the arrows are always on screen. `70dvh` stays as the upper bound so it never grows
     past the viewport on a tall window. */
  .profile-lightbox-body{display:flex;flex-direction:column;min-height:0;overflow:hidden;padding:12px 16px 18px;padding-bottom:calc(18px + env(safe-area-inset-bottom,0px));}
  /* `.img-load` sits between the body and the picture since the loading skeleton
     (app/app/photocache.js) needs a box to shimmer in while the <img> has no size. */
  /* `flex:0 1 auto` — take the height the picture wants, GIVE IT BACK when there is not
     enough. `min-height:0` is what permits the second half; a flex item refuses to
     shrink below its content without it.
     The image is a flex item of this box and inherits `align-self:stretch`, so its
     height IS the box's height with no percentage anywhere. That matters: a percentage
     max-height against a flex-sized parent does not resolve, which is how the first
     attempt at this fix left a `max-height:min(70dvh,100%)` that Chrome ignored
     completely and an image still overflowing by 265 px. `object-fit:contain` then
     letterboxes the picture inside whatever height it was given, so it is never cropped
     and never squashed. */
  .profile-lightbox-body>.img-load{flex:0 1 auto;min-height:0;width:100%;display:flex;}
  .profile-lightbox-body>.img-load>img{display:block;width:100%;min-height:0;object-fit:contain;border-radius:14px;background:var(--night);}
  .profile-lightbox-controls{flex:0 0 auto;display:grid;grid-template-columns:44px 1fr 44px;align-items:center;gap:10px;margin-top:10px;}
  .profile-lightbox-controls button{display:flex;align-items:center;justify-content:center;width:44px;height:44px;border:1px solid var(--line);border-radius:10px;background:var(--white);color:var(--gold-text);font-size:20px;font-weight:var(--w-strong);}
  .profile-lightbox-controls span{text-align:center;color:var(--muted);font-size:13px;font-weight:var(--w-strong);}
  .inline-link{color:var(--gold-text);font-weight:var(--w-strong);text-decoration:none;}
  .profile-status{margin-top:-4px;}
  .profile-save-row{display:flex;gap:10px;}
  .profile-save-row .btn{flex:1;}

  footer{text-align:center;padding:22px 0 28px;color:var(--muted);font-size:12px;}
  footer .foot-grid,footer .foot-bottom{box-sizing:border-box;width:100%;}
  /* The app wordmark, not the bird's name — the bird tagline used to sit under it
     and made the brand column read as being about the bird. Sized as the footer's
     one visual anchor. Replaced wholesale by the 4.3 rebrand assets when they arrive. */
  /* `.foot-brand .foot-kiri`, not `.foot-kiri`: the wordmark is an <a> inside a
     `.foot-col`, so the plain class LOST to `.foot-col a` (0,1,1 beats 0,1,0, and it
     is declared later). That is why the footer wordmark had been rendering at the
     link size — 12.5px grey before, 13.5px gold after — and why raising the
     font-size here changed nothing at all until the selector was fixed. */
  .foot-brand .foot-kiri{display:inline-block;padding:0;font-family:var(--font-display);
    font-weight:600;font-size:52px;color:var(--ink);line-height:1;}
  .foot-brand .foot-kiri .dot{color:var(--moon-deep);}
  /* Column, so the VANA mark sits UNDER "made with heart by" rather than beside it. */
  .foot-by{display:flex;flex-direction:column;align-items:flex-start;gap:7px;}
  .foot-by-text{font-size:11.5px;color:var(--muted);line-height:1.2;}
  .foot-vana{display:inline-flex;align-items:center;transition:opacity .12s;}
  .foot-vana:hover{opacity:0.65;}
  .foot-vana .vana-logo{height:20px;width:auto;display:block;}
  body.dark .foot-vana .vana-logo path{fill:#FFFFFF;}

  /* ---- Site footer (Phase 7) ----
     A link/contact directory rather than a signature line. Auto-fit columns so
     it collapses to a single readable column on a phone without a breakpoint. */
  .foot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));
    gap:18px 20px;text-align:left;max-width:760px;margin:0 auto;padding:0 20px;}
  .foot-col h4{font-size:11px;font-weight:var(--w-strong);letter-spacing:0.09em;text-transform:uppercase;
    color:var(--ink);margin:0 0 6px;opacity:0.75;}
  .foot-col ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:0;}
  .foot-col li{line-height:1.3;}
  /* Footer entries stay gold and 13.5px like every other action, but NOT 44px tall:
     a directory of ~20 links at that height turns the footer into a second page.
     A ~28px row is the compromise the owner asked for — still comfortably bigger
     than the 12.5px grey text this replaced. */
  .foot-col a{display:inline-flex;align-items:center;padding:4px 0;
    color:var(--gold-text);font-size:13.5px;font-weight:var(--w-med);text-decoration:none;}
  .foot-col a:hover{text-decoration:none;}
  /* The contact column pairs each address with a role, so the gap that separates
     one contact from the next lives here rather than on the list. */
  .foot-role{display:block;font-size:11px;color:var(--muted);opacity:0.65;margin-bottom:7px;}
  .foot-brand{grid-column:1/-1;display:flex;flex-direction:column;align-items:flex-start;gap:10px;}
  .foot-bottom{max-width:760px;margin:18px auto 0;padding:14px 20px 0;border-top:1px solid var(--line);
    display:flex;flex-direction:column;gap:5px;text-align:center;}
  .foot-copy{font-size:12px;color:var(--muted);}
  .foot-meta{font-size:11.5px;color:var(--muted);opacity:0.8;}
  .foot-note{font-size:11.5px;color:var(--muted);opacity:0.8;max-width:46ch;margin:2px auto 0;line-height:1.45;}
  @media (min-width:720px){
    .foot-brand{grid-column:auto;}
    .foot-bottom{flex-direction:row;justify-content:space-between;align-items:center;text-align:left;}
    .foot-note{margin:0;text-align:right;}
  }

  /* ---- Emergency home button ---- */
  .emg-home{display:flex;align-items:center;gap:12px;width:100%;background:var(--danger-bg);border:1.5px solid var(--danger-line);border-radius:16px;padding:15px 18px;text-align:left;margin-top:14px;transition:filter .12s;}
  .emg-home:hover{filter:brightness(0.98);}
  .emg-home .ic{font-size:22px;color:var(--danger);}
  /* A flex item defaults to `min-width:auto`, which means it refuses to shrink below
     its longest WORD. These two tiles sit side by side at 375px, leaving ~83px for
     text, and German "Betreuungsbogen" is a 127px single word — so the text block
     pushed its own button past the edge and the whole Home page scrolled sideways by
     5px. Measured in Chrome at 375px on 2026-08-07; English never shows it.
     The icon is excluded deliberately: it is one glyph and must stay rigid, and
     `min-width:auto` is the only thing currently protecting it from squashing. */
  .emg-home>span:not(.ic):not(.mc-arrow){min-width:0;}
  /* Shrinking alone only moves the overflow inside the button — the long word still
     has to be allowed to break. `hyphens` puts it at a sensible point in the
     languages that support it (the document carries the locale in `lang`), and
     `overflow-wrap` is the guarantee for the ones that do not. */
  .emg-home .t,.emg-home .d{overflow-wrap:break-word;hyphens:auto;}
  .emg-home .t{display:block;font-weight:var(--w-strong);font-size:15px;color:var(--danger);}
  .emg-home .d{display:block;font-size:12.5px;color:var(--muted);margin-top:2px;}
  .emg-home .mc-arrow{margin-left:auto;color:var(--danger);font-weight:var(--w-strong);}
  .emg-addvets{display:block;width:100%;min-height:44px;margin-top:8px;background:transparent;border:1.5px dashed var(--danger-line);border-radius:12px;padding:12px;color:var(--danger);font-weight:var(--w-strong);font-size:14.5px;cursor:pointer;}
  .emg-addvets:hover{background:var(--danger-bg);}
  .emg-row{display:flex;gap:10px;flex-wrap:wrap;}
  .emg-row .emg-home{flex:1;min-width:150px;margin-top:0;}
  .petsitter-home{background:var(--cream-100);border-color:var(--line);}
  .petsitter-home .ic{color:var(--gold-text);}
  .petsitter-home .t{color:var(--ink);}

  /* ---- Care / tracker ---- */
  .card{background:var(--white);border:1px solid var(--line-soft);border-radius:16px;padding:18px 20px;margin-bottom:14px;}
  /* The global reset zeroes list padding, and `list-style-position:outside` then
     hangs every marker to the LEFT of the card's text column — the bullets end up
     nearer the card border than the heading above them. One em of padding puts the
     marker on the heading's left edge and indents wrapped lines under the text,
     which is what a bulleted list is supposed to look like. Lists that draw their
     own markers (.callout, .legal-list, .foot-col, the menus) set list-style
     themselves and are unaffected. */
  .card ul{padding-left:1.15em;}
  .card li{margin:4px 0;}
  .card h3{font-family:var(--font-display);font-weight:500;font-size:19px;margin-bottom:4px;}
  .card .hint{font-size:13px;color:var(--muted);margin-bottom:14px;}
  .streak-badge{display:inline-flex;align-items:center;gap:6px;background:var(--moon-glow);color:var(--gold-text);font-weight:var(--w-strong);font-size:13px;padding:5px 12px;border-radius:20px;}
  /* The photo counter is the same badge, but it is a BUTTON: tapping it explains that
     the 24 is a standing total per bird rather than a daily allowance. It has to look
     pressable without becoming a second call to action beside the heading, so it keeps
     the badge's own colours and gains only the ⓘ and a cursor. min-height 32px, not the
     44px an action gets: this is an affordance on a label, and growing it would push
     the card heading around on a 375px screen. */
  .dp-count{border:none;font-family:inherit;cursor:pointer;min-height:32px;}
  .dp-count-i{opacity:.75;font-size:12px;}
  .dp-count:hover .dp-count-i,.dp-count:focus-visible .dp-count-i{opacity:1;}
  body.dark .streak-badge{background:rgba(242,208,107,0.14);}
  .check-item{display:flex;align-items:center;gap:13px;padding:12px 4px;border-bottom:1px solid var(--line-soft);cursor:pointer;}
  .check-item:last-child{border-bottom:none;}
  .check-box{width:26px;height:26px;border-radius:8px;border:2px solid var(--line);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:15px;color:transparent;transition:.12s;}
  .check-item.on .check-box{background:var(--moon);border-color:var(--moon);color:var(--on-accent);}
  .check-item .lbl{font-size:15px;font-weight:600;}
  .check-item.on .lbl{color:var(--muted);text-decoration:line-through;}
  /* ---- Your own daily items (ROADMAP 9.4) ----
     The row is a `.check-item` so it sits in the same rhythm as the four essentials, but
     it is NOT clickable as a whole: the tick and the delete are separate targets, and a
     row-wide handler would make removing an item a mis-tap away from ticking it. Hence
     `cursor:default` overriding the base rule. */
  .ci-row{cursor:default;}
  .ci-boxes{display:flex;gap:6px;flex-shrink:0;}
  /* Same 26px box as `.check-box`, restated rather than shared because this one carries a
     dose NUMBER when a course runs more than once a day, so it needs a visible label in
     the off state where `.check-box` deliberately hides its tick. */
  .ci-box{width:26px;height:26px;border-radius:8px;border:2px solid var(--line);background:transparent;
    display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:var(--w-strong);
    color:var(--muted);cursor:pointer;transition:.12s;padding:0;}
  .ci-box.on{background:var(--moon);border-color:var(--moon);color:var(--on-accent);}
  .ci-row .lbl{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;}
  .ci-until{font-size:12.5px;font-weight:400;color:var(--muted);}
  .ci-del{background:none;border:none;color:var(--muted);font-size:15px;line-height:1;
    padding:6px;cursor:pointer;flex-shrink:0;border-radius:8px;}
  .ci-del:hover{color:var(--danger);}

  .ci-edit{display:flex;flex-direction:column;gap:8px;padding:10px 4px 14px;border-bottom:1px solid var(--line-soft);}
  .ci-field{display:flex;align-items:center;gap:10px;font-size:13.5px;color:var(--muted);}
  .ci-field span{flex-shrink:0;}
  .ci-field .w-input{flex:1;}
  .ci-edit-actions{display:flex;gap:8px;}
  .vr-item-until{font-weight:var(--w-med);color:var(--muted);}

  /* ---- Hormonal season (ROADMAP 13.3) ---- */
  /* The routine's strike-through is wrong here: these rows carry a title plus an
     explanatory note, and striking both makes a ticked row unreadable rather than
     obviously done. The tick and the muted colour already say it. */
  .hz-check{align-items:flex-start;}
  .hz-check .check-box{margin-top:2px;}
  .hz-check .lbl{display:flex;flex-direction:column;gap:3px;}
  .hz-check.on .lbl{text-decoration:none;}
  .hz-note{font-size:13px;font-weight:400;color:var(--muted);line-height:1.5;}
  .hz-window.on{border-color:var(--moon);background:var(--moon-glow);}
  body.dark .hz-window.on{background:rgba(242,208,107,0.10);}
  .hz-window-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px;flex-wrap:wrap;}
  .hz-kicker{font-size:11.5px;font-weight:var(--w-strong);letter-spacing:0.12em;text-transform:uppercase;color:var(--gold-text);}
  /* `.card h3` (one class + one type) outspecifies a bare `.hz-season`, so the
     selector has to carry the type too or the size and margin below are ignored. */
  .card h3.hz-season{font-family:var(--font-display);font-weight:500;font-size:30px;line-height:1.15;margin:6px 0 0;letter-spacing:-0.01em;}
  .hz-window-range{font-size:15px;margin:6px 0 0;}
  .hz-countdown{font-size:13px;color:var(--muted);margin:4px 0 0;}
  /* The timeline. Solid stretches are the risk windows, the dashed track between
     them is the calm months — the picture says they are a different KIND of time,
     which a bar of twelve equal months cannot. Colours come from `var()` inside the
     SVG so the theme (and the eight supporter palettes) reach it; only the text
     needs a CSS rule, because SVG text takes `fill`, not `color`. */
  .hz-tl{display:block;width:100%;height:auto;margin:16px 0 2px;overflow:visible;}
  .hz-tl-label{fill:var(--muted);font-family:var(--font-body);font-size:15px;font-weight:600;}
  .hz-tl-label.on{fill:var(--gold-text);}
  .hz-legend{display:flex;flex-wrap:wrap;gap:6px 16px;font-size:12.5px;color:var(--muted);margin-bottom:16px;}
  .hz-legend span{display:inline-flex;align-items:center;gap:7px;}
  .hz-legend i{flex-shrink:0;display:inline-block;}
  .hz-sw-main{width:22px;height:7px;border-radius:4px;background:var(--moon-deep);}
  /* Matches the band exactly: the two tiers differ by colour, not by alpha, so the
     swatch must not fade or the legend stops describing the picture. */
  .hz-sw-second{width:22px;height:7px;border-radius:4px;background:var(--moon);}
  /* Today is `--ink`, not a third shade of the band gold: it has to read as a
     different KIND of mark, and --ink is the one variable that stays at full
     contrast against the page in all eight palettes and in dark mode. */
  .hz-sw-today{width:10px;height:10px;border-radius:50%;background:var(--ink);box-shadow:0 0 0 2px var(--white);}
  .hz-tier{font-size:13px;font-weight:600;color:var(--muted);margin:3px 0 0;}
  .hz-tier.on{color:var(--gold-text);}
  /* A segmented control, not a label plus a "switch to…" link: the hemisphere is a
     choice between two states, so showing both is what makes the current one
     legible without reading a sentence. Both halves are ≥44px per the action-control
     rules in docs/DECISIONS.md. */
  .hz-hemi{display:flex;gap:6px;padding:4px;border:1px solid var(--line);border-radius:14px;background:var(--cream);}
  body.dark .hz-hemi{background:rgba(0,0,0,0.14);}
  .hz-hemi-opt{flex:1;min-width:0;min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:7px;
    padding:10px 8px;border:0;border-radius:11px;background:transparent;color:var(--muted);
    font-family:var(--font-body);font-size:14px;font-weight:600;cursor:pointer;transition:.14s;}
  .hz-hemi-opt:hover{color:var(--ink);}
  .hz-hemi-opt.on{background:var(--white);color:var(--ink);box-shadow:0 1px 3px rgba(0,0,0,0.08);}
  body.dark .hz-hemi-opt.on{box-shadow:none;border:1px solid var(--line);}
  .hz-hemi-ic{font-size:15px;line-height:1;}
  /* Two classes, to tie with `.card .hint` and win on order — it is the last
     element in the card and must not add a second bottom margin. */
  .hz-window .hz-hemi-hint{margin:10px 0 0;}
  .hz-care.on{border-color:var(--moon);}
  .hz-tags{display:flex;flex-wrap:wrap;gap:8px;}
  /* A chip is a toggle, not a link: 44px tall with a real border and a filled
     selected state, per the action-control system in docs/DECISIONS.md. */
  .hz-tag{min-height:44px;padding:10px 14px;border-radius:22px;border:1px solid var(--line);
    background:var(--white);color:var(--ink);font-family:var(--font-body);font-size:14px;
    font-weight:600;cursor:pointer;transition:.12s;}
  .hz-tag:hover{border-color:var(--moon);}
  .hz-tag.on{background:var(--moon);border-color:var(--moon);color:var(--on-accent);}
  .hz-log-row{display:flex;gap:12px;align-items:baseline;padding:9px 2px;border-bottom:1px solid var(--line-soft);font-size:14px;}
  .hz-log-row:last-child{border-bottom:none;}
  .hz-log-day{flex-shrink:0;font-weight:600;min-width:96px;}
  .hz-log-tags{color:var(--muted);}
  .hz-log-empty{margin:0;}
  /* ---- Health tags (ROADMAP 9.2a) ----
     The chip reuses .hz-tag so the two tag surfaces in the app cannot drift into
     two different controls; .jr-tag only makes room for the glyph. */
  .jr-tag{display:inline-flex;align-items:center;gap:7px;}
  .jr-tag i{font-style:normal;font-size:15px;line-height:1;}
  .jr-log-row{display:flex;gap:12px;align-items:baseline;padding:9px 2px;border-bottom:1px solid var(--line-soft);font-size:14px;}
  .jr-log-row:last-child{border-bottom:none;}
  .jr-log-day{flex-shrink:0;font-weight:600;min-width:96px;}
  .jr-log-tags{font-size:15px;letter-spacing:0.06em;}
  .jr-log-empty{margin:0;}
  /* The day-detail modal's tag list (read-only, like everything else in there). */
  .day-tags{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:14px;padding-top:12px;border-top:1px solid var(--line-soft);}
  .day-tags-cap{width:100%;font-size:12px;font-weight:var(--w-strong);letter-spacing:0.1em;text-transform:uppercase;color:var(--muted);}
  .day-tag{display:inline-flex;align-items:center;gap:6px;font-size:13px;padding:5px 11px;border-radius:16px;background:var(--cream-100);color:var(--ink);}
  /* ROADMAP 13.7 — the editable half of the day modal. The hint carries the whole
     rule of the screen ("the routine is fixed, what happened is not"), so it sits
     above the chips rather than at the foot where it would be read after the tap. */
  .day-edit{margin-top:14px;padding-top:12px;border-top:1px solid var(--line-soft);}
  .day-edit-hint{margin:0 0 10px;}
  .day-edit .day-tags{margin-top:14px;}
  .day-edit .day-note{margin-top:14px;}
  /* The red-flag guidance travels with the hormonal chips into the day modal, and shows
     only while one of them is on. Tighter than the full-page callout because it sits
     inside a modal body that is already scrolling. */
  /* ---- ROADMAP 13.4, the first week at home ----
     The tick control is `.check-item`/`.check-box`, reused rather than reinvented, the
     same way `.tr-step` does it. Only what is genuinely new to this screen is below.
     Everything here was missing when the screen first shipped: nine of its ten classes
     had no rule at all, so the tick was invisible and the meter had no fill. */
  .fw-step{align-items:flex-start;border-bottom:none;padding-bottom:4px;}
  .fw-step .check-box{margin-top:1px;}
  /* The routine strikes through a ticked label; a finished day is not a crossed-out
     day, it is a day you got through. */
  .fw-step.on .lbl{text-decoration:none;}
  .fw-ic{margin-right:2px;}
  .fw-sum{font-size:13.5px;color:var(--muted);line-height:1.6;margin:6px 0 10px;}
  .fw-items{margin:0;padding-left:20px;display:flex;flex-direction:column;gap:8px;}
  .fw-items li{font-size:14px;line-height:1.55;}
  .fw-flags{margin:10px 0 12px;padding-left:20px;display:flex;flex-direction:column;gap:8px;}
  .fw-flags li{font-size:14px;line-height:1.55;}
  .fw-card .btn{margin-top:10px;}
  .fw-next{border:1px solid var(--moon-deep);}
  /* per-day citations, so tighter than `.trouble-sources`, which is an
     end-of-page block with its own heading. The links themselves are `.trouble-source`
     because they carry the full source title; `.fsource` is the hazard-card chip and is
     scoped under `.food-card`, so using it here matched nothing at all. */
  .fw-sources{margin-top:10px;padding-top:8px;border-top:1px solid var(--line-soft);}
  .fw-sources .trouble-source{padding:5px 0;font-size:12.5px;}
  /* Inside the red callout the gold would fight the warning colour. */
  .callout.danger .fw-sources{border-top-color:rgba(179,38,30,0.18);}
  .callout.danger .fw-sources .trouble-source{color:var(--danger);opacity:0.85;}
  .day-redflag{margin:10px 0 0;padding:10px 12px;}
  .day-redflag p{font-size:13px;margin:4px 0 6px;}
  /* "On this day in {year}" — the whole row is the control. */
  .jr-ann-open{display:flex;align-items:center;gap:12px;width:100%;text-align:left;padding:4px 0 0;background:none;border:0;cursor:pointer;}
  .jr-ann-pic{width:56px;height:56px;border-radius:10px;object-fit:cover;flex-shrink:0;}
  .jr-ann-body{display:flex;flex-direction:column;gap:4px;min-width:0;}
  .jr-ann-tags{font-size:17px;line-height:1.2;}
  /* Clamped rather than truncated with an ellipsis at one line: a note is prose and two
     lines is usually the whole of it. */
  .jr-ann-note{font-size:14px;color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
  .day-tag i{font-style:normal;font-size:14px;}
  .day-note{margin-top:14px;padding-top:12px;border-top:1px solid var(--line-soft);}
  /* Free text written by a household member: it must wrap and never widen the
     modal, whatever was pasted into it. `anywhere` rather than `break-word` so a
     long unbroken URL cannot push the card sideways. */
  .day-note p{margin:6px 0 0;font-size:14px;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere;}
  /* ---- Day notes and vet visits (ROADMAP 9.2b) ---- */
  .jr-notice{margin:12px 0;}
  .jr-notice .btn{margin-top:10px;}
  .jr-note-for{font-size:13px;color:var(--muted);margin:12px 0 6px;}
  .jr-note-input{width:100%;box-sizing:border-box;padding:12px 14px;border-radius:12px;
    border:1px solid var(--line);background:var(--white);color:var(--ink);
    font-family:var(--font-body);font-size:15px;line-height:1.5;resize:vertical;}
  .jr-note-input:focus{outline:none;border-color:var(--moon-deep);}
  .jr-note-foot{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
  .jr-count{margin-left:auto;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums;}
  .jr-note-row{display:flex;align-items:flex-start;gap:8px;border-bottom:1px solid var(--line-soft);}
  .jr-note-row:last-child{border-bottom:none;}
  .jr-note-row.on{background:var(--cream-100);border-radius:10px;}
  .jr-note-open{flex:1;min-width:0;display:block;text-align:left;background:none;border:none;
    padding:10px 6px;font-family:var(--font-body);color:var(--ink);cursor:pointer;}
  .jr-note-day{display:block;font-size:12px;font-weight:600;color:var(--gold-text);}
  /* One line, ellipsised: the list is an index, and the editor above is where a
     note is read in full. min-width:0 on the flex parent is what makes the
     ellipsis possible at all. */
  .jr-note-peek{display:block;font-size:14px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
  .jr-note-del{flex-shrink:0;align-self:center;width:32px;height:32px;border-radius:8px;border:1px solid var(--line);
    background:var(--white);color:var(--muted);font-size:13px;cursor:pointer;}
  .jr-note-del:hover{border-color:var(--danger-line);color:var(--danger);}
  .jr-due{margin:10px 0 0;font-size:14px;font-weight:var(--w-med);}
  .vv-label{display:block;font-size:12px;font-weight:var(--w-strong);letter-spacing:0.08em;
    text-transform:uppercase;color:var(--muted);margin:14px 0 6px;}
  .vv-input{width:100%;box-sizing:border-box;}
  .vv-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;}
  .vv-head h3{margin:0;}
  .vv-actions{display:flex;gap:8px;flex-wrap:wrap;}
  .vv-field{margin-top:12px;}
  .vv-lbl{font-size:12px;font-weight:var(--w-strong);letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);}
  .vv-field p{margin:4px 0 0;font-size:15px;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere;}
  .vv-followup{margin-top:12px;font-size:14px;font-weight:var(--w-med);}
  /* The report is printed, so its two 9.2b sections need the same wrapping rules
     as the screen ones or a pasted URL runs off the paper. */
  .vr-visits,.vr-notes{list-style:none;padding:0;margin:0;}
  .vr-visits li,.vr-notes li{padding:8px 0;border-bottom:1px solid var(--line-soft);font-size:14px;line-height:1.55;overflow-wrap:anywhere;}
  .vr-visits li:last-child,.vr-notes li:last-child{border-bottom:none;}
  .vr-visit-f{margin-top:3px;}
  .vr-visit-f span{font-weight:var(--w-strong);}
  .vr-note-cap{font-size:12.5px;color:var(--muted);margin:0 0 8px;}
  /* ---- Day photos (ROADMAP 9.2c) ---- */
  .dp-grid{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
  .dp-thumb{position:relative;width:92px;height:92px;}
  .dp-grid-sm .dp-thumb{width:68px;height:68px;}
  .dp-open{display:block;width:100%;height:100%;padding:0;border:1px solid var(--line);
    border-radius:12px;overflow:hidden;background:var(--cream-100);cursor:pointer;}
  .dp-open img{width:100%;height:100%;object-fit:cover;display:block;}
  .dp-del{position:absolute;top:-6px;right:-6px;width:26px;height:26px;border-radius:50%;
    border:1px solid var(--line);background:var(--white);color:var(--muted);font-size:12px;
    line-height:1;cursor:pointer;}
  .dp-del:hover{border-color:var(--danger-line);color:var(--danger);}
  /* A label wrapping a hidden file input: the whole tile is the tap target, which a
     bare <input type=file> cannot be styled into being. */
  .dp-add{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;
    width:92px;height:92px;border:1px dashed var(--line);border-radius:12px;cursor:pointer;
    color:var(--muted);background:var(--white);}
  .dp-add:hover{border-color:var(--moon-deep);color:var(--ink);}
  .dp-add span:first-of-type{font-size:22px;line-height:1;}
  .dp-add-l{font-size:11px;text-align:center;padding:0 4px;}
  .dp-status{margin-top:10px;font-size:13px;color:var(--muted);}
  /* The paid photo tier's offer, inside the photo-count explanation. Bordered and quiet
     rather than a coloured call to action: D4 says the app is free and keeps being
     described as free, and a bright button here would make the free ceiling read as a
     wall it was never meant to be. The contact line sits above it, unchanged. */
  .billing-box{margin-top:16px;padding:14px;border:1px solid var(--line);border-radius:14px;background:var(--cream-100);}
  .billing-box h3{font-size:15px;margin-bottom:4px;}
  /* SIDE BY SIDE, not stacked (owner, 2026-08-14). Two full-width buttons one under the
     other read as a queue of actions rather than as a choice between two prices; a pair
     you can compare in one glance is what a price is for. `1fr 1fr` and not `auto-fit`:
     the two cards must stay the same width even when one carries a caption and the other
     does not, or the emphasis lands on whichever string happened to be longer. */
  .billing-plans{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px;}
  .billing-plan{min-height:56px;display:flex;flex-direction:column;align-items:center;justify-content:center;
    gap:3px;padding:10px 8px;border:1px solid var(--line);border-radius:12px;background:var(--white);
    color:var(--ink);font-size:15px;line-height:1.25;text-align:center;}
  /* Carried by a BORDER, never by a fill. A gold block here would be the brightest thing
     on a screen whose subject is that the app is free — D4 again, and the same reason
     `.hz-hemi-opt.on` marks the live option with a surface change instead of a colour. */
  .billing-plan-best{border-color:var(--moon-deep);box-shadow:0 1px 3px rgba(0,0,0,0.07);}
  body.dark .billing-plan-best{box-shadow:none;}
  .billing-plan-l{font-weight:var(--w-strong);}
  /* Where `.hz-kicker` sits on the hormonal card, minus its uppercase: "two months free"
     is a kindness, and letter-spaced capitals would sell it. */
  .billing-plan-n{font-size:11.5px;font-weight:var(--w-strong);color:var(--gold-text);}
  .billing-plan[disabled]{opacity:0.55;}
  /* The reason the box exists, and the reason it is last: separated by a rule so it reads
     as the note under a receipt rather than as more of the offer. */
  .billing-why{margin-top:12px;padding-top:10px;border-top:1px solid var(--line-soft);}
  /* `.hint` is muted, and bold muted text reads as quieter rather than louder — which is
     the opposite of what the owner asked this sentence to do. The lead takes full `--ink`
     so the claim carries at a glance and the explanation behind it stays quiet. */
  .billing-why strong{color:var(--ink);}
  /* The thank-you. No divider above it — it belongs to the same thought as `.billing-why`
     and a rule would turn one explanation into two notices. The accent tint is the only
     warm surface in this box, which is the point: everything above it is the app defending
     a price, and this is the app giving something.

     The heading is the Account panel's, deliberately: same ✦, same words. A reader who
     has seen "Supporting Kiri" there must recognise this as the same programme. The mark
     is smaller here (22px against the panel's 42px) because it is titling a paragraph
     inside a box rather than opening a section. */
  .billing-thanks{margin-top:9px;padding:11px 12px;border-radius:10px;
    background:var(--moon-glow);color:var(--gold-text);}
  .billing-thanks h4{display:flex;align-items:center;gap:8px;margin:0 0 5px;
    font-family:var(--font-display);font-size:15px;line-height:1.25;color:var(--gold-text);}
  .billing-thanks-mark{display:flex;align-items:center;justify-content:center;
    width:22px;height:22px;flex-shrink:0;border-radius:7px;font-size:12px;
    background:var(--white);border:1px solid var(--moon-deep);}
  .billing-thanks p{margin:0;font-size:12.5px;line-height:1.55;color:inherit;}
  /* --moon-glow stays a LIGHT tint on every dark theme (see the .flip-back note), so the
     surface is a translucent white and the text takes --ink — the same substitution the
     other ~30 body.dark rules make, and for the same reason. */
  body.dark .billing-thanks{background:rgba(255,255,255,0.07);color:var(--ink);}
  body.dark .billing-thanks h4{color:var(--ink);}
  body.dark .billing-thanks-mark{background:rgba(255,255,255,0.10);}
  /* 320 px with a long price string in it — German and Romanian both wrap "two months
     free" onto a second line, and Thai does not wrap at all. Below this the pair costs
     more than it earns. */
  @media (max-width:359px){ .billing-plans{grid-template-columns:1fr;} }
  /* A photo that has not reached the server yet. Bordered rather than muted like the
     status line above it: the point of the sentence is that nothing is lost, and a
     message about a photo has to be readable enough to be believed. Not `--danger` —
     nothing here is the reader's fault or their emergency. */
  .dp-queued{padding:9px 11px;border:1px solid var(--line);border-left:3px solid var(--gold-text);
             border-radius:10px;background:var(--white);color:var(--ink);line-height:1.45;}
  .dp-queued a{color:var(--gold-text);font-weight:var(--w-strong);}
  .dp-empty,.dp-full-note{margin:10px 0 0;}
  /* The viewer must not force the modal wider than the screen, whatever the photo's
     aspect ratio is. */
  .dp-full{display:block;max-width:100%;max-height:70vh;max-height:70dvh;margin:0 auto;border-radius:12px;}
  /* ---- Training modules (ROADMAP 13.1) ---- */
  .tr-note{margin:-4px 0 16px;}
  .tr-card{cursor:pointer;transition:border-color .14s;}
  .tr-card:hover{border-color:var(--moon);}
  .tr-card.done{border-color:var(--moon);}
  .tr-card-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
  /* Flex default is `stretch`, which would make the emoji box as tall as a wrapped
     two-line heading and float the icon at the top. */
  .tr-ic{font-size:22px;line-height:1;flex-shrink:0;}
  .tr-card-head h3{margin:0;flex:1;min-width:0;}
  .tr-summary{font-size:14px;color:var(--muted);line-height:1.55;margin:8px 0 14px;}
  /* A meter, not a percentage: the useful comparison is between the six modules at
     a glance, and six numbers do not compare as fast as six bars. `--line-soft` is
     the track so an untouched module still shows the bar exists rather than
     rendering as blank space. */
  .tr-meter{height:6px;border-radius:3px;background:var(--line-soft);overflow:hidden;}
  .tr-meter-fill{display:block;height:100%;background:var(--moon-deep);border-radius:3px;transition:width .2s;}
  .tr-card-foot{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-top:9px;}
  /* `.card .hint` is two classes and would beat a bare `.tr-card-foot .hint` on
     specificity alone, so the bottom margin has to be cancelled explicitly or the
     card gains a gap under its own last line. */
  .tr-card .tr-card-foot .hint{margin:0;font-size:12.5px;}
  .tr-go{font-size:13px;font-weight:var(--w-med);color:var(--gold-text);flex-shrink:0;}
  .tr-mod-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px;flex-wrap:wrap;}
  .tr-mod-head h3{margin:0;}
  /* Same decision as `.hz-check`: these rows are a title plus a paragraph, and the
     daily routine's strike-through would make a ticked step unreadable exactly when
     the reader wants to re-read what they just did. The tick and the muted colour
     carry it instead. */
  .tr-step{align-items:flex-start;}
  .tr-step .check-box{margin-top:2px;}
  .tr-step .lbl{display:flex;flex-direction:column;gap:4px;}
  .tr-step.on .lbl{text-decoration:none;}
  .tr-step-n{color:var(--muted);font-variant-numeric:tabular-nums;}
  .tr-step-body{font-size:13.5px;font-weight:400;color:var(--muted);line-height:1.6;}
  .tr-reset{margin-top:16px;}
  .tr-entry{border-color:var(--moon);}
  /* The Tricks heading on the `#training` hub. It deliberately mirrors the modules
     card's own head — same display font, same badge — because the two are siblings
     on that screen and the eye should read them as one list of two things, not as a
     card followed by a loose list. It sits OUTSIDE a card, so `.card h3` does not
     reach it and the type has to be restated here. */
  .tr-section-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px;
    flex-wrap:wrap;margin:26px 0 0;padding:0 2px;}
  .tr-section-head h3{font-family:var(--font-display);font-weight:500;font-size:19px;margin:0;}
  .tr-section-hint{margin:6px 2px 14px;font-size:13px;color:var(--muted);}
  @media(max-width:420px){
    .hz-log-row,.jr-log-row{flex-direction:column;gap:2px;}
    .hz-log-day,.jr-log-day{min-width:0;}
  }
  .w-row{display:flex;gap:10px;align-items:center;}
  /* width:100% is for the STANDALONE uses (auth, onboarding, invite, household
     picker), which sit in a plain block and would otherwise fall back to the
     browser's ~20-character default width. Inside a .w-row the `flex:1` shorthand
     sets flex-basis:0%, which wins over width, so the weight/birth/food rows keep
     sharing the line with their button exactly as before. */
  .w-input{flex:1;min-width:0;width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:11px;font-family:var(--font-body);font-size:16px;background:var(--cream);color:var(--ink);}
  .w-input:focus{outline:none;border-color:var(--moon-deep);}
  .password-input{position:relative;}
  .password-input .w-input{padding-right:56px;}
  .password-toggle{position:absolute;top:50%;right:3px;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;width:44px;height:44px;padding:10px;border-radius:9px;background:transparent;color:var(--gold-text);line-height:1;}
  .password-eye{width:20px;height:20px;fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.9;}
  .password-toggle:hover{background:var(--cream-100);}
  .password-toggle:focus-visible{outline:2px solid var(--moon-deep);outline-offset:1px;}
  .w-latest{display:flex;align-items:baseline;gap:10px;margin:4px 0 14px;}
  .w-latest b{font-family:var(--font-display);font-weight:600;font-size:34px;}
  .w-latest .delta{font-size:13px;font-weight:var(--w-strong);}
  .w-alert{background:var(--danger-bg);border:1.5px solid var(--danger-line);color:var(--danger);border-radius:12px;padding:13px 16px;font-size:14px;font-weight:var(--w-med);margin:4px 0 14px;display:flex;gap:9px;align-items:flex-start;}
  /* The informational band of the weight alert (>=3%, below the >=5% clinical
     threshold). Deliberately NOT the danger palette and deliberately without a
     link to Emergency: it is an observation to weigh again, not a call to act. */
  .w-watch{background:var(--warn-bg);border:1.5px solid var(--warn-line);color:var(--warn);border-radius:12px;padding:13px 16px;font-size:14px;font-weight:var(--w-med);margin:4px 0 14px;display:flex;gap:9px;align-items:flex-start;}
  .w-ok{font-size:12.5px;color:var(--muted);margin-bottom:14px;}
  .w-chart{width:100%;height:auto;display:block;margin-top:4px;}
  .w-empty{font-size:13px;color:var(--muted);text-align:center;padding:20px 0;}
  /* ---- Health-tag strip under the weight chart (ROADMAP 9.2a) ----
     Same width:100% + viewBox width as .w-chart, which is what keeps a marker over
     the weighing it belongs to at every container size. Do not give either one a
     fixed width or a padding of its own. */
  .w-marks{width:100%;height:auto;display:block;margin-top:2px;}
  /* Set in SVG user units, so it scales with the drawing rather than with the page
     text — a 15px label under a chart squeezed to 320px would be half its width. */
  .w-mark{font-size:15px;}
  .w-mark-more{font-size:11px;font-weight:600;fill:var(--muted);font-family:var(--font-body);}
  .w-marks-key{display:flex;flex-wrap:wrap;gap:5px 14px;font-size:12px;color:var(--muted);margin:6px 0 2px;}
  .w-marks-key span{display:inline-flex;align-items:center;gap:5px;}
  .w-marks-key i{font-style:normal;font-size:13px;}
  /* ---- Streak calendar ---- */
  .cal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}
  .cal-nav{width:32px;height:32px;border-radius:8px;background:var(--white);border:1px solid var(--line);font-size:16px;color:var(--ink);font-weight:var(--w-strong);}
  .cal-nav:hover{border-color:var(--moon-deep);}
  .cal-nav:disabled{opacity:0.35;cursor:default;}
  .cal-title{font-family:var(--font-display);font-weight:500;font-size:18px;}
  .cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:5px;}
  .cal-wd{text-align:center;font-size:11px;font-weight:var(--w-strong);color:var(--muted);text-transform:uppercase;padding-bottom:2px;}
  .cal-cell{aspect-ratio:1;border-radius:10px;display:flex;align-items:center;justify-content:center;position:relative;background:var(--cream-100);}
  .cal-cell.empty{background:transparent;}
  .cal-cell .cd{font-size:12px;font-weight:var(--w-med);color:var(--muted);}
  .cal-cell.done{background:var(--moon-glow);}
  .cal-cell.done .cd{position:absolute;top:3px;left:6px;font-size:9px;color:var(--gold-text);}
  .cal-cell.done .fire{font-size:17px;line-height:1;}
  .cal-cell.partial{background:var(--moon-glow);opacity:0.45;}
  .cal-cell.today{outline:2px solid var(--moon-deep);outline-offset:-2px;}
  .cal-cell:not(.empty){cursor:pointer;transition:filter .12s;}
  .cal-cell:not(.empty):hover{filter:brightness(0.96);}
  body.dark .cal-cell{background:rgba(255,255,255,0.05);}
  body.dark .cal-cell.done{background:rgba(242,208,107,0.18);}
  body.dark .cal-cell:not(.empty):hover{filter:brightness(1.3);}
  /* ROADMAP 13.7 — "there is something in the journal on this day". A separate mark
     rather than another cell tint, because it is independent of the routine: a day can
     carry a note and no ticks, or a full routine and no note, and the two must stay
     readable together. --ink rather than --moon-deep so it never reads as a dimmer
     version of the streak state. Positioned bottom-centre, clear of .done's corner
     day-number and its 🔥. */
  .cal-dot{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);width:5px;height:5px;border-radius:50%;background:var(--ink);opacity:0.55;}
  .cal-cell.done .cal-dot{background:var(--gold-text);opacity:0.9;}
  body.dark .cal-dot{background:var(--cream);opacity:0.6;}
  /* THE CAMERA IS THE SECOND MEANING THE DOT USED TO CARRY ALONE. A day with one health
     tag and a day with four photographs drew the identical mark until 2026-08-16, so the
     calendar could not be used to find a picture. Sized to sit where the dot sat — the
     two are alternatives, never both, because two marks in a 28 px cell is a smudge. */
  .cal-cam{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);font-size:9px;line-height:1;opacity:.9;}
  @media(max-width:360px){.cal-cam{font-size:8px;bottom:1px;}}
  .cal-legend-dot .cal-cam{position:static;transform:none;font-size:12px;flex-shrink:0;}
  .cal-legend-dot a{color:var(--gold-text);font-weight:var(--w-strong);}
  .cal-legend-dot{display:flex;align-items:center;gap:8px;margin:6px 0 0;}
  /* In the legend the dot is inline, so the absolute positioning above must not apply. */
  .cal-legend-dot .cal-dot{position:static;transform:none;flex-shrink:0;}
  .day-item{display:flex;align-items:center;gap:12px;padding:10px 2px;border-bottom:1px solid var(--line-soft);font-size:15px;}
  .day-item:last-child{border-bottom:none;}
  .day-box{width:24px;height:24px;border-radius:7px;border:2px solid var(--line);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:var(--w-strong);color:transparent;}
  .day-item.on .day-box{background:var(--moon);border-color:var(--moon);color:var(--on-accent);}
  .day-item:not(.on){color:var(--muted);}
  .day-w{margin-top:14px;font-size:15px;}

  /* ---- Emergency screen ---- */
  .emg h2{color:var(--danger);}
  .emg-call{display:flex;align-items:center;gap:14px;background:var(--white);border:1px solid var(--danger-line);border-radius:14px;padding:15px 18px;text-decoration:none;color:var(--ink);margin-bottom:10px;}
  .emg-call:hover{border-color:var(--danger);}
  .emg-call .ic{width:42px;height:42px;border-radius:11px;background:var(--danger-bg);display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0;}
  .emg-call .nm{display:block;font-weight:var(--w-strong);font-size:15px;}
  .emg-call .no{display:block;font-size:13px;color:var(--muted);margin-top:2px;}
  .emg-call .tel{margin-left:auto;color:var(--danger);font-weight:var(--w-strong);font-size:20px;}
  .vet-247{display:inline-block;font-size:11px;font-weight:var(--w-strong);color:var(--danger);background:var(--danger-bg);border-radius:6px;padding:1px 6px;margin-left:6px;vertical-align:middle;}
  .vet-empty{margin-bottom:12px;}
  /* The button and the "how do I find one" link are two separate targets and were
     rendering flush against each other, reading as one control. */
  .vet-empty-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px;}
  .vet-empty-actions .btn,.vet-empty-actions .action-link{margin-top:10px;}
  .emg-tools{display:flex;gap:8px;flex-wrap:wrap;margin:2px 0 4px;}
  .emg-tools .btn{flex:1;min-width:140px;}
  .vet-edit{padding:14px 16px;margin-bottom:10px;}
  .vet-edit .profile-input{margin-bottom:8px;}
  /* Two real buttons now, so they wrap onto their own lines on a narrow phone
     instead of squeezing the remove action off the card. */
  .vet-edit-row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:2px;flex-wrap:wrap;}
  .vet-edit-row .btn{flex:1;min-width:132px;margin:0;}
  .print-btn{display:inline-flex;align-items:center;gap:8px;background:var(--night);color:var(--moon);font-weight:var(--w-strong);font-size:14px;padding:11px 18px;border-radius:11px;margin-top:6px;}

  /* ---- Hazards directory ---- */
  .hazard-filters{display:flex;flex-direction:column;gap:10px;margin:-4px 0 18px;}
  .hazard-filter-row{display:flex;align-items:center;gap:7px;flex-wrap:wrap;}
  .hazard-filter-label{min-width:64px;font-size:11px;font-weight:var(--w-strong);letter-spacing:.06em;text-transform:uppercase;color:var(--muted);}
  .hazard-filter{background:var(--white);color:var(--ink);border:1px solid var(--line);border-radius:999px;padding:10px 15px;font-family:var(--font-body);font-size:13.5px;font-weight:var(--w-strong);line-height:1.1;}
  .hazard-filter:hover{border-color:var(--moon);}
  .hazard-filter.on{background:var(--night);border-color:var(--night);color:var(--moon);}
  body.dark .hazard-filter.on{background:var(--moon);border-color:var(--moon);color:var(--on-accent);}
  .hazard-count{font-size:12px;color:var(--muted);margin:0 0 10px;}
  .food-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;}
  @media(max-width:720px){.food-grid{grid-template-columns:1fr 1fr;}}
  @media(max-width:480px){.food-grid{grid-template-columns:1fr;}.hazard-filter-label{width:100%;}.hazard-filter-row{gap:6px;}}
  .food-card{background:var(--white);border:1px solid var(--line-soft);border-top:4px solid var(--line);border-radius:12px;padding:12px 13px;}
  .food-card.safe{border-top-color:var(--safe);}
  .food-card.warn{border-top-color:var(--warn);}
  .food-card.unknown{border-top-color:var(--muted);}
  .food-card.danger{border-top-color:var(--danger);}
  .food-card .fn{font-family:var(--font-display);font-weight:500;font-size:16px;line-height:1.1;}
  .food-card .fz{font-size:11.5px;font-weight:var(--w-strong);margin-top:5px;}
  .food-card.safe .fz{color:var(--safe);}
  .food-card.warn .fz{color:var(--warn);}
  .food-card.unknown .fz{color:var(--muted);}
  .food-card.danger .fz{color:var(--danger);}
  .food-card .fnote{font-size:11.5px;color:var(--muted);margin-top:5px;line-height:1.35;}
  .food-card .fkind{display:inline-block;font-size:9.5px;font-weight:var(--w-strong);letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;}
  .food-card .fscientific{font-size:10.5px;color:var(--muted);font-style:italic;margin-top:3px;}
  .food-card .fsources{display:flex;flex-wrap:wrap;gap:5px;margin-top:9px;padding-top:8px;border-top:1px solid var(--line-soft);}
  .food-card .fsource{display:inline-flex;align-items:center;min-height:32px;padding:6px 0;
    font-size:12px;font-weight:var(--w-strong);color:var(--gold-text);text-decoration:none;}
  .food-card .fsource:hover{text-decoration:none;}

  /* ---- Home tools row ---- */
  .tools-row{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-top:12px;}
  @media(min-width:640px){.tools-row{grid-template-columns:repeat(4,1fr);}}
  .tool-btn{display:flex;flex-direction:column;align-items:center;text-align:center;gap:8px;background:var(--white);border:1px solid var(--line-soft);border-radius:14px;padding:15px 8px;font-weight:var(--w-strong);font-size:12.5px;line-height:1.25;transition:border-color .12s,transform .12s;}
  .tool-btn:hover{border-color:var(--moon);transform:translateY(-2px);}
  .tool-btn .ic{font-size:24px;flex-shrink:0;}

  /* ---- Tricks section ---- */
  .trick-list{display:flex;flex-direction:column;gap:11px;}
  .trick-card{background:var(--white);border:1px solid var(--line-soft);border-radius:14px;overflow:hidden;}
  .trick-hd{display:flex;align-items:center;gap:12px;padding:14px 16px;}
  .trick-box2{width:26px;height:26px;border-radius:8px;border:2px solid var(--line);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:15px;color:transparent;background:transparent;}
  .trick-card.on .trick-box2{background:var(--lime);border-color:var(--lime);color:var(--night);}
  .trick-name{flex:1;display:flex;align-items:center;justify-content:space-between;gap:8px;background:none;font-family:var(--font-display);font-weight:500;font-size:17px;text-align:left;}
  .trick-card.on .trick-name{color:var(--muted);}
  .trick-name .chev{color:var(--gold-text);font-size:13px;transition:transform .2s;flex-shrink:0;}
  .trick-card.open .trick-name .chev{transform:rotate(180deg);}
  .trick-how{display:none;padding:0 16px 16px 54px;font-size:14.5px;color:var(--ink);line-height:1.55;}
  .trick-card.open .trick-how{display:block;}

  /* ---- Nudges ---- */
  .nudges{display:flex;flex-direction:column;gap:10px;margin-top:6px;}
  .nudge{display:flex;align-items:center;gap:12px;border-radius:14px;padding:13px 16px;border:1px solid var(--moon);background:var(--moon-glow);}
  body.dark .nudge{background:rgba(242,208,107,0.12);}
  .nudge.info{border-color:var(--sky-200,#A7CBD6);background:var(--sky-light);}
  body.dark .nudge.info{background:rgba(203,225,232,0.12);border-color:rgba(203,225,232,0.3);}
  .nudge .ic{font-size:20px;flex-shrink:0;}
  .nudge .nt{flex:1;font-size:13.5px;font-weight:var(--w-med);color:var(--ink);}
  .nudge .na{display:inline-flex;align-items:center;min-height:40px;padding:8px 12px;margin:-8px 0;
    border-radius:10px;font-weight:var(--w-strong);font-size:14.5px;color:var(--gold-text);white-space:nowrap;text-decoration:none;}
  .nudge .na:hover{background:rgba(217,174,62,0.16);}
  .nudge .nx{display:flex;align-items:center;justify-content:center;width:38px;height:38px;margin:-8px -6px -8px 0;
    background:none;color:var(--gold-text);font-size:17px;border-radius:10px;flex-shrink:0;}
  .nudge .nx:hover{background:rgba(217,174,62,0.16);}
  /* ---- how to install it, per device (v0.63) ----
     This replaced an alert() with two sentences in it. The layout does one job: make the
     three steps SCANNABLE — a number, the control's own shape, and one short line — so a
     reader who is holding the phone can match a glyph rather than parse a sentence.
     app/app/install.js explains why the control is drawn instead of described. */
  .ins-steps-box{margin-top:12px;}
  .ins-chip{display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;
    min-height:44px;padding:9px 14px;border:1px solid var(--line);border-radius:12px;
    background:var(--cream-100);color:var(--ink);font-size:13px;font-weight:var(--w-strong);text-align:left;}
  .ins-chip-now{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .ins-chip-change{flex-shrink:0;color:var(--gold-text);font-size:12px;}
  .ins-chip-done{justify-content:center;margin-top:4px;}
  .ins-picker{display:flex;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--line);
    border-radius:14px;background:var(--cream-100);}
  .ins-row{display:flex;flex-wrap:wrap;gap:6px;}
  /* 44 px, not 40: the 2026-08-16 UX audit (KQA-08) found tap targets of 21–40 px across
     the app and set that floor. New controls start on the right side of it. */
  .ins-opt{min-height:44px;padding:8px 13px;border:1px solid var(--line);border-radius:999px;
    background:var(--white);color:var(--ink);font-size:12.5px;font-weight:var(--w-strong);}
  .ins-opt.on{background:var(--moon);color:var(--on-accent);border-color:var(--moon-deep);}
  .ins-steps{list-style:none;margin:14px 0 0;padding:0;display:flex;flex-direction:column;gap:10px;counter-reset:none;}
  .ins-steps li{display:flex;align-items:center;gap:12px;padding:12px 14px;
    border:1px solid var(--line-soft);border-radius:14px;background:var(--white);}
  .ins-num{flex-shrink:0;width:24px;height:24px;border-radius:50%;display:grid;place-items:center;
    background:var(--moon);color:var(--on-accent);font-size:12.5px;font-weight:var(--w-strong);}
  .ins-ic{flex-shrink:0;width:26px;height:26px;color:var(--gold-text);}
  .ins-ic svg{width:100%;height:100%;display:block;}
  .ins-txt{font-size:13.5px;line-height:1.45;font-weight:var(--w-med);color:var(--ink);}
  .ins-where{margin-top:12px;}
  .ins-sheet .modal-body{padding-bottom:calc(22px + env(safe-area-inset-bottom,0px));}

  /* ---- "show me where" ----
     THE ARROW POINTS AT A SCREEN EDGE, NOT AT A BUTTON, and that is the honest limit: the
     browser's toolbar is not in the document and no API reports where its menu sits. What
     is knowable is which edge the control lies beyond, which is the whole useful content
     of "it is over there". The layer covers everything so the reader looks up from the
     page and at the frame around it. */
  .ins-where-layer{position:fixed;inset:0;z-index:120;display:flex;flex-direction:column;
    align-items:center;justify-content:center;gap:16px;padding:24px;
    background:rgba(17,23,34,0.72);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);}
  .ins-where-note{margin:0;max-width:22ch;text-align:center;color:var(--moon-glow);
    font-family:var(--font-display);font-size:19px;line-height:1.3;}
  .ins-where-arrow{width:44px;height:70px;color:var(--moon);filter:drop-shadow(0 0 12px rgba(242,208,107,.5));}
  .ins-where-arrow svg{width:100%;height:100%;display:block;}
  .ins-where-done{min-width:140px;}
  /* Each variant parks the arrow at its edge and turns it to face out of the screen. The
     layer is a flex column, so `position:absolute` on the arrow takes it out of that flow
     and the note and button stay centred underneath. */
  .ins-where-layer .ins-where-arrow{position:absolute;}
  .where-top-right .ins-where-arrow{top:calc(10px + env(safe-area-inset-top,0px));right:14px;
    animation:insPointUp 1.15s ease-in-out infinite;}
  /* 96px WAS THE DOWNLOADS BUTTON. Chrome and Edge put the install icon INSIDE the address
     bar, at its right end — and the toolbar icons that sit to the RIGHT of the address bar
     (zoom, favourites, extensions, collections, downloads, profile) are what push it
     inward. The owner's Edge had six of them, about 210 px; 96 px landed squarely on the
     download arrow, which is a different control doing a different thing.

     Six icons is not a constant — a fresh install has fewer, a loaded one more — so this
     scales with the window and the WORDS carry the precision the arrow cannot
     (`install.whereAddress`, "at the right-hand end of the address bar"). Being 40 px out
     beside a 44 px glowing arrow is fine; being on the wrong button is not. */
  .where-address .ins-where-arrow{top:10px;right:clamp(150px,18vw,290px);
    animation:insPointUp 1.15s ease-in-out infinite;}
  /* Said in the same voice as a hint, deliberately quieter than the steps above it: it is
     a caveat about them, not a step of its own. */
  .ins-unsure{margin:12px 0 0;padding:11px 13px;border:1px dashed var(--line);
    border-radius:12px;background:var(--cream-100);font-size:12.5px;line-height:1.5;}
  .where-bottom .ins-where-arrow{bottom:calc(10px + env(safe-area-inset-bottom,0px));left:50%;margin-left:-22px;
    transform:rotate(180deg);animation:insPointDown 1.15s ease-in-out infinite;}
  .where-bottom-right .ins-where-arrow{bottom:calc(10px + env(safe-area-inset-bottom,0px));right:14px;
    transform:rotate(180deg);animation:insPointDown 1.15s ease-in-out infinite;}
  @keyframes insPointUp{0%,100%{transform:translateY(6px);}50%{transform:translateY(-6px);}}
  @keyframes insPointDown{0%,100%{transform:rotate(180deg) translateY(6px);}50%{transform:rotate(180deg) translateY(-6px);}}
  /* The arrow still points; it just stops travelling. A static arrow at the right edge
     says the same thing, which is why this is a removal rather than a substitute. */
  @media(prefers-reduced-motion:reduce){
    .where-top-right .ins-where-arrow,.where-address .ins-where-arrow,
    .where-bottom .ins-where-arrow,.where-bottom-right .ins-where-arrow{animation:none;}
  }

  .nudge.install-nudge{flex-direction:column;align-items:flex-start;gap:8px;}
  .install-nudge-copy{display:flex;flex-direction:column;gap:4px;width:100%;}
  .install-nudge-title{display:flex;align-items:center;gap:10px;}
  .install-nudge-title .ic{font-size:20px;}
  .install-nudge-copy strong{font-size:14px;color:var(--ink);}
  .install-nudge-copy>span{font-size:12.5px;line-height:1.4;color:var(--muted);font-weight:600;}
  .install-nudge-actions{display:flex;align-items:center;justify-content:flex-start;gap:6px;flex-wrap:wrap;width:100%;}
  .install-nudge-actions .btn{display:inline-flex;align-items:center;gap:7px;min-height:44px;padding:10px 14px;font-size:13.5px;white-space:nowrap;}
  .install-download-icon{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0;}
  .install-nudge-skip{display:inline-flex;align-items:center;justify-content:center;min-height:44px;
    padding:9px 10px;background:transparent;border-radius:10px;color:var(--gold-text);
    font-family:var(--font-body);font-size:13.5px;font-weight:var(--w-strong);white-space:nowrap;}
  .install-nudge-skip:hover{background:rgba(217,174,62,0.16);}
  .install-dismiss{display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%;font-size:12.5px;color:var(--muted);font-weight:var(--w-med);}
  .install-dismiss-toggle{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:8px 10px;border-radius:10px;background:transparent;color:var(--gold-text);font-size:13px;font-weight:var(--w-strong);}
  .install-dismiss-toggle:hover{background:rgba(217,174,62,0.16);}
  .install-dismiss-options{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;width:100%;}
  .install-dismiss-options button{min-height:44px;padding:7px 8px;border:1px solid var(--line);border-radius:10px;background:var(--white);color:var(--gold-text);font-size:12px;font-weight:var(--w-strong);line-height:1.25;}
  .install-dismiss-options button:hover{border-color:var(--moon-deep);background:var(--cream-100);}
  /* Kiri companion (4.4b) uses the warm nudge palette, distinct through its art
     and compact vertical CTA rather than a competing blue card. */
  .nudge-main{flex:1;min-width:0;}
  .nudge.mascot{align-items:center;gap:12px;}
  .nudge.mascot .nudge-main{display:flex;flex-direction:column;align-items:flex-start;gap:3px;}
  .nudge.mascot .nt{flex:none;font-weight:600;}
  .nudge.mascot .na{min-height:36px;margin:0 -8px -6px;padding:6px 8px;}
  /* Kiri's own row on Home carries the DRAWING, and it is BIGGER than a row would
     normally allow (owner, 2026-08-16): 72 px read as a notification icon rather than as
     him, which is the same note that made the moment full-screen. 116 px wide is 85 tall
     by the artwork's 940:692 ratio.

     THE ROW GIVES HIM THE HEIGHT RATHER THAN GROWING, and that is what the three rules
     below are doing together. `align-items:center` on `.nudge` would push the whole card
     to 85 px of content plus padding; instead this row tightens its own padding on the
     left and its gap, so the extra size is taken out of the SPACE around him rather than
     added to the card. `.ic` is left alone — every other nudge here uses it for an emoji
     and those are not him. */
  .nudge.mascot{padding-left:10px;gap:8px;}
  .nudge.mascot .mascot-art{width:116px;}
  .nudge.mascot .ma-emoji{font-size:46px;}
  /* On a narrow phone the text is what suffers first, so he gives some back — still half
     again the size he was, and the line beside him keeps a readable measure. */
  @media(max-width:430px){
    .nudge.mascot{padding-left:8px;gap:6px;}
    .nudge.mascot .mascot-art{width:92px;}
    .nudge.mascot .ma-emoji{font-size:38px;}
  }

  /* Legal hub + documents (Phase 7). Long-form prose, so line length and
     spacing matter more than density. */
  .legal-index{display:flex;flex-direction:column;gap:10px;margin-top:4px;}
  .legal-card{display:flex;align-items:center;gap:14px;width:100%;text-align:left;
    border-radius:16px;padding:14px 16px;border:1px solid var(--line);background:var(--card);}
  .legal-card:hover{border-color:var(--moon);}
  .legal-card .lc-ic{font-size:24px;flex-shrink:0;}
  .legal-card .lc-text{flex:1;display:flex;flex-direction:column;gap:2px;}
  .legal-card .lc-title{font-weight:var(--w-strong);font-size:15px;color:var(--ink);}
  .legal-card .lc-lead{font-size:13px;color:var(--muted);font-weight:500;}
  .legal-card .lc-go{color:var(--muted);font-size:18px;flex-shrink:0;}
  .legal-body h3{margin:22px 0 6px;font-size:15.5px;}
  .legal-body h3:first-child{margin-top:0;}
  .legal-body p{font-size:14px;line-height:1.65;color:var(--ink);margin:0 0 10px;}
  .legal-body .legal-list{margin:0 0 10px;padding-left:20px;}
  .legal-body .legal-list li{font-size:14px;line-height:1.6;color:var(--ink);margin-bottom:6px;}
  /* inline-block so the padding actually enlarges the target without breaking
     the prose line box */
  .legal-body a{display:inline-block;padding:3px 0;color:var(--gold-text);font-weight:var(--w-med);text-decoration:none;}
  .entity-table{display:flex;flex-direction:column;gap:8px;margin:2px 0 10px;}
  .entity-row{display:flex;gap:12px;flex-wrap:wrap;font-size:14px;line-height:1.5;}
  .entity-k{min-width:170px;color:var(--muted);font-weight:600;}
  .entity-v{flex:1;color:var(--ink);font-weight:var(--w-med);}
  /* Consent is a deliberate act, so the row is large enough to be an obvious
     target and the label is fully clickable. */
  .consent-row{display:flex;align-items:flex-start;gap:10px;cursor:pointer;
    font-size:13px;line-height:1.5;color:var(--ink);}
  .consent-row input{width:20px;height:20px;flex-shrink:0;margin-top:1px;accent-color:var(--gold-text);}
  /* Onboarding's "already have an account?" block, above the bird form so a
     returning user sees it before filling anything in. A real full-width button,
     not an underlined 12px .reset-link: that was a ~90x16px tap target, and it
     styles its own label because `.hint` is only defined as `.card .hint` — this
     block sits outside the card, so the class did nothing here. */
  .ob-signin{display:flex;flex-direction:column;gap:9px;margin:0 0 18px;}
  .ob-signin span{text-align:center;font-size:13.5px;font-weight:var(--w-med);color:var(--muted);}

  /* ---- Supporter recognition (ROADMAP 5.2) ---- */
  .supporter-badge{display:inline-flex;align-items:center;gap:5px;background:var(--moon-glow);
    color:var(--gold-text);border:1px solid var(--moon-deep);border-radius:20px;
    padding:3px 11px;font-size:12px;font-weight:var(--w-strong);}
  body.dark .supporter-badge{background:rgba(242,208,107,0.14);}
  .account-supporter-badge{margin-top:9px;}
  .supporter-panel{padding:20px;border:1px solid var(--line);border-radius:18px;
    background:linear-gradient(145deg,var(--cream-100),var(--white));}
  .supporter-panel-active{border-color:var(--moon-deep);
    box-shadow:inset 0 0 0 1px rgba(201,162,39,.08);}
  body.dark .supporter-panel{background:linear-gradient(145deg,rgba(255,255,255,.04),var(--white));}
  .supporter-panel-head{display:flex;align-items:flex-start;gap:13px;}
  .supporter-panel-mark{display:flex;align-items:center;justify-content:center;width:42px;height:42px;
    flex-shrink:0;border-radius:13px;background:var(--moon-glow);border:1px solid var(--moon-deep);
    color:var(--gold-text);font-size:21px;}
  body.dark .supporter-panel-mark{background:rgba(242,208,107,.14);}
  .supporter-panel-head h3{margin:0;font-size:21px;}
  .supporter-panel-head p{margin:3px 0 0;color:var(--muted);font-size:13.5px;line-height:1.55;}
  .supporter-benefits{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px;margin-top:17px;}
  /* ROADMAP 5.4.7 step 7. The line that closes the list, and it is the one sentence somebody
     has to be able to leave this panel with. Set as a hint rather than as a sixth benefit:
     it is not another thing you GET, it is the boundary around all of them, and a card of
     its own in the grid would have read as one. */
  .supporter-free-line{margin:13px 0 0;padding-top:12px;border-top:1px solid var(--line-soft);}
  .supporter-benefit{display:flex;align-items:flex-start;gap:9px;min-width:0;padding:12px;
    border:1px solid var(--line-soft);border-radius:13px;background:var(--white);}
  .supporter-benefit-icon{display:flex;align-items:center;justify-content:center;width:29px;height:29px;
    flex-shrink:0;border-radius:9px;background:var(--moon-glow);color:var(--gold-text);font-weight:900;}
  body.dark .supporter-benefit-icon{background:rgba(242,208,107,.12);}
  .supporter-benefit div{display:flex;flex-direction:column;min-width:0;}
  .supporter-benefit strong{font-size:13.5px;line-height:1.3;}
  .supporter-benefit div span{margin-top:3px;color:var(--muted);font-size:11.5px;line-height:1.45;}
  .supporter-status{display:flex;align-items:center;gap:11px;margin-top:16px;padding:12px 14px;
    border:1px solid var(--safe-line);border-radius:13px;background:var(--safe-bg);}
  .supporter-status-icon{display:flex;align-items:center;justify-content:center;width:30px;height:30px;
    flex-shrink:0;border-radius:50%;background:var(--safe);color:#fff;font-weight:900;}
  .supporter-status div{display:flex;flex-direction:column;}
  .supporter-status strong{font-size:14px;line-height:1.35;}
  .supporter-status div span{color:var(--muted);font-size:12px;}
  .supporter-theme-picker,.supporter-donate,.supporter-code{margin-top:19px;padding-top:17px;border-top:1px solid var(--line-soft);}
  .supporter-theme-picker h3,.supporter-donate h3,.supporter-code h3{margin:0 0 3px;}
  .supporter-donate-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;}
  .supporter-donate-actions .btn{margin-top:8px !important;}
  .supporter-recurring{margin:9px 0 0 !important;}
  .supporter-no-links{margin:8px 0 0 !important;}
  .foot-donate-lead{font-size:12px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
  /* auto-fit so three or four swatches wrap instead of scrolling at 375px */
  .theme-pick{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px;margin-top:10px;}
  .theme-swatch{display:flex;align-items:center;gap:9px;min-height:48px;padding:12px 14px;border-radius:12px;
    border:1.5px solid var(--line);background:var(--white);color:var(--ink);
    font-family:var(--font-body);font-size:14.5px;font-weight:var(--w-med);text-align:left;}
  .theme-swatch.on{border-color:var(--moon-deep);background:var(--moon-glow);color:var(--gold-text);}
  /* On a dark theme --moon-glow stays a LIGHT tint (it is the accent surface for
     light-based components) while --gold-text is also light, so the selected swatch
     was pale-on-pale. Tint the surface instead and let the ink carry the text — this
     is theme-agnostic, so every dark theme added later is readable by default. */
  body.dark .theme-swatch.on{background:rgba(255,255,255,0.10);color:var(--ink);}
  .theme-swatch .sw-dot{width:18px;height:18px;border-radius:50%;flex-shrink:0;
    border:1px solid rgba(0,0,0,0.18);}
  .theme-swatch .sw-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  /* The dot previews the theme's own background, so the swatch shows the colour
     rather than only naming it. */
  .sw-light .sw-dot,.theme-dot.sw-light{background:#FBF6EC;}
  .sw-dark .sw-dot,.theme-dot.sw-dark{background:#141C2A;}
  /* Three-stop dots for every theme that declares a gradient: the page colour and BOTH
     ends of the accent, because that gradient is where the mutation actually lives —
     turquoise's blue tail and pineapple's red breast are the second stop, and a
     two-stop dot showed neither.

     Sunset and Tropical joined this group on 2026-08-16, and only Sunset because it
     had to. Sunset's page moved from a distinctive plum to a dusk blue, so a flat dot
     would have been a slightly bluer copy of `dark`'s; the gold is what tells them
     apart. Tropical was found by the new test rather than by eye — it has declared a
     gradient since 5.2b and kept a flat #F1F7EA dot, a near-white circle sitting next
     to `light`'s near-white circle in the same list.

     The rule the test enforces is now true without exception: a theme that declares a
     gradient shows both of its ends here. Light and dark declare `var(--moon)` for both
     ends rather than a hex, so they are correctly flat.

     THE HEX IS LITERAL HERE AND MUST STAY LITERAL. A swatch renders the palette of a
     theme that is NOT currently applied — that is its entire job — so `var(--moon)`
     would resolve to the active theme and paint every dot the same colour. Which also
     means these nine lines are a SECOND copy of the palettes, and the only one in the
     app; tests/theme-builder.test.js checks each stop against css/app.css so the copy
     cannot drift. */
  .sw-sunset .sw-dot,.theme-dot.sw-sunset{background:linear-gradient(135deg,#16294A 42%,#F0A855 42%,#F0A855 71%,#F5D08A 71%);}
  .sw-tropical .sw-dot,.theme-dot.sw-tropical{background:linear-gradient(135deg,#F1F7EA 42%,#A8D27B 42%,#A8D27B 71%,#CFE07A 71%);}
  .sw-turquoise .sw-dot,.theme-dot.sw-turquoise{background:linear-gradient(135deg,#13262B 42%,#6FC7BE 42%,#6FC7BE 71%,#6FB6DE 71%);}
  .sw-suncheek .sw-dot,.theme-dot.sw-suncheek{background:linear-gradient(135deg,#FFEED6 42%,#F0BB35 42%,#F0BB35 71%,#FBD98A 71%);}
  .sw-cinnamon .sw-dot,.theme-dot.sw-cinnamon{background:linear-gradient(135deg,#F8F0E6 42%,#CB9455 42%,#CB9455 71%,#E9C08A 71%);}
  .sw-pineapple .sw-dot,.theme-dot.sw-pineapple{background:linear-gradient(135deg,#FCF6DC 42%,#E8C24E 42%,#E8C24E 71%,#E08A5E 71%);}
  .sw-opaline .sw-dot,.theme-dot.sw-opaline{background:linear-gradient(135deg,#10251A 42%,#40AB41 42%,#40AB41 71%,#E3D45C 71%);}
  /* The built palette has no fixed colour, so its dot is the idea of one. It is
     overwritten inline by themePicker() once a config exists — see themeDotStyle(). */
  .sw-custom .sw-dot,.theme-dot.sw-custom{background:conic-gradient(from 210deg,#F2D06B,#6FC7BE,#A48BD8,#E08A5E,#F2D06B);}

  /* ---- Theme preview, gallery and builder (ROADMAP 5.2c) --------------------
     EVERY RULE IN THIS BLOCK MAY ONLY REFERENCE TOKENS, never a literal colour and
     never a `body.dark` ancestor. `.tp` carries a complete palette in its own style
     attribute, which its descendants inherit — that is the whole mechanism, and it is
     also the whole fragility: one `body.dark .tp-card{…}` rule would repaint a light
     preview whenever the app around it happened to be dark, and would do it on some
     screens and not others. The two `body.dark` rules that DO appear below are on the
     builder's chrome (.tb-*), which is ordinary app furniture and is not inside .tp.

     Sizes are in em against .tp's own font-size, so one `font-size` scales the whole
     mock — the gallery uses a small one and the builder a large one. */
  .tp{position:relative;overflow:hidden;font-size:9px;border-radius:14px;
    border:1px solid var(--line);background:var(--cream);color:var(--ink);
    padding:0 0 1.4em;line-height:1.35;user-select:none;}
  .tp-wide{font-size:12px;}
  .tp-aura{position:absolute;inset:-30%;pointer-events:none;opacity:var(--aura-opacity);
    background:
      radial-gradient(38% 34% at 22% 16%, var(--aura-1), transparent 68%),
      radial-gradient(42% 36% at 80% 78%, var(--aura-2), transparent 70%);
    animation:auraDrift 26s ease-in-out infinite;}
  /* auraDrift WAS NEVER DEFINED, and nothing could tell. Found 2026-08-16 while doubling
     the real aura's travel: `.tp-aura` has named this animation since the preview was
     written and no `@keyframes auraDrift` has ever existed in this file, so the browser
     silently ignored the declaration and every palette preview in the gallery, on the
     account page and inside the builder has been showing a STATIC wash. A misspelt
     animation name is the quietest failure CSS has — no error, no warning, no visual
     clue unless you know what it was supposed to look like.

     Half the amplitude of the page's own aura on purpose: this is a 150 px thumbnail of
     a home screen, and the displacement that reads as "alive" at viewport scale reads as
     a wobble at thumbnail scale. `inset:-30%` covers it — worst case is 8.7 % diagonal
     at scale 0.94, which needs 13.9 % of the 25.2 % overhang. */
  @keyframes auraDrift{
    0%,100%{transform:translate3d(0,0,0) scale(1);}
    38%{transform:translate3d(6%,-5%,0) scale(1.16);}
    72%{transform:translate3d(-5%,4%,0) scale(0.94);}
  }
  .tp-head{position:relative;display:flex;align-items:center;gap:.5em;
    padding:.8em 1em;border-bottom:1px solid var(--line-soft);}
  /* The real wordmark, with the type underneath as the offline fallback — the same
     `:has()` swap the header does, so the preview cannot end up showing both or
     neither. `object-position:left` keeps it against the edge as the box narrows,
     because a 150 px gallery cell is much narrower than the header it is mocking. */
  .tp-logo{flex:1;min-width:0;height:2.3em;width:auto;max-width:100%;display:block;
    object-fit:contain;object-position:left center;}
  .tp-head:has(.tp-logo) .tp-brand{display:none;}
  .tp-brand{flex:1;font-family:var(--font-display);font-weight:600;font-size:1.7em;
    line-height:1;letter-spacing:-0.02em;color:var(--ink);}
  .tp-brand i{font-style:normal;color:var(--moon-deep);}
  /* The chips carry the header's own two icons. Empty they read as unticked
     checkboxes, which is a control the app does not have anywhere near there. */
  .tp-chip{flex-shrink:0;display:grid;place-items:center;
    width:2em;height:2em;border-radius:.6em;background:var(--white);
    border:1px solid var(--line);color:var(--gold-text);}
  .tp-chip svg{width:1.15em;height:1.15em;display:block;}
  .tp-page{position:relative;padding:.9em 1em 0;}
  .tp-hello{font-family:var(--font-display);font-size:2.1em;line-height:1.15;
    color:var(--accent-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .tp-nudge{display:flex;align-items:center;gap:.6em;margin-top:.8em;padding:.6em .8em;
    border-radius:.9em;background:var(--moon-glow);border:1px solid var(--moon);}
  /* THE SAME TRAP THE APP ALREADY HIT, and the preview walked straight into it: on a
     dark palette `--moon-glow` stays a LIGHT tint — it is the accent surface for
     light-based components — while `--ink` is also light, so the nudge line was pale
     on pale in Sunset, Turquoise and Suncheek at the same time. The app's fix is a
     translucent white tint in the ~30 `body.dark` rules; the preview cannot use those
     (its palette is its own, not the page's), so it carries the flag itself. `.tp-dark`
     comes from the theme record, never from the surrounding document. */
  .tp-dark .tp-nudge{background:rgba(255,255,255,0.10);border-color:var(--moon-deep);}
  .tp-nudge-dot{width:1.4em;height:1.4em;flex-shrink:0;border-radius:50%;
    background:linear-gradient(135deg,var(--grad-a),var(--grad-b));}
  .tp-nudge-text{min-width:0;flex:1;font-size:1.05em;color:var(--ink);
    overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .tp-cards{display:grid;grid-template-columns:1fr 1fr;gap:.6em;margin-top:.7em;}
  .tp-card{display:block;padding:.7em .8em;border-radius:.9em;background:var(--white);
    border:1px solid var(--line-soft);}
  .tp-card b{display:block;font-size:1.1em;font-weight:var(--w-strong);color:var(--ink);
    overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .tp-card i{display:block;height:.45em;margin-top:.45em;border-radius:.3em;
    background:var(--muted);opacity:.4;}
  .tp-card i.tp-short{width:58%;}
  .tp-cta{display:block;margin-top:.8em;padding:.7em;border-radius:.9em;text-align:center;
    font-weight:var(--w-strong);font-size:1.05em;color:var(--on-accent);
    background:linear-gradient(135deg,var(--grad-a),var(--grad-b));
    overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  @media(prefers-reduced-motion:reduce){ .tp-aura{animation:none;} }

  .theme-gallery,.theme-build-entry{margin-top:19px;padding-top:17px;border-top:1px solid var(--line-soft);}
  .theme-gallery h3,.theme-build-entry h3{margin:0 0 3px;}
  /* auto-fit at 150px so six previews are three columns on a tablet, two at 375px and
     never one — a single-column gallery is a scroll, and the point is comparison. */
  .theme-gallery-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
    gap:11px;margin-top:11px;}
  .theme-gallery-item{margin:0;min-width:0;}
  .theme-gallery-item figcaption{display:flex;align-items:center;gap:7px;margin-top:6px;
    font-size:12.5px;font-weight:var(--w-strong);color:var(--ink);}
  .theme-gallery-foot{margin-top:11px !important;}
  .tbe-row{display:flex;align-items:center;gap:13px;margin-top:11px;}
  .tbe-row>.tp{flex:1;min-width:0;}
  .tbe-cta{flex-shrink:0;text-decoration:none;}
  .tbe-locked{flex:1;min-width:0;margin:0;font-size:12.5px;line-height:1.5;color:var(--muted);}

  /* ---- the background panel (5.2e) ----
     Sits above the builder on #theme and is shown to everybody, so it must read as a
     finished section on its own rather than as a header for the card below it. */
  .dc-title{margin:0 0 3px;font-family:var(--font-display);font-size:19px;font-weight:600;
    color:var(--ink);line-height:1.25;}
  .dc-panel .hint{margin-bottom:13px;}
  .dc-row{display:flex;align-items:center;gap:12px;padding:9px 0;
    border-top:1px solid var(--line-soft);}
  .dc-row:first-of-type{border-top:none;}
  /* The slot rows stack: fourteen 40 px buttons wrap to three lines on a phone, and a
     label sitting beside three lines is centred against nothing in particular while
     stealing the width that would have saved a row. Above them it reads as a heading
     for the group, which is what it is. The motion row keeps the side-by-side layout —
     it is one control, not a grid. */
  .dc-slot{flex-direction:column;align-items:stretch;gap:7px;}
  .dc-row-name{display:block;font-size:11px;font-weight:var(--w-strong);
    letter-spacing:0.07em;text-transform:uppercase;color:var(--muted);line-height:1.3;}
  /* WRAP, do not scroll. A horizontal strip of fourteen 40 px buttons is 560 px, and on
     a 375 px phone a sideways scroller hides the last five behind an edge with nothing
     to suggest they are there — the reader concludes those are all the shapes. */
  .dc-glyphs{flex:1;min-width:0;display:flex;flex-wrap:wrap;gap:6px;}
  /* `--gold-text`, NOT `--decor-ink`, and the browser sweep is what found it. These
     buttons drew the glyph in the colour the PAGE paints it — which is the right instinct
     and the wrong token, because on the page that colour carries an opacity between 0.09
     and 0.35 and is deliberately almost invisible. Here it is the label of a control, and
     `tools/contrast-sweep.js` measured it at 2.05:1 on light, 2.87 on tropical, 3.36 on
     cinnamon and 3.74 on suncheek — below AA on four of the five light themes, and below
     even the 3:1 that non-text controls answer to on two of them.

     `--gold-text` exists for exactly this: small accent-coloured text at 4.5:1. It is
     5.33 to 7.99 across all nine palettes, and it keeps the buttons reading as the
     accent rather than turning them into plain ink. */
  .dc-glyph{display:grid;place-items:center;width:40px;height:40px;border-radius:10px;
    border:1.5px solid var(--line);background:var(--white);color:var(--gold-text);
    font-size:17px;line-height:1;}
  .dc-glyph:hover{border-color:var(--moon-deep);}
  .dc-glyph.on{border-color:var(--moon-deep);background:var(--moon-glow);}
  body.dark .dc-glyph.on{background:rgba(255,255,255,0.10);}
  /* The two meta-choices are not shapes, so they are not drawn as one: "the theme's
     own" shows the glyph the theme would use and is marked as a quotation of it, and
     "none" is the only button here whose mark is an instruction rather than a preview. */
  .dc-glyph-theme{border-style:dashed;}
  .dc-glyph-none{color:var(--muted);font-size:14px;}
  .dc-motion{align-items:center;margin-top:2px;}
  .dc-row-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;}
  .dc-row-text strong{font-size:14px;font-weight:var(--w-med);color:var(--ink);}
  .dc-row-text span{font-size:12.5px;line-height:1.45;color:var(--muted);}
  .dc-switch{flex-shrink:0;position:relative;width:52px;height:31px;border-radius:999px;
    border:1.5px solid var(--line);background:var(--cream-100);transition:background .18s,border-color .18s;}
  .dc-switch>span{position:absolute;top:2px;left:2px;width:23px;height:23px;border-radius:50%;
    background:var(--muted);transition:transform .18s,background .18s;}
  .dc-switch.on{background:var(--moon-glow);border-color:var(--moon-deep);}
  .dc-switch.on>span{transform:translateX(21px);background:var(--moon-deep);}
  body.dark .dc-switch.on{background:rgba(255,255,255,0.12);}
  @media(prefers-reduced-motion:reduce){
    .dc-switch,.dc-switch>span{transition:none;}
  }
  /* ---- a palette for each bird (ROADMAP 5.4.4) ----
     One row per bird: her name and the palette she carries, then the dots. The dots
     wrap rather than scroll — a horizontal scroller inside a vertically scrolling
     screen is a trap on a phone, and eleven 38 px targets fit two lines at 320 px. */
  .bt-panel{margin-top:16px;}
  .bt-panel .hint{margin-bottom:11px;}
  .bt-row{padding:11px 0;border-top:1px solid var(--line-soft);}
  .bt-row:first-of-type{border-top:none;}
  .bt-head{display:flex;align-items:baseline;gap:9px;margin-bottom:8px;}
  .bt-name{flex:1;min-width:0;font-size:14.5px;font-weight:var(--w-med);color:var(--ink);
    overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .bt-cur{flex-shrink:0;font-size:11px;font-weight:var(--w-strong);letter-spacing:.06em;
    text-transform:uppercase;color:var(--gold-text);}
  .bt-dots{display:flex;flex-wrap:wrap;gap:7px;}
  .bt-dot{display:grid;place-items:center;width:38px;height:38px;border-radius:11px;
    background:var(--white);border:1.5px solid var(--line);}
  .bt-dot:hover{border-color:var(--moon-deep);}
  .bt-dot.on{border-color:var(--moon-deep);background:var(--moon-glow);}
  body.dark .bt-dot.on{background:rgba(255,255,255,0.10);}
  .bt-dot:focus-visible{outline:2px solid var(--moon-deep);outline-offset:2px;}
  /* Dashed = "follow the app", the same idiom .dc-glyph-theme uses one card above. */
  .bt-dot-app{border-style:dashed;}
  .bt-dot .theme-dot{width:18px;height:18px;}

  .tb-heading{margin:22px 0 3px;}
  .tb-heading-hint{margin-bottom:12px;}
  .tb-locked-card{margin-top:16px;}

  .tb-preview{position:sticky;top:64px;z-index:2;}
  .tb-stage>.tp{width:100%;}
  .tb-contrast{display:flex;align-items:center;gap:8px;margin:11px 0 0;
    font-size:12.5px;line-height:1.45;color:var(--muted);}
  .tb-contrast-badge{flex-shrink:0;padding:2px 8px;border-radius:20px;font-size:11px;
    font-weight:var(--w-strong);letter-spacing:.06em;
    background:var(--safe-bg);color:var(--safe);border:1px solid var(--safe-line);}
  .tb-controls{margin-top:14px;}
  .tb-base{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px;}
  .tb-base-opt{min-height:44px;padding:0 14px;border-radius:12px;font-family:var(--font-body);
    font-size:14.5px;font-weight:var(--w-med);background:var(--white);color:var(--ink);
    border:1.5px solid var(--line);}
  .tb-base-opt.on{border-color:var(--moon-deep);background:var(--moon-glow);color:var(--gold-text);}
  body.dark .tb-base-opt.on{background:rgba(255,255,255,0.10);color:var(--ink);}
  .tb-base-opt:focus-visible{outline:2px solid var(--moon-deep);outline-offset:2px;}
  .tb-row{display:flex;align-items:center;gap:13px;padding:12px 0;
    border-top:1px solid var(--line-soft);}
  .tb-row-text{flex:1;min-width:0;display:flex;flex-direction:column;}
  .tb-row-text strong{font-size:14px;line-height:1.3;}
  .tb-row-text span{margin-top:2px;font-size:11.5px;line-height:1.45;color:var(--muted);}
  .tb-swatch-wrap{display:flex;align-items:center;gap:8px;flex-shrink:0;}
  /* A native colour input is a button-sized box in every engine and a full-bleed
     swatch in none of them, so the chrome is stripped and the well is stretched.
     Vendor-prefixed pseudo-elements, both spellings, because Chromium and Firefox
     disagree and a missed one leaves a white frame around the colour. */
  .tb-color{width:46px;height:38px;padding:0;border:1.5px solid var(--line);border-radius:10px;
    background:none;cursor:pointer;-webkit-appearance:none;appearance:none;}
  .tb-color::-webkit-color-swatch-wrapper{padding:2px;}
  .tb-color::-webkit-color-swatch{border:none;border-radius:7px;}
  .tb-color::-moz-color-swatch{border:none;border-radius:7px;}
  .tb-color:focus-visible{outline:2px solid var(--moon-deep);outline-offset:2px;}
  .tb-hex{font-size:11px;letter-spacing:.04em;color:var(--muted);min-width:62px;}
  .tb-range{flex-shrink:0;width:132px;accent-color:var(--moon-deep);}
  .tb-range:focus-visible{outline:2px solid var(--moon-deep);outline-offset:3px;}
  .tb-actions{margin-top:16px;}
  .tb-actions-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;}

  /* ---- sharing a palette (ROADMAP 5.4.4) ----
     The code is a readonly input, not a <code> block, so it can always be selected and
     copied by hand when navigator.clipboard is unavailable. It is styled to read as a
     printed token rather than as a field somebody should type in: no focus caret worth
     chasing, wide tracking, and the accent underneath it. */
  .tb-share{margin-top:14px;}
  .ts-code-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:12px;}
  .ts-code{flex:1 1 220px;min-width:0;height:44px;padding:0 12px;border-radius:12px;
    border:1.5px solid var(--line);background:var(--cream-100);color:var(--ink);
    font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
    font-size:13.5px;letter-spacing:.08em;text-align:center;cursor:pointer;}
  .ts-code:focus-visible{outline:2px solid var(--moon-deep);outline-offset:2px;}
  .ts-code-actions{display:flex;gap:8px;flex-shrink:0;}
  /* min-width so the label can become "✓ Copied" without the row reflowing under it. */
  .ts-btn{min-width:104px;}
  .ts-paste{margin-top:15px;padding-top:13px;border-top:1px solid var(--line-soft);}
  .ts-paste-label{display:block;margin-bottom:7px;font-size:12.5px;color:var(--muted);}
  .ts-in{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
    letter-spacing:.04em;text-transform:uppercase;}
  .ts-in::placeholder{text-transform:none;letter-spacing:.04em;opacity:.55;}
  /* Empty until something has happened, so it holds no space at all: an always-present
     blank line under a form reads as a message that failed to arrive. */
  .ts-msg{margin:0;font-size:12.5px;line-height:1.5;color:var(--safe);display:none;}
  .ts-msg.on{display:block;margin-top:11px;}
  .ts-msg.bad{color:var(--danger);}
  /* The one on the locked card sits above a preview rather than under a form. */
  .tb-locked-card .ts-msg.on{margin:9px 0 0;}

  /* ---- Have a say (ROADMAP 5.4.5) ----
     Each option is one tall button: title and count on the first line, the sentence
     under it, and the share of the vote as a bar along the bottom. The bar is a
     background rather than a chart because it has to read at a glance and mean nothing
     precise — the number beside the title is the fact. */
  .rs-poll,.rs-note{margin-top:16px;}
  .rs-poll .hint,.rs-note .hint{margin-bottom:12px;}
  .rs-opts{display:flex;flex-direction:column;gap:9px;}
  .rs-opt{display:flex;flex-direction:column;gap:5px;width:100%;padding:12px 13px 14px;
    border-radius:13px;border:1.5px solid var(--line);background:var(--white);
    color:var(--ink);text-align:left;}
  .rs-opt:hover{border-color:var(--moon-deep);}
  .rs-opt.on{border-color:var(--moon-deep);background:var(--moon-glow);}
  body.dark .rs-opt.on{background:rgba(255,255,255,0.09);}
  .rs-opt:focus-visible{outline:2px solid var(--moon-deep);outline-offset:2px;}
  /* The read-only version for somebody who has not supported the project: identical
     type, no hover, no press. A greyed-out button would read as broken. */
  .rs-opt-flat{cursor:default;}
  .rs-opt-flat:hover{border-color:var(--line);}
  .rs-opt-head{display:flex;align-items:baseline;gap:10px;}
  .rs-opt-head strong{flex:1;min-width:0;font-size:14.5px;font-weight:var(--w-med);
    line-height:1.35;}
  .rs-count{flex-shrink:0;font-size:13px;font-weight:var(--w-strong);color:var(--gold-text);
    font-variant-numeric:tabular-nums;}
  .rs-opt-body{font-size:12.5px;line-height:1.5;color:var(--muted);}
  .rs-bar{display:block;height:5px;margin-top:3px;border-radius:99px;background:var(--cream-100);
    overflow:hidden;}
  .rs-bar>i{display:block;height:100%;border-radius:99px;background:var(--moon);
    background-image:linear-gradient(90deg,var(--grad-a),var(--grad-b));
    transition:width .3s ease;}
  @media(prefers-reduced-motion:reduce){ .rs-bar>i{transition:none;} }
  .rs-total,.rs-loading{margin-top:11px !important;margin-bottom:0 !important;}
  .rs-textarea{width:100%;min-height:112px;resize:vertical;line-height:1.55;}
  .rs-note-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:11px;}
  .rs-saved{margin:10px 0 0 !important;color:var(--safe);}
  .rs-privacy{margin:12px 0 0 !important;padding-top:11px;border-top:1px solid var(--line-soft);}
  /* The wall opt-in. Same block furniture as the other supporter panels, and the switch
     is the one from the background panel — a reader has already learned what that shape
     does, and this is not the control to teach a new one with. */
  .supporter-wall{margin-top:19px;padding-top:17px;border-top:1px solid var(--line-soft);}
  .supporter-wall h3{margin:0 0 3px;}
  .wall-field{display:block;margin:12px 0 6px;font-size:12.5px;color:var(--muted);}
  .wall-row{margin-bottom:4px;}
  .wall-switch-row{border-top:none;padding-top:8px;}
  .wall-privacy{margin-top:12px !important;padding-top:11px;border-top:1px solid var(--line-soft);}
  .wall-pending{margin-top:8px !important;}

  .supporter-say{margin-top:19px;padding-top:17px;border-top:1px solid var(--line-soft);}
  .supporter-say h3{margin:0 0 3px;}
  .say-cta{margin-top:11px;text-decoration:none;}

  /* ---- Household roster ---- */
  .member-list{display:flex;flex-direction:column;}
  .member-row{display:flex;align-items:center;gap:10px;padding:11px 2px;
    border-bottom:1px solid var(--line-soft);}
  .member-row:last-child{border-bottom:none;}
  /* min-width:0 lets a long self-chosen name ellipsize instead of pushing the
     role and the remove button off a 375px screen. */
  .member-name{flex:1;min-width:0;font-size:15px;font-weight:var(--w-med);
    overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .member-you{font-size:11px;font-weight:var(--w-strong);letter-spacing:.06em;text-transform:uppercase;
    color:var(--gold-text);}
  .member-role{font-size:12.5px;color:var(--muted);flex-shrink:0;}

  /* ---- Tricks ---- */
  .trick-item{display:flex;align-items:center;gap:13px;padding:11px 4px;border-bottom:1px solid var(--line-soft);cursor:pointer;}
  .trick-item:last-child{border-bottom:none;}
  .trick-box{width:24px;height:24px;border-radius:7px;border:2px solid var(--line);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:14px;color:transparent;}
  .trick-item.on .trick-box{background:var(--lime);border-color:var(--lime);color:var(--night);}
  .trick-item .lbl{font-size:15px;font-weight:600;}
  .trick-item.on .lbl{color:var(--muted);}

  /* ---- Badges ---- */
  .badge-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
  @media(max-width:520px){.badge-grid{grid-template-columns:1fr;}}
  .badge{display:flex;align-items:center;gap:13px;background:var(--white);border:1px solid var(--line-soft);border-radius:14px;padding:14px 16px;}
  .badge.locked{opacity:0.5;}
  .badge .bic{width:42px;height:42px;border-radius:11px;background:var(--moon-glow);display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;}
  .badge.locked .bic{background:var(--cream-100);filter:grayscale(1);}
  body.dark .badge .bic{background:rgba(242,208,107,0.14);}
  .badge .bn{display:block;font-weight:var(--w-strong);font-size:14px;}
  .badge .bd{display:block;font-size:12px;color:var(--muted);margin-top:2px;}
  .badge-count{font-size:13px;color:var(--muted);text-align:center;margin-top:6px;}

  /* ---- Toast (badge unlocked) ---- */
  /* ---- Features / journal modal ---- */
  /* `height:100dvh` IS THE FIX FOR THE ARROWS THAT WERE CUT OFF BY THE PHONE'S BROWSER
     BAR, and it is a whole class of bug rather than one carousel. A `position:fixed`
     box sizes itself against the LAYOUT viewport, which on a mobile browser is the tall
     one — the height the page would have if the URL bar were retracted. `inset:0`
     therefore produced a container taller than what is actually on screen, and because
     the sheet is bottom-anchored (`align-items:flex-end`), the bottom of every dialog
     sat UNDER the browser bar: the ← → controls of the photo lightbox, and the last
     strip of the picture above them. Desktop Chrome and the installed PWA have no such
     bar, which is exactly why it only ever showed on a phone in a browser.
     `100dvh` is the viewport that is really visible, and it is ignored by browsers that
     do not know the unit, which then keep the old `inset:0` behaviour. Same reasoning
     for every `vh` below: the `vh` line stays as the fallback and the `dvh` line wins
     where it is understood. */
  #modal{position:fixed;inset:0;height:100dvh;z-index:60;display:none;align-items:flex-end;justify-content:center;}
  #modal.open{display:flex;}
  .modal-bg{position:absolute;inset:0;background:rgba(17,23,34,0.55);animation:fade .25s;}
  .modal-card{position:relative;background:var(--cream);width:100%;max-width:600px;max-height:88vh;max-height:88dvh;border-radius:22px 22px 0 0;overflow:hidden;display:flex;flex-direction:column;animation:sheetUp .3s ease;border:1px solid var(--line-soft);}
  @media(min-width:600px){#modal{align-items:center;} .modal-card{border-radius:22px;max-height:82vh;max-height:82dvh;}}
  @keyframes sheetUp{from{transform:translateY(30px);opacity:.5;}to{transform:none;opacity:1;}}
  .modal-head{display:flex;align-items:center;gap:12px;padding:20px 22px 14px;border-bottom:1px solid var(--line-soft);}
  .modal-head .mh-t{flex:1;}
  .modal-head .mh-k{font-size:11px;font-weight:var(--w-strong);letter-spacing:0.14em;text-transform:uppercase;color:var(--gold-text);}
  .modal-head h2{font-family:var(--font-display);font-weight:500;font-size:23px;line-height:1.1;margin-top:2px;}
  .modal-close{width:40px;height:40px;border-radius:10px;background:var(--white);border:1px solid var(--line);font-size:19px;color:var(--gold-text);flex-shrink:0;}
  .modal-close:hover{border-color:var(--moon-deep);}
  /* The bottom padding carries the iOS home-indicator inset. The card is bottom-anchored,
     so without it the last control in a sheet sits under the indicator. */
  .modal-body{overflow-y:auto;padding:8px 22px 20px;padding-bottom:calc(20px + env(safe-area-inset-bottom,0px));}

  /* ---- Loading skeleton for a full-size photo ----
     Opening a photo replaces a 320 px thumbnail with a 1200 px download. Until this,
     the dialog was empty for the duration — indistinguishable from a broken viewer.
     `is-loading` is removed by imgReady() the moment the image decodes; the min-height
     is what gives the shimmer something to occupy while the <img> still has no size,
     and it goes with the class so the real picture is never boxed by it. */
  .img-load{position:relative;}
  .img-load.is-loading{min-height:min(48vh,340px);min-height:min(48dvh,340px);border-radius:14px;overflow:hidden;}
  .img-load.is-loading::after{
    content:'';position:absolute;inset:0;z-index:1;
    background:linear-gradient(100deg,var(--cream-100) 26%,var(--white) 50%,var(--cream-100) 74%);
    background-size:260% 100%;animation:imgShimmer 1.25s linear infinite;
  }
  .img-load.is-failed{min-height:0;}
  /* THE SAME SHIMMER WITHOUT THE MIN-HEIGHT, for a box that already has a size.
     `.img-load` reserves half a viewport because it stands in for a picture whose
     dimensions are unknown until it decodes; a mutation card, a profile slot and a
     92 px photo thumbnail all know theirs from the stylesheet, and inheriting that
     min-height would blow the grid apart. Same class pair (`is-loading` / `is-failed`),
     same lifters — imgReady() and imgFailed() in app/app/photocache.js look for either
     ancestor — so a call site chooses by which one it puts in the markup and nothing
     else has to know. `border-radius:inherit` keeps the shimmer inside a rounded box;
     the boxes that use it are all `overflow:hidden` already, but the ones that are not
     yet would show square corners over a round frame. */
  .sk-cover{position:relative;}
  .sk-cover.is-loading::after{
    content:'';position:absolute;inset:0;z-index:1;border-radius:inherit;
    background:linear-gradient(100deg,var(--cream-100) 26%,var(--white) 50%,var(--cream-100) 74%);
    background-size:260% 100%;animation:imgShimmer 1.25s linear infinite;
  }
  @keyframes imgShimmer{from{background-position:130% 0;}to{background-position:-130% 0;}}
  /* The app honours this everywhere else (docs/DECISIONS.md); a shimmer is decoration,
     and the block on its own still says "something is coming". */
  @media(prefers-reduced-motion:reduce){
    .img-load.is-loading::after,.sk-cover.is-loading::after{animation:none;}
  }

  /* ============================ LOADING SKELETONS ============================
     app/app/skeleton.js builds these; this is the whole vocabulary. `.sk` is the only
     class that paints — everything beside it is a shape — so a new placeholder is two
     classes and no new animation.

     The shimmer is `imgShimmer` above, shared on purpose: one 1.25s clock means the
     boot shell, a photo skeleton and a list of pending rows travel together instead of
     beating against each other, which is what a second duration would look like. */
  .sk{display:block;position:relative;overflow:hidden;border-radius:8px;
      background:var(--cream-100);
      background-image:linear-gradient(100deg,var(--cream-100) 26%,var(--white) 50%,var(--cream-100) 74%);
      background-size:260% 100%;animation:imgShimmer 1.25s linear infinite;}
  @media(prefers-reduced-motion:reduce){.sk{animation:none;}}
  /* The announcement, not the picture. Visually hidden rather than absent: a shimmer
     tells a sighted reader that something is coming and tells a screen reader nothing
     at all. Kept out of flow so it never takes a grid cell or a flex slot. */
  .sk-status{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;
             clip-path:inset(50%);white-space:nowrap;border:0;}
  .sk-line{height:13px;margin:9px 0;}
  .sk-line.sk-lg{height:17px;}
  .sk-title{height:30px;width:62%;margin-bottom:12px;border-radius:10px;}
  .sk-w40{width:40%;} .sk-w50{width:50%;} .sk-w60{width:60%;}
  .sk-w70{width:70%;} .sk-w75{width:75%;} .sk-w90{width:90%;}
  .sk-avatar{width:56px;height:56px;border-radius:50%;flex-shrink:0;}
  .sk-bar{height:12px;border-radius:999px;margin:16px 0 14px;}
  .sk-chip{height:30px;flex:1;border-radius:999px;}
  .sk-tile{height:78px;flex:1;border-radius:16px;}
  .sk-pill{width:64px;height:20px;border-radius:999px;flex-shrink:0;}
  .sk-photo{width:100%;aspect-ratio:4/3;border-radius:0;}
  .sk-btn{height:44px;border-radius:12px;margin-top:12px;}
  .sk-brand{width:104px;height:26px;border-radius:8px;}
  .sk-dot{width:40px;height:40px;border-radius:12px;}
  .sk-spacer{flex:1;}
  .sk-screen{padding-top:2px;}
  .sk-head{margin-bottom:20px;}
  .sk-card{padding:18px;}
  .sk-hero-top{display:flex;align-items:center;gap:14px;}
  .sk-hero-text{flex:1;min-width:0;}
  .sk-chips{display:flex;gap:8px;}
  .sk-tiles{display:flex;gap:12px;margin:14px 0;}
  .sk-list{position:relative;}
  .sk-row{display:flex;align-items:center;gap:12px;padding:10px 0;
          border-bottom:1px solid var(--line-soft);}
  .sk-row:last-child{border-bottom:none;}
  .sk-row .sk-line{flex:1;margin:0;}
  .sk-mut .mut-body .sk-line{margin:8px 0;}
  /* The vote counts and their bars, while the tally is in flight. The poll's own text
     is in the bundle and is shown immediately — only the numbers are waiting, so only
     the numbers are shimmering. Replacing the whole option with a block would hide copy
     the app already has, which is the one thing a skeleton must never do. */
  .sk-count{width:26px;height:15px;border-radius:6px;}
  .sk-bar-load{display:block;height:8px;border-radius:999px;margin-top:9px;
      background:var(--cream-100);
      background-image:linear-gradient(100deg,var(--cream-100) 26%,var(--white) 50%,var(--cream-100) 74%);
      background-size:260% 100%;animation:imgShimmer 1.25s linear infinite;}
  @media(prefers-reduced-motion:reduce){.sk-bar-load{animation:none;}}

  /* ---- a button that is working ----
     Until this, an in-flight sign-in DISABLED the form and said nothing: greyed
     controls read as "not now", never as "working". The spinner borrows the button's
     own ink (`currentColor`), so it is correct on .btn-primary's --on-accent, on a
     ghost button and inside a danger outline without three rules.

     The width is pinned in JavaScript for the duration, not here — the label being
     replaced is what changes it, and only the element knows what it measured. */
  /* The control that cannot have its label taken away — a poll option, a consent switch,
     a swatch. It keeps every pixel and only says "not yet, and not because you missed". */
  .is-working{opacity:.55;pointer-events:none;transition:opacity .18s ease;}
  .btn.is-busy{position:relative;cursor:progress;}
  /* THE ONE PLACE `.btn:disabled{opacity:0.4}` IS WRONG. A busy button is disabled for a
     second or two — pressing it again would start a second request — but 40 % opacity is
     the app's way of saying "unavailable", and at that strength the spinner and the word
     that explain the wait are the faintest thing on the screen. The other controls in the
     form stay at 0.4, because they genuinely are unavailable; this one is the only thing
     still happening. */
  .btn.is-busy:disabled{opacity:.88;cursor:progress;}
  /* The replacement label is a fixed string in 18 languages and the button is holding the
     width it had before the swap, so the long ones — Dutch's "Wordt geladen…", Hungarian's
     "Betöltés…" — must not break onto a second line and grow the row they sit in. */
  .btn-busy-label{white-space:nowrap;}
  .btn-spin{display:inline-block;width:15px;height:15px;margin-right:9px;
            vertical-align:-2px;border-radius:50%;
            border:2px solid currentColor;border-top-color:transparent;
            animation:btnSpin .7s linear infinite;}
  @keyframes btnSpin{to{transform:rotate(360deg);}}
  /* Reduced motion gets a pulse instead of a rotation: the control still has to say
     "working", and a static ring beside a changed label would read as a bullet. */
  @media(prefers-reduced-motion:reduce){
    .btn-spin{animation:skPulse 1.4s ease-in-out infinite;}
    @keyframes skPulse{0%,100%{opacity:1;}50%{opacity:.35;}}
  }
  /* ---- the photo overview (#moments) ----
     A month per card, its photographed days as a grid of the pictures themselves. The
     cells are bigger than the calendar's on purpose: this screen exists to be RECOGNISED
     from, and a 28 px thumbnail is a colour rather than a memory. Only days that have a
     photo are drawn, so the grid is dense and short instead of a month of empty squares.

     `--moon-glow` under a missing thumbnail rather than a grey box: a day whose 320 px
     copy has not been made yet still reads as "there is something here", which is the
     claim the cell is making. */
  .mo-month{padding:16px;}
  .mo-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-bottom:12px;}
  .mo-head h3{margin:0;}
  .mo-count{font-size:12px;font-weight:var(--w-strong);color:var(--muted);flex-shrink:0;}
  .mo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:8px;}
  .mo-cell{position:relative;aspect-ratio:1;border-radius:12px;overflow:hidden;padding:0;
    border:1px solid var(--line);background:var(--moon-glow);cursor:pointer;
    transition:transform .12s,filter .12s;}
  .mo-cell:hover{transform:translateY(-1px);filter:brightness(1.04);}
  .mo-cell img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;}
  /* The date chip. Over a photograph it needs its own ground and a FIXED ink — the picture
     underneath is whatever the reader pointed the camera at, so no palette token can be
     chosen against it, and a token that flips between schemes (`--cream` is cream by day
     and near-black at night) would invert the chip on half the themes. White on the app's
     own night scrim, the pairing `.mut-photo-count` already uses over a mutation photo.
     The empty cell keeps tokens: `--gold-text` on `--moon-glow` is `.cal-cell.done`'s. */
  .mo-d{position:absolute;left:5px;bottom:5px;z-index:1;padding:2px 6px;border-radius:8px;
    font-size:11px;font-weight:var(--w-strong);line-height:1.3;}
  .mo-cell.has .mo-d{background:rgba(17,23,34,0.72);color:#fff;}
  .mo-cell:not(.has) .mo-d{background:transparent;color:var(--gold-text);}
  .mo-n{position:absolute;right:5px;top:5px;z-index:1;min-width:18px;height:18px;padding:0 5px;
    border-radius:999px;display:grid;place-items:center;background:var(--moon);color:var(--on-accent);
    font-size:10.5px;font-weight:var(--w-strong);}
  body.dark .mo-cell{background:rgba(242,208,107,0.16);}
  .dp-all{margin-top:10px;}
  @media(prefers-reduced-motion:reduce){.mo-cell:hover{transform:none;}}
  .share-preview{width:100%;max-width:300px;border-radius:14px;display:block;margin:2px auto 14px;box-shadow:0 4px 18px rgba(28,36,50,0.15);}
  /* The missing-photo nudge. Deliberately NOT `--warn`: nothing is wrong, the card is
     valid and shareable as it is, and dressing a suggestion as a warning teaches people
     to ignore the real ones. Muted text with the accent on the one part that is an
     action. */
  .share-nophoto{max-width:300px;margin:-6px auto 14px;text-align:center;
    font-size:12.5px;line-height:1.5;color:var(--muted);}
  .share-nophoto a{color:var(--gold-text);font-weight:var(--w-strong);white-space:nowrap;}
  .share-preview[hidden]{display:none;}
  .share-preview-loading{display:flex;align-items:center;justify-content:center;width:100%;max-width:300px;aspect-ratio:1;margin:2px auto 14px;border-radius:14px;background:var(--white);border:1px solid var(--line);color:var(--muted);font-size:13px;font-weight:var(--w-strong);}
  .share-preview-loading[hidden]{display:none;}
  .share-actions{display:flex;flex-direction:column;gap:8px;}
  .share-download{display:flex;align-items:center;justify-content:center;gap:13px;text-align:center;text-decoration:none;}
  .share-theme-picker{margin:4px 0 14px;}
  .share-theme-picker>span{display:block;margin-bottom:7px;color:var(--muted);font-size:12px;font-weight:var(--w-strong);text-transform:uppercase;letter-spacing:.08em;}
  .share-theme-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;}
  .share-theme-choice{display:flex;align-items:center;gap:6px;min-width:0;min-height:44px;padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--white);color:var(--ink);font-size:11px;font-weight:var(--w-strong);text-align:left;}
  .share-theme-choice.on{border-color:var(--moon-deep);box-shadow:inset 0 0 0 1px var(--moon-deep);}
  .share-theme-choice .sw-dot{width:16px;height:16px;border-radius:50%;border:1px solid rgba(0,0,0,.18);flex-shrink:0;}
  .share-theme-choice span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  /* ---- the share-card editor (ROADMAP 5.4.7, 2026-08-17) --------------------
     A LOCKED PALETTE DIMS ITS SWATCH AND ITS FRAME, NEVER ITS LABEL. Fading the whole
     button is the obvious way to draw "not available" and it would put the one piece of
     TEXT here behind an opacity that tests/contrast.test.js cannot see: that table is a
     frozen browser measurement of (token, surface) pairs, and an opacity multiplier is
     neither. The colour is the part being withheld, so the colour is the part that dims;
     the name stays at full --muted, which is a pairing already proven in every theme.
     The ✦ is the app's own mark for "supporter", the same one the account badge and the
     card frame carry, so it says which perk this is without a word. */
  .share-theme-choice.locked{border-style:dashed;}
  .share-theme-choice.locked .sw-dot{opacity:.42;}
  .share-theme-choice.locked::after{content:'✦';flex-shrink:0;margin-left:auto;
    font-size:11px;color:var(--gold-text);}
  .sc-hint{margin:0 0 8px;}
  /* One line, and it only occupies space once it has something to say — an empty
     `role="status"` that reserves a row leaves a gap nobody can explain. */
  .sc-msg{display:none;margin:9px 0 0;font-size:12.5px;line-height:1.5;color:var(--muted);}
  .sc-msg.on{display:block;}
  .sc-block{margin-top:20px;}
  .sc-block-title{display:block;margin-bottom:7px;color:var(--muted);font-size:12px;
    font-weight:var(--w-strong);text-transform:uppercase;letter-spacing:.08em;}
  .sc-shapes{display:flex;gap:8px;}
  .sc-shape-opt{flex:1;display:flex;align-items:center;gap:9px;min-height:44px;padding:9px 11px;
    border:1px solid var(--line);border-radius:12px;background:var(--white);color:var(--ink);
    font-size:12.5px;font-weight:var(--w-strong);text-align:left;}
  .sc-shape-opt.on{border-color:var(--moon-deep);box-shadow:inset 0 0 0 1px var(--moon-deep);}
  /* The control shows the SHAPE rather than naming it twice: the mask is the same outline
     the cover is clipped to, filled with the accent gradient so it reads as a photo slot
     rather than as an icon. */
  .sc-shape-mask{width:26px;height:26px;flex-shrink:0;
    background:linear-gradient(135deg,var(--grad-a),var(--grad-b));}
  .sc-shape-circle{border-radius:50%;}
  .sc-shape-round{border-radius:8px;}
  /* ---- the framer (ROADMAP 5.4.7 step 2) ------------------------------------
     `touch-action:none` is what makes this work on a phone at all. Without it the browser
     claims a vertical drag for the page scroll before pointermove ever fires, so the
     square can be moved sideways and not up — which is precisely the axis the zoom exists
     to give back, and it would look like the feature half-working rather than like the
     page scrolling. */
  .sc-frame{position:relative;width:100%;max-width:280px;margin:0 auto;aspect-ratio:1;
    overflow:hidden;background:var(--cream-100);border:2px solid var(--moon-deep);
    touch-action:none;cursor:grab;user-select:none;-webkit-user-select:none;}
  .sc-frame.is-dragging{cursor:grabbing;}
  .sc-frame:focus-visible{outline:3px solid var(--moon-deep);outline-offset:3px;}
  .sc-frame-circle{border-radius:50%;}
  .sc-frame-round{border-radius:18px;}
  .sc-frame img{position:absolute;left:0;top:0;max-width:none;display:block;
    pointer-events:none;-webkit-user-drag:none;}
  /* Thirds, at a weight that guides without competing with the photograph. It is the one
     thing that makes "is the bird's eye where I want it" answerable at a glance, and it is
     `aria-hidden` because it describes nothing — the control is announced by its label. */
  .sc-frame-grid{position:absolute;inset:0;pointer-events:none;
    background:
      linear-gradient(to right,transparent calc(33.33% - 1px),rgba(255,255,255,.45) calc(33.33% - 1px),rgba(255,255,255,.45) 33.33%,transparent 33.33%,transparent calc(66.66% - 1px),rgba(255,255,255,.45) calc(66.66% - 1px),rgba(255,255,255,.45) 66.66%,transparent 66.66%),
      linear-gradient(to bottom,transparent calc(33.33% - 1px),rgba(255,255,255,.45) calc(33.33% - 1px),rgba(255,255,255,.45) 33.33%,transparent 33.33%,transparent calc(66.66% - 1px),rgba(255,255,255,.45) calc(66.66% - 1px),rgba(255,255,255,.45) 66.66%,transparent 66.66%);
    mix-blend-mode:overlay;}
  .sc-zoom-row{display:flex;align-items:center;gap:12px;margin-top:12px;}
  .sc-zoom{flex:1;min-width:0;}
  .sc-frame-reset{flex-shrink:0;}
  @media(prefers-reduced-motion:reduce){ .sc-frame img{transition:none;} }
  /* ---- the frame picker (ROADMAP 5.4.7 step 6) -----------------------------
     The swatch IS the label, so the button is only a frame around a picture — no text, and
     the accessible name comes from `aria-label`. Four across, which fits eleven options in
     three rows on a 390px phone without any of them shrinking below the size at which the
     difference between `hairline` and `double` is visible. */
  .sc-frames{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;}
  .sc-frame-opt{aspect-ratio:1;display:flex;align-items:center;justify-content:center;
    padding:4px;border:1px solid var(--line);border-radius:11px;background:var(--white);
    color:var(--muted);font-size:11px;font-weight:var(--w-strong);overflow:hidden;}
  .sc-frame-opt img{width:100%;height:100%;object-fit:contain;border-radius:7px;display:block;}
  .sc-frame-opt.on{border-color:var(--moon-deep);box-shadow:inset 0 0 0 1px var(--moon-deep);}
  /* Dashed and a ✦, never an opacity — the reason is the one written at .share-theme-choice
     above: tests/contrast.test.js is a frozen measurement of (token, surface) pairs and
     cannot see a multiplier. Here the swatch is an IMAGE rather than text, so fading it
     would cost nothing that check can see either, and the frame carries the state anyway. */
  .sc-frame-opt.locked{border-style:dashed;}
  .sc-frame-opt.locked img{opacity:.5;}
  .sc-frame-none{text-align:center;line-height:1.2;}
  /* ---- the app-wide display typeface (ROADMAP 5.4.7 step 5) -----------------
     Each button is set in its own face at the app's REAL heading size, because that is the
     question being answered: choosing an interface font from a poster-sized specimen is how
     somebody ends up with headings they cannot read on their own phone. */
  .af-panel{margin-top:16px;}
  .af-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin-top:10px;}
  .af-opt{min-height:46px;padding:9px 11px;border:1px solid var(--line);border-radius:11px;
    background:var(--white);color:var(--ink);font-size:19px;line-height:1.15;
    overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .af-opt.on{border-color:var(--moon-deep);box-shadow:inset 0 0 0 1px var(--moon-deep);}
  .af-note{margin-top:9px;}
  @media(max-width:360px){ .af-grid{grid-template-columns:1fr;} }
  /* ---- the title typeface (ROADMAP 5.4.7 step 4) ----------------------------
     Each button is SET IN THE FACE IT NAMES, which is what makes the list a specimen sheet
     instead of thirteen identical rows of text — and, not incidentally, what pulls each
     lazily-declared file down when the screen opens. 17px because several of these are
     display faces that say nothing at 12. */
  .sc-fonts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;}
  .sc-font{min-height:46px;padding:8px 11px;border:1px solid var(--line);border-radius:11px;
    background:var(--white);color:var(--ink);font-size:17px;line-height:1.15;
    overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  .sc-font.on{border-color:var(--moon-deep);box-shadow:inset 0 0 0 1px var(--moon-deep);}
  /* Dashed frame and a ✦, never an opacity on the text — the same reasoning as the locked
     palette above: tests/contrast.test.js is a frozen measurement of (token, surface) pairs
     and cannot see a multiplier, so faded text is text nothing in this repo checks. Here the
     specimen IS the label, so there is no swatch to dim instead; the frame carries it. */
  .sc-font.locked{border-style:dashed;}
  .sc-font.locked::after{content:' ✦';font-size:11px;color:var(--gold-text);
    font-family:var(--font-body);}
  .sc-type-row{display:flex;gap:6px;margin-top:8px;}
  .sc-type-opt{flex:1;min-height:42px;padding:9px 6px;border:1px solid var(--line);
    border-radius:11px;background:var(--white);color:var(--ink);
    font-size:13px;font-weight:var(--w-strong);}
  .sc-type-opt.on{border-color:var(--moon-deep);box-shadow:inset 0 0 0 1px var(--moon-deep);}
  /* The two style toggles say what they DO, so the words wear their own effect. */
  .sc-italic{font-style:italic;}
  .sc-underline{text-decoration:underline;text-underline-offset:3px;}
  .sc-onefont{margin-top:8px;}
  .sc-onefont[hidden]{display:none;}
  @media(max-width:360px){ .sc-fonts{grid-template-columns:1fr;} }
  .sc-row{display:flex;align-items:center;gap:12px;padding:11px 0;
    border-bottom:1px solid var(--line-soft);}
  .sc-row:last-of-type{border-bottom:none;}
  .sc-row-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;}
  .sc-row-text strong{font-size:14px;font-weight:var(--w-med);color:var(--ink);}
  .sc-empty{font-size:12.5px;line-height:1.45;color:var(--muted);}
  .sc-autosave{margin-top:16px;}
  /* ---- the two overridable fields (ROADMAP 5.4.7 step 3) --------------------
     Indented under the switch it belongs to, with a rule down the left, so the box reads
     as part of that row rather than as a seventh control in the list. */
  .sc-override{margin:-2px 0 12px;padding:0 0 0 13px;border-left:2px solid var(--line);}
  .sc-override[hidden]{display:none;}
  .sc-override-input{width:100%;}
  .sc-override-hint{display:block;margin-top:5px;}
  /* The temperament composer on the bird's page. Two rows of pills and a free-text box
     each, because the pills are a way IN rather than the options — the field takes anything
     up to 30 characters and the box is where that happens. */
  .tp-half{margin-top:10px;}
  .tp-half:first-of-type{margin-top:6px;}
  .tp-legend{display:block;margin-bottom:6px;color:var(--muted);font-size:12px;
    font-weight:var(--w-strong);text-transform:uppercase;letter-spacing:.08em;}
  .tp-pills{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;}
  .tp-pill{min-height:36px;padding:7px 13px;border:1px solid var(--line);border-radius:999px;
    background:var(--white);color:var(--ink);font-size:13px;font-weight:var(--w-med);}
  .tp-pill.on{border-color:var(--moon-deep);background:var(--moon-glow);
    box-shadow:inset 0 0 0 1px var(--moon-deep);}
  /* The phrase the two halves make, which is the thing being composed — two fields that
     combine into one sentence are two fields until somebody sees the sentence. Empty until
     there is one, so it reserves no space on a form nobody has filled in. */
  .tp-result{margin:10px 0 0;font-family:var(--font-display);font-size:19px;color:var(--ink);}
  .tp-result:empty{display:none;}
  .sc-stage{text-align:center;}
  /* Wider than in the modal it replaces — this is a screen, and the card is what the
     screen is for. Capped so it cannot outgrow a phone's width on a tablet and turn the
     controls above it into a distant scroll. */
  .sc-stage .share-preview,.sc-stage .share-preview-loading,
  .sc-stage .share-nophoto,.sc-stage .share-actions{max-width:360px;margin-left:auto;margin-right:auto;}
  .sc-stage .dc-title{margin-bottom:12px;text-align:left;}
  .sc-stage .share-nophoto[hidden]{display:none;}
  .feat{display:flex;gap:14px;padding:13px 0;border-bottom:1px solid var(--line-soft);}
  .feat:last-child{border-bottom:none;}
  .feat .fi{font-size:22px;width:30px;flex-shrink:0;text-align:center;line-height:1.3;}
  .feat .ft{display:block;font-weight:var(--w-strong);font-size:15px;}
  .feat .fd{display:block;font-size:13.5px;color:var(--muted);margin-top:2px;line-height:1.45;}
  /* The live language list on the multi-language feature. Wraps, because the set
     grows: at 18 locales this is three rows on a phone, not a horizontal scroll. */
  .feat-locs{display:flex;flex-wrap:wrap;gap:7px;margin-top:9px;}
  .feat-loc{display:inline-flex;align-items:center;gap:5px;padding:3px 8px 3px 5px;
    background:var(--cream-100);border:1px solid var(--line-soft);border-radius:999px;
    font-size:11.5px;font-weight:var(--w-strong);letter-spacing:.04em;color:var(--ink);}
  .feat-loc .lang-flag{width:16px;height:10.67px;}
  /* AI-translation notice: one concise paragraph and one acknowledgement. */
  .trans-note p{font-size:14.5px;line-height:1.55;color:var(--ink);margin:8px 0 0;}
  /* Colour and weight, no underline — tests/action-controls.test.js holds the
     whole app to that, and the mail address is a link like any other. */
  .trans-note a{color:var(--gold-text);font-weight:var(--w-strong);word-break:break-word;}
  .modal-foot .tn-ok{width:100%;}
  .modal-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:12px;color:var(--muted);text-align:center;}
  .delete-confirm-actions{display:grid;gap:8px;}
  textarea.data-area{width:100%;min-height:88px;padding:12px 14px;border:1px solid var(--line);border-radius:11px;font-family:ui-monospace,'SF Mono',monospace;font-size:12px;background:var(--cream);color:var(--ink);resize:vertical;word-break:break-all;}
  textarea.data-area:focus{outline:none;border-color:var(--moon-deep);}
  #toasts{position:fixed;left:0;right:0;top:82px;z-index:50;display:flex;flex-direction:column;align-items:center;gap:10px;pointer-events:none;padding:0 16px;}
  .toast{display:flex;align-items:center;gap:13px;background:var(--night);color:#fff;border-radius:14px;padding:13px 18px;box-shadow:0 10px 30px rgba(0,0,0,0.25);max-width:340px;animation:toastIn .4s ease;}
  .toast .tic{width:40px;height:40px;border-radius:11px;background:var(--moon);display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;}
  .toast .th{font-size:11px;font-weight:var(--w-strong);letter-spacing:0.1em;text-transform:uppercase;color:var(--moon);}
  .toast .tn{font-weight:var(--w-strong);font-size:15px;margin-top:1px;}
  @keyframes toastIn{from{opacity:0;transform:translateY(-20px);}to{opacity:1;transform:none;}}
  /* First-visit locale confirmation: informational only, so it never takes
     focus or competes with the language picker. */
  .locale-toast{display:flex;align-items:flex-start;gap:10px;width:100%;max-width:390px;
    background:var(--night);color:#fff;border-radius:14px;padding:12px 14px;
    box-shadow:0 10px 30px rgba(0,0,0,.25);animation:toastIn .35s ease;}
  .locale-toast.out{opacity:0;transform:translateY(-10px);transition:opacity .24s,transform .24s;}
  .locale-toast .lt-icon{font-size:19px;line-height:1.2;flex:0 0 auto;}
  .locale-toast .lt-title{font-weight:var(--w-strong);font-size:14px;line-height:1.25;}
  .locale-toast .lt-body{font-size:12px;line-height:1.35;color:var(--cream-100);margin-top:3px;}

  /* ---- KIRI'S ARTWORK, SHARED (4.4b) ----
     `.mascot-art` and its three layers are used by FOUR surfaces — the moment below, the
     Home nudge, the Home customize row and the story screen — so they live here on their
     own and each surface sets only its own size. The comments that used to sit here
     described the toast this block outlived; the toast is `.mascot-moment` now and owns
     its own. */
  /* Fixed square so the row never reflows between a loaded clip and the emoji
     fallback; the clip is absolutely positioned over the emoji.

     GREW 64 → 92 → 132 px ON 2026-08-15 (owner, twice in one session), and the toast
     grows with it because the art is the tallest thing in the row — there is no separate
     height to set. The square was sized for an emoji, and an emoji reads fine at 64; a
     full-body drawing of a bird does not, because at that size the head is twenty pixels.
     The second raise came with a target rather than a number: a quarter to a third of the
     window, which is what makes the character read as a character.

     NO BADGE ANYWHERE, and no background: 2026-08-15, after the owner saw it both ways.
     Kiri sits directly on whatever surface he is on — the dark toast, the cream story
     screen — which is why this rule now names no colour at all. A surface that wants a
     frame back adds one; nothing has to know what colour anything else is.

     `aspect-ratio` REPLACES the square. The clips are 940×692, so a square box left ~21 px
     of dead transparent band above and below the drawing. Gold used to fill that and hide
     it; with no badge it is simply a hole, and it was the real cause of "he looks too far
     from the text". `object-fit:contain` still protects a future clip of another shape. */
  .mascot-art{position:relative;width:132px;aspect-ratio:940/692;flex-shrink:0;
    display:flex;align-items:center;justify-content:center;overflow:hidden;}
  .ma-emoji{font-size:60px;line-height:1;}
  /* THE FALLBACK IS HIDDEN WHILE A CLIP EXISTS, and this replaces painting the clip's own
     background — which worked but forced every surface to declare its colour twice and got
     it wrong the moment Kiri appeared on a light one.

     The bug it fixes is worth stating because it is not obvious: the emoji sits UNDER the
     clip, `object-fit:contain` leaves the element spanning the whole box while the picture
     inside it is letterboxed, and every transparent pixel of an alpha drawing is a window.
     So 🎉 showed through `celebrate` as a red smear and 🌙 through `gentle` as a crescent.

     `:has()` because the condition is "a clip is in the DOM", which is exactly what
     `data-error="removeSelf"` changes: a 404 removes the `<img>`, this stops matching, and
     the emoji comes back on its own. No JavaScript, and the fallback stays honest. */
  .mascot-art:has(.ma-clip) .ma-emoji{display:none;}
  /* THE STILL DRAWING, the middle of three layers (2026-08-15). It hides the emoji for the
     same reason and by the same mechanism the clip does, and the clip hides IT — so the
     stack resolves to the best thing that actually loaded, with no JavaScript asking.

     It exists because the emoji was never Kiri: the fallback used to be a per-mood glyph
     and a reader who finished a chapter got a ⭐ on a toast with Kiri's name on it. The
     clips are an administrator upload that is not on the server yet, so the fallback is
     what everyone sees today, and it has to be him.

     `/icons/icon-192.png` is a CORE precache entry, which is the whole reason this layer
     can be an image at all — a still kept beside the clips under `/media/` would be absent
     in exactly the offline case the fallback is for. It ships with its own rounded corners
     and transparency, so nothing here paints a background or a radius. */
  .ma-still{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block;}
  .mascot-art:has(.ma-still) .ma-emoji{display:none;}
  .mascot-art:has(.ma-clip) .ma-still{display:none;}
  /* `contain`, NOT `cover`, and the change matters more than it looks. Cover fills the
     square by cropping whatever does not fit — fine for a photograph, wrong for a
     character: a portrait wider than it is tall loses its tail and its feet, which are
     the two things that make it a conure. Contain shows the whole drawing and lets the
     gold badge show around it, which requires the clip to have a TRANSPARENT
     background — see docs/ROADMAP.md's mascot row. It carries no background of its own:
     the `.mascot-art:has()` rule above is what keeps the emoji from showing through it. */
  .ma-clip{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block;}

  /* ---- KIRI'S MOMENT (owner, 2026-08-16) --------------------------------------------
     This replaced `.mascot-toast`, and the rename is not cosmetic. A toast is a strip at
     the edge that is ignorable by design; this is a circle of the app's accent growing
     out of the middle of the screen until it covers it, with Kiri and his line arriving
     inside it one after another. The app stops to say something.

     THE COLOURS ARE THE APP'S PRIMARY BUTTON, NOT A NEW PALETTE. `--moon` is the accent
     and every one of the eight themes redefines it to its own saturated mid-tone;
     `--night` is the one dark navy that is NOT theme-scoped. tests/contrast.test.js
     already asserts that exact pair at 4.5:1 in all eight themes. Picking anything else
     here would have meant picking it eight times and proving it eight times.

     `position:fixed` inside `#toasts` on purpose: the container is already the mount
     point every mascot surface uses and the test harness stubs it, but a fixed child
     escapes its padding and its column layout entirely and resolves against the viewport.
     `#toasts` sets no transform or filter, which is the thing that would break that. */
  .mascot-moment{position:fixed;inset:0;z-index:1;display:flex;flex-direction:column;
    align-items:center;justify-content:center;pointer-events:auto;overflow:hidden;}

  /* THE CIRCLE. `clip-path` rather than a scaled square, because a scaled element blurs
     its own edge at large factors while a clip stays a crisp curve at any size. 150%
     rather than 100%: the radius is measured against the box's diagonal, so a circle that
     merely reaches the edges leaves the corners uncovered on a wide screen. */
  /* Still the --moon / --night pairing the whole component was argued onto (§14b) —
     the gradient runs between --grad-a and --grad-b, which BOTH default to --moon and
     are both proven against --night for every theme that overrides them. The flat
     `background` stays underneath as the fallback, exactly as on .btn-primary. */
  .mm-wash{position:absolute;inset:0;background:var(--moon);
    background-image:linear-gradient(155deg,var(--grad-a),var(--grad-b));
    clip-path:circle(0% at 50% 50%);
    animation:mmGrow .58s cubic-bezier(.33,0,.2,1) forwards;}
  @keyframes mmGrow{from{clip-path:circle(0% at 50% 50%);}to{clip-path:circle(150% at 50% 50%);}}

  .mm-body{position:relative;display:flex;flex-direction:column;align-items:center;
    text-align:center;gap:6px;max-width:min(520px,86vw);padding:0 16px;color:var(--on-accent);}
  /* THE STAGGER, by nth-child rather than by a class the JavaScript counts out — the CTA
     is optional, and an index written in the markup would have to know that. Each child
     starts after the wash has most of the way to go, so they arrive INTO a filled screen
     rather than onto a growing one. */
  .mm-body > *{opacity:0;animation:mmRise .42s cubic-bezier(.2,.7,.3,1) forwards;}
  .mm-body > *:nth-child(1){animation-delay:.40s;}
  .mm-body > *:nth-child(2){animation-delay:.54s;}
  .mm-body > *:nth-child(3){animation-delay:.64s;}
  .mm-body > *:nth-child(4){animation-delay:.74s;}
  /* A fifth slot, because Continue is always present and the CTA above it is not: with a
     link the button is the fifth child, without one it is the fourth. Both are covered. */
  .mm-body > *:nth-child(5){animation-delay:.84s;}
  @keyframes mmRise{from{opacity:0;transform:translateY(14px) scale(.96);}
                    to{opacity:1;transform:none;}}

  /* Kiri at the size the owner asked for: the subject of the screen, not an icon on it.
     `min()` against `vh` as well as `vw` so a short landscape phone does not push the
     text off the bottom. */
  .mascot-moment .mascot-art{width:min(280px,62vw,34vh);border-radius:0;}
  .mascot-moment .ma-emoji{font-size:min(120px,26vw);}

  .mm-kicker{margin:6px 0 0;font-size:12px;font-weight:var(--w-strong);letter-spacing:0.12em;
    text-transform:uppercase;opacity:0.72;}
  .mm-text{margin:0;font-family:var(--font-display);font-weight:500;
    font-size:clamp(20px,5.2vw,27px);line-height:1.25;}
  .mm-cta{display:inline-flex;align-items:center;min-height:44px;margin-top:6px;
    font-weight:var(--w-strong);font-size:16px;color:var(--night);
    border-bottom:2px solid color-mix(in srgb, var(--night) 40%, transparent);}
  /* THE WAY OUT (owner, 2026-08-16). The moment no longer closes itself, so this is not a
     convenience — it is the only affordance most people will look for, and it has to be
     the most obvious thing under the text. Solid `--night` on the `--moon` wash: the same
     pair as everything else here, inverted, so it reads as the one actionable object on a
     screen that is otherwise flat colour. */
  .mm-go{margin-top:18px;min-height:50px;padding:0 34px;border-radius:14px;
    background:var(--night);color:var(--moon);
    font-family:inherit;font-weight:var(--w-strong);font-size:16px;letter-spacing:0.01em;
    box-shadow:0 6px 18px color-mix(in srgb, var(--night) 26%, transparent);}
  .mm-go:hover{filter:brightness(1.12);}
  /* The focus ring has to be visible against BOTH — the button is navy and it sits on the
     accent — so it is drawn outside the button in the wash's own colour with a dark edge
     under it. `:focus-visible`, so a mouse press does not draw it. */
  .mm-go:focus-visible{outline:3px solid var(--moon);outline-offset:3px;
    box-shadow:0 0 0 6px color-mix(in srgb, var(--on-accent) 45%, transparent);}

  /* A dismiss in the corner as well as tap-anywhere and Continue: the corner is where a
     full-screen overlay's close belongs, and it is the affordance somebody uses when they
     want the thing gone rather than acknowledged. */
  .mm-close{position:absolute;top:max(14px,env(safe-area-inset-top));right:14px;
    display:flex;align-items:center;justify-content:center;width:44px;height:44px;
    background:none;border-radius:12px;color:var(--on-accent);font-size:19px;line-height:1;
    padding:0;opacity:0.65;}
  .mm-close:hover{opacity:1;background:color-mix(in srgb, var(--on-accent) 10%, transparent);}

  /* Leaving is the arrival reversed — the circle closes back to the middle. The contents
     go first and faster, or they would be visibly clipped by the shrinking edge. */
  .mascot-moment.closing .mm-wash{animation:mmShrink .40s cubic-bezier(.4,0,.7,1) forwards;}
  .mascot-moment.closing .mm-body,
  .mascot-moment.closing .mm-close{animation:mmFade .18s ease forwards;}
  @keyframes mmShrink{from{clip-path:circle(150% at 50% 50%);}to{clip-path:circle(0% at 50% 50%);}}
  @keyframes mmFade{to{opacity:0;}}

  /* NO CIRCLE AND NO STAGGER when motion is not wanted. The growth and the sequence ARE
     the effect, and they are also precisely what this preference asks not to be shown, so
     it is not a slower version — everything is simply already there. It still covers the
     screen, still reads the same, still leaves on its own. */
  @media(prefers-reduced-motion:reduce){
    .mm-wash{animation:none;clip-path:none;}
    .mm-body > *{animation:none;opacity:1;transform:none;}
    .mascot-moment.closing .mm-wash{animation:none;opacity:0;}
  }

  /* ---- Kiri's story (ROADMAP 4.4d) ----
     Reuses .screen-head, .card and .mascot-art rather than introducing a layout.
     The head centres so the artwork reads as a portrait rather than a stray icon;
     everything below is the ordinary card rhythm the rest of the app uses. */
  .story-head{display:flex;flex-direction:column;align-items:center;text-align:center;gap:12px;}
  /* 86 px SQUARE GOLD BADGE UNTIL 2026-08-15, when the toast lost its badge and the owner
     asked for the same everywhere. It has to grow to survive the change: a framed 86 px
     portrait reads as deliberate, an unframed one reads as a small picture somebody left
     there. `height` is not set — the base rule's aspect-ratio derives it, and restating a
     square here is what would bring the dead bands back on this screen only. */
  .story-head .mascot-art{width:168px;border-radius:0;}
  .story-head .ma-emoji{font-size:64px;}
  .story-head p{max-width:38em;}
  .story-card h3{display:flex;align-items:center;gap:9px;}
  .story-ic{font-size:19px;line-height:1;}
  .story-card p{margin-top:7px;line-height:1.6;}
  /* The promise card is the one that must not read as another paragraph: 13.5 makes
     "Kiri is never sad, sick or neglected" a constraint on the product, so it is
     stated on the moon band the app uses for things it is committing to.
     The BORDER is what makes that work at night. `--night` (#1C2432) and the dark
     theme's `--card` (#182031) are four points apart, so on a dark shell the band
     alone left the card indistinguishable from the four above it — seen in the
     browser, not reasoned about. In daylight the border is a gold edge on an
     already-dark card, which reads as deliberate either way. */
  .story-promise{background:var(--night);color:#fff;border:1px solid var(--moon-deep);}
  .story-promise h3{color:var(--moon);}
  .story-promise p{margin-top:7px;line-height:1.6;color:rgba(255,255,255,0.88);}
  /* Colour and weight, never an underline — tests/action-controls.test.js bans the
     underlined link style app-wide, and the first draft of this rule brought it back. */
  .foot-brand .foot-story{display:inline-block;padding:4px 0;font-size:12.5px;
    font-weight:var(--w-strong);color:var(--gold-text);}
  .foot-brand .foot-story:hover{color:var(--moon-deep);}
  @media(max-width:430px){
    .story-head .mascot-art{width:148px;}
    .story-head .ma-emoji{font-size:56px;}
  }

  /* ---- Theme transition: curtain wipe ---- */
  .theme-wipe{position:fixed;inset:0;z-index:70;pointer-events:none;will-change:transform;}
  .theme-wipe.down{animation:wipeDown .78s cubic-bezier(.5,0,.2,1) forwards;}
  .theme-wipe.up{animation:wipeUp .78s cubic-bezier(.5,0,.2,1) forwards;}
  @keyframes wipeDown{0%{transform:translateY(-100%);}42%{transform:translateY(0);}58%{transform:translateY(0);}100%{transform:translateY(100%);}}
  @keyframes wipeUp{0%{transform:translateY(100%);}42%{transform:translateY(0);}58%{transform:translateY(0);}100%{transform:translateY(-100%);}}

  /* ---- Theme toast (sun/moon swap) ---- */
  .theme-toast{display:flex;align-items:center;gap:14px;width:100%;max-width:520px;border-radius:16px;padding:11px 20px;box-shadow:0 12px 34px rgba(0,0,0,0.28);animation:ttIn .45s ease;pointer-events:none;overflow:hidden;}
  .theme-toast.dark{background:linear-gradient(90deg,#0E1420,#25324c);color:#F1EAD9;}
  .theme-toast.light{background:linear-gradient(90deg,#F2D06B,#FBF3D6);color:#1C2432;}
  .theme-toast .tt-ic{width:32px;height:32px;flex-shrink:0;}
  .theme-toast .tt-ic svg{width:100%;height:100%;display:block;}
  .theme-toast .tt-label{flex:1;text-align:center;font-family:var(--font-display);font-weight:500;font-size:16px;}
  .tt-ic.ascend{animation:ascend 1.5s cubic-bezier(.3,0,.2,1) forwards;}
  .tt-ic.descend{animation:descend 1.5s cubic-bezier(.3,0,.2,1) forwards;}
  @keyframes ascend{0%{transform:translateY(16px) scale(.7);opacity:.25;}60%{opacity:1;}100%{transform:translateY(-1px) scale(1);opacity:1;}}
  @keyframes descend{0%{transform:translateY(-8px) scale(1);opacity:1;}100%{transform:translateY(17px) scale(.7);opacity:.3;}}
  @keyframes ttIn{from{opacity:0;transform:translateY(-30px);}to{opacity:1;transform:none;}}
  @keyframes ttOut{from{opacity:1;transform:none;}to{opacity:0;transform:translateY(-30px);}}
  @media(prefers-reduced-motion:reduce){.theme-wipe{display:none;}}

  .petsitter{max-width:640px;margin:0 auto;}
  .ps-head{text-align:center;margin-bottom:12px;}
  .ps-head h1{font-size:30px;margin-bottom:2px;}
  .ps-head p{color:var(--muted);font-size:14px;}
  .ps-sec{border:1px solid var(--line);border-radius:14px;padding:12px 16px;margin-bottom:12px;background:var(--white);}
  .ps-sec h3{font-size:14px;font-weight:var(--w-strong);margin-bottom:6px;}
  .ps-sec ul{margin:0;padding-left:18px;}
  .ps-sec li{margin:3px 0;}
  .ps-vets{list-style:none;padding-left:0;}
  .ps-vets li{padding:6px 0;border-bottom:1px solid var(--line-soft);}
  .ps-vets li:last-child{border-bottom:none;}
  .ps-phone{font-weight:var(--w-strong);color:var(--danger);}
  .ps-muted{color:var(--muted);}
  .ps-danger{border-color:var(--danger-line);background:var(--danger-bg);}
  .ps-print{text-align:center;margin-top:8px;display:flex;gap:8px;justify-content:center;flex-wrap:wrap;}
  .ps-desc{margin:0;font-size:14px;line-height:1.5;}
  .ps-do{border-color:var(--moon-deep);}
  .ps-pills{display:flex;flex-wrap:wrap;gap:8px;}
  .ps-pill{display:inline-flex;align-items:center;gap:6px;background:var(--cream-100);border:1px solid var(--line);border-radius:999px;padding:5px 12px;font-size:13px;font-weight:var(--w-med);}
  .ps-pill.edit{padding:3px 5px 3px 12px;}
  .ps-pill.edit button{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;
    background:transparent;border:none;border-radius:50%;color:var(--danger);font-size:18px;line-height:1;cursor:pointer;padding:0;}
  .ps-pill.edit button:hover{background:var(--danger-bg);}
  /* Vet report (ROADMAP 9.3). Printed far more often than it is read on screen,
     so it stays a single narrow column at every width — a two-column layout that
     reflows differently in the print engine is how a report loses a section. */
  .vetreport{max-width:660px;margin:0 auto;}
  .vr-head{text-align:center;margin-bottom:14px;}
  .vr-head h1{font-size:28px;margin-bottom:2px;}
  .vr-head p{color:var(--muted);font-size:14px;}
  .vr-meta{font-size:12px;margin-top:4px;}
  .vr-sec{border:1px solid var(--line);border-radius:14px;padding:14px 16px;margin-bottom:12px;background:var(--white);}
  .vr-sec h3{font-size:14px;font-weight:var(--w-strong);margin-bottom:8px;}
  .vr-sec h4{font-size:13px;font-weight:var(--w-strong);margin:14px 0 6px;}
  .vr-sec p{font-size:14px;line-height:1.5;margin:0 0 8px;}
  .vr-muted{color:var(--muted);font-size:12px !important;line-height:1.45 !important;}
  .vr-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 14px;margin:0;}
  .vr-fact dt{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;}
  .vr-fact dd{margin:0;font-size:14px;font-weight:var(--w-med);}
  .vr-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(96px,1fr));gap:8px;margin-bottom:10px;}
  .vr-stat{border:1px solid var(--line-soft);border-radius:10px;padding:8px 10px;background:var(--cream);}
  .vr-stat-k{display:block;font-size:11px;color:var(--muted);}
  .vr-stat b{font-size:18px;}
  .vr-stat-n{display:block;font-size:11px;color:var(--muted);}
  .vr-note{border-left:3px solid var(--moon-deep);padding-left:10px;font-size:13px !important;}
  .vr-note.alert{border-left-color:var(--danger);}
  .vr-chart{width:100%;height:auto;display:block;margin:8px 0 4px;}
  .vr-axis{fill:var(--muted);font-size:11px;}
  .vr-table{width:100%;border-collapse:collapse;font-size:13px;}
  .vr-table th{text-align:left;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;padding:4px 6px;border-bottom:1px solid var(--line);}
  .vr-table td{padding:4px 6px;border-bottom:1px solid var(--line-soft);}
  .vr-table tr:last-child td{border-bottom:none;}
  .vr-num{text-align:right;font-variant-numeric:tabular-nums;}
  .vr-item{display:flex;align-items:center;gap:10px;margin:6px 0;font-size:13px;}
  .vr-item-l{flex:1;min-width:0;}
  .vr-item-n{color:var(--muted);font-size:12px;white-space:nowrap;}
  .vr-bar{width:88px;height:7px;border-radius:999px;background:var(--line-soft);overflow:hidden;flex-shrink:0;}
  .vr-bar span{display:block;height:100%;background:var(--moon-deep);}
  .vr-disclaimer{background:var(--cream);}
  .vr-disclaimer p{margin:0;font-size:12px;color:var(--muted);}
  .vr-actions{text-align:center;margin-top:8px;}
  .care-export-actions{display:flex;gap:8px;flex-wrap:wrap;}
  .ps-edit-row{display:flex;align-items:center;gap:8px;margin-bottom:8px;}
  .ps-edit-row .profile-input{flex:1;margin:0;}
  .ps-row-ctrl{display:flex;align-items:center;gap:4px;flex-shrink:0;}

  @media print{
    header.top,footer,#decor,.back-btn,.print-btn,#toasts,.ps-print,.vr-actions{display:none !important;}
    /* keep the on-screen colors in the PDF; force the light palette so a dark-mode
       user still prints the light sheet (matches the site look) */
    html{-webkit-print-color-adjust:exact;print-color-adjust:exact;}
    body,body.dark{
      background:#fff;
      --white:#FFFDF8; --cream:#FBF6EC; --cream-100:#F6EDD8; --ink:#1C2432; --muted:#626774;
      --line:rgba(28,36,50,0.14); --line-soft:rgba(28,36,50,0.08);
      --danger:#B23A22; --danger-bg:#F7E2DB; --danger-line:#EBA07A;
      --moon:#F2D06B; --moon-deep:#D9AE3E; --gold-text:#85651D; --accent-text:#AB8931;
    }
    main.wrap{padding-top:0;}
    .emg-call,.petsitter .ps-sec,.vetreport .vr-sec{break-inside:avoid;}
    /* A weight table split across a page break loses its header row, so keep the
       rows together too — the report is usually two pages at most. */
    .vr-table tr,.vr-item{break-inside:avoid;}
  }

  @media(max-width:520px){
    .hero h1{font-size:38px;}
    .hero-actions{align-items:stretch;}
    .hero-actions .hero-about,.hero-actions .home-add-bird{min-height:44px;}
    .q-text{font-size:21px;}
    .flip-q{font-size:22px;}
    .screen-head h1{font-size:27px;}
    .supporter-benefits{grid-template-columns:1fr;}
    .supporter-donate-actions{grid-template-columns:1fr;}
    .supporter-panel{padding:16px;}
    /* The builder's entry row stacks: at 375 px a preview beside a button leaves the
       preview about 130 px wide, which is narrower than the gallery's own minimum and
       reads as a smudge rather than as a home screen. */
    .tbe-row{flex-direction:column;align-items:stretch;}
    .tbe-cta{width:100%;text-align:center;}
    .tb-range{width:104px;}
    /* Not sticky on a phone: the preview is ~180 px tall and the controls are the rest
       of the screen, so pinning it leaves about two rows visible to scroll through. */
    .tb-preview{position:static;}
    .install-dismiss-options{grid-template-columns:repeat(2,minmax(0,1fr));}
    .share-theme-grid{grid-template-columns:repeat(2,minmax(0,1fr));}
  }
  @media(max-width:430px){
    /* The Home-edit row carries a handle, an icon, a name and three 38px icon
       buttons. Tightening the gaps (not the targets) keeps the unit name at the
       same width it had when those buttons were 32px. */
    .hu-row{gap:6px;}
    .hu-ctrls{gap:4px;}
    .hu-handle{padding:8px 3px;}
    .cal-cell.done .fire{font-size:13px;}
    .cal-cell.done .cd{font-size:8px;top:2px;left:4px;}
    .cal-cell .cd{font-size:11px;}
    .w-latest b{font-size:30px;}
  }
