/* =============================================================================
   ภาพรวมนักเรียน · Student Insights theme
   Purple/gold ministry palette, adapted from the reference design.
   Layout keeps the sidebar + navbar shell; dashboard uses hero + KPI + cards.
   ============================================================================= */
:root {
    --brand: #7A2E9D;
    --brand-600: #6A2589;
    --brand-700: #511C68;
    --gold: #E0A018;
    --gold-soft: #F4C24B;
    --ink: #2A1A33;
    --muted: #6F6178;
    /* Deepened from the near-white #F3EFF7 (2026-09-10, "still looks plain" follow-up) so the
       canvas itself reads as a considered lavender rather than off-white — the white cards
       only pop against a visibly-tinted ground, the same way the dark theme's near-black
       --bg makes ITS white text pop. Bounded by the worst text-on-bg pairing found in this
       file: .filter-chips's dark-mode-only #511C68 label and .filter-toggle's --brand text
       both clear >6:1 here with room to spare, so --muted (5.06:1 at the old value) is the
       actual ceiling — #EDE2F6 keeps it at 4.60:1, comfortably above the 4.5:1 floor for
       normal-size text. (One pairing that WAS below floor at the old value too —
       .retire-cause-error's #dc2626 on --bg at 4.25:1 — is fixed by moving that one row off
       --bg entirely; see .retire-breakdown-row td below.) */
    --bg: #EDE2F6;
    --surface: #FFFFFF;
    --surface-2: #FAF8FC;
    /* H6 decision (2026-08-04): card/row borders are decorative, not a component
       boundary (cards already read as separate via .card-title + shadow) — WCAG
       1.4.11 (3:1) doesn't apply. #D6C4E4 measures 1.63:1 on white, clearing the worst
       current pair (#E9E0F0 = 1.28:1) without going dark enough to read as a structural
       line. (The original "1.44:1 on --bg" comparison point no longer exists — the one
       rule that put a --line border directly on a --bg fill, .retire-breakdown-row td,
       now sits on --surface-2 instead; see its own comment.) See the design-review doc's
       H6 for the swatch table and the component-boundary alternative (#9C7FB3, 3.44:1) if
       that classification is ever revisited. */
    --line: #D6C4E4;
    /* M2 (2026-08-04): 3-step radius scale, replacing 5 uncoordinated values
       (hero 24 / card 18 / sz-box 12 / button+TomSelect 4-Bootstrap-default /
       badge 999). badge-pill's 999px stays as-is — a capsule shape, not a step
       of this scale. --bs-border-radius* overrides Bootstrap's own default so
       buttons/inputs/TomSelect stop looking like an untouched, un-themed layer
       next to the card radius. This is global (:root, shared by 15 templates
       via fragments/head.html) — same regression scope as M3. */
    /* Operational dashboard scale: compact controls, readable cards, restrained heroes. */
    --radius-sm: 8px;
    --radius: 12px;
    --radius-lg: 14px;
    --bs-border-radius: 10px;
    --bs-border-radius-sm: 8px;
    /* M1 (2026-08-04): 6-step type scale replacing 12 near-duplicate font-sizes
       (several differed by <5%, indistinguishable to the eye but still separate
       values to maintain). Mapped only onto the selectors that actually render
       on /dashboard per the design-review baseline — not swept across every
       selector in this file, since this stylesheet is shared by 15 templates
       and most of those (log viewers, master data forms, the error page, etc.)
       weren't part of that review. */
    --fs-display: 40px;
    --fs-xl: 24px;
    --fs-lg: 18px;
    --fs-md: 15px;
    --fs-sm: 13px;
    --fs-xs: 12px;
    /* Light-mode-only richer elevation (a third, brand-tinted ambient layer instead of a
       flat ink shadow) — purely box-shadow, so it costs 0px of layout and cannot touch any
       of the pixel-measured contracts elsewhere in this file. Dark mode keeps the original
       two-layer values verbatim (reset just below, in the dark token block) since it was
       never part of this "too plain" light-theme pass. Opacity bumped a second time
       (.28/.20 -> .34/.25, 2026-09-10) after "still looks plain" feedback on the first pass. */
    --shadow: 0 1px 2px rgba(42,26,51,.06), 0 2px 6px rgba(42,26,51,.06), 0 22px 40px -20px rgba(81,28,104,.34);
    --shadow-sm: 0 1px 2px rgba(42,26,51,.06), 0 8px 18px -10px rgba(81,28,104,.25);
    /* ใช้สองที่: พื้นของ .hero-card และ overlay ตอน is-loading — ประกาศครั้งเดียวเพื่อไม่ให้
       overlay กลายเป็นแผ่นสีที่ไม่ตรงกับการ์ดเมื่อมีคนแก้ gradient */
    --hero-gradient: linear-gradient(135deg, #511c68, #7a2e9d 60%, #9a40b6);
    --sidebar-width: 248px;
    /* Fallback only — dashboard.js measures the real .app-navbar height on load/resize
       and overwrites this. Navbar height depends on font metrics and content (e.g. the
       two-line brand text), so a hardcoded guess drifts and the sticky navbar ends up
       overlapping .filter-sticky; don't rely on this value being accurate. */
    --navbar-h: 69px;
    /* M9 (2026-08-04): 8pt spacing scale replacing 6 values crammed into a 14px
       range (16/20/22/24/26/30), most notably .card-widget's asymmetric 20px/22px
       padding, which wasn't a multiple of 8 on either axis. --sp-5 (20px) is kept
       only for spots that need the old value verified case-by-case; card padding
       itself now uses --sp-6 per the 8pt-strict decision. */
    --sp-2: 8px;
    --sp-3: 12px;
    --sp-4: 16px;
    --sp-5: 20px;
    --sp-6: 24px;
    --sp-8: 32px;
    /* M3 (2026-08-04, team decision: global scope). Bootstrap's own defaults
       (--bs-primary etc.) are Bootstrap blue, unrelated to this app's purple/gold
       brand — visible as blue links, blue focus rings, and a blue "selected row"
       in every DataTables instance on every page, since this :root is shared by
       all 15 templates that load fragments/head.html. One theme across the whole
       app was the explicit goal here, so this rides everywhere on purpose —
       verified against /master, /reports/student and the error page (forms,
       DataTables + TomSelect, and Bootstrap alert/button chrome respectively). */
    --bs-primary: #7A2E9D;
    --bs-primary-rgb: 122, 46, 157;
    --bs-link-color: #7A2E9D;
    --bs-link-color-rgb: 122, 46, 157;
    --bs-link-hover-color: #511C68;
    --bs-link-hover-color-rgb: 81, 28, 104;
    --bs-focus-ring-color: rgba(122, 46, 157, .28);
    --dt-row-selected: 122, 46, 157;
    /* Release Candidate semantic chart/state roles. Keep these separate from
       brand identity so meaning survives palette and theme changes. */
    --chart-category: #7A2E9D;
    --chart-neutral: #6F6178;
    --chart-positive: #15803D;
    --chart-negative: #B91C1C;
    /* C1: กระแสที่สาม ("กลับเข้าเรียน") ไม่ใช่ทั้งบวกและลบในความหมายเดียวกับอีกสองตัว
       /dashboard แยกเป็นฟ้ามาแล้วบน hero (--flow-color: #dce8ff ซึ่งเป็น tint สำหรับพื้น
       gradient) — token นี้คือคู่ของมันสำหรับพื้นการ์ดสว่าง วัดจริงบน server ที่รันอยู่ = 6.7:1
       บน #fafafc */
    --chart-flow-reactivated: #1D4ED8;
    --chart-warning: #A16207;
    --chart-geo-low: #E8DCEE;
    --chart-geo-high: #511C68;
}

/* =============================================================================
   L8 — dark mode. Every dark rule in this file keys off the data-bs-theme="dark"
   ATTRIBUTE on <html>, not @media (prefers-color-scheme: dark): the OS preference
   can't be overridden from JS, so a user-facing Light/Dark/Auto toggle needs a
   selector the toggle can drive. fragments/head.html's inline script resolves the
   three-way preference (stored localStorage value, else the OS media query) down
   to that one attribute before <body> is parsed — so "auto" behaves exactly like
   the old media query, with no light-mode flash, and Bootstrap's own dark mode
   (modal/popover/alert/badge/dropdown/nav-pill, which reads the same attribute)
   stays in lockstep with this file instead of splitting off. The blocks below
   only cover THIS app's own CSS custom properties and hand-authored rules.

   WHY :where(:root[data-bs-theme="dark"]) AND NOT :root[data-bs-theme="dark"]:
   an @media block adds no specificity, so every dark rule here used to compete
   with its light counterpart at the LIGHT selector's own weight and win purely
   on source order. A bare attribute prefix would add +0,1,0 to all of them and
   silently change which rule wins in the other direction — measured, not
   theorised: `@media(dark){ a{color:var(--brand-text)} }` correctly LOST to
   Bootstrap's `.btn-primary` (0,1,0 beats 0,0,1), but `:root[data-bs-theme=
   "dark"] a` (0,3,0) beat it, repainting the navbar's solid-purple "เข้าสู่ระบบ"
   button's label brand-purple-on-purple at 2.64:1. :where() contributes zero
   specificity, so each rule below keeps EXACTLY the weight it had as an @media
   rule and the ordering reasoning throughout this file stays valid.
   The :root token block itself is the one exception — it must keep the real
   (0,2,0) attribute selector to out-specify the light `:root{...}` block above
   it; wrapping that one in :where() would drop it to 0,0,0 and kill dark mode
   wholesale.
   ============================================================================= */
:root[data-bs-theme="dark"] {
    --bg: #1A1020;
    --surface: #241730;
    --surface-2: #2C1D3A;
    --ink: #F0E8F5;
    --muted: #B0A0BC;
    --line: #3A2A48;
    --chart-category: #B583D2;
    --chart-neutral: #B0A0BC;
    --chart-positive: #4ADE80;
    --chart-negative: #F87171;
    --chart-flow-reactivated: #93B4FF;   /* วัดจริงบน server ที่รันอยู่ = 7.6:1 บน #2c1d3a (นั่นคือ --surface-2 ในบล็อกนี้ ไม่ใช่ --surface ซึ่งคือ #241730) */
    --chart-warning: #FACC15;
    --chart-geo-low: #6B4A82;
    --chart-geo-high: #F0E8F5;
    /* NEW token, dark-mode only. --brand/--brand-600/--brand-700 are
       DELIBERATELY left unchanged (even here) — they're also used as solid
       FILL backgrounds (buttons, badges, the sidebar/hero gradients), where
       white text on them already works fine regardless of page theme, so
       touching them would be wrong, not just unnecessary. --brand-text is
       for the separate set of places --brand is used as TEXT/ICON color
       directly on this page's own --bg/--surface/--surface-2 instead (the
       sidebar toggle icon, KPI numbers, links, the geo table's brand-
       colored total, etc.) — raw --brand only measures ~2.2:1 against the
       dark surfaces above (WCAG needs 4.5:1 for text), where --brand-text
       clears 6.27:1 on --bg and 5.76:1 on --surface (verified via the same
       relative-luminance formula as dashboard.css:17-23's H6 comment).
       See the grouped "brand-text swap" block further down this file for
       every site that needed it, and the reasoning for why each does/
       doesn't. */
    --brand-text: #B583D2;
    /* Reset of the richer light-mode --shadow/--shadow-sm above (dashboard.css:51-56) back to
       their original flat ink-tinted values — dark surfaces are already dark, so a brand-tinted
       ambient layer reads as mud rather than depth there, and this pass was scoped to light. */
    --shadow: 0 1px 2px rgba(42,26,51,.04), 0 8px 24px -12px rgba(42,26,51,.18);
    --shadow-sm: 0 1px 2px rgba(42,26,51,.05), 0 4px 12px -8px rgba(42,26,51,.14);
}

body {
    margin: 0;
    font-family: "IBM Plex Sans Thai", system-ui, -apple-system, "Segoe UI", sans-serif;
    /* Two soft brand/gold glows anchored near the top corners of the DOCUMENT (not the
       viewport — background-attachment stays at its default "scroll"). An earlier version
       of this used "fixed" with the centers pushed above y=0, which reliably looked right
       in isolation but was, in practice, invisible on the real page: "fixed" pins the glow
       to the viewport, and the sticky navbar covers exactly that same viewport band at
       every scroll position, so the glow never had a frame where both it and something
       other than the navbar were visible at once. Un-fixed and centered AT y=0 instead, it
       sits behind the actual top of the document — visible on load through .content's own
       side/bottom padding and the gaps between .page-head/.kpis/the first .card-widget,
       which is the one moment (first paint) most worth spending this on. Pure
       background-image, so it costs 0px everywhere. Reset to flat --bg in dark mode below —
       tuned against the light --bg, and dark mode wasn't part of this ask. */
    background: radial-gradient(1200px 900px at 4% 0%, color-mix(in srgb, var(--brand) 10%, transparent), transparent 65%),
                radial-gradient(1000px 760px at 100% 0%, color-mix(in srgb, var(--gold) 12%, transparent), transparent 60%),
                var(--bg);
    background-repeat: no-repeat;
    color: var(--ink);
}
:where(:root[data-bs-theme="dark"]) body { background-image: none; }
.display, .font-display { font-family: "Sora", "IBM Plex Sans Thai", sans-serif; }

/* Keyboard focus indicator, page-wide. Before this rule, only 2 selectors in this
   file (.sz-clickable, .school-hist-btn) had an explicit :focus-visible — every other
   interactive element (div[role=button] rank rows, chip delete buttons, the filter
   <summary>, table drill-down cells) relied on the browser's own default outline,
   which is inconsistent across engines and easy to lose behind z-index/overflow.
   `:focus-visible` (not `:focus`) so a mouse click doesn't show the ring, only
   keyboard/programmatic focus. More specific existing rules (e.g. .sz-clickable's
   sky-blue outline) still win here by specificity — left as-is, not in this pass's
   scope. */
[role="button"]:focus-visible,
a:focus-visible,
button:focus-visible,
summary:focus-visible {
    outline: 2px solid var(--brand);
    outline-offset: 2px;
}

/* prefers-reduced-motion: collapse every CSS animation/transition to effectively
   instant, and stop the LIVE badge's infinite pulse (line ~172) from looping —
   animation-iteration-count:1 is needed for that; duration alone would leave it
   looping at 0.01ms per cycle. KPI count-up (animateCount) and ECharts animation
   are JS-driven and can't be reached from CSS — see the REDUCED_MOTION check in
   dashboard.js. */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }
}

/* ----------------------------- Layout shell ------------------------------- */
.app-wrapper { display: flex; min-height: 100vh; }

