/* topics-page.css — the /topics landing page and the five band pages.
 *
 * PROVENANCE: the bulk of this file is index.html's own <style> block, lifted verbatim on
 * 2026-08-21 so the topic cards, tier pills, filter tabs and footer on the new pages ARE the
 * ones people already know, not a re-implementation that drifts. Hero, switcher and panel
 * rules come along and are inert here; they are kept rather than pruned, because pruning by
 * eye is exactly how a rule something still depends on gets deleted.
 *
 * ⚠ THIS FILE BECOMES THE OWNER of the card styles when index.html is replaced by the new
 * home page, which has no cards on it. Until that lands the two copies are identical; after
 * it, edit HERE.
 *
 * Page chrome specific to /topics is appended at the end of the file.
 */
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

  :root {
    --bg: #FAFAF8;
    --surface: #FFFFFF;
    --border: rgba(0,0,0,0.09);
    --border-strong: rgba(0,0,0,0.16);
    --text-primary: #1A1A1A;
    --text-secondary: #5A5A5A;
    --text-tertiary: #9A9A9A;
    --accent: #261B65;
    --accent-mid: #342682;
    --accent-light: #ECEBFA;
    --gold: #F0CC5C;
    --gold-dark: #C8A030;
    --gold-light: #FBF5DC;
    --purple-mid: #7F77DD;
    /* THE CARD TIER TINTS (owner 2026-09-22, mockup variant A: "do keep the tint as it appears
       in A"). These are the ONLY tier signal on a card besides the pill's own word - the quiz
       strip and the pill are one tier-neutral dark. Values are the mockup's, as approved.
       The premium one is a CREAM: the gold family at near-white, which keeps a premium card
       recognisable without putting gold back on the strip. */
    /* ⚠ DERIVED FROM THE TINT, not picked separately: the card body is --accent-light taken 55%
       of the way to white, so it stays in the same hue family and a palette change carries it. */
    --card-tint: #F6F6FD;
    --card-tint-premium: #F7F4EC;
    --radius-sm: 6px;
    --radius-md: 10px;
    --radius-lg: 14px;
    --font-ui: 'Inter', system-ui, sans-serif;
    --font-thai: 'Sarabun', system-ui, sans-serif;
    --font-serif: Georgia, 'Times New Roman', serif;
  }

  body {
    background: var(--bg);
    color: var(--text-primary);
    font-family: var(--font-ui);
    font-size: 15px;
    line-height: 1.6;
  }

  /* Accessible/SEO heading that isn't part of the visual hero design (the hero's
     visual "heading" is the logo + stylised Thai). Standard sr-only pattern. */
  .visually-hidden {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
  }

  /* ── NAV ── */
  
  
  
  
  
  
  

  /* ── HERO ── */
  .hero {
    background: var(--accent);
    overflow: hidden;
    text-align: center;
  }
  .hero-inner {
    max-width: 620px;
    margin: 0 auto;
    padding: 0.875rem 2rem 0;
    position: relative;
    z-index: 1;
  }
  .hero-logo {
    width: 88px;
    height: auto;
    display: block;
    margin: 0 auto 0.44rem;
    opacity: 0.93;
  }
  .hero-thai {
    font-family: var(--font-thai);
    font-size: 46px;
    font-weight: 300;
    color: #F0CC5C;
    letter-spacing: 0.04em;
    line-height: 1.2;
    margin-bottom: 0.85rem;
    text-shadow: 0 0 1px rgba(29,19,77,1), 0 0 3px rgba(29,19,77,0.8), 0 1px 12px rgba(240,204,92,0.20);
  }
  .hero-tagline {
    font-family: var(--font-serif);
    font-size: 12px;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    color: rgba(255,255,255,0.50);
    margin-bottom: 0.85rem;
  }
  /* The one visible "free" signal in the hero (2026-08-10). Before this, the word "free" appeared
     NOWHERE a human could read on the index — only in the JSON-LD, the sr-only <h1> and the topic
     literally named "Hobbies & free time" — while "Premium" was chipped on 81 cards. The only tier
     signal on the page was the locked kind, which read as a paywall to cold ad traffic.
     clamp() instead of a breakpoint override so it needs no new media query; static markup so it
     is present at first paint and cannot shift the layout (the page holds Lighthouse CLS ~0). */
  .hero-free {
    font-size: clamp(13px, 3.4vw, 15px);
    line-height: 1.5;
    color: rgba(255,255,255,0.80);
    max-width: 30ch;
    margin: 0 auto 1.4rem;
  }
  .hero-rule {
    width: 48px;
    height: 1.5px;
    background: #F0CC5C;
    margin: 0 auto 1.4rem;
    opacity: 0.55;
  }
  .hero-stats-band {
    background: rgba(0,0,0,0.14);
    border-top: 0.5px solid rgba(255,255,255,0.09);
    margin-top: 0.625rem;
  }
  .hero-stats {
    display: flex;
    justify-content: center;
    gap: 3.5rem;
    flex-wrap: wrap;
    /* ⚠ THE LEADING HAS TO BE SET HERE, NOT ON .hero-stat-label. The label is an INLINE element,
       so its own line-height sizes its inline box but does NOT size the line box it sits in — the
       containing block's strut does, and that was inheriting 1.6 from the body. Tightening only
       the label left "UNIQUE" and "WORDS" visibly adrift from each other with the old leading
       still between them. Setting it on the flex container inherits to both the columns and the
       labels. Resolves to plain 1.6 at --te-ui: 1, so normal view is unchanged. */
    line-height: max(1.15, calc(1.6 * var(--te-ui, 1)));
    padding: 1.1rem 2rem;
    max-width: 620px;
    margin: 0 auto;
  }
  /* Decorative stats band, so it takes the --te-ui cap (see nav.js uiScale): at full Android
     text inflation the three stats broke onto two rows and the band grew to 415px tall
     (owner-reported, 2026-08-09). Capping the type is only half of it — the fixed 2rem mobile
     gap ate 64px of a 345px viewport on its own, so the gap flexes with the viewport too. */
  .hero-stat-num {
    font-size: calc(24px * var(--te-ui, 1));
    font-weight: 500;
    color: #F0CC5C;
    display: block;
    line-height: max(1.05, calc(1.2 * var(--te-ui, 1)));
    text-shadow: 0 0 1px rgba(29,19,77,1), 0 0 3px rgba(29,19,77,0.8);
  }
  /* ⚠ EVERY VALUE HERE IS A NO-OP AT NORMAL SIZE, BY CONSTRUCTION. --te-ui is exactly 1 unless
     Android is inflating text, so `calc(X * var(--te-ui, 1))` resolves to plain X on desktop, on
     iOS, and on an Android phone at the default font size. The max() floors only ever bite on the
     inflated side. That is what lets the band tighten under pressure while normal view is
     untouched — verified by measuring with these rules and with them overridden away.
     WHY tighten at all: at 320px/2x the band reached 204px, and "UNIQUE WORDS" wrapping at an
     inherited 1.6 line-height was 63px of it. Owner keeps the word "unique" (it is a real claim),
     so the height has to come out of the leading and the letter-spacing instead.
     ⚠ line-height was INHERITED (1.6, from the body) before this; pinning it means a future change
     to the body's line-height will no longer reach this label. Deliberate, but easy to forget. */
  .hero-stat-label {
    font-size: calc(11px * var(--te-ui, 1));
    color: rgba(255,255,255,0.44);
    line-height: max(1.15, calc(1.6 * var(--te-ui, 1)));
    letter-spacing: calc(0.08em * var(--te-ui, 1));
    text-transform: uppercase;
  }

  /* ── PAGE INTRO (gold accent bar) ── */
  /* Owner 2026-08-02 (round 7): the intro sentence is GONE entirely — hero → switcher, no
     in-between copy ("users can navigate the website to see what we are about"). */

  /* ── THREE-WAY SWITCHER (Topic Sentences / Read Thai / Playlists) ──
     Replaces the old sentences⇄read toggle (superseded). Ported from index-mock.html, the
     owner-approved cosmetics pass (2026-08-02, rounds 1-7): unselected = gold theme, selected =
     accent purple. Sits in its own slim wrapper OUTSIDE .topics-section (below) so the panel
     sections can each stay a single <section class="topics-section">, which is what keeps
     gen_home.js's #topic-grid regex (topic-grid's closing </div> immediately followed by
     </section>) matching correctly — see the deploy notes at the end of this file. */
  .switcher-wrap { max-width: 900px; margin: 0 auto; padding: 0.7rem 1.5rem 0; }
  .mode-switch { display: flex; gap: 3px; background: var(--surface); border: 0.5px solid var(--border-strong);
    border-radius: 14px; padding: 3px; margin-bottom: 0.85rem; }
  /* ⚠ `min-width: 0` + wrapping are LOAD-BEARING here — do not restore `white-space: nowrap`.
     `flex: 1` sets flex-basis to 0, so these three buttons look like they should always be
     equal width. They are not: a flex item's automatic minimum size is its MIN-CONTENT width,
     and with nowrap that is the entire label. "Topic Sentences" therefore had a floor the other
     two did not, and spent it squeezing them — measured 173px vs 112/100 at 1.4× text, with
     "Playlists" pushed 71px off the edge of the page (owner-reported on Android, 2026-08-09).
     Letting the label wrap drops the floor to the longest single WORD, which fits at every
     size we can reach. Nothing wraps at default size, so the bar is visually unchanged.
     The `var(--te-ui, 1)` sizes (see nav.js uiScale) cap Android's text inflation at 15% on
     top of that; the min-width fix stands on its own if the var is ever absent. */
  .ms-btn { flex: 1; min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
    font-family: var(--font-ui); font-size: calc(14px * var(--te-ui, 1)); font-weight: 500; color: #B29234;
    background: #FBF5DC; border: none; border-radius: 11px; padding: 12px 6px; cursor: pointer;
    transition: background 0.15s, color 0.15s; white-space: normal; text-align: center; line-height: 1.25;
    overflow-wrap: break-word; }
  .ms-btn svg { width: 17px; height: 17px; flex-shrink: 0; }
  .ms-ico { font-family: var(--font-thai); font-size: calc(17px * var(--te-ui, 1)); font-weight: 600; line-height: 1; flex-shrink: 0; }
  .ms-btn:hover { color: #8F7526; }
  .ms-btn.active { background: var(--accent); color: #fff; }
  /* ⚠ PHONES GET `flex-basis: auto`, DESKTOP KEEPS `0`. Three EQUAL thirds are only 100px wide
     at 360px, and "Topic Sentences" needs ~125px for one line — so forcing equal widths here
     wrapped the label and grew the bar 39px → 55px at DEFAULT text size, on every phone. That
     is a visible change to an owner-approved design, and it is not what this pass is for.
     Basis `auto` keeps each button at its natural width (exactly today's look), while
     `min-width: 0` + wrapping still let them give ground once the OS inflates the text.
     Desktop has room for equal thirds, so it keeps them. */
  @media (max-width: 480px) {
    .ms-btn { flex: 1 1 auto; font-size: calc(13px * var(--te-ui, 1)); padding: 11px 3px; gap: 5px; }
  }

  /* Same-origin embedded panels (Read Thai / Playlists) — lazy-loaded iframes, auto-sized to
     their content by the switcher script so they scroll as part of this page. */
  /* r130: the panel iframes are GONE (true native panels) — no .embed-frame, no frame sizing. */
  /* r131 — anti-jank (owner: switches/teleports "visually jerky"). Three parts:
     1. every panel swap fades in (.18s) instead of popping;
     2. an UNMOUNTED panel keeps 65vh of reserved height while its assets load, so the page never
        collapses and a deep-link scroll can never clamp to the bottom then snap up;
     3. .panel-veiled holds opacity 0 with NO transition while loading — removing it lets the
        .18s transition play the reveal. */
  .topics-section { transition: opacity .26s ease; }   /* r133: 180ms read as a pop (owner) */
  /* ⚠⚠ 100vh, NOT 65vh — AND IT IS ARITHMETIC, NOT TASTE (r142).
     The topics panel is ~3675px of a ~4337px document. Switching hides it and leaves only this
     reserve, so the document collapses and the browser CLAMPS scrollY to the new maximum. With
     65vh the sums do not work: scrolled so the switcher sat at the top of the viewport
     (scrollY 523), the post-switch maximum scroll was 470 — 53px short — so the page slid up and
     the switcher dropped down the screen. Measured on the live site, 2026-08-03.
     The requirement is: the incoming panel alone must be able to fill the viewport, so the
     switcher can still reach the top. reserve >= vh - switcherHeight - footerHeight, and 100vh
     satisfies that for every viewport by construction.
     ⚠ WHY ONLY IPHONE REPORTED IT: Chrome implements scroll anchoring (overflow-anchor) and
     silently compensates for the collapse; WebKit has never shipped it. The bug was always
     present everywhere — Safari and the iPhone PWA are just the only places it is visible. */
  .topics-section.panel-pending { min-height: 100vh; }
  .topics-section.panel-veiled { opacity: 0; transition: none; }

    /* ── TOPICS SECTION ──
       Reused (same class) by the Read Thai + Playlists panel sections below, so all three panels
       share one container width/padding. padding-top is 0 here because .switcher-wrap (above)
       now supplies the vertical gap before the switcher — see its comment. */
  .topics-section {
    max-width: 900px;
    margin: 0 auto;
    padding: 0 1.5rem 5rem;
  }
  /* Panel heading style (owner round 7): title = read.css .read-title; subtitle = the eyebrow
     style, UNDER the title. Used by #panel-topics's "Listen and learn by topic" heading. */
  .panel-title { font-size: 26px; font-weight: 500; letter-spacing: -0.02em; color: var(--text-primary);
    margin-bottom: 4px; line-height: 1.2; }
  .panel-sub { font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase;
    color: var(--accent); margin: 6px 0 16px; }
  @media (max-width: 600px) { .panel-title { font-size: 20px; } }

  /* ── LEVEL FILTER ── */
  .filter-tabs {
    display: flex;
    gap: 6px;
    margin-bottom: 1.75rem;
    flex-wrap: wrap;
  }
  /* Pills are chrome, not reading matter — they take the --te-ui cap too (owner asked, and
     the `.filter-tabs` row already wraps, so this only stops them eating the whole toolbar). */
  .filter-tab {
    font-size: calc(13px * var(--te-ui, 1));
    font-family: var(--font-ui);
    padding: 6px 16px;
    border-radius: 20px;
    border: 0.5px solid var(--border-strong);
    background: var(--surface);
    color: var(--text-secondary);
    cursor: pointer;
    transition: all 0.15s;
  }
  .filter-tab:hover { border-color: var(--accent); color: var(--accent); }
  .filter-tab.active {
    background: var(--accent);
    border-color: var(--accent);
    color: white;
  }
  /* Per-tier access pills: hover matches the tier palette of the topic cards below (grey for
     Free, gold for Premium) instead of the default purple. Scoped :not(.active) so the
     selected pill keeps its solid styling. Member + All access stay purple by design. */
  .filter-tab.tab-free:not(.active):hover { border-color: var(--text-tertiary); color: var(--text-primary); }
  /* A2 (owner 2026-09-22): "change the 'all' 'free' and 'premium' filter pills at the top to the
     navy color as well" - so the premium pill's gold hover goes too and all three behave alike.
     Retired: border-color: var(--gold-dark); color: #B29234. */
  .filter-tab.tab-premium:not(.active):hover { border-color: var(--accent); color: var(--accent); }

  /* ── GRID TOOLBAR ── pills + search in ONE quiet card instead of stacked loose rows
     (ported from index-mock.html). */
  .grid-toolbar { background: var(--surface); border: 0.5px solid var(--border); border-radius: var(--radius-lg);
    padding: 9px 11px 11px; margin-bottom: 0.9rem; }
  .grid-toolbar .filter-tabs { margin: 0 0 9px; gap: 6px; }
  .grid-toolbar .filter-tab { font-size: calc(12px * var(--te-ui, 1)); padding: 4px 12px; }
  .grid-toolbar .topic-search-wrap { margin: 0; }
  .grid-toolbar .topic-search { background: var(--bg); border-color: transparent; }
  .grid-toolbar .topic-search:focus { border-color: var(--accent); background: var(--surface); }
  .grid-toolbar .topic-search-count { position: static; display: block; margin-top: 4px; min-height: 0; }

  /* ── TOPIC SEARCH ──
     Sits directly under the access pills. The pills stay the outer gate; this narrows
     within them. Fixed height so the result count appearing can't shift the grid (CLS). */
  .topic-search-wrap {
    position: relative;
    display: flex;
    align-items: center;
    margin: -1.25rem 0 1.75rem;   /* pull up under the pills' own bottom margin */
    max-width: 420px;
  }
  .topic-search-icon {
    position: absolute; left: 12px; width: 15px; height: 15px;
    color: var(--text-tertiary); pointer-events: none;
  }
  /* Capped: the placeholder ("Search topics — try “temple” or “วัด”") is a single unwrappable
     line inside an input, so it cannot reflow — past a certain size it just runs out through
     the end of the field. Capping the type is the only thing that keeps it readable. */
  .topic-search {
    width: 100%;
    font-size: calc(13.5px * var(--te-ui, 1));
    font-family: var(--font-ui);
    padding: 8px 34px 8px 34px;
    border-radius: 20px;
    border: 0.5px solid var(--border-strong);
    background: var(--surface);
    color: var(--text-primary);
    transition: border-color 0.15s;
    -webkit-appearance: none;     /* kill Safari's native search chrome */
    appearance: none;
  }
  .topic-search::placeholder { color: var(--text-tertiary); }
  .topic-search:focus {
    outline: none;
    border-color: var(--accent);
  }
  .topic-search::-webkit-search-cancel-button { display: none; } /* we render our own ✕ */
  .topic-search-clear {
    position: absolute; right: 10px;
    border: 0; background: none; cursor: pointer;
    color: var(--text-tertiary); font-size: 17px; line-height: 1;
    padding: 2px 4px;
  }
  .topic-search-clear:hover { color: var(--text-primary); }
  .topic-search-count {
    position: absolute; left: 0; top: 100%;
    margin-top: 5px;
    font-size: 11.5px;
    font-family: var(--font-ui);
    color: var(--text-tertiary);
    min-height: 14px;             /* reserve the row so it can't push the grid */
  }

  /* ── TOPIC GRID ── */
  .topic-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 10px;
    /* ⛔⛔ EVERY CARD IS THE HEIGHT OF THE TALLEST (owner, 2026-09-22: "the topic pills … are no
       longer uniformly sized … across all the menus beginners, beginners to lower int etc they
       all seem different sizes. this needs to be rectified … and ideally prevented from
       happening again").
       ⚠⚠ THE CAUSE IS NOT THE QUIZ STRIP, THOUGH THAT IS WHEN IT WAS NOTICED. Measured on
       topics-beginner with the strip removed entirely: row 1 is 112px and rows 2-6 are 130px,
       because a grid equalises WITHIN a row and never ACROSS rows, and `Greetings & farewells`
       wraps to two lines where `Numbers` does not. The strip made every card taller by the same
       amount, so it changed nothing about the variation — it just made the page long enough to
       see several rows at once.
       ✅ `1fr` asks the grid the question directly: make every row as tall as the tallest. This
       is the SAME fix `.tp-bands` carries (see its note lower down) and it is preferred to a
       `min-height` reserve on .topic-name for the reason recorded there — a reserve has to
       encode a LINE COUNT, and the line count is not stable across widths or under text scaling.
       ⛔ Do not "fix" a future unevenness with a min-height here. It will equalise at the one
       width you tested and at no other. */
    grid-auto-rows: 1fr;
  }
  /* Cards ~30% shorter (owner 2026-08-02): tighter padding + gap. */
  .topic-card {
    /* the free/member tint; premium overrides it further down */
    background: var(--card-tint, #F3F4FA);
    border: 0.5px solid var(--border);
    border-radius: var(--radius-lg);
    /* ⛔⛔ THE SIDE AND BOTTOM PADDING ARE CUSTOM PROPERTIES BECAUSE SOMETHING ELSE HAS TO
       CANCEL THEM. .topic-quiz spans the card's full width by using their negatives, and CSS
       gives it no way to read them — so before this they were RESTATED, and the phone override
       below (14px 16px 13px, px on purpose) left the strip 4.2px short of the card's bottom
       edge. Owner saw it as "the quiz bit of the topic pills actually leaves some whitespace of
       the topic pill underneath it".
       ⚠ ONE SOURCE PER BREAKPOINT. Change the padding here or below and the strip follows; a
       new breakpoint that sets `padding` without these two silently reopens the same gap, so
       set the properties, not the shorthand alone. */
    --card-px: 1rem;
    --card-pb: 0.55rem;
    padding: 0.6rem var(--card-px) var(--card-pb);
    text-decoration: none;
    display: flex;
    flex-direction: column;
    /* 1px between the text lines — title, sentence count, play count now read as one block.
       The AIR goes above the title instead (.topic-card-top margin-bottom), because the tier pill
       is a different kind of thing and wants separating from them (owner, 2026-08-20). */
    gap: 1px;
    transition: border-color 0.15s, box-shadow 0.15s;
    cursor: pointer;
    position: relative;
  }
  /* App/PWA only: accent border while this card's download is in progress. */
  .topic-card.dl-working { border-color: var(--accent); }
  /* "Now playing" equalizer — app-only cue on the card whose audio is currently playing. */
  .te-eq { position: absolute; top: 0.85rem; right: 1.05rem; display: none; align-items: flex-end;
           gap: 2px; height: 13px; pointer-events: none; }
  .topic-card.playing .te-eq { display: flex; }
  /* A6: no gold equaliser on a premium card - one accent, so the base rule is correct.
     Retired: .topic-card.premium .te-eq i { background: #F0CC5C; } */
  .te-eq i { width: 3px; height: 100%; border-radius: 2px; background: var(--accent);
             transform-origin: bottom; animation: te-eq-bounce 0.9s ease-in-out infinite; }
  .te-eq i:nth-child(2) { animation-delay: 0.3s; }
  .te-eq i:nth-child(3) { animation-delay: 0.15s; }
  .te-eq i:nth-child(4) { animation-delay: 0.45s; }
  @keyframes te-eq-bounce { 0%, 100% { transform: scaleY(0.35); } 50% { transform: scaleY(1); } }
  /* Default hover = accent purple, matching the de-greyed non-member chip below (owner
     2026-08-02: reverted the grey scheme back to the free-member accent scheme).
     Member + premium cards override this below with their own purple / gold hovers. */
  .topic-card:hover {
    border-color: var(--accent);
    box-shadow: 0 0 0 3px var(--accent-light);
  }
  .topic-card.coming-soon {
    opacity: 0.48;
    cursor: default;
  }
  .topic-card.coming-soon:hover {
    border-color: var(--border);
    box-shadow: none;
  }
  .coming-soon-label {
    font-size: 10px;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--text-tertiary);
    font-weight: 500;
  }
  /* Section divider for not-yet-built topics, and the no-results message. Both span
     the full grid width since they live inside the CSS grid. */
  .coming-soon-heading {
    grid-column: 1 / -1;
    font-size: 13px;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-tertiary);
    margin: 1.75rem 0 0.25rem;
    padding-top: 1.5rem;
    border-top: 0.5px solid var(--border);
  }
  /* The multi-column "Coming soon" list block (soon-block/soon-items/soon-lk) is GONE
     (2026-08-02 index redesign) — not-yet-built topics now collapse to a single
     ".coming-soon-heading" line ("More coming soon") at the grid bottom; see soonList()
     in the script below. .coming-soon-heading itself is kept — it's reused for that line. */
  .grid-msg {
    grid-column: 1 / -1;
    color: var(--text-tertiary);
    font-size: 14px;
    padding: 1rem 0;
  }
  /* Difficulty band heading for LIVE topics — deliberately MORE assertive than the
     subtle grey "Coming soon" divider: navy, larger, sentence-case. Slimmed 2026-08-02
     alongside the shorter cards (ported from index-mock.html). */
  .level-heading {
    grid-column: 1 / -1;
    font-size: 17px;
    font-weight: 600;
    letter-spacing: -0.01em;
    color: var(--accent);
    margin: 1.4rem 0 0.7rem;
    padding-top: 1.1rem;
    border-top: 0.5px solid var(--border);
  }
  .level-heading:first-child { margin-top: 0; padding-top: 0; border-top: none; }
  .topic-card-top {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 6px;   /* separate the tier pill from the title block below it */
  }
  .level-badge {
    font-size: calc(10px * var(--te-ui, 1));
    font-weight: 500;
    letter-spacing: 0.05em;
    padding: 2px 8px;
    border-radius: 20px;
    flex-shrink: 0;
    white-space: nowrap;
  }
  .badge-beg {
    background: var(--accent-light);
    color: var(--accent);
  }
  /* ⚠ THE FIVE LEVEL BADGES ARE THE SAME DIFFICULTY SYSTEM AS .bc-* AND MUST MATCH IT.
     They were light pastels with mid-tone ink; the bands went dark on 2026-09-22 and a badge left
     on the old pastel would put two different answers to "what colour is Lower Intermediate" on
     one screen. Ink = the band's new dark; ground = the one shared tint, exactly as .bc-soft now
     is. Only .badge-beg needed nothing: it was already on the palette variables. */
  .badge-li1 {
    background: var(--accent-light);
    color: #70551A;
  }
  /* Intermediate — the 5th level, added 2026-08-21 when the 46-unit
     "Lower intermediate → Upper intermediate" band was split in three. A neutral slate
     between the gold of Lower intermediate and the violet of Upper intermediate: the two
     it sits between are already a warm and a cool, so anything on that axis reads as one
     of them at 10px. Judgement call — one line to change. */
  .badge-int {
    background: var(--accent-light);
    color: #344365;
  }
  .badge-li2 {
    background: var(--accent-light);
    color: #4A2E70;
  }
  /* Advanced (tertiary / niche) — deep crimson, distinct from the cooler lower tiers. */
  .badge-adv {
    background: var(--accent-light);
    color: #702929;
  }
  .level-badges {
    display: flex;
    gap: 4px;
    flex-shrink: 0;
    flex-wrap: wrap;
    justify-content: flex-end;
  }
  /* ── the stretched link ──────────────────────────────────────────────────────────────
     The card became a <div> on 2026-08-27 so the favourites heart could be a real <button>.
     A <button> inside an <a> is invalid HTML with unusable keyboard behaviour, and the
     download tick only got away with living inside the old anchor because on a card it is
     REPORTED STATE, never actioned — the heart is the first genuinely interactive control
     here. The anchor now wraps only the title (so it still carries real link text for a
     crawler) and its ::after covers the whole card, which keeps every part of the card
     clickable exactly as before. .topic-card is already position:relative, so ::after needs
     no extra scaffolding. The heart is a LATER sibling with a higher z-index, so it sits
     above this overlay and receives its own taps. */
  .topic-card-link {
    display: block;
    flex: 1;
    min-width: 0;
    text-decoration: none;
    color: inherit;
  }
  .topic-card-link::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    z-index: 0;
  }
  .topic-name {
    display: block;
    min-width: 0;
    overflow-wrap: break-word;   /* only bites when a word genuinely cannot fit */
    font-size: 14px;
    font-weight: 500;
    color: var(--text-primary);
    line-height: 1.28;
  }

  /* ── favourites heart ────────────────────────────────────────────────────────────────
     Positioned EXACTLY like the download tick (.topic-card .dl-badge below): absolute,
     vertically centred on the card, out of flow. That is deliberate and load-bearing:
       * it cannot change card height whether present or absent — the 2026-08-22 fault
         where a downloaded card measured 110.23px against 99.43px was caused by a 30px
         mark sitting IN FLOW in .topic-meta-row, and the fix was to take it out;
       * it cannot be pushed sideways when the tick paints in later, because neither mark
         is in flow and the heart's position does not reference the tick at all. The hearts
         therefore land in one vertical line on first paint and never move.
     Sits 26px left of the tick's 1rem inset, so the two marks read as one column. */
  .topic-fav {
    position: absolute;
    top: 50%;
    /* DEFAULT = the download tick's own slot. Download UI is app + installed-PWA only, so on a
       plain browser tab no tick can ever appear and reserving room for one leaves a dead 43px
       gutter at the card's right edge (measured live, 2026-08-27). Here the heart simply takes
       that position. html.te-dl below shifts it left where a tick IS possible. */
    right: 1rem;
    transform: translateY(-50%);
    z-index: 1;                      /* above .topic-card-link::after, so taps reach it */
    width: 18px; height: 18px;       /* the tick's box on a card — see .topic-card .dl-badge */
    display: inline-flex; align-items: center; justify-content: center;
    padding: 0; border: 0; background: none; cursor: pointer;
    color: var(--accent);
    -webkit-tap-highlight-color: transparent;
  }
  /* Invisible enlarged hit area, the same trick .dl-badge::before uses. That one is disabled
     on cards (nothing there is tappable); this one must NOT be. 44px is the platform minimum
     touch target and is >2x the 18px glyph. */
  .topic-fav::before {
    content: '';
    position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
    width: 44px; height: 44px; border-radius: 50%;
  }
  /* ⚠⚠ THE hidden ATTRIBUTE NEEDS THIS RULE TO WORK AT ALL. The browser hides [hidden] via a UA
     rule of the same specificity as any class selector, and an AUTHOR sheet wins ties — so the
     "display: inline-flex" above silently defeated it, and every signed-out visitor saw hearts on
     desktop and mobile web (owner, 2026-08-27). Favourites are account-backed, so a signed-out
     heart is a control that cannot do anything.
     ⚠ Any future "display" declaration on .topic-fav must keep this rule below it.
     ⚠⚠ AND NOTE THE TRAP IN VERIFYING IT: reading el.hasAttribute("hidden") reports the
     ATTRIBUTE, not whether the element renders. That is precisely how this shipped — the check
     said "0 visible" while 18 hearts were on screen. Assert getComputedStyle().display, or a
     bounding box, never the attribute. */
  .topic-fav[hidden] { display: none; }
  .topic-fav svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.9; }
  .topic-fav[aria-pressed="true"] svg { fill: currentColor; }
  .topic-fav:active { transform: translateY(-50%) scale(0.9); }
  /* A16 (owner 2026-09-22: "dont forget color of heart"). The heart used the GRAPHIC gold on a
     premium card, to match the tick. Both are now the one accent, so the pairing still holds and
     neither needs a tier rule. Retired: .topic-card[data-tier="premium"] .topic-fav { color:
     #F0CC5C } - and if the heart is ever re-tiered, the tick must move with it, because the owner
     reads them as one column of marks. */
  /* Devices that CAN download: the tick owns , so the heart sits 26px left of it and
     the two read as one column. Stamped on <html> in the page head before first paint (see
     gen_topics_pages.js), so this is settled per DEVICE, never per card — a per-card rule would
     shift every heart the moment the download manifest resolved, which is the movement the
     reserve exists to prevent. */
  html.te-dl .topic-fav { right: calc(1rem + 26px); }
  .topic-meta-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 6px;
  }
  /* ⚠ ONE LINE, ALWAYS (2026-09-25, owner: "whether or not a topic is downloaded doesn't affect
     pill height"). applyDownloadState() appends " · downloaded" / " · update available" to this
     line in the app/PWA. Measured: at 320px "23 sentences · update available" wrapped to a
     second line and, because the grid is grid-auto-rows:1fr, ONE such card grew EVERY card in
     the grid by 19px (157 → 176) the moment the download manifest resolved. nowrap + ellipsis
     means the status can be clipped at the narrowest widths / largest text, but never moves
     the layout; the tick/update mark beside it still states the same thing in full.
     min-width:0 is what lets a flex item shrink below its text (TEXT_SCALING.md rule 2). */
  .topic-sent-count {
    font-size: 12px;
    color: var(--text-tertiary);
    min-width: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  /* The play count gets its OWN line at the bottom of the card (owner, 2026-08-20): on one line
     with the sentence count and the download caption it was three things competing in 12px grey.
     Green is the same #1F5D3A as .reveal-all-btn on a premium topic — a deliberate second colour,
     because this is the one number on the card that is about YOU rather than about the topic. */
  .topic-plays {
    font-size: 12px;
    font-weight: 500;
    color: #1F5D3A;
    line-height: 1.3;
  }
  /* ⚠ RESERVE THE CAPTION LINE, so a card's height never depends on whether THIS visitor has
     listened to THIS topic. The slot ships empty on every card (gen_topics_pages.js) and is
     filled at runtime; without a reserve, a grid row holding one captioned card stretched
     ~16px taller than every row without one — measured 99px against 83px on the
     Beg->Lower-Int grid, and obvious once the free units were pinned to the top of each band
     (owner, 2026-08-22).
     Reserved only under html.te-plays — stamped by a head script from identity.js BEFORE
     first paint — so a signed-out visitor, who can never have a caption, keeps the original
     shorter card rather than a blank line under every one. Stamping it pre-paint is what
     stops the reserve itself becoming a layout shift.
     1.3em, not a px constant, so it tracks font-size (and any --te-ui inflation) by itself. */
  html.te-plays .topic-plays {
    min-height: 1.3em;
  }
  /* ⚠⚠ AN EMPTY RESERVE IS NOT SCALED ON ANDROID (measured on the owner's Pixel 7a, 2026-09-25).
     Android's font-size setting reaches the WebView through Chromium's TEXT AUTOSIZER, which
     enlarges only elements that CONTAIN TEXT. The slot above ships empty, so its 1.3em resolved
     against the UNSCALED 12px (15.6px) — and when "Thai listening time: …" arrived at ~220ms the
     line was autosized to 115% (17.9px) and every card in the 1fr grid grew ~2.3px after paint.
     A non-breaking space while empty makes it a text line from first paint, so it is scaled the
     same way as the caption that replaces it. `:empty` stops matching the moment the caption is
     written (applyListenTime sets textContent) and matches again when it is cleared.
     ⚠ The TEXT_SCALING harness cannot show this: its __zoom inflates EVERY element, empty ones
     included. Only a device reading can (layoutdbg.js part log). TEXT_SCALING.md §4. */
  html.te-plays .topic-plays:empty::before {
    content: '\00a0';
  }

  /* ── THE QUIZ STRIP (QUIZ_PROJECT.md §9.1) ────────────────────────────────────────────
     "A slightly taller card with the quiz strip in a different colour, APPENDED to the bottom
     of the pill rather than reading as a continuation of it" — hence the negative margins
     (it spans the card's full width and sits on its bottom edge, inside the radius), the
     divider, and its own ground.
     ⚠ IT IS A REAL <button> AND IT MUST OUT-RANK THE STRETCHED LINK. .topic-card-link::after
     covers the whole card at z-index 0; without a stacking context of its own the strip would
     look tappable and open the topic instead. This is exactly why .topic-fav carries z-index 1
     — same fix, same reason.
     ⚠ RESERVE, DO NOT GROW INTO IT: every cell ships as an em dash at a fixed min-width, so a
     card's height and its columns never depend on whether this visitor has taken a quiz. The
     listening caption learned that the expensive way (99px against 83px, measured 2026-08-22).
     ⛔ The empty state is an em dash, NEVER 0% — see quizStripHtml() in topics.js. */
  .topic-quiz {
    display: flex;
    align-items: center;
    justify-content: space-between;
    /* ⚠ The gaps TIGHTEN as the type is capped (`* var(--te-ui, 1)`, exactly 1 at default size,
       so nothing moves until the system text is raised). With four four-character scores this
       strip is 12px too wide for a 320px card at 2.0×, and there is nothing to trim in the
       content: "Quiz results:" is the label and the cells are the data. See the cells' own gap
       below — the two together are what buy the 12px back. */
    gap: calc(8px * var(--te-ui, 1));
    /* ⛔ THE BACKSTOP, AND IT COSTS NOTHING WHILE THE ROW FITS. `space-between` on a single line
       is unchanged by this; it only engages if the row genuinely cannot fit, and then the cells
       drop under the label instead of hanging off the card. A future fifth quiz, a longer label
       or a wider score can no longer produce a side-scroll on a band page.
       ⚠ It does mean --strip-h (the fav heart / download tick centring) is a few px out in that
       wrapped state. That is the right trade: the marks sit slightly off centre, rather than the
       scores leaving the card. */
    flex-wrap: wrap;
    width: auto;
    /* ⛔ DERIVED FROM THE CARD'S OWN PADDING, never restated — see --card-px/--card-pb. */
    margin: 0.5rem calc(-1 * var(--card-px, 1rem)) calc(-1 * var(--card-pb, 0.55rem));
    /* ⛔⛔ THE SIDE PADDING IS THE CARD'S OWN --card-px, NEVER A RESTATED `1rem`. It was `1rem`
       literally, which is the exact mistake the note above warns about, in the other direction:
       the MARGIN cancelled the phone's 16px while the PADDING added a rem — and a rem inflates
       under Android textZoom (TEXT_SCALING.md §1). At 320@2.0× that is 64px of padding on a
       267px strip, leaving 203px for 260px of content: the strip burst its card by 25px and the
       band page side-scrolled. One source per breakpoint, for both properties. */
    /* ⭐ 25% TALLER (owner, 2026-09-23). The strip's own box was 6 + (11.5 × 1.3) + 7 =
       27.95px, so +25% is +6.99px, taken as ~3.5px on each side → 34.95px. MEASURED in the
       browser against the shipped card, not derived on paper.
       ⚠⚠ --strip-h-on MOVES WITH IT, IN BOTH REGIMES (29 → 35.16, phone 25 → 30.95) — and both
       are MEASURED net card growth, not this padding plus 6.99. Adding the delta gave 36 and 32,
       which left the heart 0.42px and 0.53px high: an estimate wrong by less, not a fix. That
       constant
       is the card's NET growth, which the favourites heart and the download tick subtract half
       of to stay centred on the CONTENT. Change this padding without it and both marks drop
       onto the strip — the exact fault the owner reported on the live iPhone at v608. */
    padding: 9.5px var(--card-px, 1rem) 10.5px;
    border: 0;
    /* ⛔ ONE TIER-NEUTRAL DARK, white ink. See the premium rule below for why it is restated
       there rather than inherited. */
    border-top-color: transparent;
    border-radius: 0 0 var(--radius-lg) var(--radius-lg);
    background: var(--accent);
    /* ⛔ LONGHAND, never the `font` shorthand: `font: 500 11.5px/1.3 inherit` is illegal
       (inherit is not a legal family inside it) and the browser throws the WHOLE declaration
       away, falling back to the UA's 13.3px button default. That cost quiz.css a day. */
    font-family: inherit;
    font-size: calc(11.5px * var(--te-ui, 1));
    font-weight: 500;
    line-height: 1.3;
    color: #fff;
    text-align: left;
    /* r214 — it is an <a> now (see quizStripHtml). The card's own colour rules do not reach a
       link's UA defaults, so both are stated here rather than left to chance. */
    text-decoration: none;
    cursor: pointer;
    position: relative;
    z-index: 1;
  }
  .topic-quiz:hover { filter: brightness(0.97); }
  /* ⛔⛔ THE STRIP MADE THE CARD TALLER AND TWO ABSOLUTELY-POSITIONED MARKS WENT WITH IT.
     Owner, 2026-09-22, on the live iPhone: "all my downloaded topics are showing as ticks …
     when i go into these topics, it shows audio update available", and a grey tap highlight.
     Both the favourites heart and the download tick / update dot are `top: 50%` of the CARD —
     deliberately, because the owner asked for them "literally just in the middle" (2026-08-22)
     — so adding ~37px of strip below them moved both down by ~18px, onto the strip.
     ⚠ "THE MIDDLE OF THE CARD" WAS WRITTEN WHEN THE CARD WAS ONLY CONTENT. The marks belong to
     the content, not to the quiz row, so the centring has to exclude the strip rather than the
     rule be abandoned. --strip-h carries its measured height and the two marks subtract half of
     it; a card with no strip keeps 0 and is byte-identical to before.
     ⚠ MEASURED, not estimated — see the harness note in _qmeasure2.html. */
  /* ⚠ THE NET CARD GROWTH, NOT THE STRIP'S OWN HEIGHT. The strip measures 36.9px including its
     top margin, but its NEGATIVE bottom margin cancels the card's padding-bottom, so the card
     only grows by 24.9px (measured at 420px). Subtracting half of 37 instead of half of 25 put
     both marks 6px too HIGH — an estimate that was wrong by less rather than a fix.
     ⚠ Breakpoint-dependent, because the padding it cancels is: the phone block sets 13px and
     the shared rule 0.55rem, so the two regimes grow by different amounts. Measured in both. */
  .topic-card { --strip-h: 0px; }
  .topic-card.has-quiz { --strip-h: var(--strip-h-on, 35.16px); }
  .topic-card.has-quiz .topic-fav,
  .topic-card.has-quiz .dl-badge,
  .topic-card.has-quiz .dl-select { top: calc(50% - var(--strip-h) / 2); }

  /* ⚠ NOTHING ON A CARD SHOWS A TAP HIGHLIGHT (owner, same report: "when i click on the pill i
     get an ugly grey highlight around the topic name. nothing should highlight on click").
     iOS paints the highlight on the nearest interactive ancestor, and the card's stretched <a>
     is one — it simply had no visible highlight to paint until the strip changed what a tap
     landed on. Suppressed on the card and everything in it, which is what quiz.css already
     does for the entry block. */
  .topic-card, .topic-card * { -webkit-tap-highlight-color: transparent; }
  .tqs-l { flex-shrink: 0; }
  .tqs-cells {
    display: flex;
    /* ⚠ see the strip's own gap above — tightens with the cap, exactly 9px at default size */
    gap: calc(9px * var(--te-ui, 1));
    font-variant-numeric: tabular-nums;
    font-weight: 600;
  }
  /* A fixed cell width so four scores and four dashes occupy the identical space — the
     columns must not jump as results arrive from the account pull. */
  .tqs-c { min-width: 2.7em; text-align: right; }
  /* ⛔⛔ ONE DARK, BOTH TIERS. Owner, twice, after a gold variant shipped twice:
     "the dark color should be the SAME for the premium pills and the free pills!!! idk how much
     clearer i can be on this. you're still having the quiz bar be some variant of gold. it should
     be the dark color, exactly same as for the free topics!"
     ⚠ THIS RULE EXISTS ONLY TO WIN A SPECIFICITY FIGHT. `.topic-card[data-tier="premium"]
     .topic-quiz` is two classes plus an attribute, so the base `.topic-quiz` above LOSES to
     anything written at this weight - which is exactly how the gold kept coming back. Restating
     the dark here at equal specificity is the fix; !important would have hidden the cause.
     ⛔ Do not delete it as redundant: it is redundant only while nothing else is written at
     this weight, and what used to be written here is the bug. */
  .topic-card[data-tier="premium"] .topic-quiz {
    background: var(--accent);
    color: #fff;
    border-top-color: transparent;
  }
  /* the premium card's tint - the cream (see --card-tint-premium) */
  .topic-card[data-tier="premium"] { background: var(--card-tint-premium, #F7F4EC); }

  /* ── PREMIUM (locked) CARD ── */
  /* A6: the hover ring follows the one accent, like a member card's. Retired gold ring:
     border-color: var(--gold-dark); box-shadow: 0 0 0 3px var(--gold-light). */
  .topic-card.premium:hover {
    border-color: var(--accent);
    box-shadow: 0 0 0 3px var(--accent-light);
  }
  .topic-premium {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 11px;
    font-weight: 500;
    letter-spacing: 0.02em;
    /* Mellow gold: golder/less orange than the badge-li1 amber, a touch softer than the
       former intense --gold-dark, on the creamier --gold-light bg — the "coming soon" faded
       vibe at full opacity (not washed out). Retired 2026-09-22 - see the pill block below. */
    color: #fff;
    background: var(--accent);
    padding: 2px 9px 2px 7px;
    border-radius: 20px;
    white-space: nowrap;
  }
  .topic-premium svg {
    width: 11px;
    height: 11px;
  }
  /* Entitled view: owned/unlocked premium — same mellow gold, no padlock. */
  .topic-premium.unlocked {
    background: var(--accent);
    color: #fff;
  }

  /* ── MEMBER (login-only) CARD — accent (purple), distinct from gold premium ── */
  .topic-card.member:hover {
    border-color: var(--accent);
    box-shadow: 0 0 0 3px var(--accent-light);
  }
  .topic-member {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 11px;
    font-weight: 500;
    letter-spacing: 0.02em;
    color: #fff;
    background: var(--accent);
    padding: 2px 9px 2px 7px;
    border-radius: 20px;
    white-space: nowrap;
  }
  .topic-member svg { width: 11px; height: 11px; }
  /* Entitled view: same soft Beginner-pill palette, no padlock (was a strong solid purple). */
  .topic-member.unlocked { background: var(--accent); color: #fff; }
  /* ── NON-MEMBER (free, open) CARD — accent chip (de-greyed, owner 2026-08-02: reverted to the
     free-member accent scheme so it doesn't read as locked/disabled). ── */
  .topic-nonmember {
    display: inline-flex;
    align-items: center;
    font-size: 11px;
    font-weight: 500;
    letter-spacing: 0.02em;
    color: #fff;
    background: var(--accent);
    padding: 2px 9px;
    border-radius: 20px;
    white-space: nowrap;
  }
  /* Slimmer tier pills (owner 2026-08-02): less card height. Same specificity as the three
     per-tier rules above and comes later in source order, so it wins on the shared properties. */
  /* ⛔ ALL THREE PILLS ARE THE SAME DARK WITH WHITE INK (owner 2026-09-22, variant A). The
     pill no longer colour-codes the tier - it SAYS it, in words, and the card tint carries the
     rest. That is the point of the variant: the strip and the pill stop re-saying what the word
     already says. ⚠ The padlock icon must be stroked white too, below. */
  .topic-premium, .topic-member, .topic-nonmember {
    font-size: 10px; padding: 1px 7px; gap: 3px; letter-spacing: 0.03em;
    /* Pull the pill left by its own left padding so its CONTENT starts in the same column as the
       topic title below it (owner, 2026-08-20). Without this the pill's 7px inset made every card
       look like it had two different left edges.
       ⚠ On a PREMIUM pill the padlock comes first, so its "P" still sits ~13px right of the title's
       first letter — aligning that exactly would mean shifting a further 13px, which pushes the
       padlock outside the card's 1rem padding. Moving the padlock after the word is the only way
       to get both letters flush; not done, because it is a design change nobody asked for. */
    margin-left: -7px;
  }
  .topic-premium svg, .topic-member svg { width: 10px; height: 10px; }
  /* white ink means white icon: the padlock inherits `color`, but any explicit stroke would not */
  .topic-premium svg, .topic-member svg { stroke: currentColor; }
  /* App-only selection controls. The OUTER span is a big, finger-friendly tap target; the inner
     dot/tick is the smaller visible mark — so they're easy to hit without catching the card link. */
  .dl-select, .dl-badge { position: relative; display: inline-flex; align-items: center; justify-content: center;
    width: 30px; height: 30px; margin-left: 4px; vertical-align: middle; cursor: pointer; -webkit-tap-highlight-color: transparent; }
  /* Invisible enlarged hit area (~52px, >2× the visual mark) centred on the dot/tick — easy to tap. */
  .dl-select::before, .dl-badge::before { content: ''; position: absolute; top: 50%; left: 50%;
    transform: translate(-50%, -50%); width: 52px; height: 52px; border-radius: 50%; }
  /* Download select circle: light purple → dark when selected. */
  .dl-dot { width: 23px; height: 23px; border-radius: 50%; background: var(--accent-light);
    border: 1.5px solid var(--accent-light); transition: background 0.15s, border-color 0.15s, transform 0.1s; }
  .dl-select:hover .dl-dot { border-color: var(--accent); }
  .dl-select.selected .dl-dot { background: var(--accent); border-color: var(--accent); }
  .dl-select:active .dl-dot { transform: scale(0.92); }
  /* Premium topics: gold select circle (light gold → dark gold when selected) instead of purple. */
  .dl-select[data-tier="premium"] .dl-dot { background: var(--gold-light); border-color: var(--gold-light); }
  /* Update ring (dl-core §D.1, ported from dyn-index.html r83): dashed border = "there is
     something to fetch" — a superseded download or a D0 legacy claim — distinct from a plain
     download circle at a glance. */
  .dl-select.dl-update .dl-dot { background: transparent; border-color: var(--accent); border-style: dashed; }
  .dl-select.dl-update.selected .dl-dot { background: var(--accent); border-style: solid; }
  /* Downloaded tick: a lavender ring appears when selected for removal. */
  /* ⛔ A17 (owner 2026-09-22: "color of tick and dotted box"). The five
     [data-tier="premium"] overrides that painted the tick and the update dot GOLD are gone: one
     accent, so the base rules below are already right on both tiers. */
  .dl-tick { display: inline-flex; align-items: center; justify-content: center; width: 27px; height: 27px;
    border-radius: 50%; color: var(--accent); border: 2px solid transparent; transition: border-color 0.15s, background 0.15s; }
  /* the "selected for removal" ring. Was a light lavender (#9d92e0) picked against the purple
     accent; against a dark accent a pale ring disappears, so it takes --accent-mid. */
  .dl-badge.clear-selected .dl-tick { border-color: var(--accent-mid); background: var(--accent-light); }

  /* ⚠ ON A TOPIC CARD THE TICK MUST NOT SET THE ROW'S HEIGHT (owner, 2026-08-22).
     .dl-badge is 30px because on the playlist list it is a real control with a tap target.
     On a topic card it is REPORTED STATE, never actioned (see applyDownloadState() in
     topics-page.js) — and it is appended INTO .topic-meta-row, a flex row whose natural
     height is the 12px count text, ~19px. A 30px child therefore set the row height and made
     a downloaded card 110.23px against 99.43px for an identical un-downloaded one: the owner
     watched a card snap to the right height as soon as the download was deleted.
     18px keeps the glyph its original size — .dl-tick is border-box with a 2px border, so a
     14px SVG fits exactly — while fitting inside the row, so height stops depending on
     whether THIS visitor has downloaded THIS topic. Scoped to .topic-card so the playlist
     control keeps its tap target. */
  .topic-card .dl-badge,
  .topic-card .dl-select { width: 18px; height: 18px; }
  .topic-card .dl-tick  { width: 18px; height: 18px; }
  /* The update dot is the SAME kind of reported state as the tick, so it gets the same box and
     the same centring below. Before this it kept the 30px control box and stayed in flow, so it
     sat off-centre in .topic-meta-row and set the row's height — the exact fault the tick was
     fixed for on 2026-08-22, reintroduced by a second marker that the two rules did not name.
     16px keeps the 30px control's dot:box proportion (23/30) inside the smaller box; ::before is
     dropped because nothing here is tappable. */
  .topic-card .dl-dot { width: 16px; height: 16px; }
  .topic-card .dl-select::before { display: none; }
  /* ⚠ CENTRED IN THE CARD, NOT ALIGNED TO A TEXT LINE (owner, 2026-08-22: "dont align with
     text in the box — literally just have it in the middle"). It is appended into
     .topic-meta-row, the LAST text line, so in flow it measured 14px below the card's centre.
     Taking it out of flow also means it can no longer affect the row's height at all, which is
     what the 18px above was compensating for — that is now belt and braces rather than the
     mechanism. The card is already position:relative (.te-eq depends on it), and .te-eq sits at
     the top so the two never meet.
     The padding-right reserve is not decoration: out of flow the badge no longer displaces
     anything, so a long topic name or a "· update available" count would run underneath it.
     22px clears the 18px badge plus the gap. */
  .topic-card .dl-badge,
  .topic-card .dl-select { position: absolute; top: 50%; right: 1rem; transform: translateY(-50%);
    margin: 0; cursor: inherit; pointer-events: none; }
  /* ⚠⚠ THE NAME AND THE META-ROW NEED DIFFERENT GUTTERS, AND SHARING ONE WAS A BUG
     (fixed 2026-08-28). These two used to sit in one selector at 22px, and only the meta-row
     got the te-dl widening — so a long unit name reserved 22px while the heart sat at 43-61px
     and ran straight underneath it. Measured on /grammar at 1280px with the heart forced
     visible: 2 of 20 cards collided at normal size and 3 of 20 at 200% text, all of them ONLY
     in te-dl. That is why it reached the owner from the app and not from a desktop tab.

     THE ARITHMETIC, measured rather than assumed — distances from the card's right edge:
       default : heart 17-35px  (it takes the tick's own slot; no tick can appear here)
       te-dl   : tick 17-35px, heart 43-61px
     So the name needs 38px and 64px. Both clear the furthest mark by 3px.
     ⚠ These are viewport-independent: no media query touches .topic-fav's `right`, its 18px
     box, or these paddings, so the same two numbers hold at 320px and at 1280px. A narrow
     screen wraps the name more; it does not move the heart.
     ⛔ Do NOT "fix" a future collision by shortening unit names — the layout holds the name.
     ⛔ And do not merge these selectors back together. */
  .topic-card .topic-name { padding-right: 38px; }
  html.te-dl .topic-card .topic-name { padding-right: 64px; }
  /* The meta-row keeps 22px in the default state on purpose: its right-hand item is the tiny
     plays count, and 22px has never collided there. ⚠ Strictly it under-reserves by the same
     13px the name did — if a longer right-hand item is ever added to this row, it needs 38px
     too, and this comment is the reason it was left rather than missed. */
  .topic-card .topic-meta-row { padding-right: 22px; }
  html.te-dl .topic-card .topic-meta-row { padding-right: 64px; }
  /* Working (downloading) row: an animated ellipsis in place of the circle/tick. */
  .dl-working-dots { display: inline-flex; align-items: center; justify-content: center;
    width: 30px; height: 30px; margin-left: 4px; vertical-align: middle; }
  .dots { display: inline-block; width: 1.1em; text-align: left; }
  .dots::after { content: '...'; display: inline-block; width: 0; overflow: hidden; vertical-align: bottom;
    animation: te-dl-dots 1.2s steps(3, start) infinite; }
  @keyframes te-dl-dots { from { width: 0 } to { width: 1.05em } }
  /* Download/remove controls (dl-core §D.1, ported from index-mock.html + dyn-index.html): ONE
     selection set + a three-button layout — Download/Remove Selected on one half-height row,
     a quiet Remove All beneath. display:flex is the BASE state now (was JS-toggled); visibility
     is governed entirely by body.te-no-dl (§1f, below) so a plain browser tab reflows into the
     space instead of reserving it. */
  .dl-batch-bar { display: flex; flex-direction: column; align-items: stretch; gap: 6px;
    background: var(--surface); border: 0.5px solid var(--border); border-radius: var(--radius-md);
    padding: 0.65rem 0.9rem 0.55rem; margin-bottom: 0.9rem; }
  .dl-batch-btns { display: flex; gap: 8px; }
  .dl-batch-btn { flex: 1; font-family: var(--font-ui); font-size: 13px; font-weight: 500; color: #fff;
    background: var(--accent); border: none; border-radius: var(--radius-sm); padding: 5px 12px; cursor: pointer; }
  .dl-batch-btn:hover:not([disabled]) { background: var(--accent-mid); }
  .dl-batch-btn[disabled] { background: var(--border); color: var(--text-tertiary); cursor: default; }
  .dl-clear-btn { background: var(--surface); color: var(--text-secondary); border: 0.5px solid var(--border-strong); }
  .dl-clear-btn:hover:not([disabled]) { background: var(--bg); color: var(--accent); border-color: var(--accent); }
  /* Quiet Remove All — a reset, not a third verb for the current selection; danger tone on hover only. */
  .dl-removeall-btn { background: transparent; color: var(--text-tertiary); border: 0.5px solid var(--border);
    font-size: 12px; padding: 4px 10px; }
  .dl-removeall-btn:hover:not([disabled]) { background: #FDF1F2; color: #B00020; border-color: #E8C4C8; }
  /* #dl-batch-status = the RUNNING JOB's progress; #dl-batch-note = transient tap feedback.
     Deliberately separate elements (dl-core §D.1) so a selection tap mid-download can never
     evict the only evidence that work is still happening. */
  /* ⚠ THE EMPTY STATUS LINE NO LONGER RESERVES A BLANK ROW (2026-08-16). It used to sit between the
     buttons and the hint as ~22px of dead white (min-height 1.1em + the 8px flex gap), which is what
     made this card read as too thick. :empty collapses it — and because a flex item with display:none
     takes no gap either, the whole row goes, not just its box.
     It was never an honest anti-shift reserve anyway: min-height was 1.1em (14.3px) while the text's
     own line box is line-height 1.6 (20.8px), so a status message ALREADY grew the card by ~6px. The
     line-height/min-height pair below now match exactly, so the only movement is the row appearing.
     That is input-driven and near-neutral in the normal flow: updateBatchBar() hides the hint the
     moment anything is selected, so the status row replaces a row of almost the same height (15 vs
     17px). Don't "restore" a standing reserve — it buys ~2px of stability for 22px of white. */
  .dl-batch-status { font-size: 13px; color: var(--text-secondary); line-height: 1.2; min-height: 1.2em; }
  .dl-batch-status:empty { display: none; }
  .dl-batch-status.err { color: #B00020; }
  .dl-batch-note { font-size: 12.5px; color: var(--text-tertiary); line-height: 1.2; min-height: 1.2em; }
  .dl-batch-hint { font-size: 12.5px; color: var(--text-tertiary); line-height: 1.35; }
  /* Confirm dialog (clear downloads). */
  .dl-modal { position: fixed; inset: 0; z-index: 1000; background: rgba(20,16,40,0.55); display: flex; align-items: center; justify-content: center; padding: 1.25rem; }
  .dl-modal-card { background: var(--surface); border-radius: var(--radius-lg); max-width: 360px; width: 100%; padding: 1.8rem 1.5rem; text-align: center; box-shadow: 0 12px 40px rgba(0,0,0,0.25); }
  .dl-modal-card p { color: var(--text-secondary); font-size: 14.5px; line-height: 1.6; margin-bottom: 1.5rem; }
  .dl-modal-actions { display: flex; gap: 10px; }
  .dl-modal-actions button { flex: 1; font-family: var(--font-ui); font-size: 15px; font-weight: 500; border-radius: var(--radius-sm); padding: 11px 16px; cursor: pointer; }
  .dl-modal-cancel { background: var(--surface); color: var(--text-secondary); border: 0.5px solid var(--border-strong); }
  .dl-modal-confirm { background: #C0392B; color: #fff; border: none; }
  .dl-modal-confirm:hover { background: #a93226; }

  /* ── §1f DOWNLOAD-UI VISIBILITY (owner rule, 2026-08-02, ported from index-mock.html) ──
     Download interfaces are APP/PWA-ONLY: the Android app and an installed (standalone) PWA see
     them; a plain browser tab (Safari/Chrome, desktop or phone) never does. Everything below is
     a normal-flow element, so hiding it reflows the layout — nothing reserves the space. Belt &
     braces alongside the JS-level gate: dlCardState() (below) already returns null when
     !OFFLINE_UI, so a plain browser tab never even gets this markup — the caption text it would
     add to .topic-sent-count is skipped at the source, not merely hidden. The predicate that adds
     this class lives in the download-engine script, below. */
  body.te-no-dl .dl-batch-bar,
  body.te-no-dl .dl-select,
  body.te-no-dl .dl-badge,
  body.te-no-dl .dl-working-dots {
    display: none !important;
  }

  /* ── FOOTER ── */
  .site-footer {
    border-top: 0.5px solid var(--border);
    padding: 1.5rem 2rem;
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: center;
    gap: 1rem;
  }
  /* ⚠ THIS FOOTER WAS THE SIDE-SCROLL. Six links in a `display: flex` row with no wrap, and
     on mobile the grid switches to one centred column, where `justify-items: center` sizes
     each item to MAX-CONTENT — so both the link row and the copyright line could exceed the
     column and take the whole document with them (measured: 190px of overflow at 1.4× text,
     page scrollWidth 535 against a 345px viewport). `max-width: 100%` keeps every child inside
     the column and `flex-wrap` lets the links reflow. Keep both. */
  .site-footer > * { max-width: 100%; min-width: 0; }
  .footer-note {
    font-size: calc(12px * var(--te-ui, 1));
    color: var(--text-tertiary);
  }
  .footer-links {
    display: flex;
    gap: 1.1rem;
    flex-wrap: wrap;
  }
  .footer-links a {
    font-size: calc(12px * var(--te-ui, 1));
    color: var(--text-secondary);
    text-decoration: none;
    font-weight: 500;
  }
  .footer-links a:hover { color: var(--accent); }

  /* ── RESPONSIVE ── */

  /* Tablet: 600–900px — 2 column grid, smaller hero text */
  @media (max-width: 900px) {
    .topic-grid {
      grid-template-columns: repeat(2, 1fr);
    }
    .hero-thai { font-size: 36px; }
    .hero-stats { gap: 2.5rem; }
  }

  /* Mobile: up to 600px — single column, compact hero */
  @media (max-width: 600px) {
    .topic-grid {
      grid-template-columns: 1fr;
    }


    
    .hero-inner { padding: 2.5rem 1.25rem 0; }
    .hero-logo { width: 91px; margin-bottom: 1.25rem; }
    .hero-thai { font-size: 30px; }
    .hero-tagline { font-size: 11px; letter-spacing: 0.16em; }
    /* ⚠ px + vw, NOT rem. Android's textZoom scales the ROOT font size, so every `rem` length
       inflates with the text — a 2rem gap became 33.6px and ate 67px of a 345px viewport,
       which is what pushed the third stat onto its own row. px and vw are immune. */
    /* Padding was 80px of that 204px band - `1.25rem` doubling under textZoom, nothing to do
       with the text itself. px stops it inflating; * var(--te-ui) actively tightens it when
       inflated; max() stops it collapsing. At --te-ui: 1 this is exactly 20px/16px, i.e.
       byte-identical to the 1.25rem/1rem it replaces. */
    .hero-stats {
      gap: clamp(10px, 4vw, 32px);
      padding: max(12px, calc(20px * var(--te-ui, 1))) max(10px, calc(16px * var(--te-ui, 1)));
      flex-wrap: wrap;
    }
    /* ⚠ BASIS `auto`, NOT `0` — equal thirds were CAUSING the wrap, not absorbing it. With
       basis 0 all three columns get 87px at 320px/2x, and "UNIQUE WORDS" needs ~100px, so it
       broke onto two lines and took the band to 117px. Content-sized columns let the widest
       label claim the room the two short ones do not want (58 / 89 / 115), and it fits on one
       line — band 83px. Exactly the same mistake, and the same fix, as the .ms-btn switcher.
       `min-width: 0` stays so that at some more extreme size they can still shrink and wrap
       rather than overflow. Phones only; desktop keeps its natural centred spacing. */
    .hero-stats > div { flex: 1 1 auto; min-width: 0; text-align: center; }
    .hero-stat-num { font-size: calc(20px * var(--te-ui, 1)); }
    .switcher-wrap {
      padding: 0.7rem 1rem 0;
    }
    .topics-section {
      padding: 0 16px 3rem;      /* px sides — see .topic-card */
    }
    .filter-tabs {
      gap: 5px;
    }
    .filter-tab {
      font-size: calc(12px * var(--te-ui, 1));
      padding: 5px 12px;
    }
    .topic-search-wrap { max-width: none; margin-top: -1rem; }
    .topic-search { font-size: calc(12.5px * var(--te-ui, 1)); padding: 7px 30px; }
    .topic-card {
      /* px, not rem — rem padding inflates under Android textZoom.
         ⚠ AND THE TWO PROPERTIES MOVE WITH IT, or .topic-quiz keeps cancelling the DESKTOP
         padding and leaves a white band below the strip. That is the bug this pair exists to
         make impossible. */
      --card-px: 16px;
      --card-pb: 13px;
      /* net growth under the phone padding — see the note by --strip-h.
         ⚠ r214: MEASURED at 340px, not derived — 30.95px, where 25 + the strip's own 6.99px
         growth would have said 32. The two regimes cancel different card padding, which is why
         this is not the desktop number and cannot be reasoned to from it. */
      --strip-h-on: 30.95px;
      padding: 14px var(--card-px) var(--card-pb);
      min-width: 0;              /* grid item: stop min-content blowing past the 1fr column */
    }
    .level-badge {
      font-size: calc(9px * var(--te-ui, 1));
    }
    /* ⚠ THE PADDING AND GAP ARE TIGHTENED HERE, NOT ON THE SHARED RULE (owner, 2026-08-23:
       "things could be less spread out - theres large spaces between lines"). On a phone the
       grid collapses to ONE COLUMN, so `gap` stops being horizontal breathing room between
       items and becomes vertical space between stacked lines — the same 1rem reads as
       generous on a desktop row and as a hole on a phone. Desktop is one line and is unchanged.

       ⚠⚠ LOAD-BEARING FOR THE SPLASH, NOT COSMETIC. `/` is a flex column filling exactly one
       screen, so every pixel the footer gives back goes to the stage. Measured at 393px wide:
       footer 140px → 69px, which is what lets the region descriptions AND the greeting's streak
       line appear in a browser tab — where the URL bar costs ~100px the installed app never
       pays. Raise these and you take that back. */
    .site-footer {
      grid-template-columns: 1fr;
      justify-items: center;
      text-align: center;
      padding: 0.8rem 1.25rem;
      gap: 0.3rem;
    }
  }

  /* Very small screens: up to 380px — tighten further */
  @media (max-width: 380px) {
    .hero-thai { font-size: 26px; }
    .hero-logo { width: 77px; }
    .hero-stats { gap: clamp(8px, 3.5vw, 24px); }   /* px/vw, not rem — see the ≤600px rule */
    
    .filter-tabs {
      flex-wrap: wrap;
    }
    .filter-tab {
      font-size: calc(11px * var(--te-ui, 1));
      padding: 4px 10px;
    }
  }

  /* Desktop/tablet (≥601px): tighten the hero's vertical rhythm — closer lines,
     smaller gaps — so the dark stats band lands above the fold on load. Phones are
     all ≤600px, so this leaves the mobile hero (handled above) untouched. */
  @media (min-width: 601px) {
    .hero-inner { padding-top: 0.35rem; }
    .hero-logo { margin-bottom: 0.05rem; }     /* Thai script sits right under the logo */
    .hero-thai { margin-bottom: 0.3rem; }      /* tagline sits closer under the Thai script */
    .hero-tagline { margin-bottom: 0.7rem; }   /* less space above the gold rule */
    .hero-rule { margin-bottom: 0.7rem; }      /* less space below the gold rule */
    .hero-stats-band { margin-top: 0.3rem; }
    .hero-stats { padding: 0.5rem 2rem; }      /* shorter band so the gold labels clear the fold */
  }

/* ═══════════════════════════════════════════════════════════════════════════
   /topics PAGE CHROME — added 2026-08-21 with the redesign. Everything above
   this line came from index.html; everything below is new and owned here.
   ═══════════════════════════════════════════════════════════════════════════ */

.tp-wrap { max-width: 760px; margin: 0 auto; padding: 2rem 1.5rem 3rem; }
.tp-wrap.tp-wide { max-width: 900px; padding-top: 1.5rem; }
.tp-title { font-size: 30px; font-weight: 600; letter-spacing: -0.025em; color: var(--accent);
  overflow-wrap: anywhere; }
/* Copied value-for-value from index.html's retired .panel-sub, which is what playlists.html
   inlines for "Learn Thai by Your Ear". The two pages must look like siblings. */
.tp-sub { font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--accent); margin: 6px 0 16px; }

/* Breadcrumb — the point of putting a level between the home page and a topic is
   that you can always see where you are and step back without the browser's back
   button. */
.tp-crumb { font-size: 12.5px; color: var(--text-tertiary); margin-bottom: 1rem;
  /* ⚠ `anywhere`, not `break-word`. Only `anywhere` also shrinks the element's INTRINSIC
     width, and intrinsic width is what forces the page to scroll sideways. Measured at
     320@2.0x: the crumb's <em> overflowed by 21px before this. */
  overflow-wrap: anywhere; }
.tp-crumb a { color: var(--accent); text-decoration: none; }
.tp-crumb a:hover { text-decoration: underline; }
.tp-crumb span { margin: 0 0.4rem; opacity: 0.6; }

/* Search stays on the LANDING page, not the band pages: someone who already knows
   what they want should not have to guess which band it sits in. */
.tp-search { position: relative; display: flex; align-items: center; max-width: 440px; margin: 1.2rem 0 2rem; }
.tp-search > svg { position: absolute; left: 12px; width: 15px; height: 15px; color: var(--text-tertiary); pointer-events: none; }
#tp-q {
  width: 100%; font-family: var(--font-ui); font-size: 14px;
  padding: 9px 34px; border-radius: 20px; border: 0.5px solid var(--border-strong);
  background: var(--surface); color: var(--text-primary); -webkit-appearance: none; appearance: none;
}
#tp-q:focus { outline: none; border-color: var(--accent); }
#tp-q::-webkit-search-cancel-button { display: none; }
.tp-clear { position: absolute; right: 10px; border: 0; background: none; cursor: pointer;
  color: var(--text-tertiary); font-size: 17px; line-height: 1; padding: 2px 4px; }
.tp-clear:hover { color: var(--text-primary); }

/* ── the five band rows ─────────────────────────────────────────────────── */
/* ⚠ grid-auto-rows: 1fr MAKES ALL FIVE BAND CARDS THE HEIGHT OF THE TALLEST (owner,
   2026-08-22). Their names wrap to different line counts — at 390px only "Beginner" fits on one
   line, on the owner's phone "Upper Intermediate → Advanced" and "Beginner → Lower Intermediate"
   do too — so the short ones rendered 55.7px against 81.3px.
   ⚠ THIS REPLACES A min-height RESERVE ON .tp-band-name, WHICH WAS WRONG TWICE. It has to encode
   a line count, so (a) it only equalised at the widths where the split really was 1-vs-2 lines —
   at 360@1.4× the names take 1/3/4/3/3 lines and at 320@2.0× they take 3/12/14/13/10, and a
   2-line floor equalises neither — and (b) at >=900px every name already fits on ONE line and the
   set was already uniform, so a 2-line floor grew every card by 29px for nothing.
   1fr asks the grid the question directly: make every row as tall as the tallest. No line count,
   no breakpoint, nothing to keep in sync with the font. Measured uniform at 320@2.0×, 360@1.4×,
   390@1×, 900@1× and 1200@1×, and byte-identical to before at the widths that were already
   uniform. */
/* ⚠⚠ /topics NAMES ITS ROWS EXPLICITLY, AND THAT IS LOAD-BEARING (2026-08-29). Since the three
   headings (Favourites / Grammar by Ear / Sentences by Topic) went in, .tp-bands holds heading
   elements as well as tiles — and an `1fr` track sizes to the tallest fr track, so a heading
   sitting on grid-auto-rows would be given a whole tile's height. gen_topics_pages.js emits an
   inline `grid-template-rows: auto 1fr auto 1fr auto repeat(N, 1fr)`, which gives the headings
   `auto` and leaves every tile on 1fr — one grid, so all seven tiles stay equal.
   ⛔ Do not replace that with a wrapper per heading or a subgrid: either splits the tiles across
   formatting contexts and the equal-height guarantee goes with it, visibly at the narrow widths
   where the names wrap to different line counts. The rule below is the fallback for any row the
   template does not name. */
.tp-bands { display: grid; gap: 10px; grid-auto-rows: 1fr; }

/* ⭐ FAVOURITES EQUALISES ACROSS SECTIONS, WHICH grid-auto-rows CANNOT DO (owner, 2026-09-23).
   That page renders ONE .topic-grid PER SECTION -- Grammar by Ear, then a grid per difficulty
   band -- and a grid equalises rows within itself and never across two grids. So the cards
   agreed inside each section and disagreed between them, on a single screen where the
   difference is obvious.
   ⛔ MERGING THEM INTO ONE GRID DOES NOT WORK, and this was measured before it was written off:
   the band headings are interleaved, and a full-width <h2> inside `grid-auto-rows: 1fr` is
   STRETCHED to a whole card row (59px against 59px cards in a test grid), because 1fr sizes
   every implicit row to the largest one. One grid would leave a card-sized hole under every
   heading.
   ✅ So the grids stay separate and share a MEASURED height, published by topics-fav.js as
   --tp-fav-card-h on #tp-fav-root.
   ⚠ NOT the hand-written min-height this file forbids on .topic-name. That one encodes a line
   count and is wrong at every width except the one it was tested at; this is the real rendered
   maximum, re-measured on resize, on visibilitychange and after every decoration pass.
   ⚠ The fallback is 0 rather than a guess: if the script never runs, the cards simply keep
   their natural heights and the page looks exactly as it did before. */
#tp-fav-root .topic-card { min-height: var(--tp-fav-card-h, 0); }
/* ⚠⚠ THE SAME TRAP AS .topic-fav[hidden] ABOVE, AND IT WENT UNGUARDED HERE FOR MONTHS.
   The UA hides [hidden] with a rule of the same specificity as a class selector, and an
   AUTHOR sheet wins ties — so the `display: grid` on the line above silently defeated it.
   topics-page.js has always done `bands.hidden = true` when a search runs; it has never had
   any visual effect, which is why search results appeared BELOW the full category list
   instead of replacing it (owner, 2026-09-22: "they are not really visible").
   ⚠ Any future `display` declaration on .tp-bands must keep this rule below it.
   ⚠⚠ AND VERIFY IT BY getComputedStyle().display OR A BOUNDING BOX, NEVER BY
   el.hasAttribute("hidden") — the attribute was set correctly the whole time. */
.tp-bands[hidden] { display: none; }
.tp-band {
  display: flex; align-items: center; gap: 1rem; text-decoration: none; position: relative;
  background: var(--surface); border: 0.5px solid var(--border);
  border-radius: var(--radius-lg); padding: 1.05rem 1.25rem 1.05rem 1.6rem;
  overflow: hidden; transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
/* The strip is the band's identity, keyed off its CEILING level — two bands share a
   floor ("Beginner" and "Beginner → Lower intermediate"), so a floor-keyed hue would
   give those two the same colour and the strip would stop carrying information. */
.tp-band::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--bc); }
.tp-band:hover { border-color: var(--bc); box-shadow: 0 0 0 3px var(--bc-soft); transform: translateY(-1px); }
.tp-band-name { flex: 1 1 auto; min-width: 0; font-size: 18px; font-weight: 600; letter-spacing: -0.015em; color: var(--bc, var(--accent));
  overflow-wrap: anywhere; }
.tp-band-count { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--text-tertiary); white-space: nowrap; }
.tp-band-go { color: var(--bc); font-size: 19px; line-height: 1; opacity: 0.5; }
.tp-band:hover .tp-band-go { opacity: 1; }

/* ⛔⛔ THE BAND IDENTITY COLOURS - DARK FAMILIES, ONE SHARED PILL TINT (owner, 2026-09-22).
   He asked for exactly this shape: "i think they shouldnt all be navy, but they should be other
   dark color families in the same way that currently its a variation of light color families",
   and "the pills should all get the same tint as the free topics".
   ⚠ SO THE SIX HUES SURVIVE AND ONLY THEIR LIGHTNESS MOVES. These encode BAND, not tier, and a
   reader uses them to tell one band from another - collapsing them into one navy would destroy the
   only thing they do. What made them "bright and garish looking" was that they were LIGHT colours
   sitting against a dark accent, so each is taken to ~L30 at its own hue.
   ⚠ AND --bc-soft IS NOW THE ONE TINT, var(--accent-light), on every band: the pill ground is
   shared and only the bar differs. That also makes the pills follow a palette change for free,
   which six hard-coded pastels never did.
   White on each: beg 12.8 - li1 7.0 - int 9.8 - li2 11.0 - adv 10.3 - fav 10.1, all past AA.
   Retired: beg #4B41AD/#EEEDFE - li1 #C8A030/#FBF5DC - int #4A5578/#EEF0F6 - li2 #7F77DD/#F0EFFD
   - adv #A23A3A/#FBECEC - fav #6C5CE7/#EFEDFE. */
.bc-beg { --bc: #312574; --bc-soft: var(--accent-light); }
.bc-li1 { --bc: #70551A; --bc-soft: var(--accent-light); }
.bc-int { --bc: #344365; --bc-soft: var(--accent-light); }
.bc-li2 { --bc: #4A2E70; --bc-soft: var(--accent-light); }
.bc-adv { --bc: #702929; --bc-soft: var(--accent-light); }
/* Favourites is a ROUTE, not a difficulty band, so it deliberately does not borrow any band's
   colour — it takes the site accent. Same tile shape and same grid row, so it cannot drift in
   height (.tp-bands is grid-auto-rows: 1fr). */
.bc-fav { --bc: #642B64; --bc-soft: var(--accent-light); }
/* The heart in the tile is the SAME glyph the cards carry, at the same stroke weight. That is
   the whole mechanism by which the tile explains itself: nothing on it says "press the heart on
   a topic", the shape does. Filled, because the tile is the destination rather than a toggle. */

/* ── the Favourites view ────────────────────────────────────────────────────────────────── */
/* One heading per band that actually HAS a favourite — an empty band heading above nothing
   reads as a loading failure. topics-fav.js emits these; see renderFavPage(). */
/* ⚠ ALSO USED ON /topics for the three group headings (owner, 2026-08-27: "the headings would
   be the same style as the headings that appear within the favourites menu"). Reused, not
   re-declared — two heading rules with identical values look fine until someone edits one. */
.tp-fav-band { font-size: 13px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--text-secondary); margin: 1.6rem 0 0.7rem; }
.tp-fav-band:first-child { margin-top: 0; }
.tp-fav-empty { font-size: 14px; line-height: 1.6; color: var(--text-secondary);
  text-align: center; max-width: 34ch; margin: 2.5rem auto; }

/* ── a band page's own header ────────────────────────────────────────────── */
.tp-head { display: flex; align-items: baseline; gap: 1rem; justify-content: space-between;
  flex-wrap: wrap;   /* the count drops under the title rather than squeezing it */
  border-left: 5px solid var(--bc, var(--accent)); padding-left: 1rem; margin-bottom: 1.3rem; }
.tp-head h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; color: var(--bc, var(--accent)); line-height: 1.15;
  /* min-width:0 because a flex item will NOT shrink below its content's minimum width
     without it, whatever the wrapping rules say. Measured at 320@2.0x: +23px of page
     overflow before this pair. The type is deliberately NOT capped with --te-ui — this is
     content, not chrome, and capping a heading's growth is what WCAG 1.4.4 forbids. Let it
     get big; just make it wrap. */
  min-width: 0; overflow-wrap: anywhere; }
.tp-stat { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--text-tertiary); white-space: nowrap; }

/* Sideways movement between levels without going back up to /topics — what a learner
   does when a band turns out to be the wrong one. */
.tp-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 1.1rem; }
.tp-tab { font-family: var(--font-ui); font-size: 12.5px; padding: 5px 11px; border-radius: 20px;
  border: 0.5px solid var(--border-strong); background: var(--surface); color: var(--text-secondary);
  text-decoration: none; white-space: nowrap; }
.tp-tab:hover { border-color: var(--accent); color: var(--accent); }
/* ⛔⛔ THESE WERE THE SOURCE OF THE NAVY AND THEY WERE HARD-CODED, WHICH IS HOW THEY NEARLY
   GOT LEFT BEHIND. The first recolour took #1C124E *from* .tp-tab.on / .tp-title / .tp-band-name
   because the owner said "the navy color is already used in the side scroll navigator ... make
   sure it matches/ there is no discrepancy". They were literals, so gen_theme.py never touched
   them - and when the accent brightened on 2026-09-22 they would have sat on the OLD navy while
   everything else moved, recreating the exact discrepancy he asked me to prevent.
   ✅ They are var(--accent) now, so "the scroller matches" is true by construction rather than by
   two literals happening to agree. */
.tp-tab.on { background: var(--accent); border-color: var(--accent); color: #fff; }

/* Hidden by the tier filter. A CLASS, not a re-render: rebuilding the grid destroys the
   card under a finger mid-tap, which is the documented cause of the "have to click
   twice" on the PWA. Hydrate, never rebuild. */
.topic-card.tp-off { display: none; }

@media (max-width: 600px) {
  /* ⚠ px, not rem. Android's textZoom inflates every rem length as well as the text, so at
     2x these two side paddings took 70px out of a 305px viewport and squeezed the content
     that has to fit. px and vw are immune. The tabs' negative margin below matches this
     number and must be changed with it. */
  .tp-wrap { padding: 1.4rem 18px 2.5rem; }
  .tp-wrap.tp-wide { padding-top: 1.1rem; }
  .tp-title { font-size: 24px; }
  .tp-head h1 { font-size: 22px; }
  .tp-stat { display: none; }
  .tp-band { padding: 0.9rem 1rem 0.9rem 1.35rem; gap: 0.7rem; }
  .tp-band-name { font-size: 16px; }
  .tp-band-count { font-size: 10px; }
  .tp-band-go { display: none; }
  /* The five band names are long and must NOT be abbreviated (user-facing level labels
     are always spelled out), so wrapped they take five rows and push the topics off the
     screen. One scrolling row instead, with a fade so it reads as "more this way". */
  .tp-tabs {
    flex-wrap: nowrap; overflow-x: auto; scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch; scrollbar-width: none;
    margin-left: -18px; margin-right: -18px; padding: 0 18px 2px;   /* matches .tp-wrap's px padding */
    -webkit-mask-image: linear-gradient(to right, #000 0, #000 88%, transparent 100%);
            mask-image: linear-gradient(to right, #000 0, #000 88%, transparent 100%);
  }
  .tp-tabs::-webkit-scrollbar { display: none; }
  .tp-tab { scroll-snap-align: start; }
}

/* ── the Muay Thai mascot ─────────────────────────────────────────────────
   RECOVERED, not invented. This block lived on the "My flagged sentences" page until
   that page was retired with the flags feature (commit 6905723, sw v357). Its image is
   still in sw.js's PRECACHE with nothing left referencing it — the only trace it ever
   existed. Lifted verbatim from the pre-retirement sentences.html: same "In training"
   caption, same rules.
   ⚠ The ellipsis animates WIDTH, not `content` — the app's webview cannot animate
   `content`. steps(3, start) shows 1 -> 2 -> 3 dots. Do not "simplify" it. */
.fluency-block { text-align: center; margin: 2.5rem 0 0.5rem; }
.fluency-img { width: 158px; max-width: 42%; height: auto; display: block; margin: 0 auto 0.4rem; }
.fluency-text { font-size: 15px; font-weight: 500; color: var(--accent); letter-spacing: 0.02em; }
.fluency-text .dots { display: inline-block; width: 1.1em; text-align: left; }
.fluency-text .dots::after {
  content: '...'; display: inline-block; width: 0; overflow: hidden;
  vertical-align: bottom; animation: fluency-dots 1.5s steps(3, start) infinite;
}
@keyframes fluency-dots { from { width: 0; } to { width: 1.05em; } }

/* ── iOS: stop the search field force-zooming the page on focus (2026-08-27) ────────────
   Mobile Safari zooms the viewport whenever a focused form control's COMPUTED font-size is
   under 16px, and there is no way to opt out of that per-field. The search input was
   calc(13.5px * var(--te-ui,1)) at desktop widths and calc(12.5px * …) below 700px, so it
   zoomed on every tap and the visitor was left panned in with no way back except pinching.

   ⛔ The fix is NOT `maximum-scale=1` / `user-scalable=no` on the viewport. That stops the
   zoom by disabling pinch-zoom for the whole site, which is an accessibility regression far
   worse than the bug — and it would apply on every page, not just this field.

   ⚠ 16px is a HARD FLOOR and is deliberately NOT wrapped in var(--te-ui). --te-ui is a
   COUNTER-multiplier capped at 1 (TEXT_SCALING.md §2: `--te-ui = min(1, CAP / raw)`), so
   calc(16px * var(--te-ui,1)) can only ever resolve to 16px or LESS — which would put us
   straight back under the threshold on any device reporting text inflation. Multiplying here
   would silently undo the fix on exactly the phones most likely to need it.

   Scoped to (pointer: coarse) so desktop keeps the smaller field: the zoom behaviour only
   exists on touch, and this is a real visual change (the input is noticeably larger on a
   phone) that the owner accepted specifically to kill the zoom. */
@media (pointer: coarse) {
  /* ⚠⚠ #tp-q IS THE REAL SEARCH INPUT, and the first attempt at this fix missed it entirely.
     The element is `<input id="tp-q" type="search">` with NO CLASS — so every `.topic-search`
     rule in this file, including the one that was supposed to fix the zoom, never matched it.
     `.topic-search` survives only on app-offline-mock.html. The live styling is `#tp-q` at ~1024
     (font-size: 14px), and being an ID it would have beaten a class rule even had the class been
     present. The owner reported the zoom still happening, which is the only reason it was caught.
     ⚠ THE LESSON IS THE SAME ONE AS .topic-fav[hidden]: a rule that exists is not a rule that
     APPLIES. Confirm the selector matches the element — getComputedStyle on the real node —
     before believing a CSS fix. Two of these in one day.
     Both selectors are listed so the mock behaves the same and neither can drift. */
  #tp-q,
  .topic-search { font-size: 16px; }
}

/* The Grammar by Ear hub is the ONLY listing page with a standing explainer — see the
   note in gen_grammar_page.js for why the other listing pages deliberately have none. */
/* ⛔ NO max-width (owner, 2026-08-29). It was 62ch - a readability measure - and on a desktop
   three-column grid that stopped the line two columns short of the cards below it, which read as
   a layout fault rather than a typographic choice. The container (.tp-wrap.tp-wide) sets the
   width now, so the explainer spans the same span as the grid it introduces. Unchanged on
   mobile, where 62ch was never the constraint. */
.tp-intro { margin: 0.15rem 0 1.1rem; font-size: 15px; line-height: 1.6;
  color: var(--text-secondary); }
@media (max-width: 800px) { .tp-intro { font-size: 14px; margin-bottom: 0.9rem; } }

/* ⭐ W71 "New here?" — topics.html only (gen_topics_pages.js). Horizontal padding and gaps in px
   (TEXT_SCALING rule 1); the text block is flex 1 1 0 + min-width 0 so it wraps under OS text
   scaling instead of pushing the chevron off the edge. Hidden once tapped, and during a search. */
.tp-newhere { display: flex; align-items: center; gap: 8px; max-width: 440px; box-sizing: border-box;
  margin: -0.9rem 0 1.8rem; padding: 11px 14px; border-radius: var(--radius-md);
  background: var(--accent-light); border: 0.5px solid var(--border-strong);
  color: var(--accent); text-decoration: none; font-size: 14px; line-height: 1.35; }
.tp-newhere:hover { background: #E2E0F6; }
.tp-newhere-q { font-weight: 600; }
.tp-newhere-t { flex: 1 1 0; min-width: 0; }
.tp-newhere-go { font-size: 20px; line-height: 1; flex-shrink: 0; }
.tp-newhere[hidden], .tp-newhere.tp-newhere-off { display: none; }

/* The allow-listed area's entry, under the fighter (topics-page.js, end of file). */
.v-entry { display: inline-block; margin: 14px 0 0; padding: 8px 18px; border-radius: 999px;
  background: var(--surface); border: 1px solid var(--border); color: var(--accent);
  font-family: 'Sarabun', 'Inter', system-ui, sans-serif; font-size: calc(16px * var(--te-ui, 1));
  font-weight: 500; text-decoration: none; }
.v-entry:hover { border-color: var(--accent); }