.app-sidebar {
    width: var(--sidebar-width);
    background: linear-gradient(180deg, var(--brand-700), #3c1450);
    color: rgba(255,255,255,.82);
    flex-shrink: 0;
    transition: margin-left .2s ease;
}
.app-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
/* R6: cap the reading measure on ultrawide. Measured at 1920 with the sidebar collapsed,
   content ran 1853px — a single .rank-name label stretched to 726px and the history table
   to the full width, so the eye has to travel the whole monitor to pair a row's label with
   its value. 1600 keeps every card comfortably above its own min width at 1440 (the most
   common desktop here) and only engages past ~1650.
   Applied to .content, NOT .app-main: the navbar is app chrome and stays full-bleed, and
   .filter-sticky's negative margins still bleed to the (now centered) content edges. */
.content { padding: 22px 26px 60px; flex: 1; max-width: 1600px; margin-inline: auto; width: 100%; }

/* ------------------------------- Sidebar ---------------------------------- */
.sidebar-brand {
    display: flex; align-items: center; gap: .6rem;
    padding: 1.1rem 1.25rem;
    font-family: "IBM Plex Sans Thai", sans-serif; font-size: 1.2rem; font-weight: 700; color: #fff;
    border-bottom: 1px solid rgba(255,255,255,.10);
    text-decoration: none; transition: background .15s;
}
.sidebar-brand:hover { background: rgba(255,255,255,.08); color: #fff; }
.sidebar-brand i { color: var(--gold-soft); }

.sidebar-menu { list-style: none; margin: 0; padding: .75rem 0; }
.sidebar-menu a {
    display: flex; align-items: center; gap: .75rem;
    padding: .7rem 1.25rem;
    color: rgba(255,255,255,.82); text-decoration: none;
    border-left: 3px solid transparent;
    transition: background .15s, color .15s;
}
.sidebar-menu a:hover { background: rgba(255,255,255,.08); color: #fff; }
.sidebar-menu a.active {
    background: rgba(255,255,255,.12); color: #fff; border-left-color: var(--gold-soft);
}
.sidebar-menu i { font-size: 1.1rem; width: 1.25rem; text-align: center; }
.sidebar-group { border-top: 1px solid rgba(255,255,255,.10); margin-top: .25rem; padding-top: .25rem; }
/* The menu is all groups now (the public links moved to the navbar switcher), so the
   first group would double the .sidebar-brand's bottom border with its own top one. */
.sidebar-menu > .sidebar-group:first-child { border-top: 0; margin-top: 0; padding-top: 0; }
.sidebar-group-toggle {
    display: flex; align-items: center; gap: .75rem; width: 100%;
    padding: .7rem 1.25rem;
    background: none; border: none; border-left: 3px solid transparent;
    color: rgba(255,255,255,.82); font: inherit; text-align: left; cursor: pointer;
    transition: background .15s, color .15s;
}
.sidebar-group-toggle:hover { background: rgba(255,255,255,.08); color: #fff; }
.sidebar-group-toggle i:first-child { font-size: 1.1rem; width: 1.25rem; text-align: center; }
.sidebar-caret { margin-left: auto; font-size: .85rem; transition: transform .15s ease; }
.sidebar-group-toggle.open .sidebar-caret { transform: rotate(180deg); }
.sidebar-submenu {
    list-style: none; margin: 0; padding: 0;
    max-height: 0; overflow: hidden;
}
.sidebar-submenu.open { max-height: 20rem; }
.sidebar-submenu a { padding-left: 2.6rem; }

/* -------------------------------- Navbar ---------------------------------- */
.app-navbar {
    display: flex; align-items: center;
    background: var(--surface);
    padding: .55rem 1.25rem;
    border-bottom: 1px solid var(--line);
    box-shadow: 0 1px 3px rgba(42,26,51,.05), 0 10px 24px -18px rgba(81,28,104,.30);
    position: sticky; top: 0; z-index: 1030;
}
/* Keep the login/logout button on one line — a wrapped button grows the navbar, and
   --navbar-h (see dashboard.js) is what keeps .filter-sticky's offset in sync with that. */
.app-navbar .btn { white-space: nowrap; flex: 0 0 auto; }
.navbar-spacer { flex: 1; }
.sidebar-toggle { font-size: 1.4rem; color: var(--brand); text-decoration: none; }
/* Theme toggle sits on the right of the navbar; same icon-button treatment as the
   sidebar toggle, one step smaller so it reads as a secondary control. */
/* 44x44 is the WCAG 2.5.5 minimum; the glyph keeps its 1.15rem size and the box grows
   around it, so the navbar row (69px) is unchanged and only the hit area gets bigger. */
.theme-toggle { font-size: 1.15rem; color: var(--brand); text-decoration: none; padding: .15rem .4rem;
    min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }

/* Install-as-app button (fragments/navbar.html). No size of its own — it rides Bootstrap's
   btn-sm so it matches the login button beside it exactly; an earlier 44px (WCAG 2.5.5 AAA)
   box read as a second, taller row of chrome next to that 31px button. 31px still clears
   WCAG 2.5.8 (AA, 24px minimum), which is the level the rest of this navbar sits at.
   The label collapses to the bare icon on narrow phones so it never pushes the login button
   off the navbar; the button carries an aria-label, so the accessible name survives the span
   being display:none. */
.pwa-install { margin-right: .25rem; }
@media (max-width: 575.98px) { .pwa-install span { display: none; } }

/* Native <dialog>, not a Bootstrap modal: this fragment is shared by pages that never load
   the Bootstrap JS bundle. All colours come from the tokens, which the :root[data-bs-theme=
   "dark"] block already redefines — so this needs no dark-mode counterpart. */
.pwa-install-dialog {
    border: 0; border-radius: var(--radius); padding: 20px 22px; max-width: 340px;
    color: var(--ink); background: var(--surface); box-shadow: var(--shadow); }
.pwa-install-dialog::backdrop { background: rgba(42, 26, 51, .5); }
.pwa-install-dialog h2 { font-size: var(--fs-lg); margin: 0 0 .75rem; }
.pwa-install-dialog ol { margin: 0 0 1rem; padding-left: 1.25rem; font-size: var(--fs-sm); line-height: 1.7; }
.navbar-brand { display: flex; align-items: center; gap: 10px; margin-left: .5rem; min-width: 0; }
.navbar-logo {
    width: 48px; height: 48px; border-radius: 100%; background: #fff;
    display: grid; place-items: center; 
    /*padding: 4px;  */
    flex: none;
    box-shadow: 0 2px 8px -2px rgba(42,26,51,.3); border: 1px solid var(--line);
}
.navbar-logo img { width: 100%; height: 100%; object-fit: contain; }
.navbar-text { display: flex; flex-direction: column; justify-content: center; line-height: 1.25; min-width: 0; }
.navbar-title { font-weight: 600; color: var(--ink);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.navbar-subtitle { font-size: .74em; font-weight: 400; color: var(--muted);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.navbar-env { font-size: .85rem; color: var(--muted); }
.navbar-user { font-size: .9rem; color: var(--ink); display: inline-flex; align-items: center; gap: .35rem; }

/* ---------------- Primary public nav / view switcher -------------------------
   In the shared navbar (fragments/navbar.html), rendered on /dashboard, /overview
   and /schools — the whole public navigation now that the sidebar is
   authenticated-only. A segmented control with the current page filled in the
   app's primary purple; the same vocabulary as the nav-pills and primary buttons.
   It has to READ as a control against the white navbar (the earlier faint
   --surface-2 fill + --line border blended in), so: a 1.5px brand border, a soft
   shadow for lift, and brand-coloured labels (not --muted) — inactive segments
   match the nav-pill idiom (.nav-pills .nav-link:not(.active) is brand-coloured
   too, with the same dark override). ~37px tall, so it still rides the 69px navbar
   row and leaves --navbar-h (and /overview's one-viewport contract, which
   subtracts it) untouched. Icon-only below 1024 — the width the sidebar already
   goes off-canvas at — so the labels never crowd the auth cluster. The ภาพรวม
   segment also drops out below 1024: /overview's whole reason to exist is the
   zoom:.8 one-viewport layout, which the max-width:1087px block turns off — on a
   phone it becomes a one-column scroll stack indistinguishable from /dashboard
   but with fewer features, so the shortcut only adds confusion. It stays when
   it's the current page (:not(.is-active)) so a shared /overview link still shows
   where you are. */
.view-switch {
    display: inline-flex; flex: 0 0 auto; gap: 3px;
    margin-left: .6rem; padding: 3px;
    background: var(--surface-2); border: 1.5px solid var(--brand); border-radius: 999px;
    box-shadow: var(--shadow-sm);
}
.view-switch-seg {
    display: inline-flex; align-items: center; gap: .4rem;
    padding: 6px 14px; border-radius: 999px;
    font-family: "IBM Plex Sans Thai", sans-serif;
    font-size: var(--fs-sm); font-weight: 600; line-height: 1;
    color: var(--brand); text-decoration: none; white-space: nowrap;
    transition: background .12s ease, color .12s ease;
}
.view-switch-seg i { font-size: 1rem; }
.view-switch-seg:hover { background: rgba(122, 46, 157, .10); }
.view-switch-seg.is-active,
.view-switch-seg.is-active:hover { background: var(--brand); color: #fff; }

@media (max-width: 1024px) {
    .view-switch-seg { padding: 7px 10px; }
    .view-switch-seg span { display: none; }
    .view-switch-seg[aria-label="ภาพรวม"]:not(.is-active) { display: none; }
}

/* ------------------------------ Page title -------------------------------- */
.page-title { font-family: "IBM Plex Sans Thai", sans-serif; font-size: 1.4rem; font-weight: 700; margin-bottom: 1.1rem; }

/* ================================ HERO ==================================== */
/* /overview's header band (the only .hero left — /dashboard uses .page-head below).
   It shares its surface/line/badge colors with .page-head so both pages read as one
   system. The purple gradient it used to carry sat directly above .kpi.hero-card,
   which paints the same --hero-gradient — two purple blocks stacked, with the page
   title competing against the headline number for first look.
   Edges are INSET box-shadows, never borders: /overview's one-viewport contract
   asserts scrollHeight === innerHeight exactly, and a real border would add 2px of
   box height at zoom .8. Inset shadows paint inside the box, costing 0px.
   The gold bar is listed FIRST so it paints over the --line outline on the left
   edge (earlier shadows sit on top); reversing them leaves a grey hairline on the
   accent. Gold is the app's existing "you are here" mark -- the sidebar's active
   item uses the same left edge in --gold-soft. */
.hero {
    background: var(--surface);
    border-radius: var(--radius-lg);
    padding: var(--sp-6); color: var(--ink); position: relative; overflow: hidden;
    /* var(--shadow-sm) appended (2026-09-10, "still looks plain" follow-up): the two inset
       shadows above were purely an edge treatment (a border substitute, per the comment
       above) — this band had zero actual elevation, making it the flattest thing on either
       page it appears on despite sitting first, above the fold. A trailing NON-inset shadow
       composites fine alongside inset ones in one box-shadow list and, like every other
       shadow addition in this file, costs 0px — still safe under /overview's zoom-critical
       one-viewport contract. */
    box-shadow: inset 3px 0 0 var(--gold), inset 0 0 0 1px var(--line), var(--shadow-sm);
    margin-bottom: 18px;
}
.hero-main {
    display: flex; align-items: flex-end; justify-content: space-between;
    gap: 24px; flex-wrap: wrap;
}
/* R7: was var(--fs-display) — the same 40px/800 as the total-students KPI, so the page's
   name competed with its most important number and nothing read as "look here first".
   A page title is a label, not data: 22px/700 identifies the page and steps out of the way. */
.hero-title { margin: 0; font-size: 1.375rem; font-weight: 700; line-height: 1.25; }
.hero-sub { margin: 6px 0 0; opacity: .86; max-width: 600px; font-size: var(--fs-md); }
.hero-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
.badge-pill {
    display: inline-flex; align-items: center; gap: 8px;
    background: rgba(255,255,255,.14); border: 1px solid rgba(255,255,255,.22);
    border-radius: 999px; padding: 8px 15px; font-size: var(--fs-sm); font-weight: 500; color: #fff;
}
/* LIVE status readout: a pulsing lamp, the sync date, and a ticking tabular-figure
   clock as three named tokens instead of one run of text that wraps three ragged
   ways on a phone. Desktop keeps one row inside .badge-pill's chrome — the size
   contrast is weight + tracking, never font-size, because this sits under
   /overview's zoom:.8 where .9rem is already the 11.5px floor (the phone layout is
   in the max-width:1087px block near the end of this file). */
.live-badge { flex-wrap: wrap; column-gap: 12px; row-gap: 2px; }
.live-badge .live-lamp {
    display: inline-flex; align-items: center; gap: 7px;
    font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
    color: var(--chart-positive);
}
.live-badge .live-asof { color: var(--muted); }
.live-badge .live-time {
    margin-left: auto; font-weight: 600;
    font-variant-numeric: tabular-nums; letter-spacing: .01em;
}

/* ============================== PAGE HEAD ================================= */
/* Same band as .hero above -- same surface, same gold accent, same inset technique,
   so the two pages' headers are identical by construction rather than by two rules
   that have to be kept in sync. Only the padding differs (this page is denser). */
.page-head {
    margin-bottom: var(--sp-4);
    background: var(--surface);
    border-radius: var(--radius-lg);
    /* Same trailing var(--shadow-sm) as .hero above, same reason: this band had no real
       elevation before, just the inset edge treatment. */
    box-shadow: inset 3px 0 0 var(--gold), inset 0 0 0 1px var(--line), var(--shadow-sm);
    padding: var(--sp-3) var(--sp-4);
}
.page-head-content {
    display: flex; align-items: center; justify-content: space-between;
    gap: var(--sp-4); flex-wrap: wrap;
}
.page-head-title { margin: 0; font-size: var(--fs-xl); font-weight: 700; line-height: 1.25; color: var(--ink); }
.page-head .hero-title { margin: 0; font-size: var(--fs-xl); font-weight: 700; line-height: 1.25; color: var(--ink); }
.page-head-meta { display: flex; align-items: center; gap: var(--sp-2); }
/* Both header bands are --surface now, so the badge cannot also be --surface or it
   vanishes into its own container. It drops to the page background instead. */
.page-head .badge-pill,
.hero .badge-pill { background: var(--bg); border: 1px solid var(--line); color: var(--ink); }
.history-trigger { display: none !important; }
.section-heading {
    margin: var(--sp-8) 0 var(--sp-3);
    font-size: var(--fs-sm); font-weight: 700; letter-spacing: .08em;
    text-transform: uppercase; color: var(--muted);
    display: flex; align-items: center; gap: var(--sp-3);
}
.section-heading::after {
    content: ""; flex: 1 1 auto; height: 1px;
    /* Fades out instead of ending on a hard flat edge — still var(--line) at its brightest,
       still 1px, so nothing about its footprint changes. */
    background: linear-gradient(90deg, var(--line), transparent);
}
.badge-pill .dot {
    width: 7px; height: 7px; border-radius: 50%; background: var(--chart-positive);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--chart-positive) 22%, transparent);
    animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }

/* ============================== KPI cards ================================= */
/* R3: grid-auto-rows:1fr เคยยืด .hero-card ให้สูงเท่า size-card (360px) ทั้งที่มีเนื้อหา
   ~130px เหลือ gradient ว่าง ~230px กลางจอแรก — วัดจากหน้าจริงใน runtime audit 2026-08-12
   ตอนนี้แต่ละแถวสูงตามเนื้อหา ส่วน size-card ยึดคอลัมน์ 2 ทั้ง 2 แถวไว้เอง (ดูบล็อก
   .kpi.size-card ด้านล่าง) จึงยังจัดเรียงตรงกันโดยไม่ต้องยืดใคร */
/* 1.2fr, not 1.6fr: the change breakdown living inside the hero was pushing the school-size
   card down to 522px at 1440 (its five boxes 244px each) while the hero's own headline had
   room to spare. Swept live at 1440 and 1280 — 1.2fr gives the size card 617px / 544px
   (boxes 292 / 255) and the headline still renders 56px on one line in both, with the
   breakdown column never overflowing and .kpis staying 290px tall. Moving the breakdown out
   of the hero instead was measured too: it leaves 129px of empty gradient in a row whose
   height the size card sets, i.e. the void R3 removed, plus a new strip below. */
.kpis {
    display: grid; grid-template-columns: 1.2fr 1fr;
    gap: var(--sp-4); margin-bottom: 18px;
}
.kpi {
    background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
    /* M9: was 18px 20px (an outlier not in the doc's own spacing audit — .card-widget's
       20/22 was the one flagged — but left as-is it'd be a 7th distinct spacing value
       against M9's "≤5 rendered" bar, so it's folded into --sp-6 along with the rest). */
    padding: var(--sp-6); box-shadow: var(--shadow-sm); position: relative; overflow: hidden;
}
/* Distribute small KPI content vertically (label, value, footer as separate tiers) for visual
   clarity. Each row sizes to content; the size-card anchors the right column across both rows. */
.kpi:not(.size-card) { display: flex; flex-direction: column; justify-content: space-between; }
/* Phone stacking of the KPI grid lives in the ≤640px block in the Responsive
   section — it must come after the .kpi.size-card placement rule below to win.
   (It was ≤768px until 2026-08-18: at tablet portrait the two cards still fit side by
   side, and stacking them there cost the trend chart 219px of fold.) */
.kpi .k-top { display: flex; align-items: center; justify-content: space-between; }
.kpi .k-label { font-size: var(--fs-sm); color: var(--muted); font-weight: 600; }
.kpi .k-ic { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: var(--surface-2); color: var(--brand); }
.kpi .k-val { font-family: "Sora", sans-serif; font-weight: 800; font-size: var(--fs-display); letter-spacing: -.02em; margin-top: 10px; line-height: 1; color: var(--brand); }
.kpi .k-val .u { font-size: .42em; font-weight: 600; color: var(--muted); margin-left: 6px; font-family: "IBM Plex Sans Thai"; }
/* L2 follow-up: .k-label is reused inside .k-val for the "คน" unit suffix (e.g.
   "9,867,093 คน") — without this override it inherits .k-val's Sora, which has no Thai
   glyphs, so "คน" would render in a different fallback font than the surrounding number.
   Scoped to .k-val .k-label only; the .k-label used as each KPI's plain heading (outside
   .k-val) is untouched — it was never Sora in the first place. */
.kpi .k-val .k-label { font-family: "IBM Plex Sans Thai", sans-serif; }
.kpi .k-foot { margin-top: 12px; font-size: var(--fs-xs); color: var(--muted); }
.kpi.hero-card { background: var(--hero-gradient); color: #fff; border: none; }
/* R3: การ์ดสูงตามเนื้อหาแล้ว จึงไม่ต้อง center เพื่อกลบพื้นที่ว่างอีก — gap ยังคงไว้
   ให้ label / ตัวเลข / delta chip อ่านเป็นก้อนเดียว */
.kpi.hero-card { grid-column: 1; grid-row: 1; }
.kpi.hero-card .k-foot { margin-top: 0; }
/* .92, not .85: the hero gradient's lightest stop is #9A40B6, where .85 white measures
   4.47:1 — under the 4.5 needed for these two (13px/600 and 12px/400 are both normal-size
   text). .92 clears 4.95:1 on that same worst stop. Same reasoning as the delta chip below. */
.kpi.hero-card .k-label, .kpi.hero-card .k-foot { color: rgba(255,255,255,.92); }
/* Inset ring + a touch of outer lift — same rgba fill as before (no contrast change,
   glyph stays #fff on it), just a bit more dimensional than a flat tinted square. */
.kpi.hero-card .k-ic {
    background: rgba(255,255,255,.16);
    color: #fff;
    box-shadow: inset 0 0 0 1px rgba(255,255,255,.22), 0 2px 6px -2px rgba(0,0,0,.25);
}
.kpi.hero-card .k-val { color: #fff; }
/* H5: delta chip inside the hero KPI's .k-foot (previously empty). */
.kpi-trend-row { display: flex; flex-direction: column; gap: 6px; }
.kpi-trend-delta { display: inline-block; font-size: var(--fs-sm); font-weight: 600; font-variant-numeric: tabular-nums; }
/* Duplicate detection is a second operational signal within the established hero rather than a
   third KPI grid cell: the desktop grid's two existing cells already hold the measured row height.
   Authenticated-only (sec:authorize in dashboard.html) — a PersonID-backed data-quality flag, not
   another hero fact, so it gets a bounded plate with a --gold left spine (the same "this is the
   selected school" accent .duplicate-detail-modal .dup-col.is-anchor uses). The fixed hero gradient is
   shared light/dark, so gold stays on the 3px rule ONLY — every text value is #fff or
   rgba(255,255,255,.92), which clears 4.5:1 on the gradient's lightest stop (#9A40B6) even with
   the .05 white fill on top; the same constraint the .chg-* overrides further down were written
   for. The only sub-.92 value is the decorative "·" separator, which carries no meaning. */
.kpi.hero-card.dashboard-hero-card .dup-inline {
    display: flex;
    flex-direction: column;
    gap: 1px;
    margin-top: 2px;
    padding: 6px 12px;
    border: 1px solid rgba(255,255,255,.18);
    border-left: 3px solid var(--gold-soft);
    border-radius: 10px;
    background: rgba(255,255,255,.05);
    color: rgba(255,255,255,.92);
    font-size: var(--fs-xs);
}
/* Calm state: no snapshot has run yet, or a genuine zero. The spine goes neutral and the count
   drops to body weight so a permanent amber-style flag never cries wolf. Toggled by
   th:classappend="${duplicateKpi.personCount() > 0} ? 'is-flagged'" in dashboard.html. */
.kpi.hero-card.dashboard-hero-card .dup-inline:not(.is-flagged) {
    border-left-color: rgba(255,255,255,.45);
}
/* The two rows. Each is its own flex row so the count+label and the as-of+link stay grouped;
   the plate is a fixed two-row stack, not a viewport-driven wrap. */
.kpi.hero-card.dashboard-hero-card .dup-headline,
.kpi.hero-card.dashboard-hero-card .dup-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0 7px;
}
.kpi.hero-card.dashboard-hero-card .dup-label {
    font-weight: 600;
    font-size: var(--fs-sm);
    color: rgba(255,255,255,.92);
}
/* gold stays on the 3px spine only — on the gradient's lightest stop it is ~2.4:1. */
.kpi.hero-card.dashboard-hero-card .dup-count {
    color: #fff;
    font-family: "Sora", sans-serif;
    font-size: 1.25rem;
    font-weight: 700;
    line-height: 1.2;
    letter-spacing: -.01em;
    font-variant-numeric: tabular-nums;
}
.kpi.hero-card.dashboard-hero-card .dup-inline:not(.is-flagged) .dup-count {
    font-size: var(--fs-md);
    font-weight: 600;
    color: rgba(255,255,255,.92);
}
.kpi.hero-card.dashboard-hero-card .dup-asof { color: rgba(255,255,255,.92); }
.kpi.hero-card.dashboard-hero-card .dup-link {
    color: #fff;
    font-weight: 600;
    white-space: nowrap;
    text-decoration-thickness: 1px;
    text-underline-offset: 2px;
}
.kpi.hero-card.dashboard-hero-card .dup-link::before { content: "\00b7\00a0"; color: rgba(255,255,255,.55); text-decoration: none; }
.kpi.hero-card.dashboard-hero-card .dup-link::after { content: "\00a0\2192"; }
.kpi.hero-card.dashboard-hero-card .dup-link:focus-visible {
    outline: 2px solid #fff;
    outline-offset: 3px;
    border-radius: 2px;
}
.kpi.hero-card.dashboard-hero-card .kpi-trend-delta {
    font-size: 1.125rem;
    font-weight: 700;
    letter-spacing: -.01em;
}
.kpi-trend-delta.kpi-trend-loading, .kpi-trend-delta.kpi-trend-empty { color: rgba(255,255,255,.7); font-weight: 500; }
/* Cross-task fix (post-L8 whole-branch review): dashboard.html:881 reuses the shared
   .chg-up/.chg-down/.chg-flat classes (Task 7/H5) for this chip, but those colors
   (#15803d/#dc2626/--muted, plus their dark-mode #4ADE80/#F87171 counterparts from
   Task 12) were calibrated for a WHITE or dark PAGE surface — this chip instead sits on
   .kpi.hero-card's fixed purple --hero-gradient gradient (#511C68 -> #7A2E9D -> #9A40B6),
   which Task 12 deliberately leaves unchanged in both themes (same fixed-dark-on-fixed-
   surface reasoning as the sidebar). Every .chg-* variant fails 4.5:1
   against at least one of the 3 gradient stops (down to ~1:1 for the flat/gray state) —
   re-verified via the WCAG relative-luminance formula against ALL THREE stops, not just
   the worst one. These overrides win by specificity + !important (needed to beat .chg-*'s
   own !important) and apply identically in light/dark mode, since the gradient itself
   doesn't change between themes. The arrow glyph (▲/▼/–) still carries direction
   independently of color, so this loses no information. */
.kpi.hero-card .kpi-trend-delta.chg-up   { color: #d8f0e1 !important; }  /* 4.60-10.18:1 across the gradient */
.kpi.hero-card .kpi-trend-delta.chg-down { color: #fde4e4 !important; }  /* 4.60-10.14:1 across the gradient */
.kpi.hero-card .kpi-trend-delta.chg-flat { color: rgba(255,255,255,.9) !important; }  /* .85 (used elsewhere on this card) only clears 4.46:1 on the worst stop; .9 clears 4.82:1+ */
/* ------------------- Merged hero: KPI + change breakdown ------------------- */
.kpi.hero-card.dashboard-hero-card { container-type: inline-size; }
.kpi.hero-card.dashboard-hero-card .hero-grid { display: grid; grid-template-columns: 1fr; gap: var(--sp-4); width: 100%; }
/* 470, not 576 (M5, 2026-09-02). The container is the hero CARD's content box, which measures
   474px at a 1024px viewport — four px under the old threshold, so the hero stacked and the KPI
   row stood 386.5px tall there against 266.5px at 1280. 470 lets 1024 use the same two-column
   hero as 1280; a 900px viewport still stacks (card content ~440px), so the stacked shape is
   kept where it is genuinely needed rather than removed. Paired with deleting the sz-grid
   one-column override above — see that comment; each lever alone is masked by the other. */
@container (min-width: 470px) {
    .kpi.hero-card.dashboard-hero-card .hero-grid { grid-template-columns: 1.3fr minmax(0, 1fr); gap: var(--sp-5); }
}
.kpi.hero-card.dashboard-hero-card .hero-kpi { display: flex; flex-direction: column; gap: var(--sp-2); min-width: 0; }
.kpi.hero-card.dashboard-hero-card .k-val { font-size: clamp(2.5rem, 11cqi, 3.5rem); }
/* cqi reads the CARD, but once the hero splits the headline only gets the wider of two
   columns — so the cap has to come down with the split, and only with it (M5, 2026-09-02).
   At 1024 the card is 474px: 11cqi = 52.14px and "9,859,907" measured 269.8px against a
   256.6px column, 13.2px of overflow.
   SIZED FOR "10,000,000", NOT FOR TODAY'S TOTAL — that is the whole reason it is 8.5 and not
   the 10cqi that fitting the current number would allow. Digit width is not uniform in Sora
   800: "12,345,678" and "10,000,000" are both 10 characters but measure 255.0px and 292.2px
   (zero is the widest glyph), so character count predicts nothing and the live total is the
   wrong thing to fit. At 10cqi/47.4px today's 9,859,907 clears the column by 11.3px while
   10,000,000 overflows it by 35.6px — and the real total was 9,859,907 when this was written,
   ~140k short. 8.5cqi renders 40.29px here (the 2.5rem floor is what actually binds at this
   width) and puts 10,000,000 at 248.4px, inside 256.6px. Widening the hero's share of the
   split was the alternative and it does not reach: even 1.8fr leaves 10,000,000 short while
   squeezing .what-changed toward wrapping.
   WHERE IT BITES, measured per viewport — it is NOT a 1024-only patch:
     1440 (container 692, column 379.9) — no-op. 8.5cqi and 11cqi both clamp to 56px and
       10,000,000 fits at 345.3px either way.
     1280 (container 605, column 330.6) — the cap is REQUIRED here, and this half predates M5:
       the hero was already two columns at this width, and 11cqi/56px puts 10,000,000 at
       345.3px, overflowing by 14.7px. 8.5cqi renders 51.417px / 317px, inside. Cost is 4.6px
       of headline at today's total.
     1024 (container 474, column 256.6) — 52.14px -> 40.29px, the case described above.
     900 and below — untouched: the card is ~406px, under the 470 threshold, so the hero stays
       one column and the base 11cqi applies to a full-width headline, as it should.
   Scoped to the container query rather than the base rule for exactly that last row. Must stay
   AFTER the base rule above: a container query adds no specificity, so source order is the only
   thing that makes this win. */
@container (min-width: 470px) {
    .kpi.hero-card.dashboard-hero-card .k-val { font-size: clamp(2.5rem, 8.5cqi, 3.5rem); }
}
.kpi.hero-card.dashboard-hero-card .what-changed {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--sp-2);
    border-left: none;
    min-width: 0;
}
.kpi.hero-card.dashboard-hero-card .what-changed-metrics { grid-template-columns: 1fr; gap: var(--sp-2); }
.kpi.hero-card.dashboard-hero-card .what-changed-metric {
    background: none; border: none; padding: 0;
    display: flex; align-items: baseline; justify-content: flex-start; gap: var(--sp-3);
}
.kpi.hero-card.dashboard-hero-card .what-changed-metric strong { margin-top: 0; margin-left: auto; color: rgba(255,255,255,.9); }
.kpi.hero-card.dashboard-hero-card .card-title { color: #fff; }
.kpi.hero-card.dashboard-hero-card .what-changed-label,
.kpi.hero-card.dashboard-hero-card .what-changed-status,
.kpi.hero-card.dashboard-hero-card .what-changed-period,
.kpi.hero-card.dashboard-hero-card .what-changed-note,
.kpi.hero-card.dashboard-hero-card .card-sub { color: rgba(255,255,255,.92); }
.kpi.hero-card.dashboard-hero-card .what-changed-metric strong[data-direction="up"]   { color: #d8f0e1; }
.kpi.hero-card.dashboard-hero-card .what-changed-metric strong[data-direction="down"] { color: #fde4e4; }
.kpi.hero-card.dashboard-hero-card .what-changed-metric strong[data-direction="flat"] { color: rgba(255,255,255,.9); }
/* The three sources describe different real-world flows, not merely a numeric direction.
   Give each a restrained rail and value tint: admitted, left, and returned students remain
   distinguishable at a glance without competing with the total above. */
.kpi.hero-card.dashboard-hero-card .what-changed-metric[data-flow="incoming"]   { --flow-color: #d8f0e1; }
.kpi.hero-card.dashboard-hero-card .what-changed-metric[data-flow="retired"]    { --flow-color: #fde4e4; }
.kpi.hero-card.dashboard-hero-card .what-changed-metric[data-flow="reactivated"] { --flow-color: #dce8ff; }
.kpi.hero-card.dashboard-hero-card .what-changed-metric[data-flow]::before {
    content: ""; width: 3px; align-self: stretch; border-radius: 999px; background: var(--flow-color);
}
.kpi.hero-card.dashboard-hero-card .what-changed-metric[data-flow] strong[data-direction] { color: var(--flow-color); }
/* The sync date is already in the page head's "LIVE · ข้อมูล <วันที่>" badge, on the same
   screen. Hidden rather than deleted — renderWhatChanged writes to this node every refresh. */
.kpi.hero-card.dashboard-hero-card .what-changed-period { display: none; }
.kpi.hero-card.dashboard-hero-card .kpi-trend-note,
.kpi.hero-card.dashboard-hero-card .kpi-trend-delta.kpi-trend-loading,
.kpi.hero-card.dashboard-hero-card .kpi-trend-delta.kpi-trend-empty { color: rgba(255,255,255,.9); }
/* These duplicate values exist only inside the merged dashboard hero: the KPI trend
   owns their visible presentation. Keep the nodes for renderWhatChanged(), and scope
   the concealment so /overview's change panel remains unaffected. */
.kpi.hero-card.dashboard-hero-card #whatChangedNet { display: none; }
.kpi.hero-card.dashboard-hero-card #whatChangedSizeNote { display: none; }
/* Only visible while a schoolSize band filter is active (toggled in renderKpiTrendDelta) —
   smaller/dimmer than the delta chip itself since it's a caveat, not the headline number. */
.kpi-trend-note { display: inline-block; font-size: var(--fs-xs); color: rgba(255,255,255,.7); cursor: help; }
.kpi-trend-period { display: inline-block; font-size: var(--fs-xs); color: rgba(255,255,255,.92); font-variant-numeric: tabular-nums; }
.kpi .accent { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; }

/* Release Candidate: the strip is an information bridge between the KPI and
   the detailed trend, not a second chart. Its values remain readable when
   the page is narrowed and when the theme changes. */
.what-changed {
    display: grid;
    grid-template-columns: minmax(220px, 1.1fr) minmax(0, 2fr);
    gap: var(--sp-4);
    align-items: center;
    border-left: 4px solid var(--chart-category);
}
.what-changed-heading { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.what-changed-period { color: var(--muted); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
.what-changed-note { color: var(--chart-neutral); font-size: var(--fs-xs); cursor: help; }
.what-changed-status { grid-column: 1; grid-row: 2; color: var(--muted); font-size: var(--fs-xs); }
/* renderWhatChanged blanks this on the happy path (the old "เทียบกับรอบก่อนหน้า" only restated
   the card's own title). The node stays for its role="status" aria-live duty on the error /
   empty / first-round paths, so hide it exactly when it has nothing to announce. */
.what-changed-status:empty { display: none; }
.what-changed-status[data-state="error"] { color: var(--chart-negative); }
.what-changed-status[data-state="empty"] { color: var(--chart-neutral); }
.what-changed-metrics { grid-column: 2; grid-row: 1 / span 2; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-3); }
.what-changed-metric { min-width: 0; padding: var(--sp-3); background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius-sm); }
.what-changed-metric strong { display: block; margin-top: 4px; font-family: "Sora", sans-serif; font-size: var(--fs-lg); font-variant-numeric: tabular-nums; color: var(--chart-category); }
/* R5: ทิศทางเป็นของทุก metric ไม่ใช่เฉพาะ net — เข้าใหม่/คืนสถานะ = บวก, จำหน่าย = ลบ
   ใช้ token ชุดเดียวกับ .chg-up/.chg-down ในตาราง trend ด้านล่างของหน้า เพื่อให้ผู้ใช้
   เรียนภาษาสีครั้งเดียวแล้วใช้ได้ทั้งหน้า */
.what-changed-metric strong[data-direction="up"]   { color: var(--chart-positive); }
.what-changed-metric strong[data-direction="down"] { color: var(--chart-negative); }
.what-changed-metric strong[data-direction="flat"] { color: var(--chart-neutral); }
.what-changed-label { display: block; color: var(--muted); font-size: var(--fs-xs); }
/* R2: net อยู่ในหัวการ์ดแล้ว — Sora + tabular-nums ให้เท่ากับตัวเลขที่เหลือบนหน้า
   และใช้ data-direction ชุดเดียวกับ .what-changed-metric strong (Task R5) */
#whatChangedNet { font-family: "Sora", sans-serif; font-variant-numeric: tabular-nums; }
#whatChangedNet[data-direction="up"]   { color: var(--chart-positive); }
#whatChangedNet[data-direction="down"] { color: var(--chart-negative); }
#whatChangedNet[data-direction="flat"] { color: var(--chart-neutral); }

/* ประโยคสรุปของกราฟแนวโน้ม ตอนนี้อยู่ใต้กราฟบน card surface แล้ว ไม่ใช่บน hero gradient
   จึงใช้ muted text ปกติ และต้องไม่ซ่อนที่ <=1200px: node นี้ไม่ได้ทำให้ hero row สูงขึ้นแล้ว */
.what-changed-insight {
    margin: 8px 0 0;
    font-size: var(--fs-sm);
    line-height: 1.45;
    color: var(--muted);
}
.what-changed-insight:empty { display: none; }

/* --------------------------- School-size card ----------------------------- */
/* M6: rebuilt to the same white-surface + tinted-shadow language as every other
   .card-widget — a total header, a card-title-style title, then a grid of
   near-white size boxes (was: opaque gold header / brand-700 title band / ink
   number boxes, the one card that still looked pre-redesign). */
.kpi.size-card {
    grid-column: 2; grid-row: 1;
    /* Same top accent + tinted ring as .card-widget (see its comments) — both are
       background/box-shadow layers only, 0px cost. */
    background-color: var(--surface);
    background-image: linear-gradient(90deg, var(--brand), #9A40B6 55%, var(--gold-soft));
    background-repeat: no-repeat;
    background-size: 100% 4px;
    background-position: top;
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm), inset 0 0 0 1px color-mix(in srgb, var(--brand) 12%, transparent);
    padding: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
}
.size-card .sz-head {
    color: var(--ink);
    padding: 12px 18px;
    text-align: center;
    border-bottom: 1px solid var(--line);
}
.size-card .sz-head-num {
    font-family: "Sora", sans-serif;
    font-weight: 800;
    /* R7: 28px is the secondary step between the 56px hero KPI and body text — this is now
       the second-most-important number above the fold (it was var(--fs-xl) = 24px). */
    font-size: 1.75rem;
    line-height: 1;
    /* Every other non-hero KPI number on this page is .k-val, which is brand
       purple (line ~304) — .sz-head-num was left on inherited --ink instead,
       the one secondary KPI number that didn't match that language. Brand
       color (not size/background) is the lever here on purpose: it makes this
       number read as "a KPI value" like its siblings, while staying
       subordinate to the hero .k-val, which is still larger (--fs-display
       40px vs --fs-xl 24px, the H4 1.5x+ rule) AND sits on its own full
       gradient card — a stronger cue than text color alone. */
    color: var(--brand);
}
.size-card .sz-head-label { font-size: var(--fs-sm); font-weight: 600; margin-top: 2px; }
.size-card .sz-title {
    font-family: "IBM Plex Sans Thai", sans-serif;
    font-size: var(--fs-lg);
    font-weight: 700;
    text-align: center;
    padding: 8px 0;
    letter-spacing: .02em;
    border-bottom: 1px solid var(--line);
}
/* Dark-mode-only override: --line (#3A2A48) only reaches 1.29:1 against the dark
   --surface (#241730) here — same class of "too faint to read as a divider" issue
   H6 fixed for light mode (1.28:1 → chose #D6C4E4 for 1.63:1), but --line itself
   is shared by every decorative card border site-wide, so it's left alone;
   scoped instead to just these two dividers. Reuses the existing dark geoRamp's
   #6B4A82 (see renderGeoMap above) rather than inventing a new hex — 2.36:1
   here, visibly a line without competing with the ink text above/below it. */
:where(:root[data-bs-theme="dark"]) .size-card .sz-head, :where(:root[data-bs-theme="dark"]) .size-card .sz-title { border-bottom-color: #6B4A82; }
.size-card .sz-grid {
    display: grid;
    align-content: start;
    grid-template-columns: 1fr 1fr;
    gap: 8px;
    padding: 12px;
}
.size-card .sz-box {
    background: var(--surface-2);
    border: 1px solid var(--line);
    border-left-width: 3px;
    border-radius: var(--radius-sm);
    padding: 10px 8px 10px 6px;
    text-align: center;
}
/* ONE property per band per theme. This ramp used to be written out four times — text
   color (light), left border (light), text color (dark), left border (dark) — 20
   declarations for 10 colors, and the meter under each number ignored all of them and
   used a flat var(--brand). --band-color is now the single source all three consumers
   read, so the left edge, the figure and the bar always agree, and a band's color is
   defined exactly once per theme. The left edge still carries the ramp so size reads
   before the eye reaches the number (the original glance-test rationale).
   WHY A RAMP AT ALL: school size is an ORDERED scale (tiny -> xlarge), so the five
   boxes read as a sequential-darkness ramp — large = dark, small = light — and size
   is obvious before any caption is read. The light values clear 4.5:1 against
   --surface-2: re-measured live 2026-08-18 after this refactor, lightest #9456b0 =
   4.72:1 and darkest #14081a = 18.43:1, i.e. unchanged from before it. */
.size-card .sz-box[data-size="tiny"]   { --band-color: #9456b0; }
.size-card .sz-box[data-size="small"]  { --band-color: #7f37a1; }
.size-card .sz-box[data-size="medium"] { --band-color: #62257f; }
.size-card .sz-box[data-size="large"]  { --band-color: #421954; }
.size-card .sz-box[data-size="xlarge"] { --band-color: #14081a; }
.size-card .sz-box[data-size] { border-left-color: var(--band-color); }
.size-card .sz-num { color: var(--band-color); }
.size-card .sz-box-wide { grid-column: span 2; }
.size-card .sz-num {
    font-family: "Sora", sans-serif;
    font-weight: 800;
    font-size: 1.5rem;
    line-height: 1;
}
.size-card .sz-cap { color: var(--muted); font-size: var(--fs-xs); margin-top: 4px; }
/* Single-line boxes: number and caption side by side instead of stacked, ~46px per box
   instead of ~130px. Scoped to .sz-rows: /overview renders the same markup as a
   five-across strip and overrides padding/font-size/columns but not display or
   text-align, so an unscoped rule would reshape that page too. */
.size-card.sz-rows .sz-box { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto 1fr auto; align-items: start; column-gap: 8px; text-align: left; padding: 8px 10px; }
/* Box internals shared with /overview, which keeps its own placement (five across, tighter
   spacing) but reads the same inside: figure and share on the top line, name then range below,
   meter on the floor. Only this part is common — each page's own .sz-box rule sets its spacing. */
.size-card.sz-rows .sz-num,
.overview-page .ov-kpis .size-card .sz-num { grid-column: 1; align-self: baseline; }
/* The share reads as an attribute of the figure, so it sits with it rather than starting a
   third stacked line — that is the line the caption reclaims. */
.size-card.sz-rows .sz-share,
.overview-page .ov-kpis .size-card .sz-share { grid-column: 2; justify-self: end; align-self: baseline; }
.size-card.sz-rows .sz-cap,
.overview-page .ov-kpis .size-card .sz-cap { grid-column: 1 / -1; grid-row: 2; color: var(--ink); line-height: 1.25; }
.size-card.sz-rows .sz-range,
.overview-page .ov-kpis .size-card .sz-range { display: block; color: var(--muted); }
.size-card.sz-rows .sz-meter,
.overview-page .ov-kpis .size-card .sz-meter { grid-row: 3; align-self: end; }
/* One border per box: the size ramp on the left edge already carries the band, so the outline
   around it was a second, weaker line saying nothing. */
.size-card.sz-rows .sz-box { border-top: 0; border-right: 0; border-bottom: 0; }
/* These boxes are ~296px wide (twice /overview's), so name and range fit one line — which is
   ~50px of card height back, and this row has to stay above the fold. Same two facts, one line;
   /overview keeps them stacked because its boxes are too narrow to hold both. */
.size-card.sz-rows .sz-range { display: inline; }
.size-card.sz-rows .sz-range::before { content: " · "; }
.size-card.sz-rows .sz-num { font-size: 1.25rem; }
.size-card.sz-rows .sz-cap { margin-top: 0; }
.size-card .sz-share { color: var(--muted); font-family: "Sora", sans-serif; font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
.size-card .sz-share[hidden] { display: inline; visibility: hidden; }
.size-card .sz-meter { grid-column: 1 / -1; display: block; height: 3px; margin-top: 5px; border-radius: 999px; background: var(--line); overflow: hidden; }
.size-card .sz-meter[hidden] { display: block; visibility: hidden; }
/* Same --band-color the number above it uses (the light band block is above this rule,
   the dark ramp below); the var() fallback keeps today's brand fill if a box ever
   renders without a data-size. */
/* A soft lighten-to-band-color sheen instead of one flat fill — still driven by the same
   single --band-color per box, so the left edge / figure / bar never disagree. */
.size-card .sz-meter-fill {
    display: block; width: var(--size-share, 0%); height: 100%; border-radius: inherit;
    background: linear-gradient(90deg, color-mix(in srgb, var(--band-color, var(--brand)) 65%, white), var(--band-color, var(--brand)));
    transition: width .24s ease-out;
}

/* Keep the dashboard's first KPI row comfortably within the initial viewport.
   The compact density is desktop-only so the deliberately roomy mobile cards stay intact. */
@media (min-width: 769px) {
    .kpis:not(.ov-kpis) .size-card.sz-rows .sz-grid { gap: 6px; padding: 8px; }
    .kpis:not(.ov-kpis) .size-card.sz-rows .sz-box { padding: 6px 8px; }
    .kpis:not(.ov-kpis) .size-card.sz-rows .sz-meter { margin-top: 3px; }
    .kpis:not(.ov-kpis) .kpi.hero-card.dashboard-hero-card { justify-content: center; }
}
/* Below 900px the outer KPI grid is still two columns, so a size row's caption would be
   squeezed between the count and share columns — give each box the full inner width instead.
   ONE block, not two: this used to be declared identically in a 769-899 block and again in
   the <=768 block, and deleting one copy was invisible to the eye — which is exactly how
   commit 4757c8e reverted shipped fix 1963dfb. /overview has no .sz-rows marker. */
@media (max-width: 899px) {
    .size-card.sz-rows .sz-box { display: block; text-align: center; }
    .size-card.sz-rows .sz-cap { margin-top: 4px; }
    /* Stacked, a box is back to /overview's width class, so the range takes its own line
       again rather than wrapping mid-phrase. */
    .size-card.sz-rows .sz-range { display: block; }
    .size-card.sz-rows .sz-range::before { content: none; }
}
/* This range used to force .sz-grid back to ONE column ("preserve the row-per-band reading
   order rather than squeezing two labelled bands side by side"). Removed 2026-09-02 (M5)
   after measuring what it actually cost: five 54px rows instead of three made .sz-grid 310px
   at 1024 against 190px at 1280, and it was the reason /dashboard's fold was 168px WORSE at
   1024 than at 1280 for no content reason — the trend chart was 0px visible at 1024×768 vs
   43.6px at 1280×720. The readability worry did not survive measurement: at 1024 the two
   columns give each band a 205.5px box and every caption still renders on ONE line (checked
   per band, tiny→xlarge; .sz-box-wide keeps its span-2 so the fifth band is full-width).
   Removing this ALONE only buys 23px — the hero card becomes the taller of the two and pins
   the row — so it is load-bearing together with the .hero-grid container threshold below.
   Change one without the other and the 119px goes away. */
/* L8 dark-mode ramp: the light-mode 5 values above were computed against a
   near-white --surface-2 and read as near-invisible on the new dark
   --surface-2 (#2C1D3A) — same class of bug Task 1/M5 hit twice. Direction
   flips (tiny=darkest/most-saturated here, xlarge=lightest) — that's fine,
   the requirement is monotonic + each step individually readable, not
   matching light mode's specific direction. All 5 clear >=4.7:1 against dark
   --surface-2 (verified via the WCAG relative-luminance formula). Same ten
   values as before this task, only relocated into one --band-color per box
   per theme instead of being repeated as separate text/border declarations. */
:where(:root[data-bs-theme="dark"]) .size-card .sz-box[data-size="tiny"]   { --band-color: #ab7bc1; }
:where(:root[data-bs-theme="dark"]) .size-card .sz-box[data-size="small"]  { --band-color: #be98cf; }
:where(:root[data-bs-theme="dark"]) .size-card .sz-box[data-size="medium"] { --band-color: #d2b8de; }
:where(:root[data-bs-theme="dark"]) .size-card .sz-box[data-size="large"]  { --band-color: #e8dcee; }
:where(:root[data-bs-theme="dark"]) .size-card .sz-box[data-size="xlarge"] { --band-color: #fbf9fc; }
/* Clickable size band → filters the schools table. Selected box gets a ring + lift.
   Was a white inset ring, sized for the dark --ink fill; on the near-white
   --surface-2 box a white ring is invisible, so hover/selected now use
   --line/--brand instead. */
.size-card .sz-clickable { cursor: pointer; transition: box-shadow .12s, transform .12s; }
/* Ring now picks up the box's own --band-color instead of the flat --line, plus a small
   lift to match .sz-selected below (the transition on the base rule already covers both
   box-shadow and transform, so this reuses it rather than adding a new one). */
.size-card .sz-clickable:hover {
    box-shadow: inset 0 0 0 1px var(--band-color, var(--line)), 0 4px 10px -6px rgba(42,26,51,.20);
    transform: translateY(-1px);
}
.size-card .sz-clickable:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
.size-card .sz-selected { box-shadow: inset 0 0 0 2px var(--brand); transform: translateY(-1px); }
/* While a band is selected the other four count 0 by construction (the server zeroes
   them — that zeroing IS the "you are filtering" signal), but their share stays the real
   national figure, so each unselected box reads "0 · น้อยกว่า 40 คน · 12.0%": a count for
   the filtered set next to a percentage for the unfiltered one, 8px apart. Dimming makes
   the box read as EXCLUDED, so the 0 is understood as "not in this filter" rather than
   "no data", and the percentage stays legible as the reference it is.
   :has() is the whole mechanism — syncSizeBoxes already owns .sz-selected, so no JS
   changes. Browsers without :has() simply skip it and keep today's undimmed boxes.
   DROPPING THE FILL, NOT OPACITY: opacity composites the text toward whatever is behind
   it, which costs real contrast — measured, `opacity:.5` puts the caption/share at 2.63:1
   and the count at 2.18:1, and even `.8` still fails light mode at 3.56:1. Removing the
   surface fill instead lands the text on the card ground, where it reads BETTER than
   before (5.74:1 light / 6.93:1 dark, up from 5.44 / 6.40), so the percentage stays fully
   legible as the reference it is. The selected box keeps its fill, its 2px brand ring and
   its lift; an excluded band is the one without them. */
.size-card .sz-grid:has(.sz-selected) .sz-box:not(.sz-selected) {
    background: none;
    border-left-color: transparent;
}
/* Cross-filter controls: make their behaviour discoverable without adding DOM
   nodes that would be discarded when the dashboard re-renders. */
.size-card .sz-clickable,
.rank-row { position: relative; }
.size-card .sz-clickable::after,
.rank-row::after {
    content: "\F3E5";
    font-family: "bootstrap-icons";
    font-size: 11px;
    position: absolute;
    top: 6px;
    right: 8px;
    color: var(--brand);
    opacity: 0;
    transition: opacity .12s;
    pointer-events: none;
}
.size-card .sz-clickable:hover::after,
.size-card .sz-clickable:focus-visible::after,
.rank-row:hover::after,
.rank-row:focus-visible::after { opacity: .55; }
.size-card .sz-clickable[aria-pressed="true"]::after,
.rank-row[aria-pressed="true"]::after { opacity: 1; }

/* ============================== Cards/charts ============================== */
.grid { display: grid; grid-template-columns: 1.55fr 1fr; gap: var(--sp-4); margin-bottom: var(--sp-4); align-items: start; }
/* A grid item's auto min-width is min-content; an ECharts <canvas> contributes its
   pixel-width attribute as min-content, so on a viewport shrink (e.g. rotate) the canvas
   would pin the 1fr track wide and overflow the page. min-width:0 lets the track shrink;
   max-width:100% keeps the canvas inside its card until ECharts.resize() refits it. */
.grid > .card-widget { min-width: 0; }
#levelChart, #geoMap { max-width: 100%; }
/* กรอบรอบแผนที่ ให้เห็นชัดว่าพื้นที่ zoom/ลาก อยู่ตรงไหน */
#geoMap { border: 1px solid var(--line); border-radius: 8px; }

/* Geo map card: map ซ้าย + ตารางรายจังหวัด/อำเภอ/ตำบล ขวา (see #geoMap + div mobile override below) */
.geo-card { grid-column: 1 / -1; }
.geo-layout { display: flex; flex-wrap: wrap; gap: 16px; align-items: stretch; }
.geo-layout #geoMap { flex: 1 1 420px; min-width: 300px; height: 480px; }
/* R13: the flex sizing that used to sit on .geo-table-panel now lives on the <details>
   wrapper — it is the flex item .geo-layout lays out. The panel inside just fills it. */
.geo-table-details { flex: 1 1 300px; min-width: 260px; max-width: 420px; display: flex; flex-direction: column; }
.geo-table-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; }
/* Side by side, the table is simply there — nothing to expand, so hide the affordance.
   dashboard.js keeps the element itself `open` at these widths. */
.geo-table-summary {
    display: none; cursor: pointer; list-style: none; user-select: none;
    font-size: var(--fs-sm); font-weight: 600; color: var(--brand);
    padding: 8px 0;
}
.geo-table-summary::-webkit-details-marker { display: none; }
.geo-table-summary::before { content: "▸ "; display: inline-block; transition: transform .2s ease; }
.geo-table-details[open] > .geo-table-summary::before { content: "▾ "; }
/* 820px, NOT the 1024 sidebar breakpoint: .geo-layout wraps on its flex BASES
   (map 420 + table 300 + 16 gap = 736px of content), which at this page's padding lands
   around a 780px viewport. Collapsing at 1024 would hide a table that still fits beside the
   map. Keep this in sync with GEO_STACK_MAX in dashboard.js. */
@media (max-width: 820px) {
    .geo-table-summary { display: block; }
    /* Only stacked does the panel need its own height cap — side by side it matches the map. */
    .geo-table-panel { height: 420px; }
}
@media (min-width: 821px) {
    .geo-table-details, .geo-table-panel { height: 480px; }
}
#geoTableTitle { margin-bottom: 6px; }
.geo-table-scroll { flex: 1; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; }
.geo-table { width: 100%; border-collapse: collapse; font-size: 13px; }
/* L7: attribution text moved into a Bootstrap Popover behind this icon (click/tap/keyboard,
   never hover-only — CC BY 4.0 requires the text stay reachable on touch devices). */
.geo-attribution { display: flex; justify-content: flex-end; margin-top: 4px; }
.geo-attribution-btn {
    display: inline-flex; align-items: center; justify-content: center;
    width: 28px; height: 28px; padding: 0; border: none; border-radius: 50%;
    background: transparent; color: var(--muted); font-size: 16px; line-height: 1;
    cursor: pointer; transition: background .12s, color .12s;
}
.geo-attribution-btn:hover { background: var(--surface-2); color: var(--ink); }
.geo-attribution-btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; color: var(--ink); }

/* M7 — 100%-stacked bar replacing the /dashboard donut (dashboard-uiux-redesign-plan.md).
   /dashboard-only element. The plot band is top-anchored at a fixed height and the
   name+value legend flows downward from a fixed y (see renderDegreeStackedBar), so the
   two never collide however many rows the legend wraps to. Host height only has to be
   tall enough not to clip the last legend row: 2 rows on a full-width card, up to 4
   once the card narrows on a phone (where "name: 3,735,467" fits two per row). Before
   this, height:96px + a bottom-anchored legend meant the 4-row mobile wrap painted over
   the bar. */
#degreeLevelBar { height: 96px; max-width: 100%; margin-bottom: var(--sp-4); }
/* Phone width: the card narrows enough that the 8 name+value legend items wrap to
   5 rows ("เตรียมอนุบาล: 21,150" takes a row on its own). Tall enough for all of
   them, since a clipped last row hides a whole education level. Tablet/desktop keep
   96px — the wide card wraps the legend to 2 rows. */
@media (max-width: 640px) { #degreeLevelBar { height: 172px; } }
@media (max-width: 992px) { .grid { grid-template-columns: 1fr; } }

.card-widget {
    /* Brand-gradient hairline along the top edge — the same signature every white card in
       the app now carries (see .kpi.size-card below). A second background LAYER, not a
       border: border-width changes the box's rendered height on every element here that
       sizes by content (several are pixel-measured, e.g. the 266.5px KPI row and
       /overview's one-viewport contract), while an extra background-image layer paints
       inside the existing box and costs 0px. Applies to every .card-widget site-wide,
       .ov-panel included (same class, see overview.html) — safe for the same reason.
       Widened 3px -> 4px and given a third gold stop (2026-09-10, "still looks plain"
       follow-up) so it reads as an actual accent rather than a hairline that only shows up
       on close inspection; #9A40B6 is the hero gradient's own lightest stop, reused here
       rather than inventing a new purple. */
    background-color: var(--surface);
    background-image: linear-gradient(90deg, var(--brand), #9A40B6 55%, var(--gold-soft));
    background-repeat: no-repeat;
    background-size: 100% 4px;
    background-position: top;
    border: 1px solid var(--line); border-radius: var(--radius);
    /* A brand-tinted ring just inside the existing pale --line border — box-shadow, so it
       layers on top of the 1px border without adding any width of its own. --line itself
       stays untouched (H6's decorative-contrast reasoning is unrelated and it's read by
       far too many other rules to safely retune here); this only makes THIS edge read as
       more deliberately colored. */
    box-shadow: var(--shadow-sm), inset 0 0 0 1px color-mix(in srgb, var(--brand) 12%, transparent);
    /* M9: was 20px 22px, asymmetric for no documented reason. --sp-6 (24, 8pt-strict
       per team decision) on both axes. */
    padding: var(--sp-6);
}
.card-widget.p-0 { padding: 0; overflow: hidden; }
/* Per-card fetch feedback (replaces the full-page spinner for filter-driven refreshes,
   so a card that already finished doesn't sit dimmed just because a slower sibling hasn't). */
/* Per-card fetch feedback. เดิมใช้ opacity:.55 ซึ่งยังอ่านค่าเก่าออกทั้งช่วงที่รอ — วัดได้
   ว่า cache miss ใช้ 1.9-3.3 วินาที (2026-08-19) นานพอให้ผู้ใช้อ่านตัวเลขของตัวกรองก่อนหน้า
   แล้วเข้าใจว่าเป็นผลของตัวกรองใหม่ overlay ทับแทน โดย JS ไม่ต้องแก้เลย: cardLoading()
   toggle class เดิม และ textContent ยังอยู่ครบ (refreshKpis อ่านกลับไปตั้งต้น count-up) */
.card-widget.is-loading, .kpi.is-loading { position: relative; pointer-events: none; }
.card-widget.is-loading::after, .kpi.is-loading::after {
    content: ""; position: absolute; inset: 0; z-index: 2;
    border-radius: inherit; background: var(--surface);
    background-image: linear-gradient(90deg,
        rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, .45) 50%, rgba(255, 255, 255, 0) 100%);
    background-size: 200% 100%;
    animation: skeleton-sweep 1.2s ease-in-out infinite;
}
@keyframes skeleton-sweep { from { background-position: 200% 0; } to { background-position: -200% 0; } }
/* prefers-reduced-motion: หยุด sweep แต่คง overlay ไว้ — การปิดตัวเลขเก่าเป็นเรื่องความถูกต้อง
   ของข้อมูล ไม่ใช่ decoration จึงห้ามถอดทิ้งพร้อม animation */
@media (prefers-reduced-motion: reduce) {
    .card-widget.is-loading::after, .kpi.is-loading::after { animation: none; background-image: none; }
}
/* hero card มีพื้นเป็น gradient ม่วง ไม่ใช่ --surface — overlay สีการ์ดปกติจะเป็นแผ่นขาว
   กลางการ์ดม่วง ใช้ gradient ตัวเดียวกันซ้ำ ผ่าน var เพื่อไม่ให้สองที่หลุดจากกันเมื่อแก้สี */
.kpi.hero-card.is-loading::after { background: var(--hero-gradient); background-image: none; }
/* Keep stale state in the existing label line: the prior number remains useful and visible,
   while an overlay could cover it or the school-size filter controls. */
.kpi.is-stale { position: relative; }
.kpi-stale-badge {
    display: inline-flex; align-items: center; margin-inline-start: 6px;
    font-size: var(--fs-xs); line-height: 1; font-weight: 600; white-space: nowrap;
    color: #7a1f1f; background: #fde4e4;
    border: 1px solid #f3b7b7; border-radius: 999px; padding: 2px 6px;
}
.kpi-stale-badge[hidden] { display: none; }
:where(:root[data-bs-theme="dark"]) .kpi-stale-badge {
    color: #ffd9d9; background: #5a1f1f; border-color: #7d3030;
}
/* Overview renders at 80% zoom, so the shared 12px badge would otherwise land at
   9.6px effective. Keep this compensation local: /dashboard already renders the
   shared --fs-xs at its intended size. */
.overview-page .kpi-stale-badge { font-size: .9375rem; }
/* Chart cards spend their padding twice over: 24px top and bottom around a chart that
   is already inset by its own axis margins. 16px is enough separation and buys the
   chart 16px — measured 2026-08-17 at 1280x720, trend card 465px -> 401px with the
   tablist move above. :has() scopes this to cards that actually hold a chart, so the
   table/ranking cards keep the roomier 24px their dense text needs.
   That combined saving (this padding cut + the tablist move) is width-dependent, not a
   flat number at every width: at 768 the header row wraps to two lines and the gain
   drops to only 5px. The seven-day insight was moved to the trend card, so it no
   longer consumes any of this row's height at 1024. */
.card-widget:has(.hist-chart) { padding: 16px; }
/* The tablist now lives in the header row, where it must not stretch or add the row
   margin it carried as a standalone block. */
.trend-tabs { margin-bottom: 0; flex-wrap: nowrap; }
.card-title { font-family: "IBM Plex Sans Thai", sans-serif; font-size: var(--fs-lg); font-weight: 700; }
.card-sub { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; margin-bottom: .5rem; }
/* The piecewise map legend is DOM content, not an ECharts overlay: it takes card space
   above the canvas and wraps rather than covering the map on a narrow card. .9rem remains
   11.52px after /overview's .8 zoom, its established minimum effective reading size. */
.geo-map-legend { display: flex; flex-wrap: wrap; gap: 4px 8px; margin: -2px 0 8px; color: var(--muted); }
.geo-map-legend-chip { display: inline-flex; align-items: center; gap: 4px; min-width: 0; line-height: 1.2; }
.geo-map-legend-swatch { flex: 0 0 10px; width: 10px; height: 10px; border: 1px solid rgba(42,26,51,.25); border-radius: 2px; }
.geo-map-legend-label { font-size: .9rem; white-space: nowrap; }
.widget-title { font-family: "IBM Plex Sans Thai", sans-serif; font-size: 1rem; font-weight: 700; margin-bottom: 1rem; }
.card-empty {
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 6px; min-height: 120px; color: var(--muted); font-size: var(--fs-sm); text-align: center;
}

/* Grouped "view detail" modal (student/school search) */
.detail-section {
    background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius-sm);
    padding: 14px 18px;
}
.detail-section + .detail-section { margin-top: 1rem; }
.detail-section-title {
    font-family: "IBM Plex Sans Thai", sans-serif; font-weight: 700; font-size: .82rem; color: var(--brand-600);
    text-transform: uppercase; letter-spacing: .04em;
    margin-bottom: .8rem; padding-bottom: .4rem; border-bottom: 1px solid var(--line);
}
.detail-section-title i { margin-right: .35rem; }
/* flex (not grid) so a sparse last row (e.g. 2 items) doesn't stretch into leftover
   track space — items just sit at their natural width and wrap. */
.detail-grid { display: flex; flex-wrap: wrap; gap: 1rem 1.5rem; }
.detail-item { flex: 0 1 200px; }
.detail-item.detail-wide { flex: 1 1 100%; }
/* Long-but-not-full items (school name, department) share a row on wide screens,
   stack full-width on mobile — mobile-first so the default is the phone layout. */
.detail-item.detail-half { flex: 1 1 100%; }
@media (min-width: 768px) {
    .detail-item.detail-half { flex: 1 1 calc(50% - .75rem); }
}
.detail-item .detail-label { font-size: .78rem; color: var(--muted); margin-bottom: .15rem; }
.detail-item .detail-value { font-size: .95rem; font-weight: 600; color: var(--ink); word-break: break-word; }
.detail-item .detail-value.detail-mono { font-family: "Courier New", monospace; letter-spacing: .02em; }
.badge-status { display: inline-block; padding: .25rem .65rem; border-radius: 999px; font-size: .82rem; font-weight: 600; }
.badge-status-active { background: #DCFCE7; color: #15803D; }
.badge-status-inactive { background: #F1F0F4; color: var(--muted); }
/* L8: both pills are fully hardcoded (not var(--...)-based), so they don't
   inherit anything from the :root dark block above and need their own dark
   variant. Active pill reuses the same #4ADE80 green as .chg-up/.log-tag.tag-up
   below (one accessible dark-mode green across the whole page, not a second
   independent color); inactive pill switches to the (already-dark-themed)
   --surface-2/--muted tokens instead of its hardcoded light hex pair. */
:where(:root[data-bs-theme="dark"]) .badge-status-active { background: rgba(74, 222, 128, .16); color: #4ADE80; }
:where(:root[data-bs-theme="dark"]) .badge-status-inactive { background: var(--surface-2); color: var(--muted); }

/* stat cards reused on other pages */
.stat-card {
    background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
    padding: 1.1rem 1.25rem; display: flex; align-items: center; gap: 1rem; height: 100%;
}
.stat-icon { width: 52px; height: 52px; border-radius: .8rem; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; flex-shrink: 0; }
.stat-value { font-family: "Sora", sans-serif; font-size: 1.6rem; font-weight: 800; line-height: 1.1; color: var(--brand); }
.stat-label { color: var(--muted); font-size: .85rem; }

/* ----------------------------- Ranking list ------------------------------- */
.rank-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
.rank-row { display: grid; grid-template-columns: 24px minmax(80px,1.1fr) 1.4fr 80px; align-items: center; gap: 11px; padding: 9px 8px; border-radius: 10px; }
.rank-row:hover { background: var(--surface-2); }
.rank-no { font-family: "Sora", sans-serif; font-weight: 700; color: var(--muted); font-size: .9rem; text-align: center; }
.rank-name { font-weight: 500; font-size: var(--fs-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rank-bar-wrap { height: 10px; background: var(--surface-2); border-radius: 99px; overflow: hidden; }
.rank-bar { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--brand), #9A40B6); }
/* Reserve the trailing edge for the cross-filter funnel, which is absolutely
   positioned on .rank-row.  Keeping this on the value cell preserves the
   right-aligned number while ensuring the icon cannot paint over it. */
.rank-val { font-family: "Sora", sans-serif; font-weight: 700; font-size: 1rem; text-align: right; font-variant-numeric: tabular-nums; padding-right: 20px; }

/* --------------------------- Bootstrap accents ---------------------------- */
/* Gradient fill instead of one flat brand swatch, plus a brand-tinted shadow for lift —
   background/box-shadow only, so Bootstrap's own box model (and every place that measures
   a button's rect) is unaffected. --brand/--brand-600/--brand-700 are the same tokens in
   both themes on purpose (see the dark token block's comment), so this reads correctly —
   and needs no separate override — in dark mode too. */
.btn-primary {
    background: linear-gradient(180deg, var(--brand), var(--brand-600));
    border-color: var(--brand-600);
    box-shadow: 0 1px 2px rgba(42,26,51,.08), 0 6px 14px -8px rgba(81,28,104,.45);
}
.btn-primary:hover, .btn-primary:focus {
    background: linear-gradient(180deg, var(--brand-600), var(--brand-700));
    border-color: var(--brand-700);
}
.btn-success { background: var(--gold); border-color: var(--gold); color: #2a1a33; }
.btn-success:hover { background: var(--gold-soft); border-color: var(--gold-soft); color: #2a1a33; }
.btn-outline-primary { color: var(--brand); border-color: var(--brand); }
.btn-outline-primary:hover {
    background: var(--brand); border-color: var(--brand);
    box-shadow: 0 4px 10px -6px rgba(81,28,104,.40);
}
.page-link { color: var(--brand); }
.page-item.active .page-link { background: var(--brand); border-color: var(--brand); }
.table > :not(caption) > * > * { color: var(--ink); }
/* The rule above forces every table cell to --ink, which outranks the --bs-table-color that
   Bootstrap's .table-light sets. In dark mode --ink is #F0E8F5 while .table-light still pins
   --bs-table-bg to #f8f9fa, so the six thead.table-light headers in the app (master/detail x2,
   report-school, report-student, syncs, sync-detail) rendered at 1.13:1 — measured, effectively
   invisible. Repoint the header's own bg token at the dark surface instead: #F0E8F5 on #2C1D3A
   is 13.06:1. Light mode is untouched. */
:where(:root[data-bs-theme="dark"]) thead.table-light {
    --bs-table-bg: var(--surface-2);
    --bs-table-color: var(--ink);
    border-bottom-color: var(--line);
}
a { color: var(--brand); }

/* ----------------------------- Error / login ------------------------------ */
.login-body {
    display: flex; align-items: center; justify-content: center; min-height: 100vh;
    background: linear-gradient(135deg, var(--brand-700), var(--brand)); padding: 1rem;
}
.error-card { background: #fff; border-radius: 1rem; padding: 2rem; width: 100%; max-width: 420px; }
/* L8: this card declares no color of its own — it inherits body's color:var(--ink),
   which DOES flip dark in the :root block above, while this background stays a
   literal #fff. Left as-is that's near-white-text-on-white-background (~1.07:1) —
   the error page goes effectively blank. Same bug class as the loading overlays
   in Subsystem 8, just via a themed-text/unthemed-background pairing instead of
   the reverse. */
:where(:root[data-bs-theme="dark"]) .error-card { background: var(--surface); }

/* ---------------------------- Loading spinner ----------------------------- */
.loading-overlay {
    position: fixed; inset: 0; background: rgba(255,255,255,.6);
    display: none; align-items: center; justify-content: center; z-index: 1080;
}
.loading-overlay.is-active { display: flex; }
.spinner-border.text-primary { color: var(--brand) !important; }
/* L8: the near-opaque WHITE mask (rgba(255,255,255,.6)) paints a bright
   rectangle over a dark page during any full-page loading state; same for the
   spinner glyph itself (raw --brand only ~2.2:1 against dark surfaces — see the
   --brand-text note in the :root dark block). Dark mask tinted from --bg. */
:where(:root[data-bs-theme="dark"]) .loading-overlay { background: rgba(26, 16, 32, .7); }
:where(:root[data-bs-theme="dark"]) .spinner-border.text-primary { color: var(--brand-text) !important; }

/* DataTables processing indicator — center on viewport instead of top of table */
.dataTables_processing,
div.dt-processing {
    position: fixed !important;
    top: 50% !important;
    left: 50% !important;
    transform: translate(-50%, -50%) !important;
    width: auto !important;
    margin: 0 !important;
    padding: .6rem 1.25rem !important;
    background: rgba(255,255,255,.92) !important;
    border-radius: .5rem !important;
    box-shadow: 0 4px 16px rgba(0,0,0,.12) !important;
    font-size: .95rem !important;
    color: var(--brand) !important;
    z-index: 1079 !important;
}

/* These four pages relocate DataTables' processing node into the result card before its first
   draw. Keeping it outside the generated layout table preserves Bootstrap's table scrollport
   while the feedback remains centred in the card. */
.results-card { position: relative; }
.results-card > .dt-processing {
    position: absolute !important;
    top: 50% !important;
    left: 50% !important;
    transform: translate(-50%, -50%) !important;
    z-index: 5 !important;
}

/* Duplicate-student report: a quiet school list that opens into the person-to-school
   relationship. The only strong visual cue is the school selected from the list. */
.duplicate-report-heading { margin-bottom: var(--sp-4); }
.duplicate-report-context { max-width: 72ch; color: var(--muted); }
.duplicate-report-context span { white-space: nowrap; }
/* The one figure that must match the dashboard KPI. The per-school table below is a
   breakdown, not a running total — the same person is counted at each of their schools —
   so this is a bounded callout (gold spine, tint, info icon), not a caption, and the hint
   reads at body weight so no one misses why the column sums higher. */
.duplicate-report-total { margin-top: var(--sp-3); padding: 10px 14px; border: 1px solid var(--line); border-left: 3px solid var(--gold); border-radius: var(--radius-sm); background: var(--surface-2); }
.duplicate-report-total > .bi { margin-right: 5px; color: var(--gold); }
.duplicate-report-total strong { font-family: "Sora", sans-serif; font-weight: 700; font-size: var(--fs-lg); font-variant-numeric: tabular-nums; color: var(--ink); }
.duplicate-report-total .drt-unit { margin-left: 4px; font-weight: 600; color: var(--ink); }
.duplicate-report-total .drt-hint { display: block; max-width: 74ch; margin-top: 4px; color: var(--ink); font-size: var(--fs-md); }
/* Quiet pointer from the school report to its sibling — the nationwide สังกัด×สังกัด view */
.duplicate-report-crosslink { color: var(--muted); font-size: var(--fs-sm); }
.duplicate-report-crosslink > .bi { margin-right: 5px; color: var(--brand); }

/* Department-to-department overlap (its own page, /reports/duplicate-overlap): pick a สังกัด on
   the left, read its overlap partners as a ranked bar list on the right. Kept apart from the
   school list so the two counts are never read as one — the page's only "total" is the nationwide
   KPI callout; the bars are pairwise and never sum to it (a student in three สังกัด lands in
   every pair they form). */
.duplicate-overlap-page .duplicate-report-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: var(--sp-3); }
.dup-overlap-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); flex-shrink: 0; }
.dup-overlap-empty { font-size: var(--fs-sm); }
.dup-overlap { padding: var(--sp-4); }
.dup-overlap-note { max-width: 78ch; }
.dup-overlap-note-hint { display: block; margin-top: 3px; color: var(--muted); font-size: var(--fs-xs); }
.dup-overlap-body { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr); gap: var(--sp-4); align-items: start; }

/* left rail — the สังกัด picker, biggest overlap tally first; the number = the panel's own sum */
.dup-overlap-list { display: flex; flex-direction: column; gap: 2px; max-height: 22rem; overflow-y: auto; padding-right: 4px; }
.dup-overlap-dept { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 8px 10px; border: 1px solid transparent; border-radius: var(--radius-sm); background: none; color: var(--ink); text-align: left; cursor: pointer; }
.dup-overlap-dept:hover { background: var(--surface-2); }
.dup-overlap-dept:focus-visible { outline: 3px solid var(--brand); outline-offset: 1px; }
.dup-overlap-dept.is-active { border-color: var(--brand); background: color-mix(in srgb, var(--brand) 8%, var(--surface)); }
.dup-overlap-dept-name { font-weight: 600; font-size: var(--fs-sm); }
.dup-overlap-dept-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.dup-overlap-dept-code { color: var(--muted); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
.dup-overlap-dept-count { font-family: "Sora", sans-serif; font-weight: 700; font-variant-numeric: tabular-nums; font-size: var(--fs-sm); color: var(--ink); }
.dup-overlap-dept.is-active .dup-overlap-dept-count { color: var(--brand); }
.dup-overlap-panel-total { font-family: "Sora", sans-serif; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--ink); }

/* right panel — the ranked partners */
.dup-overlap-panel[hidden] { display: none; }
.dup-overlap-panel-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin: 0 0 var(--sp-3); color: var(--muted); font-size: var(--fs-sm); }
.dup-overlap-panel-head strong { color: var(--ink); font-size: var(--fs-md); font-weight: 700; }
.dup-overlap-bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.dup-overlap-bar { display: grid; grid-template-columns: minmax(10rem, 16rem) 1fr auto; align-items: center; gap: 12px; }
.dup-overlap-bar-label { display: flex; flex-direction: column; gap: 1px; min-width: 0; color: var(--ink); font-size: var(--fs-sm); }
.dup-overlap-bar-label > span:first-child { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; }
.dup-overlap-bar-code { color: var(--muted); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
.dup-overlap-bar-track { height: 10px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.dup-overlap-bar-fill { display: block; height: 100%; min-width: 2px; border-radius: inherit; background: var(--brand); }
.dup-overlap-bar-value { font-family: "Sora", sans-serif; font-weight: 700; font-variant-numeric: tabular-nums; font-size: var(--fs-sm); color: var(--ink); }
.dup-overlap-within { margin: var(--sp-3) 0 0; padding-top: var(--sp-3); border-top: 1px solid var(--line); color: var(--muted); font-size: var(--fs-sm); }
.dup-overlap-within strong { margin: 0 3px; color: var(--ink); font-family: "Sora", sans-serif; font-variant-numeric: tabular-nums; }
:where(:root[data-bs-theme="dark"]) .dup-overlap-dept.is-active { background: color-mix(in srgb, var(--brand) 24%, var(--surface)); }
:where(:root[data-bs-theme="dark"]) .dup-overlap-dept.is-active .dup-overlap-dept-count { color: var(--brand-text); }
/* --surface-2 barely separates from the card on the dark theme — give the empty track its own tint */
:where(:root[data-bs-theme="dark"]) .dup-overlap-bar-track { background: rgba(255, 255, 255, .09); }

@media (max-width: 768px) {
    /* stay a vertical list — a horizontal pill rail of long Thai สังกัด names hides all but one */
    .dup-overlap-body { grid-template-columns: 1fr; }
    .dup-overlap-list { max-height: 17rem; }
    .dup-overlap-bar { grid-template-columns: 1fr auto; column-gap: 10px; }
    .dup-overlap-bar-label > span:first-child { -webkit-line-clamp: 3; line-clamp: 3; }
    .dup-overlap-bar-track { grid-column: 1 / -1; order: 3; }
}

.duplicate-filter { padding: var(--sp-4); }
.duplicate-report #resultsCard { min-height: 246px; }
.duplicate-table-scroll { overflow-x: auto; }
.duplicate-school-table { min-width: 720px; --bs-table-hover-bg: rgba(122, 46, 157, .06); }
.duplicate-school-table .duplicate-sequence { width: 4.5rem; color: var(--muted); text-align: center; }
.dup-school-name { font-weight: 700; color: var(--ink); }
.dup-school-code { margin-top: 1px; color: var(--muted); font-size: var(--fs-xs); }
.dup-count { font-family: "Sora", sans-serif; font-variant-numeric: tabular-nums; font-weight: 700; white-space: nowrap; }
.duplicate-detail-modal .modal-content { border-color: var(--line); background: var(--surface); }
.duplicate-detail-modal .modal-header { align-items: flex-start; border-bottom-color: var(--line); }
.duplicate-modal-kicker { color: var(--muted); font-size: var(--fs-xs); font-weight: 600; }
.duplicate-detail-body { background: var(--surface-2); }
.duplicate-detail-empty { color: var(--muted); }
.duplicate-detail-empty .bi { display: block; margin-bottom: var(--sp-2); font-size: 1.75rem; }
/* One review case = one national ID. The reviewer's job here is a single judgment:
   one student enrolled twice, or two students sharing a mistyped ID? Layout serves that —
   a verdict strip states the call, then aligned columns let the eye run straight down the
   fields (name first, school's own code demoted) to confirm or overturn it. */
.duplicate-detail-modal .dup-case { padding: var(--sp-4); background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); }
.duplicate-detail-modal .dup-case + .dup-case { margin-top: var(--sp-3); }
.duplicate-detail-modal .dup-case-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 3px 10px; }
.duplicate-detail-modal .dup-case-label { color: var(--muted); font-size: var(--fs-xs); font-weight: 600; }
.duplicate-detail-modal .dup-case-id { font-family: "Sora", sans-serif; font-weight: 700; font-size: var(--fs-lg); letter-spacing: .05em; font-variant-numeric: tabular-nums; color: var(--ink); }
/* One card is one person = one unit of the dashboard count. The columns below are that
   person's enrolments, always two or more — this line stops the eye counting columns. */
.duplicate-detail-modal .dup-case-scope { margin-top: 3px; color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }

/* Verdict — the one place this modal raises its voice. Colour encodes state, never brand:
   caution amber for an identity clash, quiet green for agreement. The 'unknown' state is
   deliberately left on this neutral grey base — there is no .dup-verdict--unknown rule. */
.duplicate-detail-modal .dup-verdict { display: flex; gap: 10px; margin-top: var(--sp-3); padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
.duplicate-detail-modal .dup-verdict > .bi { flex: none; font-size: 1.05rem; line-height: 1.35; color: var(--muted); }
.duplicate-detail-modal .dup-verdict-title { font-weight: 700; color: var(--ink); }
.duplicate-detail-modal .dup-verdict-diff { margin-top: 3px; color: var(--ink); font-size: var(--fs-sm); font-weight: 600; }
.duplicate-detail-modal .dup-verdict--match .dup-verdict-diff { color: var(--muted); font-weight: 400; }
.duplicate-detail-modal .dup-verdict-hint { margin-top: 2px; color: var(--muted); font-size: var(--fs-xs); }
.duplicate-detail-modal .dup-verdict-names { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; color: var(--ink); font-size: var(--fs-sm); font-weight: 600; }
.duplicate-detail-modal .dup-verdict-name { padding: 1px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); }
.duplicate-detail-modal .dup-verdict--mismatch { border-color: color-mix(in srgb, var(--chart-warning) 45%, var(--line)); background: color-mix(in srgb, var(--chart-warning) 12%, var(--surface)); }
.duplicate-detail-modal .dup-verdict--mismatch > .bi { color: var(--chart-warning); }
.duplicate-detail-modal .dup-verdict--match { border-color: color-mix(in srgb, var(--chart-positive) 35%, var(--line)); background: color-mix(in srgb, var(--chart-positive) 9%, var(--surface)); }
.duplicate-detail-modal .dup-verdict--match > .bi { color: var(--chart-positive); }

/* Columns — one per enrolment. Every column shares the same field order so the rows line up
   across the row and a difference reads as a straight horizontal jog. */
/* subgrid: every column's head lands in the shared first row and every field grid in the
   second, so the rows line up across columns whatever a school name's length — no magic
   min-height, and the mobile block below drops back to a plain stack. */
.duplicate-detail-modal .dup-cols { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(240px, 1fr); grid-template-rows: auto 1fr; gap: var(--sp-3); margin-top: var(--sp-3); overflow-x: auto; }
.duplicate-detail-modal .dup-col { position: relative; display: grid; grid-row: 1 / -1; grid-template-rows: subgrid; min-width: 0; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); overflow-wrap: anywhere; }
.duplicate-detail-modal .dup-col.is-anchor { border-color: var(--gold); box-shadow: inset 3px 0 0 var(--gold); }
.duplicate-detail-modal .dup-col.is-anchor .dup-col-head { padding-right: 7rem; }
.duplicate-detail-modal .dup-col-head { padding: 10px 12px; border-bottom: 1px solid var(--line); }
/* absolute so it never adds head height — subgrid then keeps a badge-less column flush,
   no reserved whitespace under a one-line school name. */
.duplicate-detail-modal .dup-col-badge { position: absolute; top: 8px; right: 8px; padding: 1px 7px; border-radius: 999px; background: #FCE9B6; border: 1px solid #EAC35D; color: #6C4B00; font-size: .72rem; font-weight: 700; white-space: nowrap; }
.duplicate-detail-modal .dup-col-school { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--ink); font-weight: 700; }
.duplicate-detail-modal .dup-col-sub { margin-top: 1px; color: var(--muted); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
.duplicate-detail-modal .dup-fields { margin: 0; padding: 6px 8px 10px; }
.duplicate-detail-modal .dup-field { display: grid; grid-template-columns: 5.25rem 1fr; gap: 8px; padding: 5px 4px; border-top: 1px solid var(--line); }
.duplicate-detail-modal .dup-field:first-child { border-top: 0; }
.duplicate-detail-modal .dup-field dt { color: var(--muted); font-size: var(--fs-xs); font-weight: 600; }
.duplicate-detail-modal .dup-field dd { margin: 0; color: var(--ink); font-size: var(--fs-sm); }
.duplicate-detail-modal .dup-field dd.is-numeric { font-family: "Sora", sans-serif; font-variant-numeric: tabular-nums; letter-spacing: .01em; }
.duplicate-detail-modal .dup-field dd.is-muted { color: var(--muted); font-style: italic; }
.duplicate-detail-modal .dup-field.is-key dd { font-weight: 700; }
/* contained highlight, no negative margin: a left-bleeding wash would paint over
   .dup-col.is-anchor's inset gold spine and notch it for the row's height. */
.duplicate-detail-modal .dup-field.is-diff { background: color-mix(in srgb, var(--chart-warning) 13%, transparent); box-shadow: inset 3px 0 0 var(--chart-warning); border-radius: 4px; }
.duplicate-detail-modal .dup-field.is-diff dt { color: color-mix(in srgb, var(--chart-warning) 78%, var(--ink)); }
/* consecutive differing fields (name + birth date sit next to each other) read as one block */
.duplicate-detail-modal .dup-field.is-diff:has(+ .dup-field.is-diff) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
.duplicate-detail-modal .dup-field.is-diff + .dup-field.is-diff { border-top-color: transparent; border-top-left-radius: 0; border-top-right-radius: 0; }
.duplicate-report .dup-detail-trigger:focus-visible,
.duplicate-detail-modal .btn-close:focus-visible,
.duplicate-detail-modal .btn:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
:where(:root[data-bs-theme="dark"]) .duplicate-detail-modal .dup-col-badge { color: #FFE8A1; background: #5C4308; border-color: #98751A; }
:where(:root[data-bs-theme="dark"]) .duplicate-detail-modal .dup-verdict-name { background: var(--surface); }
:where(:root[data-bs-theme="dark"]) .duplicate-report .dup-detail-trigger:focus-visible,
:where(:root[data-bs-theme="dark"]) .duplicate-detail-modal .btn-close:focus-visible,
:where(:root[data-bs-theme="dark"]) .duplicate-detail-modal .btn:focus-visible { outline-color: var(--brand-text); }

@media (max-width: 575px) {
    .duplicate-report-heading { margin-bottom: var(--sp-3); }
    .duplicate-report-context span { display: block; margin-top: 2px; white-space: normal; }
    .duplicate-filter { padding: var(--sp-3); }
    .duplicate-report #resultsCard { padding: var(--sp-3); }
    .duplicate-detail-modal .modal-dialog { margin: 0; min-height: 100%; }
    .duplicate-detail-modal .modal-content { min-height: 100dvh; border-radius: 0; }
    .duplicate-detail-modal .modal-body { padding: var(--sp-3); }
    .duplicate-detail-modal .dup-case { padding: var(--sp-3); }
    .duplicate-detail-modal .dup-cols { grid-auto-flow: row; grid-auto-columns: auto; grid-template-rows: none; overflow-x: visible; }
    .duplicate-detail-modal .dup-col { grid-row: auto; grid-template-rows: none; }
    .duplicate-detail-modal .dup-col.is-anchor .dup-col-head { padding-right: 12px; }
    .duplicate-detail-modal .dup-col-badge { position: static; display: inline-block; margin-bottom: 6px; }
    .duplicate-detail-modal .dup-field { grid-template-columns: 4.5rem 1fr; }
}

/* L8: same near-opaque WHITE badge problem as .loading-overlay above (DataTables'
   OWN "Processing..." indicator, shown during ajax draws on the /schools grid) —
   every declaration here carries !important, so the dark override must too or
   the light rule wins regardless of source order/specificity. */
:where(:root[data-bs-theme="dark"]) .dataTables_processing,
:where(:root[data-bs-theme="dark"]) div.dt-processing {
    background: rgba(36, 23, 48, .92) !important;
    color: var(--brand-text) !important;
}

/* ----------------------------- Change cells ------------------------------- */
.chg { font-variant-numeric: tabular-nums; font-weight: 600; }
.chg-up { color: #15803d !important; }
.chg-down { color: #dc2626 !important; }
.chg-flat { color: var(--muted); }
/* L8: #15803d (green) / #dc2626 (red) were calibrated for a light surface and
   read as too-dark/muddy on the new dark --surface/--bg. #4ADE80/#F87171
   (common accessible dark-theme green/red) verified via the WCAG relative-
   luminance formula: #4ADE80 clears 9.72:1 on dark --surface / 10.58:1 on dark
   --bg; #F87171 clears 6.12:1 on dark --surface / 6.67:1 on dark --bg — both
   comfortably above the 4.5:1 text floor. */
:where(:root[data-bs-theme="dark"]) .chg-up { color: #4ADE80 !important; }
:where(:root[data-bs-theme="dark"]) .chg-down { color: #F87171 !important; }

/* --------------------------- History table -------------------------------- */
.hist-table th { font-size: .82rem; color: var(--muted); font-weight: 600; }
.hist-table td { font-size: .92rem; }
.hist-chart { height: 240px; }
.hist-table-details { margin-top: var(--sp-4); }
.hist-table-summary {
    cursor: pointer; color: var(--brand); font-size: var(--fs-sm); font-weight: 600;
    padding: 6px 0; list-style: none; display: inline-flex; align-items: center; gap: 6px;
}
.hist-table-summary::before { content: "▸"; font-size: 11px; transition: transform .15s; }
.hist-table-details[open] > .hist-table-summary::before { transform: rotate(90deg); }
.hist-table-summary:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.hist-table-details .table-responsive { margin-top: var(--sp-3) !important; }

/* Both disclosure controls color themselves with --brand (#7A2E9D), which has no dark
   variant — measured 2.18:1 on the dark surface, the only text on the page under the
   4.5:1 floor. --brand-text (#B583D2) is the dark-mode brand tone that already exists
   for exactly this, and clears 5.76:1. Must sit AFTER both base rules: :where() keeps
   the theme selector at zero specificity, so source order is what decides. */
:where(:root[data-bs-theme="dark"]) .hist-table-summary,
:where(:root[data-bs-theme="dark"]) .geo-table-summary { color: var(--brand-text); }
.retire-drill { cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
/* Was background: var(--bg) — decoupled (2026-09-10) so this row's fill doesn't ride
   whatever the page canvas token is tuned to. It also fixes a pre-existing gap: the
   .retire-cause-error red (#dc2626) inside this row measured only 4.25:1 on the old --bg,
   under the 4.5:1 floor for its 13.6px text; on --surface-2 it clears 4.58:1. */
.retire-breakdown-row td { background: var(--surface-2); border-top: 1px dashed var(--line); padding: 14px 16px; }
.retire-cause-loading, .retire-cause-error { font-size: .85rem; color: var(--muted); }
.retire-cause-error { color: #dc2626; }
:where(:root[data-bs-theme="dark"]) .retire-cause-error { color: #F87171; }   /* same dark-mode red as .chg-down */

.retire-cause-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 10px; }
.retire-cause-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 10px 12px; }
.retire-cause-card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.retire-cause-status { font-weight: 600; font-size: .88rem; color: var(--ink); }
.retire-cause-count { font-variant-numeric: tabular-nums; font-weight: 700; font-size: .95rem; color: var(--brand-700); white-space: nowrap; }
.retire-cause-count small { font-weight: 500; font-size: .72rem; color: var(--muted); }
.retire-cause-bar { height: 4px; background: var(--bg); border-radius: 2px; overflow: hidden; }
.retire-cause-bar-total { margin-top: 6px; }
.retire-cause-bar-fill { height: 100%; background: var(--brand); border-radius: 2px; }
.retire-cause-list { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--line); display: flex; flex-direction: column; gap: 6px; }
.retire-cause-line-top { display: flex; justify-content: space-between; gap: 8px; font-size: .78rem; color: var(--muted); }
.retire-cause-line-count { font-variant-numeric: tabular-nums; }
.retire-cause-line .retire-cause-bar-fill { background: var(--gold); }

/* ------------------------- Update log timeline ---------------------------- */
.log-list { list-style: none; margin: 0; padding: 4px 0 0; }
.log-item { position: relative; padding: 0 0 18px 26px; }
.log-item::before {
    content: ""; position: absolute; left: 7px; top: 6px; bottom: -2px; width: 2px; background: var(--line);
}
.log-item:last-child::before { display: none; }
.log-dot {
    position: absolute; left: 0; top: 4px; width: 16px; height: 16px; border-radius: 50%;
    background: var(--surface); border: 3px solid var(--brand); z-index: 1;
}
.log-when { font-size: .82rem; color: var(--muted); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.log-title { font-weight: 600; margin-top: 2px; }
.log-desc { font-size: .9rem; color: var(--muted); margin-top: 2px; }
.log-tag { font-size: .72rem; font-weight: 600; padding: 1px 9px; border-radius: 999px; }
.log-tag.tag-up { background: rgba(21,128,61,.12); color: #15803d; }
.log-tag.tag-down { background: rgba(220,38,38,.12); color: #dc2626; }
.log-tag.tag-flat { background: var(--surface-2); color: var(--muted); }
/* L8: same green/red pair + reasoning as .chg-up/.chg-down above (duplicated
   here per CLAUDE.md's note that these are the same semantic colors used
   twice in this file). Tint background upgraded to match the new text color's
   hue at low alpha so the pill doesn't look mismatched. */
:where(:root[data-bs-theme="dark"]) .log-tag.tag-up { background: rgba(74,222,128,.16); color: #4ADE80; }
:where(:root[data-bs-theme="dark"]) .log-tag.tag-down { background: rgba(248,113,113,.16); color: #F87171; }

/* modal title accent */
.modal-title i { color: var(--brand); margin-right: 6px; }

/* Filter bar sticks to top of viewport on scroll. Negative margins + padding
   let the opaque background span the .content side padding so cards don't
   show through behind it.
   Placed BEFORE the "Responsive" section below on purpose: the 1024px/768px
   media queries there override this same margin/padding for narrower
   viewports, and CSS breaks same-specificity ties by source order, not by
   which media condition is narrower. With this rule declared after those
   overrides (as it was before), it always won regardless of viewport width,
   so phones inherited the desktop -26px margin and overflowed horizontally
   by ~12px (measured: 375-wide viewport, .filter-sticky right edge at 387px).
   Keep this block above the Responsive section, not below it. */
.filter-sticky {
    position: sticky;
    top: var(--navbar-h, 69px); /* sit just below the sticky navbar (JS keeps this in sync) */
    z-index: 20;
    background: var(--bg);
    margin: 0 -26px;
    padding: 6px 26px 4px;
    box-shadow: 0 8px 12px -10px rgba(42,26,51,.25);
}
/* On /schools the grid heading owns the page's sticky region; the filter remains
   a normal, collapsible control and its URL/filter behavior is unchanged. */
.schools-filter { position: static; top: auto; z-index: auto; }

/* --------------------------- Pinned filter bar ---------------------------- */
/* Dashboard only. The seven slicers drive every visual on the page, so the panel STARTS
   open (JS, see initCrossFilter) — but it stays collapsible everywhere, desktop included.
   schools.html reuses the same .filter-sticky/.filter-details markup WITHOUT this class
   and starts collapsed instead, so the class is the whole scoping mechanism.
   Two rail shapes were built and rejected before this (see the template comment): both
   put a bordered ~278px card in a much taller 280px column, and the empty remainder read
   as a card that had failed to load. A full-width bar has no such void, and .filter-sticky
   above already makes it follow the scroll. */

/* Desktop: the <summary> shares the slicers' flex row (they wrap on their own) instead of
   taking a row of its own, so the collapse control costs 0px while open. Measured at 1440:
   sidebar open (slicers wrap to 2 rows) bar = 84px open / 50px closed — same 84px as before
   the toggle existed. Sidebar collapsed (slicers fit 1 row) 51px / 50px: collapsing is worth
   almost nothing there, which is fine — a row of its own would have cost +44px on EVERY
   viewport, and most users never collapse. */
@media (min-width: 1025px) {
    .filter-pinned .filter-details { display: flex; align-items: flex-start; gap: .6rem; }
    /* Lowering the global 44px min-height (WCAG 2.5.5 AAA / touch) to the 2.5.8 AA floor of
       24px is load-bearing, not cosmetic: this is a mouse-only breakpoint, and a 44px item
       on a 29px one-row flex line stretches the whole bar 51px -> 68px (measured). */
    .filter-pinned .filter-toggle { flex: none; min-height: 24px; margin-top: 2px; white-space: nowrap; }
    /* Bootstrap's .mt-1/.mb-1 are dropped here on purpose: a flex item's margins do NOT
       collapse into .filter-details the way they did while it was a block, which made the
       open bar 4px TALLER than before the toggle existed. Zeroing both keeps the open state
       pixel-identical to the pre-toggle layout (measured at 1440, sidebar open and closed);
       #filterChips carries its own .mt-1 for the gap below. !important is required to
       outrank Bootstrap's own .mt-1/.mb-1 !important, same as the .d-flex case below. */
    .filter-pinned .slicer-bar { flex: 1 1 0; min-width: 0; margin-block: 0 !important; }
}

/* ------------------------------ Responsive -------------------------------- */
/* Desktop: pushing the sidebar out collapses it (content reflows to full width). */
.app-wrapper.collapsed .app-sidebar { margin-left: calc(-1 * var(--sidebar-width)); }

/* Backdrop is inert on desktop; the tablet/phone media query below switches it on. */
.sidebar-backdrop { display: none; }

/* Tablet & phone — including iPad portrait (768–834px) and landscape (1024px).
   The sidebar becomes an off-canvas drawer overlaying the content with a
   tap-to-close backdrop. Breakpoint mirrors TABLET_MAX in dashboard.js. */
@media (max-width: 1024px) {
    .app-sidebar {
        position: fixed; top: 0; left: 0; height: 100%;
        z-index: 1045; margin-left: calc(-1 * var(--sidebar-width));
    }
    /* `.show` wins over `.collapsed` here by source order (both same specificity). */
    .app-wrapper.show .app-sidebar { margin-left: 0; box-shadow: 0 0 40px rgba(0,0,0,.4); }

    .sidebar-backdrop {
        display: block; position: fixed; inset: 0; z-index: 1044;
        background: rgba(20,8,28,.45);
        opacity: 0; visibility: hidden; transition: opacity .2s ease, visibility .2s ease;
    }
    .app-wrapper.show .sidebar-backdrop { opacity: 1; visibility: visible; }
}

/* Tablet: tighten the content gutters and let the charts grid stack early. */
@media (max-width: 1024px) {
    .content { padding: 18px 18px 48px; }
    .filter-sticky { margin: 0 -18px; padding-left: 18px; padding-right: 18px; }
    /* Below desktop the summary stacks ABOVE the slicers again (the flex row above is
       desktop-only) and the panel starts collapsed: seven slicers wrap to one row each
       here (measured 228px, see the .filter-details:not([open]) note further down), far
       too much to pin open above the fold on a phone. */
    .navbar-title { font-size: .98rem; }
}

/* Phone & small tablet: stack the hero, shrink type, make controls full-width. */
@media (max-width: 768px) {
    .hero { padding: 18px 18px; }
    /* R7 follow-up: this used to step DOWN from a 40px desktop title. Desktop is 22px now,
       so 1.5rem (24px) would make the phone title bigger than the desktop one — and bigger
       than it needs to be next to a KPI that also has to shrink here. */
    .hero-title { font-size: 1.25rem; }
    .hero-main { flex-direction: column; align-items: flex-start; }
    .hero-meta { align-items: flex-start; width: 100%; }
    .content { padding: 14px 14px 40px; }
    .filter-sticky { margin: 0 -14px; padding-left: 14px; padding-right: 14px; }
    .navbar-title { font-size: .9rem; }
    .navbar-env { display: none; }                  /* free up width on the narrow navbar */
    .navbar-user { display: none; }                 /* hide name on narrow; keep the logout button */
    .what-changed { grid-template-columns: 1fr; gap: var(--sp-3); }
    .what-changed-status { grid-column: auto; grid-row: auto; }
    /* Three boxes, not four, since net moved into the card title (R2) — repeat(2)
       left "คืนสถานะ" alone on a half-width second row. Three fit: at 375 each track
       is ~96px against a ~55px widest value ("-1,323" at 18px Sora). */
    .what-changed-metrics { grid-column: auto; grid-row: auto; grid-template-columns: repeat(3, minmax(0, 1fr)); }
    /* On a phone this is a compact flow ledger, not three stacked mini-cards: show every
       source in one glance so the school-size card enters the first viewport sooner. */
    .kpi.hero-card.dashboard-hero-card .what-changed { gap: var(--sp-3); }
    .kpi.hero-card.dashboard-hero-card .what-changed-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
    .kpi.hero-card.dashboard-hero-card .what-changed-metric {
        display: grid; grid-template-columns: 1fr; justify-items: center; gap: 2px; text-align: center;
    }
    /* Desktop's flex layout pushes the number right via margin-left:auto (line ~500) — inside
       this grid that same margin shoves the grid item to the track's right edge instead of
       letting justify-items:center apply, so the number drifted out from under its own label. */
    .kpi.hero-card.dashboard-hero-card .what-changed-metric strong { margin-left: 0; }
    .kpi.hero-card.dashboard-hero-card .what-changed-metric[data-flow]::before { width: 18px; height: 2px; align-self: auto; }
    .kpi.hero-card.dashboard-hero-card .what-changed-metric .what-changed-label { font-size: 11px; white-space: nowrap; }
    /* Measured 2026-08-14 at 375px: the name column resolved to 80px against 276px of text,
       and three of the four departments start with "สำนักงานคณะกรรมการ" — every row truncated
       to the same prefix, with the full string reachable only through title= (dead on touch).
       The bar is the cheapest column to give up: this list is four rows long and prints the
       value next to it, so the bar was re-encoding a number already on screen. */
    .app-wrapper:not(.overview-page) .rank-row { grid-template-columns: 24px minmax(0, 1fr) auto; }
    .app-wrapper:not(.overview-page) .rank-row .rank-bar-wrap { display: none; }
    .app-wrapper:not(.overview-page) .rank-name { white-space: normal; overflow: visible; text-overflow: clip; }
    .app-wrapper:not(.overview-page) .rank-val { padding-right: 20px; }
    /* Same move for /dashboard's Top-10 school list: the 3-across row (name | bar | value)
       resolved the name to ~5 Thai characters on a phone, so every school read "วิทยาลัย…".
       School names run longer than the dept names above, so dropping the re-encoding bar and
       letting the name wrap matters even more here. Scoped to the id — /overview's own
       .top10-row rules keep their stacked-bar layout. */
    #dashboardTop10Schools .top10-row { grid-template-columns: minmax(0, 1fr) auto; }
    #dashboardTop10Schools .top10-bar-wrap { display: none; }
    #dashboardTop10Schools .top10-name { white-space: normal; overflow: visible; text-overflow: clip; }
    .slicer-bar .form-select { flex: 1 1 100%; min-width: 0; width: 100% !important; }
    .slicer-bar .ts-wrapper { flex: 1 1 100%; min-width: 0; width: 100% !important; }
}

/* The KPI row stacks only below 640px, not at 768. At tablet portrait the hero and the
   size card still fit side by side (measured at 768x1024: hero 387px, size card 322px, no
   overflow and nothing clipped) and stacking them there cost 219px of vertical space that
   the trend chart needed — it sat at y=1015 against a 1024px fold, i.e. 9px of a 240px
   chart visible. With the row kept two-wide: .kpis 628px -> 409px, chart top -> 795,
   229px of it above the fold, page 3,526 -> 3,305. Checked down to 722px: still no
   overflow, .k-val still one line at 40px.
   The size-band full-width treatment above deliberately did NOT move with these rules —
   it must keep applying through 768 or each band's caption is clipped (measured: 12px). */
@media (max-width: 640px) {
    .kpis { display: flex; flex-direction: column; }
    .kpis > .kpi.hero-card { order: 1; }
    .kpis > .kpi.size-card { order: 3; }
    .kpi.size-card { grid-column: 1; grid-row: auto; }
}

/* LIVE readout on /dashboard: same strip as /overview's phone header — drop
   .badge-pill's capsule chrome, span the page-head row, lamp + clock on line 1 and
   the sync date on line 2 (order-only swap; DOM stays lamp -> date -> clock).
   Its own block so theKpiRowKeepsTwoColumnsThroughTabletPortrait can keep asserting
   the KPI-stacking block above byte-for-byte. */
@media (max-width: 640px) {
    .page-head-meta { width: 100%; }
    .page-head .badge-pill { background: none; border: none; padding: 0; width: 100%; }
    .page-head .live-badge .live-time { order: 1; }
    .page-head .live-badge .live-asof { order: 2; flex-basis: 100%; }
}

/* Tiny phones: the geo map + its side table are inline-styled flex children with
   min-width:300px/260px — wider than a ~320px viewport. Let them shrink and stack
   full-width (flex-wrap already stacks them; this just drops the min-width floor).
   SCOPED TO .geo-layout ON PURPOSE: /overview reuses the same #geoMap id inside a
   COLUMN flex panel, where flex-basis is the MAIN size and therefore outranks the
   `height: 360px !important` the stacked-overview media query sets further down.
   Unscoped, this rule collapsed that map to a 2px canvas at 375px (measured
   2026-08-14) while every other /overview panel rendered normally. */
@media (max-width: 560px) {
    .geo-layout #geoMap, .geo-layout #geoMap + div { min-width: 0 !important; max-width: none !important; flex-basis: 100% !important; }
}

/* Phones: .navbar-text is the only flex item with min-width:0, so it absorbs all the
   overflow from the sidebar toggle + logo + theme toggle + login button (none of which
   shrink) — measured fixed budget is exactly 291px, so available width = viewport-291.
   Below that, show nothing rather than a truncated/ellipsized brand name — every page
   repeats its own title/hero heading below the navbar, so dropping the brand text here
   loses no information.
   470 was the exact fit for the old title "ระบบข้อมูลนักเรียน RealTime" (180px natural ->
   291+180 = 471). That title lost the word นักเรียน (it duplicated the page heading one
   row below, which still carries it), so at 132px it is no longer the widest line: the
   subtitle is, at 167px, needing only 458px. The threshold is therefore ~12px
   conservative now and deliberately left alone — it hides the brand slightly early on a
   458-470px viewport, which costs nothing given the heading below. Re-measure both lines
   before moving it; whichever is wider sets the number. */
@media (max-width: 470px) {
    .navbar-text { display: none; }
    /* Same move as .pwa-install span at 575.98px: drop the auth button's label so the
       icon-only view switcher never pushes it off the (non-wrapping) navbar. Both
       buttons carry an aria-label, so the accessible name survives. */
    .navbar-auth-label { display: none; }
}

/* --------------- Schools table: drill-down (child row) -------------------- */
/* ID-scoped to the dashboard's per-school grid; no effect on other pages.   */
#schoolsTable td.dt-control { cursor: pointer; user-select: none; }
#schoolsTable td.dt-control::before {
    content: '\002B';            /* + */
    display: inline-flex; align-items: center; justify-content: center;
    width: 1.5em; height: 1.5em; border-radius: 50%;
    background: var(--surface-2); border: 1px solid var(--line);
    color: var(--brand); font-weight: 700; line-height: 1;
    transition: background .15s, color .15s, border-color .15s;
}
#schoolsTable tbody tr:hover td.dt-control::before { border-color: rgba(122, 46, 157, .35); }
#schoolsTable tr.shown td.dt-control::before {
    content: '\2212';            /* − */
    background: var(--brand); border-color: var(--brand); color: #fff;
}
#schoolsTable tr.shown > td { background-color: rgba(122, 46, 157, .06); }

/* ---- Main schools grid: branded header, hover, badges, numbers ---------- */
#schoolsTable { --bs-table-hover-bg: rgba(122, 46, 157, .06); }
/* Do not use Bootstrap's .table-responsive here: its overflow scrollport would
   make the header sticky relative to that wrapper rather than the page. */
#schools .schools-table-container {
    overflow: visible;
}
/* Tablet widths keep table columns readable inside a local horizontal scroller.
   The range stops before desktop so the page-sticky table heading keeps its viewport
   containing block where the full grid fits. */
@media (min-width: 576px) and (max-width: 1024px) {
    #schools .schools-table-container { overflow-x: auto; max-width: 100%; }
    #schoolsTable { min-width: 820px; }
}
#schoolsTable thead th {
    position: sticky;
    top: var(--navbar-h);
    z-index: 2;
    background: var(--surface-2);
    color: var(--brand-700);
    font-weight: 700;
    border-bottom: 2px solid var(--brand);
    white-space: nowrap;
    padding-top: .6rem; padding-bottom: .6rem;
}
#exportSchoolsBtn {
    background: var(--gold);
    border-color: var(--gold);
    color: var(--brand-700);
    font-weight: 700;
}
#exportSchoolsBtn:hover, #exportSchoolsBtn:focus-visible {
    background: var(--brand);
    border-color: var(--brand);
    color: #fff;
}
#schoolsTable tbody td { border-bottom-color: var(--line); vertical-align: middle; }
#schoolsTable td.sch-num {
    font-weight: 600; color: var(--brand-700);
    font-variant-numeric: tabular-nums;
}
/* Per-school "สถิติย้อนหลัง" trigger — a filled brand-tinted circle so it reads as a
   clickable button at a glance, not a faded link easy to miss next to the school name. */
#schoolsTable .school-hist-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    width: 26px;
    height: 26px;
    padding: 0;
    margin: 0;
    line-height: 1;
    border-radius: 50%;
    background: rgba(122, 46, 157, .12);
    color: var(--brand);
    border: none;
    font-size: .8rem;
    cursor: pointer;
    transition: background .15s ease, color .15s ease, transform .15s ease;
}
#schoolsTable .school-hist-btn:hover,
#schoolsTable .school-hist-btn:focus-visible {
    background: var(--brand);
    color: #fff;
    transform: scale(1.08);
}
/* DataTables controls on-brand (scoped to this grid only) */
#schoolsTable_wrapper .page-item.active .page-link {
    background: var(--brand); border-color: var(--brand);
}
#schoolsTable_wrapper .page-link { color: var(--brand-700); }
/* ...but NOT the current page's own pill: the rule above (1,1,0 via the #id)
   beats Bootstrap's own `.active > .page-link { color:#fff }` (0,2,0), so the
   current page number rendered --brand-700 text on the --brand fill set two
   rules up — 1.58:1, effectively invisible. Restore white on the solid fill.
   Must come AFTER the rule above (higher specificity here, but keep the order
   obvious). The dark-mode counterpart is handled by the `:not(.active)` in the
   brand-text swap block at the end of this file, which leaves the white alone. */
#schoolsTable_wrapper .page-item.active .page-link { color: #fff; }
#schoolsTable_wrapper .page-link:focus {
    box-shadow: 0 0 0 .2rem rgba(122, 46, 157, .25);
}
#schoolsTable_wrapper .form-control:focus,
#schoolsTable_wrapper .form-select:focus {
    border-color: var(--brand);
    box-shadow: 0 0 0 .2rem rgba(122, 46, 157, .18);
}

#schools .drill-box {
    padding: .75rem .9rem;
    margin: .35rem .25rem;
    background: linear-gradient(180deg, #faf7fc 0%, #ffffff 70%);
    border: 1px solid rgba(122, 46, 157, .15);
    border-left: 3px solid #7A2E9D;
    border-radius: 10px;
}
#schools .drill-empty {
    color: #6c757d; font-size: .85rem;
    display: flex; align-items: center; gap: .4rem;
}
#schools .drill-head {
    display: flex; align-items: center; gap: .4rem;
    font-weight: 600; color: #511C68; font-size: .85rem;
    margin-bottom: .6rem;
}
/* grid: badge col auto-sizes to widest label; grades flex-fill; total pinned right */
#schools .drill-groups-grid {
    display: grid;
    grid-template-columns: max-content 1fr max-content;
    gap: .35rem .6rem;
    align-items: center;
    margin-bottom: .1rem;
}
/* display:contents lifts badge/grades/total into the parent grid as direct items */
#schools .drill-group { display: contents; }
#schools .drill-level {
    font-weight: 600; color: #511C68; font-size: .75rem;
    background: rgba(122, 46, 157, .10); border-radius: 5px;
    padding: .15rem .5rem; white-space: nowrap;
}
/* grade pills sit in a wrapping flex inside the group */
#schools .drill-grades {
    display: flex; flex-wrap: wrap; gap: .3rem; flex: 1;
}
#schools .drill-grade {
    display: flex; align-items: center; justify-content: space-between;
    padding: .2rem .55rem; border-radius: 7px;
    background: #fff; border: 1px solid #eee; font-size: .82rem;
    min-width: 5.8rem;
}
#schools .drill-grade:hover { border-color: rgba(122, 46, 157, .35); }
#schools .drill-grade-name { color: #444; white-space: nowrap; }
#schools .drill-grade-num {
    font-weight: 600; color: #333; font-variant-numeric: tabular-nums;
}
#schools .drill-group-total {
    font-weight: 600; color: #511C68; font-size: .78rem;
    white-space: nowrap; flex-shrink: 0;
    font-variant-numeric: tabular-nums;
    background: rgba(122, 46, 157, .12); border-radius: 999px;
    padding: .15rem .75rem;
}
#schools .drill-foot {
    display: flex; justify-content: space-between; align-items: center;
    margin-top: .6rem; padding: .4rem .65rem;
    border-top: 1px dashed rgba(122, 46, 157, .30);
    font-weight: 700; color: #511C68; font-size: .85rem;
}
#schools .drill-foot-num { color: #E0A018; font-variant-numeric: tabular-nums; }

/* L8: the schools-table drill-down block above is the single most hardcoded
   block in this file — essentially zero var(--...) tokens, so none of it
   inherits anything from the :root dark overrides. One dark-mode pass,
   mapping each hardcoded color to its nearest themed equivalent:
     - the near-white gradient background -> --surface-2/--surface (still a
       subtle top-to-bottom lift, just in the dark direction)
     - #511C68 (brand-700) as TEXT -> --brand-text (same reasoning as every
       other brand-as-text site — raw brand/brand-700 fails contrast on dark)
     - #6c757d / #444 / #333 (Bootstrap muted-gray / near-black body text) ->
       --muted / --ink, the tokens that already serve those roles elsewhere
     - the grade-pill's #fff/#eee (white chip on a white card) -> --surface-2/
       --line, matching how nested surfaces are styled everywhere else in
       this file
     - #7A2E9D border-left and #E0A018 (gold) foot number are left unchanged:
       both already clear >=7:1 against the dark surfaces (verified via the
       WCAG formula), so they read fine as-is. */
:where(:root[data-bs-theme="dark"]) #schools .drill-box {
    background: linear-gradient(180deg, var(--surface-2) 0%, var(--surface) 70%);
    border: 1px solid rgba(181, 131, 210, .22);
}
:where(:root[data-bs-theme="dark"]) #schools .drill-empty { color: var(--muted); }
:where(:root[data-bs-theme="dark"]) #schools .drill-head { color: var(--brand-text); }
:where(:root[data-bs-theme="dark"]) #schools .drill-level { color: var(--brand-text); background: rgba(181, 131, 210, .16); }
:where(:root[data-bs-theme="dark"]) #schools .drill-grade { background: var(--surface-2); border-color: var(--line); }
:where(:root[data-bs-theme="dark"]) #schools .drill-grade:hover { border-color: rgba(181, 131, 210, .45); }
:where(:root[data-bs-theme="dark"]) #schools .drill-grade-name { color: var(--muted); }
:where(:root[data-bs-theme="dark"]) #schools .drill-grade-num { color: var(--ink); }
:where(:root[data-bs-theme="dark"]) #schools .drill-group-total { color: var(--brand-text); background: rgba(181, 131, 210, .18); }
:where(:root[data-bs-theme="dark"]) #schools .drill-foot { color: var(--brand-text); border-top-color: rgba(181, 131, 210, .35); }

/* ===================== Cross-filter: chips, slicers, selections ===================== */
.filter-chips { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; }
.filter-chips .chip {
    display: inline-flex; align-items: center; gap: .35rem;
    background: rgba(122, 46, 157, .10); color: #511C68;
    border: 1px solid rgba(122, 46, 157, .25); border-radius: 999px;
    padding: .15rem .6rem; font-size: .85rem;
}
/* L8: color:#511C68 is --brand-700 as TEXT directly on the page's --bg — same
   text-on-page-bg reasoning as the --brand-text note in the :root dark block
   (brand-700 is even darker than --brand, so it fails contrast on a dark
   surface at least as badly). The rgba(122,46,157,..) tint background/border
   are left as-is: verified visually they still read as a subtle purple pill
   over the dark --bg, just a touch richer than over white — no change needed. */
:where(:root[data-bs-theme="dark"]) .filter-chips .chip { color: var(--brand-text); }
.filter-chips .chip-x {
    border: 0; background: transparent; color: inherit; cursor: pointer;
    font-size: 1rem; line-height: 1; padding: 0;
}

/* Collapsible filter: native <details>/<summary> toggle, no JS. */
.filter-toggle {
    display: inline-flex; align-items: center; gap: .4rem;
    cursor: pointer; user-select: none; list-style: none;
    font-size: .875rem; font-weight: 600; color: var(--brand);
    padding: .15rem .1rem;
}
.filter-toggle::-webkit-details-marker { display: none; }  /* hide default ▶ (Safari) */
.filter-caret { font-size: .75rem; transition: transform .2s ease; }
.filter-details[open] .filter-caret { transform: rotate(180deg); }
/* On a phone this <summary> is the only way into the filters, and it measured 26px tall
   at 375px — 2px over WCAG 2.5.8's floor. min-height (not height) so the label can still
   wrap; the rail is 48px tall and absorbs the extra without moving anything below it. */
.filter-toggle { min-height: 44px; }

/* P0-4: the bar carries Bootstrap's .d-flex, whose `display: flex !important` beats
   whatever the UA uses to hide a closed <details>'s children — so the closed filter
   kept rendering at full height (measured 228px) with only 48px reserved by
   .filter-sticky, overlapping the KPI section by 217px and leaving all 14 slicer
   controls in the tab order. !important is required to outrank .d-flex's own. */
.filter-details:not([open]) .slicer-bar { display: none !important; }

/* Trend / history tabs (.nav-pills). Bootstrap 5.3 compiles the active pill's fill as a
   LITERAL `#0d6efd` into `.nav-pills{--bs-nav-pills-link-active-bg:#0d6efd}` — it does not
   read `--bs-primary`, so this page's :root override of that token never reached it and
   Bootstrap blue was the only non-palette color on the dashboard. Retarget the token itself
   (1 declaration) instead of fighting `.nav-pills .nav-link.active` on specificity.
   White on --brand = 7.76:1, so the active pill needs no dark-mode variant. */
.nav-pills { --bs-nav-pills-link-active-bg: var(--brand); }

.slicer-bar .form-select {
    flex: 0 0 auto; width: auto;    /* size to content; bar wraps before text is clipped */
    padding-top: .15rem; padding-bottom: .15rem;
    font-size: .8rem; line-height: 1.3;
}
/* TomSelect leaves the native <select> in the DOM (position:absolute, full intrinsic
   width ~399px). Its own hide CSS isn't loaded here, so the ghost select overflows the
   viewport on mobile → horizontal scroll → clipped chart labels. The .ts-control is the
   visible control, so just hide the original. */
select.tomselected { display: none !important; }
/* Tom Select wrappers replace the native <select>; keep them compact + inline like the originals. */
.slicer-bar .ts-wrapper { flex: 0 0 auto; width: auto; }
/* M1: was .8rem (12.8px) — bumped to --fs-sm (13px). Thai vowel/tone marks start
   clipping into each other below ~13px; this is the one M1 selector that goes UP,
   not down, and it's an accessibility floor, not scale-purity. */
.slicer-bar .ts-wrapper .ts-control { min-height: calc(1.3em + .3rem + 2px); padding-top: .15rem; padding-bottom: .15rem; font-size: var(--fs-sm); }
/* R15 — the clear_button plugin's × is a real control sized by nothing but its glyph:
   measured 9x19.5px on /dashboard and 9x18px on /overview, both under WCAG 2.5.8's 24x24
   minimum. The plugin gives it no width/height at all, so one base rule fixes all six pages
   that build a TomSelect (/dashboard, /overview and the four authenticated search/report
   pages) — they all pull this stylesheet from fragments/head.html.
   `right` has to move WITH the width or the glyph drifts. The plugin sets it to
   `max(var(--ts-pr-caret), .75rem)`, which pins the box's RIGHT edge — so widening the box
   pushes the centred glyph left by half the growth (measured: 7px, from 42px off the wrapper's
   right edge to 49). That is not cosmetic: .ts-control reserves exactly 50px of right padding
   for this furniture, and at 49 the glyph lands past that boundary, on top of the truncated
   option text. 29px puts the centre back at 42, its original spot, with the box spanning 30-54
   off the wrapper's right.
   Growing LEFT (into the text) rather than right (over the caret) is deliberate — the caret is
   how the dropdown opens, and a hit area covering it would steal that click. Verified: a probe
   at 14px off the wrapper's right still returns .ts-control, not this button.
   Specificity must reach (0,3,0) to beat `.plugin-clear_button.form-select .clear-button`;
   this selector ties it and wins on order, since fragments/head.html loads dashboard.css after
   the TomSelect CDN theme. A one-class `.clear-button` selector applies the width and silently
   loses the `right` — which reads as "the fix worked" until you look at where the × sits. */
.ts-wrapper.plugin-clear_button .clear-button {
    width: 24px; height: 24px; right: 29px;
    display: flex; align-items: center; justify-content: center;
}
/* L8 (Subsystem 2/3/4): Tom Select's CDN theme (tom-select.bootstrap5.min.css) is
   NOT dark-mode-aware — verified live via getComputedStyle: the closed control's
   placeholder/typed text and border render at a fixed #343a40 (a light-mode gray)
   regardless of data-bs-theme, which measures ~1.6:1 against the new dark page
   background — effectively invisible. The DROPDOWN's own option rows happened to
   already render legibly (inherit Bootstrap's dark --bs-body-bg/-color some other
   way in that CDN build), but the control itself and the dropdown container's own
   base text color did not, so both get an explicit override here rather than
   relying on the CDN theme. This app has ~zero color CSS of its own for Tom
   Select otherwise (per CLAUDE.md), so this is new, not a modification of an
   existing override.
   Cross-task fix (post-L8 whole-branch review): .ts-control's `color` is the ONLY
   property here that actually applies — `background`/`border-color` were removed
   after confirming the CDN theme's own `!important` rules
   (`.ts-wrapper.form-select:not(.disabled) .ts-control{background:transparent!important}`,
   `.ts-wrapper.form-select .ts-control{border:none!important}`) always win in BOTH
   themes (pre-existing, not introduced by L8). Left in originally, they were dead
   weight that implied they controlled the control's appearance when they didn't. */
:where(:root[data-bs-theme="dark"]) .ts-wrapper .ts-control {
    color: var(--ink);
}
:where(:root[data-bs-theme="dark"]) .ts-wrapper .ts-control input,
:where(:root[data-bs-theme="dark"]) .ts-wrapper .ts-control > div.item {
    color: var(--ink);
}
:where(:root[data-bs-theme="dark"]) .ts-wrapper.focus .ts-control { border-color: var(--brand-text); }
:where(:root[data-bs-theme="dark"]) .ts-dropdown {
    color: var(--ink);
    background: var(--surface-2);
    border-color: var(--line);
}
:where(:root[data-bs-theme="dark"]) .ts-dropdown .option { color: var(--ink); }
:where(:root[data-bs-theme="dark"]) .ts-dropdown .active { background: var(--brand); color: #fff; }

/* Selected dept ranking row */
.rank-row { cursor: pointer; border-radius: 8px; }
.rank-row.row-selected,
.rank-row[aria-pressed="true"] { background: color-mix(in srgb, var(--gold) 18%, var(--surface)); border: 1px solid color-mix(in srgb, var(--gold) 55%, var(--line)); }
.rank-row[aria-pressed="true"] .rank-name { font-weight: 800; }
.sz-clickable[aria-pressed="true"] {
    box-shadow: inset 0 0 0 2px var(--brand);
    background: color-mix(in srgb, var(--brand) 8%, var(--surface));
}

/* Schools table rows are clickable (whole-row drill-down) */
#schoolsTable tbody tr { cursor: pointer; }

/* ========================= ThaID Welcome Modal ========================= */
.thaid-modal-backdrop {
    position: fixed; inset: 0; z-index: 1050;
    background: rgba(42, 26, 51, 0.72);
    display: flex; align-items: center; justify-content: center;
    padding: 16px;
    backdrop-filter: blur(3px);
}
.thaid-welcome-card {
    position: relative;
    background: var(--surface, #fff);
    border-radius: 20px;
    padding: 36px 32px 32px;
    max-width: 380px; width: 100%;
    box-shadow: 0 24px 64px -16px rgba(42, 26, 51, 0.55);
    text-align: center;
}
.thaid-close-btn {
    position: absolute; top: 14px; right: 16px;
    background: none; border: none; cursor: pointer;
    font-size: 1.25rem; color: var(--muted, #6F6178);
    line-height: 1; padding: 4px 6px; border-radius: 6px;
}
.thaid-close-btn:hover { background: var(--line, #E9E0F0); color: var(--ink, #2A1A33); }
.thaid-logo-wrap {
    width: 60px; height: 60px; border-radius: 14px;
    background: var(--bg, #F3EFF7); margin: 0 auto 16px;
    display: flex; align-items: center; justify-content: center;
    overflow: hidden;
}
.thaid-logo-img { width: 44px; height: 44px; object-fit: contain; }
.thaid-welcome-title {
    font-family: "Sora", "IBM Plex Sans Thai", sans-serif;
    font-size: 1.5rem; font-weight: 800;
    color: var(--ink, #2A1A33); margin: 0 0 6px;
}
.thaid-welcome-sub {
    font-size: .88rem; color: var(--muted, #6F6178); margin: 0 0 24px; line-height: 1.5;
}
.thaid-login-btn {
    display: inline-flex; align-items: center; justify-content: center; gap: 12px;
    background: #fff;
    color: var(--ink, #2A1A33); text-decoration: none;
    padding: 11px 24px; border-radius: 12px; font-weight: 600; font-size: 1rem;
    width: 100%; box-sizing: border-box;
    border: 1.5px solid #B9A9C9;
    transition: background .15s, transform .1s, box-shadow .15s, border-color .15s;
    box-shadow: 0 2px 10px -4px rgba(42, 26, 51, 0.25);
}
.thaid-login-btn:hover {
    background: #f7f5f9; transform: translateY(-1px); color: var(--ink, #2A1A33);
    border-color: #9B86B3;
    box-shadow: 0 5px 18px -6px rgba(42, 26, 51, 0.35);
}
.thaid-logo-btn { height: 30px; width: auto; display: block; flex-shrink: 0; }
.thaid-error-msg {
    margin: 16px 0 0; padding: 10px 14px;
    background: #fef2f2; border: 1px solid #fecaca; border-radius: 10px;
    color: #b91c1c; font-size: .875rem; display: flex; align-items: flex-start; gap: 8px;
    text-align: left;
}
.thaid-error-msg i { flex-shrink: 0; margin-top: 1px; }
/* L8: .thaid-login-btn/:hover declare a literal white/near-white background
   (#fff/#f7f5f9) but text color var(--ink, #2A1A33) — that fallback LOOKS
   theme-safe but never fires, since --ink IS defined in the dark :root block
   above; the custom-property lookup succeeds and returns the dark-mode ink
   (#F0E8F5), giving near-white-on-white (~1.07:1) on this app's most-seen
   surface (the anonymous /dashboard welcome modal's login button). Same bug
   shape as .error-card above, caught by the same grep sweep. .thaid-error-msg
   is a lower-priority tone fix (its light-red-on-light-pink already has
   enough internal contrast to stay legible, just looks slightly bright next
   to a dark modal) — darkened for visual consistency, not a contrast failure. */
:where(:root[data-bs-theme="dark"]) .thaid-login-btn { background: var(--surface); border-color: var(--line); }
:where(:root[data-bs-theme="dark"]) .thaid-login-btn:hover { background: var(--surface-2); border-color: rgba(181, 131, 210, .45); }
:where(:root[data-bs-theme="dark"]) .thaid-error-msg { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .35); color: #F87171; }

/* =============================================================================
   /overview — single-viewport page. Layout overrides below are scoped under
   .overview-page so they can never affect /dashboard, which reuses the same
   unscoped classes (.card-widget, .rank-list, .kpi, #geoMap, ...). The new
   .top10-* classes (Top-10 panels) are NOT .overview-page-scoped — they're
   new class names unique to this branch and currently unused elsewhere, so
   this is safe, but keep that in mind before reusing .top10-* on another page.
   No-scroll layout only above 1200px width; below that it's a normal
   scrollable stack, matching the existing 1024px sidebar-off-canvas cutoff.
   ============================================================================= */
.overview-page .ov-content {
    /* 8/6, not 14/10, and the three stack margins below are 6px, not 10 — together they free
       22 CSS px, which is what brought 1088x624 INTO the contract (it overflowed 14px). All of
       it came out of whitespace: measured at that viewport, .ov-grid stayed pinned at its 440px
       floor, the grade chart's scrollport stayed 197 against its 192 gate, and the Top-10 lists
       showed the same 81px as before. At the two older contract viewports the freed space goes
       to the grid instead — Top-10 visible height 174 -> 196 at 1280x720 and 399 -> 421 at
       1440x900 — so this is the rare change that is strictly additive above the fold. */
    padding: 8px 20px 6px;
    display: flex;
    flex-direction: column;
    /* The shared, unscoped .content class (used by every page, including this
       element via class="content ov-content") sets flex: 1 (flex-basis: 0%),
       which — combined with flex items' default min-height:auto — makes the
       height:100vh below get ignored by the flex layout algorithm and lets
       this element grow to its content's natural size instead of the
       viewport, breaking the no-scroll goal. flex: 0 0 auto overrides that:
       flex-basis becomes auto, which defers to the explicit height as this
       item's fixed main size, and grow/shrink:0 stop it from being resized
       further. Also subtract --navbar-h (defined near the top of this file)
       — the sticky .app-navbar is a sibling ABOVE this element in the same
       flex column, so plain 100vh overshoots the viewport by the navbar's
       own height.
       height must stay DEFINITE (not min-height) — .ov-grid's "grid-template-
       rows: 1fr 1fr" and its panels' "overflow-y: auto" internal scrolling
       (below) only resolve correctly against a definite ancestor height; try
       min-height here instead and the whole chain goes indefinite, so the
       1fr rows stop clamping and balloon to each panel's full unclipped
       content (verified: ov-grid measured 1240px tall at 1920x1080, where it
       should fit in ~445px — worse than the bug this was meant to fix).
       The max() floor below is what actually protects short viewports: on
       real laptop screens (1366x768, 1440x900 — the common case, not the
       exception) the plain vh calc left .ov-grid nothing to render into and
       ECharts squeezed bars/donut/map into a sliver, labels overlapping into
       unreadable garbage. max(vh-calc, 1050px) keeps height definite while
       guaranteeing enough room for hero+filter+kpis plus .ov-grid's own
       440px floor (below); short viewports just make .ov-content taller
       than the visible viewport, and the page scrolls normally (nothing in
       the .app-wrapper/.app-main chain clips it) — the same graceful
       fallback the <1199px rule already uses. 700px = slim hero (54) +
       2-line filter wrap (72, seen at 1200-1350px width) + the school-size
       KPI strip (~120, content-driven and near width-independent) + the three
       10px stack margins + .ov-grid's 440px floor + .ov-content's own 24px
       padding, minus the slack that the hero and filter don't actually take at
       most widths. It has come down three times (1050 → 920 → 830 → 700) as
       that card got shorter — re-measure .ov-kpis before touching it again.
       This floor is what decides whether the page fits without scrolling: the
       binding constraint is viewport HEIGHT — and --ov-zoom below buys 25% more
       of it.
       690, not 700 (2026-09-03), when 1088x624 was brought into the contract. That
       viewport — the narrowest AND shortest still running zoom .8, since the fallback
       thresholds are 1360*.8 and 780*.8 — has (624 - 69) / .8 = 693.75 CSS px to give,
       so a 700px floor forced the page 6.25 CSS px taller than the room available and
       guaranteed a scrollbar there no matter how little the children needed. The floor
       only ever bound in that few-px band just above the media query, so lowering it
       costs nothing anywhere else; it still protects the case this comment describes
       (a taller --navbar-h shrinks the calc), and below it the graceful scrolling
       fallback is unchanged. Re-derive it, don't nudge it, if the stack above changes:
       it is hero + filter + .ov-kpis + three 6px margins + .ov-grid's 440px floor +
       this element's own 14px of padding. */
    flex: 0 0 auto;
    /* Render the page's own content at 80%, so a 100%-zoom browser shows what the
       user would otherwise have to zoom out to see: at 1520x860 the grid goes from
       533 to ~665 layout px, and the charts, not the chrome, get all of it.
       `zoom` (not `transform: scale()`) because zoom participates in layout — the
       scrollbars, hit targets and the flex/grid maths below all stay correct, where
       a transform would leave every box its full unscaled size and just paint it
       smaller. ECharts sizes its canvas from clientHeight, which is layout px, so
       charts are drawn full-size and scaled down: text stays crisp.
       The navbar is outside this element and deliberately unscaled — it is app
       chrome, not page content.
       COMPENSATION: zoom multiplies every used length in here, `100vh` included, so
       a plain `100vh` would render at 0.8 of the viewport and leave a fifth of the
       screen blank. Dividing by the same factor puts it back. Keep the literal in
       the calc equal to --ov-zoom (calc can't read the variable through the max()
       reliably across browsers, so the two are written out side by side). */
    --ov-zoom: .8;
    zoom: var(--ov-zoom);
    /* The shared .content caps width at 1600px, but zoom:.8 scales that cap too, so this
       page could never use more than 1600*.8 = 1280 device px — every wider monitor got
       empty gutters that grew with the screen. This page is a fit-the-viewport monitor
       view, so it takes the full width; raise a cap here if ultrawide reads stretched. */
    max-width: none;
    height: max(calc((100vh - var(--navbar-h)) / .8), 690px);
    box-sizing: border-box;
}
/* The hero is a title and two status pills. On /dashboard it can be a banner; here
   every pixel it takes comes out of the charts, so keep it to a single slim bar. */
.overview-page .ov-hero { padding: 8px 20px; margin-bottom: 6px; flex: 0 0 auto; }
.overview-page .ov-hero .hero-title { font-size: 1.15rem; }
.overview-page .ov-hero .hero-main { align-items: center; }
.overview-page .ov-hero .hero-meta { flex-direction: row; align-items: center; gap: 8px; }
.overview-page .ov-hero .badge-pill { font-size: .9rem; }
.overview-page .ov-filter { flex: 0 0 auto; margin-bottom: 6px; }
.overview-page .ov-filter .filter-chips:empty { margin: 0; }

.overview-page .ov-kpis {
    flex: 0 0 auto;
    margin-bottom: 6px;
    grid-template-columns: 1fr 2.2fr;
}
.overview-page .ov-kpis .kpi { padding: 12px 16px; }
.overview-page .ov-kpis .kpi .k-top > .k-label { font-size: .9rem; }
/* The "คน" unit suffix inside .k-val was the last --fs-sm left on this page: 13px * .8 = 10.4px
   effective, the smallest text on a screen read from 2-3 metres. .9375rem lands at the page's
   12px floor.
   HEIGHT BUDGET — this is inside .hero-card, whose spare room is down to ~11px after the C3
   delta foot took 13.8 of the original ~25 (see the .ov-kpi-delta block below), and .ov-kpis
   sits OUTSIDE .ov-grid, so growing it moves the PAGE. Safe only because .k-val's height is
   set by the 38.4px figure beside it, not by this suffix: the unit's box goes 17.6px -> 19.6px
   inside a 30.7px row, so the row (and .ov-kpis at 110.6px) does not move. Verified at
   1440x900, 1280x720 and 1088x624 — re-measure all three if the figure's size ever drops. */
.overview-page .ov-kpis .kpi .k-val .k-label { font-size: .9375rem; }
/* TomSelect's clear_button plugin ships its own 13px, which this page's zoom takes to 10.4px.
   position:absolute, so unlike the rule above this one is free of the height budget — it can
   never grow the slicer row. Scoped to .overview-page so /dashboard and the authenticated
   pages keep TomSelect's own size. */
.overview-page .clear-button { font-size: .9375rem; }
/* R15 on this page, and it is NOT free of the height budget — read both rules as one unit.
   30px, because zoom .8 renders the base 24px rule above as 19.2 device px; 30 x .8 = 24, the
   same declared-vs-effective pairing as every font floor on this page. Keep the two numbers in
   sync if --ov-zoom ever changes.
   The min-height is the half that is easy to miss: .ts-control sets `overflow: hidden` AND is
   the button's offsetParent (position: relative), so it CLIPS the hit area — at the stock
   height the box measured a full 24x24 in getBoundingClientRect() while elementFromPoint on its
   bottom edge returned the wrapper, i.e. a target that measures right and is not clickable.
   Growing the button alone therefore buys nothing here; the control has to make room. Its
   specificity must stay at or above `.slicer-bar .ts-wrapper .ts-control` (0,3,0) above or it
   silently no-ops — hence the four-class selector.
   COST, measured: .ov-filter 48.5 -> 51.2px. .ov-filter sits OUTSIDE .ov-grid, so it moves the
   page — but the grid absorbs it: overflow stays 0 at 1440x900 and 1280x720 and the grade
   chart's scrollport stays 197 (gate 192). At 1088x624, outside the stated contract and already
   overflowing 11px unfiltered, it deepens to 14.
   `right` is 26.5, not the base rule's 29, for the same glyph-drift reason spelled out there —
   the numbers differ because the box does (30 vs 24) and because zoom scales the offset: 26.5
   restores the centre to 34.2 device px off the wrapper's right, against 34.3 measured before
   any of this. Both values were solved on the live page, not computed; recompute them the same
   way if either box size changes. */
.overview-page .ts-wrapper.plugin-clear_button .clear-button { width: 30px; height: 30px; right: 26.5px; }
.overview-page .slicer-bar .ts-wrapper .ts-control { min-height: 30px; }
/* The dropdown panel defaults to width:100% of its collapsed control (~140-175px, TomSelect's
   own rule, no inline override) — fine for "ทั้งหมด" but not for a full department name like
   สำนักงานคณะกรรมการการศึกษาขั้นพื้นฐาน, which then wraps mid-syllable every ~10 characters:
   Thai has no spaces for the browser to prefer a break at. Sizing the menu to its longest
   option instead (capped so it can't run off-screen) fixes the wrap without touching
   word-break/overflow-wrap, both already `normal`. Sitewide (every .slicer-bar TomSelect),
   since it's a floating overlay and doesn't move any layout. */
.slicer-bar .ts-wrapper .ts-dropdown { width: max-content; min-width: 100%; max-width: min(320px, 90vw); }
/* Text-size half of the same fix, /overview-only: this control already reads fine at 1x on
   /dashboard (13px); here the page's zoom:.8 was taking it down to ~10.2px effective. .9375rem
   matches the floor already established for the hero KPI delta above (C3) — 15px * .8 = 12px
   effective. Doesn't touch layout height: the dropdown is an absolutely-positioned overlay. */
.overview-page .slicer-bar .ts-wrapper .ts-control,
.overview-page .ts-dropdown .option,
.overview-page .ts-dropdown .optgroup-header { font-size: .9375rem; }
/* Restack the school-size card from three stacked bands (total / title / boxes) into
   a header band over ONE row of six equal boxes. The total was a full-height
   slab beside the bands: a different shape, a different height and its own column,
   so it read as a separate card wedged into this one. As a sixth box — same width,
   same radius, same type scale, same --surface-2 fill as the other five (M6) — the
   strip reads as one row of figures. Also the shortest the card has been
   (229px originally, ~144 side-by-side, ~118 here), and every pixel saved goes to
   the charts below. */
.overview-page .ov-kpis .kpi.size-card {
    grid-row: 1;
    /* Undo the .ov-kpis .kpi padding above for this card only — it applies to both
       KPIs, and here it just inset the head/boxes and added 24px of height the
       charts wanted. Base .kpi.size-card is padding:0 for the same reason. */
    padding: 0;
    display: grid;
    grid-template-columns: 1fr 5fr;   /* head = exactly one box wide */
    grid-template-rows: auto auto;
    grid-template-areas:
        "title title"
        "head  grid";
    align-content: start;
}
.overview-page .ov-kpis .size-card .sz-title { grid-area: title; padding: 4px 0; font-size: .9rem; }
/* Match .sz-box exactly (fill, border, radius, padding, centring). The margin
   reproduces .sz-grid's own padding so the six boxes sit on one baseline. M6: the
   base .sz-head rule is deliberately fill-less (it's a full-width band on
   /dashboard, not a box) — here it's shaped as a sixth box, so it needs its own
   --surface-2 fill + border or it renders as a transparent ghost beside the five
   bordered boxes. */
.overview-page .ov-kpis .size-card .sz-head {
    grid-area: head;
    display: flex; flex-direction: column; justify-content: center;
    background: var(--surface-2);
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    margin: 10px 0 10px 10px;
    padding: 6px 5px;
    text-align: center;
}
.overview-page .ov-kpis .size-card .sz-head-num { font-size: 1.35rem; }
.overview-page .ov-kpis .size-card .sz-head-label { font-size: .9rem; font-weight: 600; margin-top: 2px; }
.overview-page .ov-kpis .size-card .sz-grid { grid-area: grid; padding-left: 6px; }
.overview-page .ov-kpis .size-card .sz-num { font-size: 1.35rem; }
/* ZOOM COMPENSATION: .ov-content renders this page at zoom:.8, which multiplies every
   declared length — so .72rem landed at an effective 9.2px on a screen meant to be read
   from 2-3 metres. Each size in the /overview block is therefore its intended size / 0.8.
   Measured 2026-08-17 at 1440x900 after this change: effective 11.5px, page still exactly
   one screen (scrollHeight 900 = innerHeight 900), no panel clipped. Keep the selector at
   this specificity (.size-card in the middle) — a shorter one loses to the base rule.
   Bumped .9rem -> .9375rem 2026-09-11 to match the 12px-effective floor the hero KPI delta
   established later (C3 below): 11.5px was this card's own settled floor, not a page-wide
   one. Absorbed by .sz-box's flexible middle grid row (grid-template-rows: auto 1fr auto,
   see the sz-box rule below) — re-verified scrollHeight === innerHeight at 1440x900,
   1280x720 and 1088x624, still 0 overflow at all three. */
.overview-page .ov-kpis .size-card .sz-cap,
.overview-page .ov-kpis .size-card .sz-share { font-size: .9375rem; margin-top: 2px; }
.overview-page .ov-kpis .size-card .sz-box { padding: 6px 5px; }
/* ALIGNMENT: the five boxes are the same height, but their contents were a plain
   centred stack, so a caption that wraps (ขนาดใหญ่พิเศษ at every width, and
   ขนาดกลาง/ขนาดใหญ่ at 1280) pushed that box's meter 17px below its neighbours' —
   five bars at three different heights, measured 2026-08-26. A three-row grid pins
   the parts instead: figure + share on the top line, the caption free to wrap into
   the middle row, and the meter always on the floor. Scoped to /overview: /dashboard
   renders the same markup as a centred 2+2+1 block that must not become a grid. */
.overview-page .ov-kpis .size-card .sz-box {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-rows: auto 1fr auto;
    align-items: start;
    column-gap: 6px;
    text-align: left;
    padding: 7px 8px;
}
.overview-page .ov-kpis .size-card .sz-cap { margin-top: 3px; }
/* Band name and its range on two fixed lines, for every box. Left to wrap on its own,
   the same caption broke differently per width — "ขนาดกลาง (120–719 / คน)" at 1280 but one
   line at 1440 — so the strip re-flowed as the window changed. Two deliberate lines read
   the same everywhere, and cost no height: the tallest caption already wrapped. */
.overview-page .ov-kpis .size-card .sz-meter { margin-top: 5px; }
/* One border per box, not two: the ramp on the left edge already carries the band,
   so the 1px outline around it was a second, weaker line saying nothing. */
.overview-page .ov-kpis .size-card .sz-box,
.overview-page .ov-kpis .size-card .sz-head { border-top: 0; border-right: 0; border-bottom: 0; }
/* The total is the row's anchor: left-aligned like the boxes, its figure on their
   top line rather than floating in the middle of a taller box. */
.overview-page .ov-kpis .size-card .sz-head { justify-content: flex-start; text-align: left; padding: 7px 8px; }
/* Card titles everywhere else on this page start at the left edge of their content;
   this one was centred over the strip. Separate rule so the asserted base
   declaration block stays byte-identical. */
.overview-page .ov-kpis .size-card .sz-title { text-align: left; padding-left: 10px; }
/* One row of five instead of /dashboard's 2+2+1. The five bands are an ordered
   scale, so a single row reads left-to-right as that scale (and matches the
   sequential sz-* color ramp); the 2+2+1 shape left the biggest band orphaned in
   a full-width box that read as a separate summary. Also ~60px shorter, which
   goes to .ov-grid — the whole point of this page. */
.overview-page .ov-kpis .size-card .sz-grid { grid-template-columns: repeat(5, 1fr); gap: 6px; padding: 10px; }
.overview-page .ov-kpis .size-card .sz-box-wide { grid-column: auto; }
/* The total-students card holds one label + one number but is stretched to the
   size-card's full height, so the base .kpi rule's justify-content:space-between
   pinned them to opposite ends with a ~250px void between. Center the pair and
   scale the number up: the card then reads as a deliberate headline figure
   rather than a mostly-empty box. */
.overview-page .ov-kpis .kpi.hero-card { justify-content: center; gap: 6px; }
.overview-page .ov-kpis .kpi.hero-card .k-val { font-size: 2.4rem; margin-top: 0; }

/* The main grid: fills the remaining viewport height when there's room (each
   panel then scrolls internally instead, see .ov-panel below). Below the
   440px floor — hero+filter+kpis already ate most of a short viewport — it
   stops shrinking and the page scrolls instead of squeezing charts into a
   sliver (440px ~= two 214px rows, the height ECharts needs to lay out bar
   labels/donut/map legend without overlapping; verified at 1920x1080 where
   this grid naturally lands at ~445px and renders cleanly). */
.overview-page .ov-grid {
    flex: 1 1 auto;
    min-height: 440px;
    display: grid;
    /* col1 change / col2 dept: both narrow list-style panels. col3 grade: the bar chart,
       kept widest. col4 map. Row 2 reuses col1+col2 for school10 (2 * 1.05fr) and col3 for
       prov10, which keeps prov10 in the cell next to the map. */
    grid-template-columns: 1.05fr 1.05fr 1.45fr 1.15fr;
    /* The first row preserves the grade chart's 274px content floor. The second row
       absorbs the remaining viewport budget instead of imposing another fixed floor:
       this keeps the 1280x720 page within its viewport while leaving the 1440x900
       geometry unchanged. */
    grid-template-rows: minmax(274px, 280px) minmax(0, 1fr);
    /* Four columns, not five. Five fit every chart at full height but squeezed each
       panel to ~230px wide, where four of the six card titles wrapped onto a second
       line and the school names truncated after about five Thai characters — the
       page read as busier, not richer. The height the map needs comes out of the
       compacted header above, while the change panel makes room for the latest
       student movement without duplicating the grade-level breakdown. */
    grid-template-areas:
        "change   dept     grade  map"
        "school10 school10 prov10 map";
    gap: 12px;
}
.overview-page .ov-dept     { grid-area: dept; }
.overview-page .ov-grade    { grid-area: grade; }
.overview-page .ov-change   { grid-area: change; }
.overview-page .ov-map      { grid-area: map; }
.overview-page .ov-school10 { grid-area: school10; }
.overview-page .ov-prov10   { grid-area: prov10; }

/* Keep the search control within the original title line: this panel has a fixed height. */
.overview-page .ov-school10 .ov-school-search {
    flex: 1 1 auto;
    /* Sit against the label; space-between would otherwise centre it in the row. */
    margin-right: auto;
    min-width: 0;
    max-width: 180px;
    height: 1.6em;
    /* Left padding clears the magnifier drawn by background-image below. */
    padding: 0 8px 0 1.7em;
    font-size: inherit;
    font-family: inherit;
    line-height: 1.6;
    /* Filled + brand border + icon so the box reads as a control, not as part of the title.
       border-box sizing keeps the 1.5px border inside height:1.6em — the header row, and so
       the list below it, does not grow. */
    border: 1.5px solid var(--brand);
    border-radius: 999px;
    background: var(--surface-2) no-repeat .55em center / .85em
        url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%237A2E9D' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='6.5' cy='6.5' r='4.5'/%3E%3Cpath d='M10 10l4 4'/%3E%3C/svg%3E");
    color: inherit;
    box-shadow: 0 1px 2px rgba(122, 46, 157, .15);
}
.overview-page .ov-school10 .ov-school-search::placeholder { color: var(--muted); opacity: 1; }
.overview-page .ov-school10 .ov-school-search:focus {
    outline: none;
    background-color: var(--surface);
    box-shadow: 0 0 0 3px rgba(122, 46, 157, .25);
}
/* SVG in a data URI cannot read CSS tokens: swap the icon stroke to dark --brand-text. */
:root[data-bs-theme="dark"] .overview-page .ov-school10 .ov-school-search {
    border-color: var(--brand-text);
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23B583D2' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='6.5' cy='6.5' r='4.5'/%3E%3Cpath d='M10 10l4 4'/%3E%3C/svg%3E");
}
.overview-page .ov-school10 .ov-school-label { flex: 0 0 auto; }
/* A selected-school chip belongs in the tight filter strip, so never let a long Thai name
   create a second line and push the page beyond its one-viewport contract. */
.overview-page .chip-school {
    max-width: 22ch;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Each panel is a flex column with its own scroll — a longer-than-expected
   list/chart clips inside the card instead of growing the page. */
.overview-page .ov-panel {
    min-width: 0;
    min-height: 0;
    display: flex;
    flex-direction: column;
    padding: 14px 16px;
    overflow: hidden;
}
/* /0.8 zoom compensation — see the .sz-cap block above. Effective: 12.2px -> 15.2px. */
.overview-page .ov-panel .card-title { flex: 0 0 auto; font-size: 1.19rem; margin-bottom: 6px; }
/* The school panel's title shares its row with the "ดูทั้งหมด" link (see overview.html).
   .card-title stays flex:0 0 auto in the panel column above — this only changes what
   happens INSIDE that one line, so the panel's height budget is unaffected. */
.overview-page .ov-panel .card-title.ov-title-row {
    display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
}
/* .9rem = 11.5px effective under the page's zoom:.8 — the same floor .ov-change-period and
   .sz-cap use. Do not drop below it. nowrap keeps the title row one line: a wrap here costs
   the list below a whole row. */
.overview-page .ov-view-all { font-size: .9rem; font-weight: 600; white-space: nowrap; }
/* C3: the match count rides inside the link and is revealed only while #schoolSearch has a
   keyword (overview.js toggles .has-count). Unsearched it always equalled #szTotal in the size
   card directly above, printing the same 34,144 twice. display:none, not visibility, so the
   nowrap title row never reserves width for a number that is not there. */
.overview-page .ov-view-all .ov-view-all-count { display: none; }
.overview-page .ov-view-all.has-count .ov-view-all-count { display: inline; }

/* ---- C3: hero KPI delta on /overview -------------------------------------
   HEIGHT BUDGET — .ov-kpis sits OUTSIDE .ov-grid, so unlike an .ov-panel this box grows the
   PAGE, and /overview's contract is scrollHeight === innerHeight. The room comes from the
   hero card being height-driven by its .size-card sibling: measured 2026-09-02, .hero-card
   content was 61.1px + 24px padding inside a 110.6px box = ~25px spare. This foot measures
   13.8px on one line, leaving .ov-kpis at its existing 110.6px. If the size card ever gets
   shorter, this is the first thing that will push the page past one viewport — re-measure
   .ov-kpis AND scrollHeight === innerHeight at 1440x900, 1280x720 and 1088x624 before
   changing either card.
   ONE LINE IS THE BUDGET. A second line costs 13.8px, more than the ~11px left over. The
   full failure sentence ("ไม่สามารถโหลดข้อมูลการเปลี่ยนแปลงได้") did exactly that at 1088x624,
   the narrowest width still running at zoom .8: foot 13.8 -> 27.6, .ov-kpis 110.6 -> 117.5.
   Two guards, because either alone can be undone by a future string: overview.js passes this
   chip a SHORT status (resetChange's chipText), and nowrap here makes any over-long text show
   up as horizontal clipping — visible, and harmless to the contract — instead of silent
   downward growth. flex-wrap stays on so the ⓘ note can drop rather than clip the number.
   Sizes only: the colours come from the shared .kpi.hero-card .kpi-trend-delta.chg-* rules,
   which already carry the on-gradient contrast pair. ALL THREE spans are .9375rem — the base
   .kpi-trend-period/.kpi-trend-note use --fs-xs, which lands at 8.8px under this page's zoom,
   and even .85rem only reaches 10.9. 15px * .8 = 12px effective, the page's floor. */
.overview-page .ov-kpi-delta {
    display: flex; align-items: baseline; flex-wrap: wrap; gap: 0 6px;
    margin-top: 4px; line-height: 1.15;
}
.overview-page .ov-kpi-delta .kpi-trend-delta { font-size: .9375rem; white-space: nowrap; }
.overview-page .ov-kpi-delta .kpi-trend-period { font-size: .9375rem; }
.overview-page .ov-kpi-delta .kpi-trend-note { font-size: .9375rem; }

.overview-page .ov-change-period {
    color: var(--muted);
    /* The source note is still secondary, but the overview's .8 zoom made .9rem read
       too faint. .95rem/500 lands at a legible 12.2px effective in both themes. */
    font-size: .95rem;
    font-weight: 500;
    font-variant-numeric: tabular-nums;
}
/* ---- Flow ledger (the "ที่มาของการเปลี่ยนแปลง" panel) ---------------------
   Was three text rows marooned in the vertical centre of the card, ~60% of it
   blank. Now three flows on ONE shared scale — each bar's width is its share of
   the largest of the three — so ออก reads as visibly longer than เข้าใหม่ +
   กลับเข้าเรียน without comparing digits. Pill bar = the same bar vocabulary as
   .rank-row / .top10-row on this page. The net is NOT a fourth figure here: the
   hero KPI delta (#ovKpiDelta) owns that number; #ovChangeNet carries a
   plain-language direction line, sized and written by overview.js. */
.overview-page .ov-change { justify-content: flex-start; gap: 9px; }

/* Two lines per flow — label + figure, then a full-width bar under them, the same
   shape as .top10-row. The bar spans the whole card so the three lengths sit on one
   axis and ออก can be read against เข้าใหม่ + กลับเข้าเรียน at a glance. Rows are
   display:contents so label and figure share columns across all three flows. */
.overview-page .ov-change-rows {
    display: grid;
    grid-template-columns: 1fr max-content;
    align-items: baseline;
    column-gap: 10px;
    row-gap: 5px;
}
.overview-page .ov-change-row { display: contents; }
/* A small lift before ออก groups it apart from the two inflows without a rule. */
.overview-page .ov-change-row[data-flow="retired"] .ov-change-label { margin-top: 6px; }

.overview-page .ov-change-label { color: var(--ink); font-size: 1rem; font-weight: 600; }

.overview-page .ov-flow-track {
    grid-column: 1 / -1;
    height: 12px;
    border-radius: 99px;
    background: var(--surface-2);
    /* A hairline ring so the empty part of the track still reads on a white card
       (--surface-2 is near-white in light mode) — the remainder is information
       here: how close เข้า came to matching ออก. Inset, so it costs no height. */
    box-shadow: inset 0 0 0 1px var(--line);
    overflow: hidden;
    margin-bottom: 12px;
}
.overview-page .ov-change-row:last-of-type .ov-flow-track { margin-bottom: 0; }
.overview-page .ov-flow-bar {
    display: block;
    height: 100%;
    width: 0;
    border-radius: 99px;
    background: var(--chart-neutral);
}
.overview-page .ov-change-row[data-flow="incoming"]    .ov-flow-bar { background: var(--chart-positive); }
.overview-page .ov-change-row[data-flow="reactivated"] .ov-flow-bar { background: var(--chart-flow-reactivated); }
.overview-page .ov-change-row[data-flow="retired"]     .ov-flow-bar { background: var(--chart-negative); }

.overview-page .ov-change-row strong {
    font-family: "Sora", sans-serif;
    /* Keep the semantic-colour figure at least as readable as its label: 1rem becomes
       12.8px effective under the overview zoom, with a firm numeric weight. */
    font-size: 1rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    text-align: right;
}
.overview-page .ov-change strong[data-direction="up"] { color: var(--chart-positive); }
.overview-page .ov-change strong[data-direction="down"] { color: var(--chart-negative); }
.overview-page .ov-change strong[data-direction="flat"] { color: var(--chart-neutral); }
/* เนื้อสีของ "กลับเข้าเรียน" ต้องตรงกับ /dashboard (ฟ้า) — ทับ data-direction="up" ที่จะทำให้
   มันกลายเป็นเขียวเท่ากับ "เข้าใหม่". Deliberately targets strong[data-direction], not bare
   strong: [data-flow="reactivated"] strong alone is (0,3,1), the SAME specificity as the
   direction rules above, so it would only win by sitting later in the file — this file has
   shipped that exact dead-equal-specificity bug before (see .ov-dept .rank-name below,
   ~line 1853). Adding [data-direction] makes this (0,4,1), so it wins regardless of order.
   Same idiom as /dashboard's .what-changed-metric[data-flow] strong[data-direction] (line 480).
   Safe because overview.js only clears data-direction on placeholder/error states
   (resetChange) or the null guard in setChangeMetric — it is always present whenever a real
   number is rendered. */
.overview-page .ov-change [data-flow="reactivated"] strong[data-direction] { color: var(--chart-flow-reactivated); }

/* The card's conclusion in words — #ovChangeNet, no longer a figure (see above).
   .95rem = 11.4px effective under zoom:.8, the same floor .ov-change-period holds.
   :empty guard keeps the first-period state (no diff) from reserving a blank line. */
.overview-page .ov-change-summary {
    margin: 2px 0 0;
    color: var(--muted);
    font-size: .95rem;
    line-height: 1.3;
}
.overview-page .ov-change-summary:empty { display: none; }
/* The schoolSize caveat is the hero delta's job here (#ovKpiDeltaSizeNote) — hide
   the in-card copy, keep the node for overview.js. */
.overview-page #ovChangeSizeNote { display: none; }
/* M10 drill-state indicator (.ov-grade only): flex:0 0 auto so it can never grow/shrink and
   steal height from .ov-scroll below it — same idiom as .card-title above. The page's .8 zoom
   scales .9375rem to a 12px effective floor; the selector remains local so /dashboard keeps
   the roomier shared .card-sub treatment.
   HEIGHT BUDGET — do not raise this box without re-measuring. Every px here comes straight out
   of .ov-scroll, and renderGradeChart has a hard cliff: below `categories * 24` of scrollport it
   abandons the fitted height and jumps to the natural `max(250, n*30+40)`, which then overflows
   and hides the bottom categories. Going .74rem → .9375rem at the default `line-height`/4px
   margin cost 5px (scrollport 194 → 189) and hid ปวช./ปวส./ปริญญาตรี at 1440 AND 1280 — the page
   contract still read `overflow 0`, so only a live chart measurement catches it. line-height 1.1
   + a 2px margin buys the legibility AND leaves the scrollport at 197, 5px clear of the 192 gate. */
.overview-page .ov-panel #levelChartSub {
    flex: 0 0 auto; font-size: .9375rem; line-height: 1.1; margin: 0 0 2px; min-height: 0;
}
/* Two .card-sub lines now stack above the map (#geoMapTotal + the drill breadcrumb), and this
   panel's height is fixed by .ov-grid — every px they take comes out of #geoMap. Same
   compaction idiom as #levelChartSub above. .9375rem = 12px effective under zoom:.8, the same
   floor as the rest of the page (bumped from .9rem/11.5px 2026-09-11; re-verified 0 overflow
   at 1440x900/1280x720/1088x624 and the map itself unclipped). */
.overview-page .ov-map .card-sub { flex: 0 0 auto; font-size: .9375rem; margin-bottom: 3px; }
.overview-page .ov-panel .rank-list,
.overview-page .ov-panel .top10-list {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
}
/* The สังกัด panel is the narrowest grid column (1.1fr). With the short dept names
   the template renders here (see overview.html), the name needs far less room than
   the base rule's minmax(80px,1.1fr) — give the freed width to the bar, which is
   what makes the ranking readable at a glance. */
.overview-page .ov-dept .rank-row {
    /* The value track is max-content, not a fixed width: at .ov-dept's 17px Sora a
       7-digit total ('6,176,422') needs 70px plus the 20px the base .rank-val reserves
       for the cross-filter funnel = 90px, so the old 74px cut the last digit off the
       two largest departments (and pushed the row 10px past the card, adding a
       horizontal scrollbar). The bar track absorbs the difference. */
    grid-template-columns: 20px 56px minmax(0, 1fr) max-content;
    gap: 8px;
    padding: 8px 6px;
}
/* /0.8 zoom compensation — see the .sz-cap block above. These are the rules that actually
   win for .ov-dept's rank rows (same specificity as the old .ov-panel .rank-name/.rank-val
   rule but later in the file, so that rule never applied here — deleted, see git history).
   Effective: 11.0px / 10.9px -> 13.8px / 13.6px. Verified 2026-08-17 that the wider type does not
   truncate any ranking name (clientWidth == scrollWidth on all four rows). */
.overview-page .ov-dept .rank-name { font-size: 1.075rem; }
.overview-page .ov-dept .rank-val { font-size: 1.0625rem; }
/* .rank-no (the 1/2/3/4 badge) was left at the base .9rem/11.5px effective when the two
   rules above were floored — bump it to the same 12px-effective floor as the rest of the
   page, 2026-09-11. */
.overview-page .ov-dept .rank-no { font-size: .9375rem; }

/* CANVAS HIT-TESTING vs. zoom: zrender maps pointer events with (clientX - rect.left), where
   the rect is device px but every shape it drew is in layout px — an ancestor's CSS zoom is
   never accounted for. On this page (zoom: .8) that scaled every hit by 1/.8 from the canvas'
   top-left: pointing at the มัธยมต้น bar reported ประถม, and the map answered with a province
   roughly a fifth of the way up from the cursor (verified 2026-08-26; ECharts 5.6.0 behaves the
   same, so this is not fixed upstream). Cancelling the page zoom on the canvas host itself puts
   its layout px and device px back in agreement, which is all zrender needs.
   1.25 = 1 / --ov-zoom, written out because calc() can't read that variable reliably here — the
   same convention as the height calc on .ov-content. Keep the two in sync.
   renderGradeChart divides its computed height by this element's own zoom, so the chart still
   occupies the same space on screen; its text is now drawn at true size instead of 0.8x.
   #geoMap needs no such adjustment — it is a flex item sized by the panel (height: auto below),
   and the same measurement showed it kept its exact box while its hit-testing came back:
   pointing at กรุงเทพมหานคร reported อุทัยธานี before, กรุงเทพมหานคร after (same for เชียงใหม่/
   สงขลา/ขอนแก่น), which on a click means drilling into the wrong province. */
.overview-page #levelChart,
.overview-page #geoMap { zoom: 1.25; }

/* #levelChart is deliberately not constrained here: renderGradeChart sizes it by bar
   count (30px each, ~900px for the full master list), and forcing that into the
   cell crushed 29 bars into ~15px rows with 11px labels colliding. It keeps its
   own height and scrolls inside .ov-scroll instead. */
.overview-page .ov-panel #geoMap {
    flex: 1 1 auto;
    min-height: 0;
    height: auto !important;
    width: 100%;
}
/* The scroll viewport for a chart that sets its own height (see overview.html).
   The panel itself stays overflow:hidden so the card-title never scrolls away. */
.overview-page .ov-panel .ov-scroll {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overflow-x: hidden;   /* the vertical scrollbar narrows the box; don't let that
                             one-off overflow add a second, horizontal scrollbar */
}
.overview-page .ov-panel .ov-scroll #levelChart { width: 100%; }

/* Top-10 list rows: same bar-list visual language as .rank-row. On /overview and
   /dashboard the school rows are clickable (see .top10-school-row below); the provincial
   and geo ranking rows elsewhere follow their own row classes. */
.top10-list { display: flex; flex-direction: column; gap: 4px; }
.top10-row {
    display: grid; grid-template-columns: minmax(0,1.6fr) 1fr 70px;
    align-items: center; gap: 10px; padding: 6px 4px;
}
.top10-name { font-size: .85rem; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top10-sub { display: block; font-size: var(--fs-xs); font-weight: 400; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top10-bar-wrap { height: 6px; border-radius: 4px; background: var(--surface-2); overflow: hidden; }
.top10-bar { display: block; height: 100%; border-radius: 4px; background: var(--brand); }
/* R16: this was the one figure on the page still set in IBM Plex — every other number
   (.rank-val, .sz-num, the KPI values) uses Sora with tabular figures, so the Top-10 column
   didn't align digit-to-digit with the rest and read as a different kind of value. */
.top10-val { font-family: "Sora", sans-serif; font-size: .85rem; font-weight: 700; color: var(--brand); text-align: right; font-variant-numeric: tabular-nums; }
/* /dashboard's Top-10 rows open #historyModal school-scoped (see dashboard.html's Top-10
   IIFE). Unscoped so it also applies here; the identical .overview-page copy below wins on
   /overview by specificity, so the two never disagree. */
.top10-school-row { cursor: pointer; border-radius: var(--radius-sm); }
.top10-school-row:hover { background: var(--surface-2); }
.top10-school-row:focus-visible {
    outline: 2px solid var(--brand-text, var(--brand));
    outline-offset: 2px;
}
/* In /overview's grid these lists sit in ~230px columns, where the 3-across row
   left the name ~66px — about five Thai characters, so every school read as
   "วิทยา…". Put the bar on its own line under the name instead: the name roughly
   doubles, the bar gets the full width (a better read of the proportion anyway),
   and the value stays aligned on the right. */
.overview-page .top10-row {
    grid-template-columns: minmax(0, 1fr) 62px;
    grid-template-areas:
        "name val"
        "bar  bar";
    gap: 3px 8px;
    padding: 5px 4px;
}
.overview-page .top10-name { grid-area: name; }
.overview-page .top10-bar-wrap { grid-area: bar; }
.overview-page .top10-val { grid-area: val; align-self: center; }
/* /0.8 zoom compensation for the Top-10 rows: .85rem/12px declared read as 10.9/9.6px
   effective under zoom:.8. .9375rem puts all three at 12px effective (bumped from .9rem/
   11.5px 2026-09-11 to match the floor used elsewhere on the page — see the .sz-cap and
   .ov-map .card-sub comments). The name still ellipsizes — it did at the smaller size too. */
.overview-page .top10-name,
.overview-page .top10-val,
.overview-page .top10-sub { font-size: .9375rem; }

/* The geographic ranking mirrors the map's clickable table on /dashboard. */
.overview-page .top10-geo-row { cursor: pointer; border-radius: var(--radius-sm); }
.overview-page .top10-geo-row:hover { background: var(--surface-2); }
.overview-page .top10-geo-row:focus-visible {
    outline: 2px solid var(--brand-text, var(--brand));
    outline-offset: 2px;
}

/* School Top-10 rows select a school. Keep their feedback separate from geographic rows:
   geographic rows drill the map, while a school row needs a persistent selected state. */
.overview-page .top10-school-row { cursor: pointer; border-radius: var(--radius-sm); }
.overview-page .top10-school-row:hover { background: var(--surface-2); }
.overview-page .top10-school-row:focus-visible {
    outline: 2px solid var(--brand-text, var(--brand));
    outline-offset: 2px;
}
.overview-page .top10-selected {
    box-shadow: inset 3px 0 0 var(--brand);
    background: rgba(var(--bs-primary-rgb), .10);
}
.overview-page .top10-selected .top10-name { font-weight: 600; }

/* Desktop Overview only: the widened school card can trade horizontal room for density.
   This has to override the shared flex column list, but only for School Top-10; provinces
   remain a single ordered ranking. CSS multi-column layout fragments according to available
   HEIGHT: after Task 5 raised the type floor, 1280x720 produced four physical columns and put
   rows 7-10 beyond the horizontal scrollport. A five-track grid has deterministic placement:
   items 1-5 fill the left column and 6-10 fill the right, regardless of card height. The
   complementary fallback below restores the ordinary one-column flow. */
@media (min-width: 1088px) and (min-height: 800px) {
    .overview-page .ov-school10 .top10-list {
        display: grid;
        grid-template-rows: repeat(5, minmax(0, 1fr));
        grid-template-columns: repeat(2, minmax(0, 1fr));
        grid-auto-flow: column;
        column-gap: 16px;
        row-gap: 4px;
    }
    /* Search results are not capped at ten; use the scrollable single-column list so an
       eleventh result cannot create an overflowing third grid column. */
    .overview-page .ov-school10 .top10-list.searching {
        display: flex;
    }
}

/* N8 (รอบ 4): panel สูงตาม .ov-grid ที่ยืดตาม viewport ส่วนแถวสูงตามความยาวชื่อ — ความสูงที่ได้
   จึงไม่เคยลงตัวเป็นจำนวนเต็มของแถว วัดที่ 1280x720: scrollHeight 658 vs clientHeight 186
   แถวล่างถูกเฉือนครึ่งแนวนอน ซึ่งอ่านเหมือน render พัง ไม่ใช่ "มีต่อด้านล่าง"
   fade ขอบล่างบอกว่ามีต่อ และ scroll-snap ทำให้ทุกครั้งที่หยุดเลื่อน แถวจะจบพอดีขอบ
   (ไม่ล็อกความสูง panel — ดูเหตุผลในแผน Task 9) */
.overview-page .top10-list,
.overview-page .ov-dept .rank-list {
    scroll-snap-type: y proximity;
    -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 20px), transparent 100%);
    mask-image: linear-gradient(to bottom, #000 calc(100% - 20px), transparent 100%);
}
/* ไม่มี fade เมื่อไม่มีอะไรให้เลื่อน — ลิสต์ที่พอดีอยู่แล้วจะได้ไม่ถูกทำให้ดูเหมือนมีต่อ */
.overview-page .top10-list:not([data-scrollable]),
.overview-page .ov-dept .rank-list:not([data-scrollable]) {
    -webkit-mask-image: none;
    mask-image: none;
}
.overview-page .top10-row { scroll-snap-align: start; }
.overview-page .ov-dept .rank-row { scroll-snap-align: start; }

/* The single-viewport grid needs room in BOTH axes, so it is gated on both.
   - The four columns stop working around 1360 LAYOUT px each: card titles wrap to
     two lines, the doughnut legend crowds its ring, school names truncate. (1199 was
     the old cutoff, borrowed from the sidebar breakpoint; measured at 1216px it was
     already well past unusable, so the gate now sits where the layout actually
     breaks rather than where another component's breakpoint happens to be.)
   - Below ~780 layout px tall .ov-grid can't reach a workable height even with the
     header compacted, so the panels squash instead.
   MEDIA QUERIES MEASURE THE VIEWPORT, WHICH --ov-zoom DOES NOT SCALE, so both
   thresholds are the layout limits above times the 0.8 zoom: 1360*.8 = 1088 and
   780*.8 = 624. Change --ov-zoom and these two numbers move with it.
   Either way, fall back to a normal scrollable one-column stack: it scrolls, but
   every card is full size and legible, which beats a grid squeezed past its limits. */
@media (max-width: 1087px), (max-height: 623px) {
    /* Full scale again: the point of shrinking to 80% was to win vertical room for
       the grid, and the stack scrolls anyway — on a phone it would just be small. */
    .overview-page .ov-content { height: auto; overflow: visible; zoom: 1; }
    /* The chart hosts cancel the page zoom (see the hit-testing note above) — but there is
       no page zoom to cancel here, so keeping 1.25 re-broke the very thing it fixed, the
       other way: measured 2026-08-26 at 390x844, canvas net zoom 1.25 and a tap on any bar
       reported nothing at all. It must track .ov-content's zoom, so reset both together. */
    .overview-page #levelChart,
    .overview-page #geoMap { zoom: 1; }
    /* Phone LIVE readout: drop .badge-pill's capsule chrome (it just wrapped a fat
       pill around wrapping text) and let the strip span the header, so the lamp +
       clock sit on one row (clock pushed right by .live-time's margin-left:auto)
       and the sync date drops to its own line. zoom is 1 here, so these sizes are
       literal. */
    .overview-page .ov-hero .hero-meta { flex: 1 1 100%; align-items: stretch; }
    .overview-page .ov-hero .badge-pill {
        background: none; border: none; padding: 2px 0; width: 100%; font-size: .95rem;
    }
    /* Row 1 pairs the lamp with the clock (both "right now"), the sync date drops
       to row 2. DOM order stays lamp -> date -> clock for the desktop one-liner, so
       the swap is order-only and mobile-only. */
    .overview-page .ov-hero .live-badge .live-time { order: 1; }
    .overview-page .ov-hero .live-badge .live-asof { order: 2; flex-basis: 100%; }
    /* Stacked, the base .kpis grid-auto-rows:1fr stretched the one-number total card
       to the size-card's height — a ~250px void under the figure. Let both rows size
       to their content instead. */
    .overview-page .ov-kpis { grid-template-columns: 1fr; grid-auto-rows: auto; }
    /* Back to /dashboard's stacked card: side-by-side only pays off when the strip is
       fighting for vertical space, and stacked KPIs aren't. */
    .overview-page .ov-kpis .kpi.size-card {
        grid-column: 1; grid-row: auto;
        display: flex; flex-direction: column;
    }
    /* ...which means the total goes back to being the card's plain header band
       (transparent, like the base .sz-head rule on /dashboard), not a box in the
       row: drop the box styling (fill, border, margin, radius) it carries above. */
    .overview-page .ov-kpis .size-card .sz-head {
        background: none; border: none; margin: 0; border-radius: 0; padding: 12px 18px;
    }
    /* Stacked, the title sits under that 18px-inset header rather than beside the boxes,
       so it takes the header's inset instead of the strip's 10px one. */
    .overview-page .ov-kpis .size-card .sz-title { padding-left: 18px; }
    /* H4 (2026-08-04): was 1.9rem — with .k-val fixed at 2.4rem in this layout (no
       narrow-breakpoint override), that gave only a 1.26x ratio against the page's
       primary total-student number, failing the >=1.5x hierarchy bar. 1.5rem (24px,
       matching --fs-xl) restores 2.4/1.5 = 1.6x without touching layout — the box
       still sizes to content (grid-auto-rows: auto, line 1168) so a smaller number
       just leaves a bit more breathing room, not a broken fit. */
    .overview-page .ov-kpis .size-card .sz-head-num { font-size: 1.5rem; }
    /* No font-size override for .sz-head-label here on purpose: this block resets
       zoom to 1, so the base .overview-page rule's .9rem already lands at a real
       14.4px. The old .85rem override existed only to undo the /0.8 compensation
       back when the base rule was .72rem. */
    .overview-page .ov-kpis .size-card .sz-grid { padding-left: 12px; }
    .overview-page .ov-kpis .size-card .sz-title { padding: 8px 0; font-size: 1rem; }
    /* Five bands side by side need the wide right-hand column; once the KPIs stack
       into one column there isn't the width, so fall back to /dashboard's 2+2+1. */
    .overview-page .ov-kpis .size-card .sz-grid { grid-template-columns: 1fr 1fr; }
    .overview-page .ov-kpis .size-card .sz-box-wide { grid-column: span 2; }
    .overview-page .ov-kpis .kpi.hero-card { justify-content: space-between; }
    /* Phone stack ends map -> province ranking -> school list (school last, by request).
       The DOM keeps school10 before prov10 so the DESKTOP grid's reading order stays
       aligned with what it paints (school10 fills the wide left of row 2, prov10 the
       narrow cell next to the map); the one small DOM/paint mismatch this trades for
       lands here on the phone stack only, where it does not affect the filter panels. */
    .overview-page .ov-grid {
        grid-template-columns: 1fr;
        grid-template-rows: none;
        grid-template-areas:
            "dept"
            "grade"
            "change"
            "map"
            "prov10"
            "school10";
    }
    /* Full-width stacked panels have plenty of room — go back to the base one-line
       row instead of the narrow-column stack. */
    .overview-page .top10-row {
        grid-template-columns: minmax(0, 1.6fr) 1fr 70px;
        grid-template-areas: "name bar val";
        gap: 10px;
        padding: 6px 4px;
    }
    .overview-page .ov-panel { overflow: visible; }
    .overview-page .ov-panel .rank-list,
    .overview-page .ov-panel .top10-list,
    .overview-page .ov-panel .ov-scroll { overflow-y: visible; max-height: none; }
    .overview-page .ov-panel #geoMap { height: 360px !important; }
    /* #levelChart keeps its own bar-count height here too — the page already
       scrolls in this stacked layout, so there's nothing to clip it against. */
    /* จำนวนนักเรียนรายจังหวัด/อำเภอ/ตำบล can run to 77 rows (every province) — letting
       it fall back to the page-scrolls-everything default above like the other panels
       added ~2500px to the page just for this one list. Keep its own scroll instead:
       372px = 10 rows * 33.6px (measured at 375x812) + 9 * 4px row gap, so ~10 rows
       show before it scrolls, same idea as the desktop grid's per-panel scroll. */
    .overview-page .ov-prov10 .top10-list {
        max-height: 372px;
        overflow-y: auto;
    }
}

/* L8 dark-mode "brand-text" swap: every remaining site where --brand is used
   as TEXT/ICON color directly on this page's own --bg/--surface/--surface-2
   (not as a solid fill background) — see the --brand-text note in the :root
   dark block near the top of this file for why raw --brand fails contrast
   here. Grouped in one place (rather than scattered per-selector, like most
   of this file's other L8 blocks are) because the "which sites needed this
   and why" reasoning is identical across all of them.
   DELIBERATELY PLACED LAST IN THE FILE: every selector below has an
   unconditional (non-media-query) light-mode declaration for the same
   property earlier in this file. CSS breaks an equal-specificity tie by
   SOURCE ORDER, so an earlier `.kpi .k-val{color:X}` dark rule loses to a
   LATER unconditional `.kpi .k-val{color:Y}` even while dark mode is active.
   This block must out-order every light rule it overrides, so it has to be
   last (verified live: an earlier placement of this exact block, right after
   the :root dark tokens, silently never applied — caught via getComputedStyle,
   not visual inspection, since the failure mode looks identical to "dark mode
   not active" rather than "override present but out-ordered").
   That ordering rule still holds after the @media → attribute-selector switch
   because the prefix is wrapped in :where(), which contributes ZERO
   specificity — see the note at the top of this file.
   Each rule's LIGHT-mode declaration itself is untouched; this only adds the
   dark override.
   Deliberately NOT applied to --brand used as a solid fill BACKGROUND
   (.btn-primary, the active pagination pill, the schools-table dt-control's
   SHOWN state, hero/sidebar gradients, .top10-bar/.rank-bar/.retire-cause-
   bar-fill fills) — those already carry white text and don't depend on page
   theme; nor to purely-decorative borders (thead border-bottom, .form-control
   focus border, .log-dot's ring) which fall under the same "decorative, not
   WCAG-bound" exemption dashboard.css:17-23's H6 comment already established
   for --line. Focus-visible outlines ARE included below: an unreadable focus
   ring is an accessibility regression, not decoration.
   Covers --brand AND its --brand-600/--brand-700 siblings used the same
   text-on-page-surface way (a first pass of this block only grepped for
   `var(--brand)` verbatim and missed the darker-shade siblings — e.g. `a {
   color: var(--brand) }`, the single most-applied rule in this whole
   stylesheet since it's the global link color for all 15 templates, plus
   `.page-link`/`.btn-outline-primary`/`.detail-section-title`/
   `.retire-cause-count`/the schools-table header+number-column text, all
   caught on a follow-up exhaustive `--brand[a-z0-9-]*` sweep). */
:where(:root[data-bs-theme="dark"]) [role="button"]:focus-visible,
:where(:root[data-bs-theme="dark"]) a:focus-visible,
:where(:root[data-bs-theme="dark"]) button:focus-visible,
:where(:root[data-bs-theme="dark"]) summary:focus-visible,
:where(:root[data-bs-theme="dark"]) .size-card .sz-clickable:focus-visible,
:where(:root[data-bs-theme="dark"]) .geo-attribution-btn:focus-visible {
    outline-color: var(--brand-text);
}
:where(:root[data-bs-theme="dark"]) a,
:where(:root[data-bs-theme="dark"]) .page-item:not(.active) .page-link,
:where(:root[data-bs-theme="dark"]) .sidebar-toggle,
:where(:root[data-bs-theme="dark"]) .theme-toggle,
:where(:root[data-bs-theme="dark"]) .kpi .k-ic,
:where(:root[data-bs-theme="dark"]) .kpi .k-val,
:where(:root[data-bs-theme="dark"]) .size-card .sz-head-num,
:where(:root[data-bs-theme="dark"]) .stat-value,
:where(:root[data-bs-theme="dark"]) .modal-title i,
:where(:root[data-bs-theme="dark"]) .filter-toggle,
:where(:root[data-bs-theme="dark"]) .top10-val,
:where(:root[data-bs-theme="dark"]) .detail-section-title,
:where(:root[data-bs-theme="dark"]) .retire-cause-count,
:where(:root[data-bs-theme="dark"]) #schoolsTable thead th,
:where(:root[data-bs-theme="dark"]) #schoolsTable td.sch-num,
:where(:root[data-bs-theme="dark"]) #schoolsTable td.dt-control::before,
:where(:root[data-bs-theme="dark"]) #schoolsTable .school-hist-btn,
:where(:root[data-bs-theme="dark"]) #schoolsTable_wrapper .page-item:not(.active) .page-link {
    color: var(--brand-text);
}
/* The tab pills are <button class="nav-link">, so the `a` selector above never reached
   them — and even as anchors they'd lose, since Bootstrap's own `.nav-link` (0,1,0)
   outranks `a` (0,0,1). Inactive pills inherit --bs-link-color (#7A2E9D), which measures
   2.18:1 on the dark surface. `:not(.active)` is required: the ACTIVE pill sits on a
   solid --brand fill, where --brand-text would be near-invisible; it keeps white. */
:where(:root[data-bs-theme="dark"]) .nav-pills .nav-link:not(.active) { color: var(--brand-text); }
/* Same reasoning as the nav-pills line above: inactive segments carry brand text,
   which is too dark on the dark navbar; the border reads fine at --brand. The
   active segment sits on a solid --brand fill and keeps its white. */
:where(:root[data-bs-theme="dark"]) .view-switch { border-color: var(--brand-text); }
:where(:root[data-bs-theme="dark"]) .view-switch-seg:not(.is-active) { color: var(--brand-text); }
:where(:root[data-bs-theme="dark"]) .btn-outline-primary { color: var(--brand-text); border-color: var(--brand-text); }
:where(:root[data-bs-theme="dark"]) .size-card .sz-selected { box-shadow: inset 0 0 0 2px var(--brand-text); }

/* =============================================================================
   P0 mobile monitoring controls and school cards
   ============================================================================= */
.overview-page .overview-filter-trigger { display: none; }
.overview-page .overview-filter-dialog[open] {
    display: block;
    position: static;
    inset: auto;
    width: auto;
    max-width: none;
    max-height: none;
    margin: 0;
    padding: 0;
    overflow: visible;
    color: inherit;
    background: transparent;
    border: 0;
}
.overview-page .overview-filter-dialog-head { display: none; }

@media (max-width: 768px) {
    .overview-page .ov-filter { margin: 0 0 12px; }
    .overview-page .overview-filter-trigger {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        min-height: 44px;
        padding: 0 14px;
        color: var(--brand-text);
        background: var(--surface);
        border: 1px solid var(--line);
        border-radius: var(--radius-sm);
        font-weight: 700;
    }
    .overview-page .overview-filter-dialog[open] {
        position: fixed;
        inset: auto 0 0;
        width: min(100%, 680px);
        max-width: 100%;
        max-height: min(78dvh, 680px);
        margin: auto;
        padding: 18px;
        overflow-y: auto;
        color: var(--ink);
        background: var(--surface);
        border: 1px solid var(--line);
        border-radius: 20px 20px 0 0;
        box-shadow: 0 -16px 48px rgba(0, 0, 0, .24);
    }
    .overview-page .overview-filter-dialog::backdrop { background: rgba(16, 8, 22, .58); }
    .overview-page .overview-filter-dialog-head {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        margin-bottom: 14px;
    }
    .overview-page .overview-filter-dialog .slicer-bar,
    .overview-page .overview-filter-dialog .slicer-bar .ts-wrapper {
        width: 100%;
    }
    .overview-page .overview-filter-dialog .slicer-bar .ts-wrapper { flex-basis: 100%; }
    .overview-page .overview-filter-dialog .filter-chips { margin-top: 12px; }
}

@media (max-width: 575px) {
    #schoolsTable.mobile-card-table thead { display: none; }
    #schoolsTable.mobile-card-table,
    #schoolsTable.mobile-card-table tbody,
    #schoolsTable.mobile-card-table tr { display: block; width: 100%; }
    #schoolsTable.mobile-card-table tbody tr {
        display: grid;
        grid-template-columns: minmax(0, 1fr) auto;
        grid-template-areas:
            "school school"
            "department total"
            "province drill";
        gap: 6px 10px;
        margin: 10px 0;
        padding: 12px;
        background: var(--surface);
        border: 1px solid var(--line);
        border-radius: var(--radius-md);
    }
    #schoolsTable.mobile-card-table tbody td {
        display: none;
        padding: 0;
        border: 0;
        vertical-align: middle;
    }
    #schoolsTable.mobile-card-table tbody td:nth-child(1) {
        display: flex;
        grid-area: department;
        align-items: center;
        font-weight: 700;
        color: var(--brand-text);
    }
    #schoolsTable.mobile-card-table tbody td:nth-child(2) {
        display: block;
        grid-area: school;
        min-width: 0;
        font-weight: 700;
    }
    #schoolsTable.mobile-card-table tbody td:nth-child(2) > div { align-items: flex-start !important; }
    #schoolsTable.mobile-card-table tbody td:nth-child(3) {
        display: block;
        grid-area: province;
        color: var(--muted);
    }
    #schoolsTable.mobile-card-table tbody td:nth-child(7) {
        display: block;
        grid-area: total;
        align-self: center;
        font-family: "Sora", sans-serif;
        font-weight: 700;
        white-space: nowrap;
    }
    #schoolsTable.mobile-card-table tbody td:nth-child(7)::before {
        content: "นักเรียน ";
        color: var(--muted);
        font-family: "IBM Plex Sans Thai", sans-serif;
        font-size: var(--fs-xs);
        font-weight: 600;
    }
    #schoolsTable.mobile-card-table tbody td:nth-child(8) {
        display: flex;
        grid-area: drill;
        align-items: center;
        justify-content: flex-end;
        min-width: 44px;
        min-height: 44px;
        color: var(--brand-text);
        font-size: var(--fs-xs);
        font-weight: 700;
        white-space: nowrap;
    }
    #schoolsTable.mobile-card-table tbody td:nth-child(8)::after { content: "ดูรายละเอียด"; }
    #schoolsTable.mobile-card-table tbody td:nth-child(8)::before { margin-right: 4px; }

    /* The drill-down child row is a single td[colspan=8]; the card grid above is
       built for real data rows. Forcing this row into the half-width "department"
       area (display:flex) stopped .drill-box shrinking and pushed the page ~80px
       past the viewport. Reset the child row + its td to a plain block, and stack
       the inner 3-col grade grid (max-content 1fr max-content never fit a phone
       card) so each level's grades sit under their own badge. */
    #schoolsTable.mobile-card-table tbody tr.shown + tr {
        display: block;
        margin: 0;
        padding: 0;
        background: none;
        border: 0;
    }
    #schoolsTable.mobile-card-table tbody tr.shown + tr > td {
        display: block;
        grid-area: auto;
        width: auto;
        font-weight: 400;
        color: inherit;
    }
    #schools .drill-groups-grid { grid-template-columns: 1fr; }
    /* Right-align the per-level totals so they line up with each other and with the
       grand total in .drill-foot below, instead of floating left under the grades. */
    #schools .drill-group-total { justify-self: end; }
    /* Grade pills: content-sized on phones so short codes (ป.1 580) pack ~3 per row
       — the base 5.8rem min-width forced 2 — while a wide label (ปวช.1 2,211,
       กศน.มัธยมปลาย) takes only the room it needs instead of overflowing a fixed
       track. A real gap replaces space-between, which needs slack the tight pill
       no longer has. */
    #schools .drill-grade { min-width: 0; flex: 0 0 auto; justify-content: flex-start; gap: .4rem; }
}
