/* ── Programs redesign — design tokens + primitives ──
   New Swiss/institutional visual system for the /programs view (cards
   default, table toggle) and the shared header, per the 2026-08 redesign
   brief. Token names are prefixed --prog- because they collide by name
   with the existing app-wide palette (index.html's own --ink/--accent/etc
   mean different colors and are consumed by hundreds of rules across the
   untouched Institutions/Admissions/Map/Calculator views) -- this file
   coexists with that system rather than replacing it. Same "classic
   multi-file, one global scope, no build step" convention as
   layout-tokens.css and search.js/detail-modal.js/etc.

   Dark mode: follows the EXACT mechanism already established at
   index.html:107-326, not a new one -- a plain :root{} for light
   defaults, a plain :root{} inside @media(prefers-color-scheme:dark) for
   the OS case, and :root[data-theme="dark"]/[data-theme="light"] manual
   overrides that win on attribute-selector specificity alone (no
   :not() guard needed -- [data-theme] is specificity (0,0,2,0) vs plain
   :root's (0,0,1,0), and being inside @media doesn't add specificity).
   Because this file has no build step, the three blocks must be kept in
   sync by hand on every edit -- index.html:240-249 already documents one
   real incident from getting that wrong (a token accidentally
   self-referencing another dark-mode token and resolving to the same
   color as its own background, going invisible). */

:root {
  /* #285 Phase 12: retargeted to mobile's exact lightColors palette
     (mobile/src/theme.js) -- see docs/design/mobile/MOBILE_BRIEF.md §7.
     --prog-ink-60/-45 keep the OLD ink's rgb triple's ratio logic (just
     re-based on the new ink's rgb) rather than being recomputed from
     scratch -- the .66/.60 alphas were hand-verified against the sRGB
     relative-luminance formula for WCAG AA (#285 Phase 9) and the new
     base ink (#14161A vs old #16181c) is close enough in luminance that
     the same alphas should still clear AA, but this needs a real
     contrast re-check during verification, not just an assumption. */
  --prog-ink:         #14161A;
  --prog-ink-60:      rgba(20,22,26,.66);
  --prog-ink-45:      rgba(20,22,26,.60);
  --prog-rule:        rgba(20,22,26,.12);
  --prog-rule-soft:   rgba(20,22,26,.08);
  --prog-surface:     #ffffff;
  --prog-surface-2:   #F5F6F8;
  --prog-canvas:      #EDEEF0;
  /* Single accent, used once per region: active nav item, primary button,
     selected checkbox, current bar in a chart. If two things on screen
     are blue, one of them is wrong. Already identical between web and
     mobile before this phase -- #2A5DB0 was never a web/mobile
     divergence to begin with. */
  --prog-accent:      #2A5DB0;
  --prog-accent-fill: #2A5DB0;
  /* Mobile has one tint tier (navySoft), not web's prior two (accent-bg/
     accent-tint) -- both point at the brief's own explicit #EEF3FB
     rather than inventing an in-between shade with no spec basis. */
  --prog-accent-bg:   #EEF3FB;
  --prog-accent-tint: #EEF3FB;
  --prog-good:        #1F7A4D;
  --prog-good-bg:     #e1f0e7;
  --prog-warn:        #B4451F;
  --prog-warn-bg:     #f7e4da;

  --prog-font-ui:   'Golos Text', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  --prog-font-mono: 'JetBrains Mono', ui-monospace, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    /* #285 Phase 12: mobile's OWN dark palette was flagged by its own
       author comment as "derived... not sourced from a mockup... not
       treated as verified" (mobile/src/theme.js). Web's dark values
       here are already shipped and WCAG-verified (#285 Phase 9) -- and
       turn out nearly identical anyway, since mobile derived its dark
       accent/good/warn from the SAME light accent via the same
       lighten-for-text/punchy-fill-for-buttons logic web already used.
       Kept as the source of truth for this direction rather than
       overwritten by mobile's admitted guess; mobile's darkColors gets
       updated to match THESE afterward instead (see mobile side of this
       phase). Only good-bg/warn-bg move from a derived rgba to mobile's
       explicit solid goodSoft/warnSoft, for the same reason light mode's
       did. */
    --prog-ink:         #e9eaec;
    --prog-ink-60:      rgba(233,234,236,.62);
    /* #285 Phase 9: .48 computed to only ~4.18:1 against --chart-surface
       (#1c2027) -- narrowly failed AA too, not just the light theme.
       .54 clears ~4.9:1. ink-60 was already ~6:1, untouched. */
    --prog-ink-45:      rgba(233,234,236,.54);
    --prog-rule:        rgba(233,234,236,.14);
    --prog-rule-soft:   rgba(233,234,236,.09);
    --prog-surface:     var(--chart-surface);
    --prog-surface-2:   var(--chart-bg);
    --prog-canvas:      #0f1114;
    /* Same dual-role split index.html's own --navy/--navy-fill comment
       already solved once (index.html:219-231): one hex can't be both
       readable-as-TEXT on a dark surface and dark-enough to carry white
       button text. --prog-accent lightens for the text/active-state
       role; --prog-accent-fill stays a punchier mid-blue for the fill
       role. Do not collapse these back into one token. */
    --prog-accent:      #6ea1f2;
    --prog-accent-fill: #3568bc;
    --prog-accent-bg:   #17273f;
    --prog-accent-tint: #121b2c;
    --prog-good:        #3fae82;
    --prog-good-bg:     #16281f;
    --prog-warn:        #e2604a;
    --prog-warn-bg:     #3a1a1a;
  }
}

:root[data-theme="dark"] {
  /* Kept in sync with the @media block above by hand -- see this file's
     top comment for why. */
  --prog-ink:         #e9eaec;
  --prog-ink-60:      rgba(233,234,236,.62);
  --prog-ink-45:      rgba(233,234,236,.54);
  --prog-rule:        rgba(233,234,236,.14);
  --prog-rule-soft:   rgba(233,234,236,.09);
  --prog-surface:     var(--chart-surface);
  --prog-surface-2:   var(--chart-bg);
  --prog-canvas:      #0f1114;
  --prog-accent:      #6ea1f2;
  --prog-accent-fill: #3568bc;
  --prog-accent-bg:   #17273f;
  --prog-accent-tint: #121b2c;
  --prog-good:        #3fae82;
  --prog-good-bg:     #16281f;
  --prog-warn:        #e2604a;
  --prog-warn-bg:     #3a1a1a;
}

:root[data-theme="light"] {
  --prog-ink:         #14161A;
  --prog-ink-60:      rgba(20,22,26,.66);
  --prog-ink-45:      rgba(20,22,26,.60);
  --prog-rule:        rgba(20,22,26,.12);
  --prog-rule-soft:   rgba(20,22,26,.08);
  --prog-surface:     #ffffff;
  --prog-surface-2:   #F5F6F8;
  --prog-canvas:      #EDEEF0;
  --prog-accent:      #2A5DB0;
  --prog-accent-fill: #2A5DB0;
  --prog-accent-bg:   #EEF3FB;
  --prog-accent-tint: #EEF3FB;
  --prog-good:        #1F7A4D;
  --prog-good-bg:     #e1f0e7;
  --prog-warn:        #B4451F;
  --prog-warn-bg:     #f7e4da;
}

/* ── Type scale ──
   Rule from the brief: every NUMBER is mono, every WORD is Golos Text.
   Never bold a heading above 19px -- size carries hierarchy, not weight. */
.prog-label {
  font: 600 10px var(--prog-font-mono);
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--prog-ink-45);
}
.prog-title {
  font: 400 21px var(--prog-font-ui);
  letter-spacing: -.015em;
  color: var(--prog-ink);
  margin: 0; /* #285 Phase 9: this class now also lands on real <h1>/<h2>
                elements (was div-only before), which carry a UA-stylesheet
                default margin a plain div never had -- zero it explicitly
                so using a heading tag doesn't change existing layout. */
}
.prog-title-lg {
  font: 400 26px var(--prog-font-ui);
  letter-spacing: -.015em;
  color: var(--prog-ink);
}
.prog-num {
  font-family: var(--prog-font-mono);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: var(--prog-ink);
}
.prog-num-lg {
  font: 600 26px var(--prog-font-mono);
  letter-spacing: -.02em;
  font-variant-numeric: tabular-nums;
  color: var(--prog-ink);
}

/* ── Primitives ── */

.prog-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 9px 16px;
  font: 500 12.5px var(--prog-font-ui);
  border: none;
  cursor: pointer;
  background: none;
  color: var(--prog-ink);
}
.prog-btn--primary {
  background: var(--prog-accent-fill);
  color: #fff;
}
.prog-btn--secondary {
  border: 1px solid var(--prog-rule);
  color: var(--prog-ink);
}
.prog-btn--tertiary {
  color: var(--prog-ink-60);
  padding-left: 0;
  padding-right: 0;
}
.prog-btn--active {
  /* #285 Phase 10 follow-up: was `background: var(--prog-ink)`, a real,
     severe contrast bug caught by the user in dark mode -- --prog-ink is
     a TEXT-color token that intentionally flips between themes (dark ink
     in light mode, light ink in dark mode, so it always reads against its
     own surface). Using it as a filled button's background broke exactly
     because of that: in dark mode it resolves to near-white (#e9eaec),
     paired with this rule's own white text -- measured live at a 1.2:1
     contrast ratio (WCAG AA needs 4.5:1 for normal text). The visual
     INTENT was a solid near-black chip, which is genuinely what
     --prog-ink already looked like in light mode (#16181c) -- so this is
     hardcoded to that same literal value, theme-invariant, rather than
     switched to a different color: same look as before in light mode,
     now correct in dark mode too. */
  background: #16181c;
  color: #fff;
}

.prog-input {
  height: 40px;
  padding: 0 13px;
  font: 400 13.5px var(--prog-font-ui);
  /* #285 Phase 10 follow-up: was a literal rgba(22,24,28,.22) per the
     brief's original spec -- authored when dark mode was still listed as
     a non-goal (Phase 1-2's own decision #2 overrode that), so this one
     border was never revisited for it. Theme-invariant near-black at low
     opacity reads as a barely-there border in light mode and an
     essentially invisible one in dark mode (dark-on-dark). Swapped to the
     token every other input-like border in this file already uses. */
  border: 1px solid var(--prog-rule);
  background: var(--prog-surface);
  color: var(--prog-ink);
}
.prog-input::placeholder { color: var(--prog-ink-45); }

.prog-checkbox {
  width: 15px;
  height: 15px;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  font: 600 9px var(--prog-font-mono);
  /* #285 Phase 10 follow-up: same theme-invariant-border issue as
     .prog-input above -- a checkbox's own outline is its whole visible
     affordance when unchecked (its "✓" is intentionally color:transparent
     until .checked), so an invisible-in-dark-mode border effectively made
     every unchecked checkbox invisible. */
  border: 1px solid var(--prog-rule);
  color: transparent;
  background: none;
  cursor: pointer;
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}
.prog-checkbox.checked {
  background: var(--prog-accent-fill);
  border-color: var(--prog-accent-fill);
  color: #fff;
}

.prog-badge {
  display: inline-block;
  font: 600 9px var(--prog-font-mono);
  letter-spacing: .06em;
  padding: 2px 5px;
  background: var(--prog-accent-bg);
  color: var(--prog-accent);
}

/* Track 56x3px, fill width computed in JS as min(competition/9, 1);
   fill color: --prog-good <=2.5, --prog-ink 2.6-4.9, --prog-warn >=5 --
   see programs-view.js's competitionBarColor(). */
.prog-competition-bar {
  display: inline-block;
  width: 56px;
  height: 3px;
  background: #e6e6e2;
}
.prog-competition-bar__fill {
  display: block;
  height: 100%;
}

.prog-table-row {
  position: relative;
  display: grid;
  grid-template-columns: 30px 40px 1fr 120px 96px 84px 120px 110px;
  align-items: center;
  padding: 12px 24px;
  border-bottom: 1px solid var(--prog-rule-soft);
}
/* #285 Phase 11 follow-up: reported live as "not visible edge" in dark
   mode -- measured, not assumed: --prog-rule-soft's dark value
   (rgba(233,234,236,.09)) against --prog-surface's dark value (#1c2027)
   computes to a ~1.27:1 contrast ratio, far under WCAG 1.4.11's 3:1
   minimum for UI-component boundaries -- basically imperceptible.
   Raising the token itself would ripple into every other consumer
   (headers, card borders, inputs) that nobody flagged as broken; a
   background stripe on alternate rows is the standard, robust fix for
   "which row am I looking at" that doesn't depend on a hairline border's
   contrast at all. Applies to both Programs' and Institutions' tables
   (.prog-table-row is the shared base class) since both share the exact
   same underlying border. */
.prog-table-row:nth-child(even) {
  background: var(--prog-surface-2);
}
/* Stacks the compare checkbox above .prog-table-row__hitarea (see that
   rule's own comment) -- the checkbox is a normal grid cell, not
   absolutely positioned, so without this it would paint BELOW the
   absolutely-positioned hitarea and become unclickable. */
.prog-table-row .prog-checkbox { position: relative; z-index: 1; }
.prog-table-header__sortbtn {
  border: none;
  background: none;
  padding: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}
.prog-table-header {
  display: grid;
  grid-template-columns: 30px 40px 1fr 120px 96px 84px 120px 110px;
  padding: 0 24px 9px;
  /* #285 Phase 10 follow-up: same theme-invariant-border issue -- see
     .prog-input's own comment above. */
  border-bottom: 1px solid var(--prog-rule);
  font: 600 9.5px var(--prog-font-mono);
  letter-spacing: .07em;
  color: var(--prog-ink-45);
}
.prog-table-header__sorted { color: var(--prog-accent); }

/* Logo: 1:1 on a white square, 1px --prog-rule border, object-fit:
   contain, 4px inner padding -- never cropped. 44px on cards (overlapping
   the photo band), 28px in table rows. Missing logo falls back to the
   same square carrying the institution's initials -- reuses the existing
   instInitials() helper and its onerror-swap idiom (index.html:6582,
   7907-7910), just restyled square/mono instead of the current
   rounded/bold treatment. */
.prog-logo {
  display: flex;
  align-items: center;
  justify-content: center;
  background: #fff;
  border: 1px solid var(--prog-rule);
  flex: none;
  overflow: hidden;
}
.prog-logo img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  padding: 4px;
  box-sizing: border-box;
}
.prog-logo--card {
  width: 44px;
  height: 44px;
  position: absolute;
  left: 14px;
  bottom: -18px;
}
.prog-logo--table {
  width: 28px;
  height: 28px;
}
.prog-logo-fallback {
  font: 600 11px var(--prog-font-mono);
  /* #285 Phase 10 follow-up: was var(--prog-ink-45), a real, severe
     contrast bug in dark mode -- .prog-logo's own background is a
     theme-INVARIANT #fff (deliberate: real institution logo images
     assume a white tile), but ink-45 is a TEXT token that flips to a
     LIGHT color in dark mode, giving light text on white -- measured
     live at 1.2:1 (WCAG AA needs 4.5:1). This is the primary visual
     identity for ~98.6% of institutions (logos populate only ~1.4%,
     per Phase 1-2's own research), so it's not a minor case. Fixed to
     the literal value ink-45 already correctly resolved to in LIGHT
     mode -- same reasoning as the .prog-btn--active fix above: the
     background doesn't change with theme, so neither should this. */
  color: rgba(22,24,28,.60);
}
.prog-logo--table .prog-logo-fallback { font-size: 8px; }

/* Photo band: 16:9, object-fit:cover, no rounding, no overlay gradient.
   112px desktop. Missing photo -> --prog-surface-2 fill with initials,
   never a broken image or stock substitute. */
.prog-photo {
  position: relative;
  height: 112px;
  background: var(--prog-surface-2);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.prog-photo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.prog-photo-fallback {
  font: 500 11px var(--prog-font-mono);
  letter-spacing: .05em;
  color: var(--prog-ink-45);
}

/* ── Shared header (rebuilt, used by every view) ── */
.prog-header {
  display: flex;
  align-items: center;
  gap: 22px;
  padding: 0 24px;
  height: 56px;
  /* #285 Phase 10 follow-up: same theme-invariant-border issue -- see
     .prog-input's own comment above. This one's site-wide (every view
     shares this header), so worth the same fix even though a missing
     bottom rule is more cosmetic than the checkbox/input cases. */
  border-bottom: 1px solid var(--prog-rule);
  background: var(--prog-surface);
  position: relative; /* anchors .prog-header__mobile-menu (Phase 8) */
}
.prog-header__wordmark {
  display: flex;
  align-items: baseline;
  gap: 7px;
  flex: none;
}
.prog-header__wordmark-main {
  font: 700 16px var(--prog-font-ui);
  letter-spacing: -.02em;
  color: var(--prog-ink);
}
.prog-header__wordmark-sub {
  font: 500 10.5px var(--prog-font-mono);
  letter-spacing: .1em;
  color: var(--prog-accent);
}
.prog-header__nav {
  display: flex;
  gap: 22px;
  margin-left: 6px;
}
.prog-header__nav-item {
  font: 400 13px var(--prog-font-ui);
  color: var(--prog-ink-60);
  padding-bottom: 2px;
  border-bottom: 2px solid transparent;
  background: none;
  border-left: none;
  border-right: none;
  border-top: none;
  cursor: pointer;
}
.prog-header__nav-item.active {
  font-weight: 500;
  color: var(--prog-ink);
  border-bottom-color: var(--prog-accent);
}
.prog-header__right {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-left: auto;
}
.prog-header__lang {
  font: 500 11px var(--prog-font-mono);
  color: var(--prog-ink-45);
  cursor: pointer;
}
.prog-header__lang .dim { color: var(--prog-ink-45); opacity: .6; }

/* Restyled for the new light header (the old .lang-btn rule was tuned for
   the OLD dark #14171c header bar -- #b7bfcc text on THIS header's white
   background would be near-invisible). Only usage site left after the
   redesign is this header's lang switcher. */
.lang-btn {
  padding: 3px 2px;
  border: none;
  background: transparent;
  color: var(--prog-ink-45);
  cursor: pointer;
  font: 500 11px var(--prog-font-mono);
}
.lang-btn:hover { color: var(--prog-ink); }
.lang-btn.active { color: var(--prog-accent); font-weight: 600; }

/* Theme toggle / follows / NMT-quick-access -- icon-only buttons in the
   header's right cluster. Square, no fill, hover darkens per the "no
   shadows/gradients, hairline separation" rule. */
.prog-header__icon-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border: none;
  background: none;
  color: var(--prog-ink-45);
  cursor: pointer;
}
.prog-header__icon-btn:hover { color: var(--prog-ink); }
.prog-header__icon-btn .ic { width: 16px; height: 16px; }
.prog-header__icon-btn .my-follows-count {
  position: absolute;
  top: 2px;
  right: 2px;
  background: var(--prog-accent-fill);
  color: #fff;
  font: 600 9px var(--prog-font-mono);
  min-width: 14px;
  height: 14px;
  border-radius: 7px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 3px;
}

/* ── Programs view layout ── */
#programs-view {
  max-width: var(--content-max);
  margin: 0 auto;
  background: var(--prog-surface);
  color: var(--prog-ink);
}
.prog-searchbar {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 16px 24px;
  border-bottom: 1px solid var(--prog-rule);
  background: var(--prog-surface-2);
}
.prog-layout {
  display: grid;
  grid-template-columns: 264px 1fr;
}
/* #285 Phase 11: Institutions' own sidebar layout -- same two-column
   grid as .prog-layout above (264px rail + flexible results panel),
   kept as its own class rather than reusing .prog-layout directly since
   the two wrap genuinely different results-panel contents
   (#tabsContainer + the Institutions #resultsContainer, vs Programs'
   own results header + sort bar + #programsResults). */
.inst-layout {
  display: grid;
  grid-template-columns: 264px 1fr;
  /* #285 Phase 11 follow-up: reported live -- the rail/results grid
     started flush at the viewport edge while the search bar above it
     (.controls/.search-card, index.html's own OLD centered-max-width
     container) stayed inset and centered, so the two rows had different
     left edges and never lined up. Programs' own .prog-layout avoids
     this because it and .prog-searchbar are both children of the SAME
     already-centered #programs-view wrapper (programs.css:495-499);
     Institutions has no equivalent shared wrapper -- .controls and
     .inst-layout are siblings -- so this needs the identical max-width
     centering applied here directly instead, matching .controls'
     var(--content-max) + margin:0 auto exactly (index.html:536-540). */
  max-width: var(--content-max);
  margin: 0 auto;
  /* .controls (the search bar's own wrapper) applies `padding: 16px 16px 0`
     -- .search-card's actual left/right edges sit 16px INSIDE .controls'
     own max-width box, not flush with it. Matching only max-width+margin
     above got .inst-layout's OUTER edge to line up with .controls' outer
     edge, but left it 16px wider than .search-card on each side. This
     closes that gap so the rail/results grid's edges land exactly where
     the search bar's own content does. */
  padding: 0 16px;
}
/* #tabsContainer/#resultsContainer keep their own pre-existing padding
   (index.html:880-884/1272-1276), but their max-width+margin:auto
   self-centering (designed for when they were standalone full-width
   blocks, each independently centering itself in the viewport) actively
   fights their new role as .inst-layout GRID CHILDREN -- a block trying
   to center itself at up to 1320px wide inside a ~1000px grid track
   overflows the track rather than filling it, which is what actually
   produced the "not aligned" report: the results column's right edge
   landed well past the search bar's own right edge. Dropping max-width/
   margin here lets each simply fill its grid column instead. */
.inst-layout #tabsContainer, .inst-layout #resultsContainer {
  max-width: none;
  margin: 0;
}
/* The actual remaining cause of the misalignment (confirmed by measuring
   computed grid-template-columns directly -- neither of the two rules
   above moved it): the classic CSS Grid "1fr overflow" gotcha. Grid items
   default to min-width:auto, which means a track can never shrink below
   its content's min-content size -- #resultsContainer's card grid has
   plenty of cards whose combined min-content width exceeds the 1fr
   column's fair share, so the column (and everything in it) expanded to
   fit the content instead of respecting 1fr, pushing the results panel's
   right edge out past the search bar's own. min-width:0 on the grid ITEM
   is the standard fix -- lets it shrink to its assigned track width and
   wrap/scroll its own content instead of blowing out the track. */
.inst-layout > .prog-results-panel {
  min-width: 0;
}
/* #285 Phase 11b: Cards/Table view toggle, table half. Reuses
   .prog-table-header/.prog-table-row directly (border/padding/font/
   checkbox-stacking/hitarea-z-index all come from those base rules) --
   only the column widths differ from Programs' own 8-column layout,
   since an institution row has a different field set (no NMT-min/
   chance columns; adds a funding-type column instead). overflow-x:auto
   on the wrapper is a safety net at the narrow end of the desktop-only
   range this view is shown in (1000px-1200px-ish) rather than a real
   mobile treatment -- table view is already hidden below 1000px (see
   renderResults()'s own isMobile check + .prog-view-toggle's mobile
   CSS), so this only ever has to cover genuine desktop widths. */
/* #285 Phase 11 follow-up: reported live ("filters not used any more?")
   and confirmed with the user -- #filtersToggleBtn (the "Фільтри ▾"
   button next to the search box) only ever toggled the OLD collapsible
   filter panel, since replaced by the always-visible
   #institutionsFilterRail sidebar; toggleInstitutionsFilterSheet() only
   has a real effect at the rail's own mobile-sheet breakpoint (max-
   width:1000px), so on desktop the button was a dead click that looked
   interactive. Hidden here at the same breakpoint the sheet mechanism
   itself uses -- unchanged on mobile (still the real, functional sheet
   toggle, badge included). #filterCountBadge lives inside this button,
   so it's invisible on desktop too now; that's fine -- each facet's own
   rail row already shows its own "N selected" there, and the aggregate
   count still shows on mobile plus in #scrollTopFilterBadge once
   scrolled. (Tests that asserted this badge's visibility were updated to
   check its text content instead, which #updateFilterCountBadge() sets
   regardless of the button's own display state.) */
@media (min-width: 1001px) {
  #filtersToggleBtn {
    display: none;
  }
}
.inst-table { overflow-x: auto; }
.inst-table-header, .inst-table-row {
  grid-template-columns: 30px 40px 1fr 130px 90px 130px 120px;
}
.prog-rail {
  border-right: 1px solid var(--prog-rule);
  padding: 20px;
  background: var(--prog-surface-2);
  display: flex;
  flex-direction: column;
  gap: 20px;
}
/* #285 audit P2: as a plain grid item the rail was stretched to the full
   row height -- measured at 10,055px with its content ending around
   1,297px, so the left third of the window was blank for ~8,700px and
   changing a filter meant scrolling back to the top of an 11-viewport page
   (Region alone sits ~1,143px down). align-self:start stops the stretch;
   sticky keeps the filters on screen while the results scroll. max-height
   + overflow-y so a rail taller than the viewport scrolls within itself
   rather than clipping.

   Deliberately behind min-width, NOT on the base .prog-rail rule. Below
   641px this element is not a sidebar at all -- it is the off-canvas
   bottom sheet, anchored with left/right/bottom and hidden by
   translateY(100%). Those #id rules override `position`, so `sticky` was
   harmless, but nothing there resets `top`: an over-constrained fixed box
   resolves in favour of `top`, so a stray `top: 0` re-anchored the sheet
   to the TOP of the viewport and translateY(100%) then parked it across
   the bottom 146px -- directly over the bottom tab bar, swallowing every
   tap on it. That took out 27 mobile e2e tests before this was scoped. */
@media (min-width: 641px) {
  .prog-rail {
    position: sticky;
    top: 0;
    align-self: start;
    max-height: 100vh;
    overflow-y: auto;
  }
}
.prog-rail__divider { height: 1px; background: var(--prog-rule); }
/* #285 Phase 9: .prog-rail__row is now a real <button> (was a <div
   onclick>, unreachable by keyboard) -- width/border/background/text-align
   here are the button-reset this needed; the rest of the rule is
   unchanged from before. */
.prog-rail__row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  font: 400 13px var(--prog-font-ui);
  color: var(--prog-ink);
  padding: 0 0 9px;
  border: none;
  border-bottom: 1px solid var(--prog-rule-soft);
  background: none;
  text-align: left;
  cursor: pointer;
}
.prog-rail__row:last-child { border-bottom: none; padding-bottom: 0; }
.prog-rail__row-value {
  font: 500 11px var(--prog-font-mono);
  color: var(--prog-ink-45);
}
.prog-rail__row-value--active { color: var(--prog-accent); }
.prog-rail__must-have { display: flex; flex-direction: column; gap: 9px; }
/* Same button-reset reasoning as .prog-rail__row -- was <label><span
   class="prog-checkbox">...</span>Text</label>, where only the tiny span
   was clickable/reachable at all; now the whole row is a real button. */
.prog-rail__must-have button {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  font: 400 13px var(--prog-font-ui);
  color: var(--prog-ink);
  border: none;
  background: none;
  padding: 0;
  text-align: left;
  cursor: pointer;
}

/* Price histogram + range brush */
.prog-histogram {
  display: flex;
  align-items: flex-end;
  gap: 2px;
  height: 46px;
}
.prog-histogram__bar { flex: 1; background: #d8d8d4; min-height: 2px; }
.prog-histogram__bar--in-range { background: var(--prog-accent-fill); }
.prog-slider-track {
  height: 2px;
  background: #e2e2de;
  position: relative;
  margin-top: 8px;
}
.prog-slider-fill {
  position: absolute;
  top: 0; bottom: 0;
  background: var(--prog-accent-fill);
}
.prog-slider-handle {
  position: absolute;
  top: -4px;
  width: 10px; height: 10px;
  margin-left: -5px;
  background: var(--prog-surface);
  border: 2px solid var(--prog-accent-fill);
  border-radius: 50%;
  cursor: grab;
}
.prog-price-inputs {
  display: flex;
  justify-content: space-between;
  margin-top: 10px;
  gap: 8px;
}
.prog-price-inputs input {
  width: 100%;
  font: 500 12px var(--prog-font-mono);
  color: var(--prog-ink);
  padding: 4px 7px;
  border: 1px solid var(--prog-rule);
  background: var(--prog-surface);
}

/* Results header */
.prog-results__header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  padding: 18px 24px 16px;
}
.prog-results__sub {
  font: 400 12.5px var(--prog-font-ui);
  color: var(--prog-ink-60);
  margin-top: 5px;
}
.prog-view-toggle { display: flex; gap: 10px; align-items: center; }
.prog-view-toggle .prog-btn[data-view].active {
  /* Same #285 Phase 10 follow-up contrast fix as .prog-btn--active above
     -- this exact rule is what made the Cards/Table toggle's active
     button (white text) nearly invisible in dark mode. */
  background: #16181c;
  color: #fff;
  border-color: #16181c;
}

/* Card grid */
.prog-card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  border-top: 1px solid var(--prog-rule);
  border-left: 1px solid var(--prog-rule);
}
@media (max-width: 1199px) { .prog-card-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 767px)  { .prog-card-grid { grid-template-columns: 1fr; } }

.prog-card {
  background: var(--prog-surface);
  /* Reported live as a "grey box" -- was gap:1px + a --prog-rule
     background on the GRID CONTAINER (the standard hairline-grid trick),
     which draws that background through any UNFILLED trailing cell too
     (e.g. 5 cards in a 3-column grid leaves one empty track showing a
     full-height solid grey rectangle, since Grid never fills a gap with
     content). Borders on each real card instead -- an empty track then
     has nothing drawn in it at all, matching what "no card" should look
     like. Right/bottom only (not left/top) since the container's own
     border-left/border-top already frame the first row/column; every
     card thus "owns" exactly one interior seam to its right and below,
     same convention .prog-table-row already uses for its own border-bottom. */
  border-right: 1px solid var(--prog-rule);
  border-bottom: 1px solid var(--prog-rule);
  position: relative;
}
/* #285 Phase 9: invisible full-cover "open detail" button, sits UNDER the
   compare checkbox (z-index 0 vs. the checkbox's 1 below) since a <button>
   can't contain another interactive control -- see _renderProgramsCards()'
   own comment in programs-view.js. Same click behavior as the old
   <div onclick> card (whole card navigates, checkbox stops propagation),
   now keyboard-reachable. Applies identically to .prog-table-row and
   .prog-mrow further down. */
.prog-card__hitarea, .prog-table-row__hitarea, .prog-mrow__hitarea {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: none;
  background: none;
  padding: 0;
  cursor: pointer;
  z-index: 0;
}
.prog-card__compare-check {
  position: absolute;
  right: 12px;
  top: 12px;
  z-index: 1;
  width: 20px;
  height: 20px;
  background: rgba(255,255,255,.94);
  /* Reported live: "checkbox to select almost not visible" -- sitting on
     an arbitrary photo, the base .prog-checkbox rule's 1px --prog-rule
     border (designed for a flat --prog-surface background) could vanish
     against a light or busy photo. A fixed dark border + shadow gives it
     a real edge regardless of what's underneath. */
  border-color: rgba(0,0,0,.45);
  box-shadow: 0 1px 4px rgba(0,0,0,.35);
}
.prog-card__compare-check.checked {
  box-shadow: 0 1px 4px rgba(0,0,0,.45);
}
.prog-card__body { padding: 28px 18px 18px; }
.prog-card__name { display: flex; align-items: center; gap: 7px; }
.prog-card__name-text { font: 500 14.5px var(--prog-font-ui); color: var(--prog-ink); }
.prog-card__inst { font: 400 12px var(--prog-font-ui); color: var(--prog-ink-60); margin-top: 4px; }
/* Reported live: "looks like duplicates with different scores per seat" --
   two different Program rows at the same institution can legitimately
   share a program name (different faculties each running their own
   "Стоматологія ортопедична" offer, say), differing only in price/
   competition with nothing on the card explaining why. faculty_name was
   already returned by /api/v1/programs and simply never rendered. */
.prog-card__faculty { font: 400 11px var(--prog-font-ui); color: var(--prog-ink-45); margin-top: 2px; }
.prog-card__price { display: flex; align-items: baseline; gap: 6px; margin: 15px 0 3px; }
.prog-card__price-unit { font: 400 11.5px var(--prog-font-ui); color: var(--prog-ink-45); }
.prog-card__note { font: 400 11.5px var(--prog-font-ui); }
.prog-card__stats {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 10px;
  margin-top: 15px;
  padding-top: 13px;
  border-top: 1px solid var(--prog-rule-soft);
}
.prog-card__stat-label { font: 600 9px var(--prog-font-mono); letter-spacing: .07em; color: var(--prog-ink-45); }
.prog-card__stat-value { font: 400 14px var(--prog-font-mono); color: var(--prog-ink); margin-top: 3px; }

/* Compare tray */
.pcmp-tray {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 24px;
  /* Same #285 Phase 10 follow-up contrast fix as .prog-btn--active above
     -- this tray's own white/near-white text (.pcmp-tray__label,
     rgba(255,255,255,.5) etc) was nearly unreadable against var(--prog-ink)
     in dark mode for the same reason. */
  background: #16181c;
  position: sticky;
  bottom: 0;
}
.pcmp-tray__chips { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.pcmp-tray__label { font: 500 11px var(--prog-font-mono); letter-spacing: .07em; color: rgba(255,255,255,.5); }
.pcmp-tray__chip {
  font: 400 12.5px var(--prog-font-ui);
  padding: 6px 11px;
  border: 1px solid rgba(255,255,255,.28);
  color: #fff;
  display: flex;
  align-items: center;
  gap: 6px;
}
.pcmp-tray__chip button { background: none; border: none; color: inherit; cursor: pointer; font: inherit; }

@media (max-width: 900px) {
  .prog-layout { grid-template-columns: 1fr; }
  .prog-rail { border-right: none; border-bottom: 1px solid var(--prog-rule); }
}

.prog-region-popover {
  position: absolute;
  left: 0;
  top: 100%;
  margin-top: 4px;
  width: 220px;
  max-height: 280px;
  overflow-y: auto;
  background: var(--prog-surface);
  border: 1px solid var(--prog-rule);
  padding: 8px;
  z-index: 20;
  cursor: default;
}
/* #285 Phase 9: real <button>, was <label><span class="prog-checkbox">
   (only the span was reachable/clickable). Width/border/background/
   text-align are the button-reset. */
.prog-region-popover__row {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  padding: 6px 4px;
  font: 400 12.5px var(--prog-font-ui);
  color: var(--prog-ink);
  border: none;
  background: none;
  text-align: left;
  cursor: pointer;
}

/* ── Institution detail modal restyle (redesign #285 Phase 3) ──
   Decided with the user: restyle detail-modal.js's rendered HTML in place
   rather than rewrite it -- showDetail() already behaves like a real route
   (pushState to /institution/:id, popstate, SEO via
   functions/institution/[id].js), and its ~1500 lines of faculty-filter/
   price-trend/admission-timeline state work correctly today. Every rule
   inside #detail-modal (.card-hero, .badge, .info-table, .program-card,
   .fac-group, .timeline-item, etc., defined in index.html's main <style>
   block) already paints through the semantic tokens --ink/--surface/
   --surface-sunk/--line/--navy/--navy-dark/--navy-soft/--good*/--warn*
   rather than hardcoded hex -- and --text/--text-secondary/--primary/--bg/
   --border are themselves already just aliases for --ink/--ink-soft/
   --navy/--paper/--line (index.html:161-174). So repainting the ENTIRE
   modal to the new visual system is a single scoped override of those
   base tokens to their --prog-* counterparts, not ~60 individual per-class
   overrides -- CSS custom properties resolve `var()` at the point of use
   by walking the live inheritance chain, so any element inside
   #detail-modal referencing var(--text) sees THIS block's --ink, not
   :root's, with zero changes to detail-modal.js itself. No new dark-mode
   branching is needed here either: --prog-ink et al already flip for OS/
   manual dark mode inside programs.css's own token blocks above, so this
   override inherits that correctness for free.

   --brass/--brass-soft/--brass-ink/--brass-fill/--brass-on (the "contract
   education" / "private ownership" amber accent) are deliberately NOT
   remapped -- the --prog-* system has no third categorical hue to give
   them, and inventing one during a restyle-in-place pass would be a new
   design decision, not a repaint. They keep their original amber values
   unchanged; only the ink/surface/line/blue/green/red roles move to the
   new palette.

   #priceTrendSection's chart (--chart-bg/--chart-surface/--chart-accent/
   etc) is its own always-dark visual language, independent of site theme
   before this change -- left untouched, out of scope. */
/* #285 Phase 9c correction: --text/--text-secondary/--bg/--border/
   --primary are declared ONCE, at :root, as var(--ink)/var(--ink-soft)/
   var(--paper)/var(--line)/var(--navy). This comment block above used to
   claim that's enough -- "any element inside #detail-modal referencing
   var(--text) sees THIS block's --ink, not :root's" -- but that's wrong.
   A custom property's SPECIFIED value is a token stream, and when that
   stream is itself `var(--ink)`, it gets substituted using --ink's value
   AT THE ELEMENT WHERE THAT DECLARATION LIVES (:root), not re-substituted
   per descendant -- the RESULT of that substitution is what inherits down,
   already fixed. Confirmed live: querying --text/--border on #detail-modal
   returned the OLD sitewide value even with this block's --ink/--line
   correctly overridden. Any rule using var(--text)/var(--text-secondary)/
   var(--bg)/var(--border)/var(--primary) instead of the base token
   directly was silently NOT repainted by this remap, undetected since
   Phase 3. Fixed by also redeclaring the alias names themselves here --
   now THEIR var(--ink) etc reference resolves at THIS scope. --accent
   stays unmapped (aliases --brass, deliberately -- see this file's
   existing comment on why). */
#detail-modal {
  --ink:          var(--prog-ink);
  --ink-soft:     var(--prog-ink-60);
  --ink-faint:    var(--prog-ink-45);
  --paper:        var(--prog-canvas);
  --surface:      var(--prog-surface);
  --surface-sunk: var(--prog-surface-2);
  --line:         var(--prog-rule);
  --navy:         var(--prog-accent-fill);
  --navy-dark:    var(--prog-accent);
  --navy-fill:    var(--prog-accent-fill);
  --navy-soft:    var(--prog-accent-bg);
  --good:         var(--prog-good);
  --good-dark:    var(--prog-good);
  --good-soft:    var(--prog-good-bg);
  --warn:         var(--prog-warn);
  --warn-dark:    var(--prog-warn);
  --warn-soft:    var(--prog-warn-bg);
  --text:           var(--ink);
  --text-secondary: var(--ink-soft);
  --bg:             var(--paper);
  --border:         var(--line);
  --primary:        var(--navy);
}
#detail-modal .modal-content {
  font-family: var(--prog-font-ui);
}
#detail-modal .prog-num,
#detail-modal .program-fee-amount,
#detail-modal .program-fee-total {
  font-family: var(--prog-font-mono);
}

/* ── Program detail (redesign #285 Phase 3, web/program-detail.js) ──
   New content, not a restyle of anything pre-existing, so these use
   --prog-* tokens directly (same as the rest of this file) rather than
   going through the #detail-modal alias block above -- that block exists
   only to repaint detail-modal.js's OWN pre-existing classes without
   touching its ~1500 lines of JS. */
.prog-detail-breadcrumb {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 6px;
}
.prog-detail-code {
  background: var(--prog-accent-bg);
  color: var(--prog-accent);
  padding: 2px 6px;
}
.prog-detail-breadcrumb__inst {
  font: 400 13px var(--prog-font-ui);
  color: var(--prog-accent);
  cursor: pointer;
  border: none;
  background: none;
  padding: 0;
}
.prog-detail-breadcrumb__inst:hover { text-decoration: underline; }
.prog-detail-name {
  margin: 0 0 6px;
  color: var(--prog-ink);
}
.prog-detail-meta {
  font: 400 13.5px var(--prog-font-ui);
  color: var(--prog-ink-60);
  margin-bottom: 16px;
}
.prog-detail-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 20px;
}
.prog-detail-stats {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 1px;
  background: var(--prog-rule);
  border: 1px solid var(--prog-rule);
  margin-bottom: 24px;
}
.prog-detail-stat {
  background: var(--prog-surface);
  padding: 12px 14px;
}
.prog-detail-stat__value {
  font-size: 19px;
  margin-top: 4px;
}
.prog-detail-section-title {
  font: 600 11px var(--prog-font-mono);
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--prog-ink-45);
  margin: 24px 0 10px;
}
.prog-detail-histogram {
  display: flex;
  align-items: flex-end;
  gap: 3px;
  height: 64px;
}
.prog-detail-hist-bar {
  flex: 1;
  background: var(--prog-rule);
}
.prog-detail-hist-bar.current {
  background: var(--prog-accent-fill);
}
.prog-detail-hist-caption {
  font: 400 12px var(--prog-font-ui);
  color: var(--prog-ink-45);
  margin-top: 6px;
}
.prog-detail-chances {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}
.prog-detail-chances__score {
  font-size: 21px;
  color: var(--prog-ink);
}
.prog-detail-chances__cutoff {
  font: 400 12.5px var(--prog-font-ui);
  color: var(--prog-ink-60);
}
.prog-detail-keydates {
  display: flex;
  align-items: center;
  gap: 8px;
  font: 400 13.5px var(--prog-font-ui);
  color: var(--prog-ink);
}
.prog-detail-desc {
  font: 400 13.5px var(--prog-font-ui);
  color: var(--prog-ink-60);
  line-height: 1.6;
}
.prog-detail-empty {
  font: 400 13px var(--prog-font-ui);
  color: var(--prog-ink-45);
}
@media (max-width: 600px) {
  .prog-detail-stats { grid-template-columns: repeat(2, 1fr); }
}

/* ── Compare route (redesign #285 Phase 5, web/program-compare.js) ──
   #program-compare-modal keeps its own chrome (header/close button) on the
   old token system -- but gets the same scoped custom-property remap
   #detail-modal already uses (see that block's own comment), so the modal
   frame around the new grid doesn't look like two different eras of the
   app stitched together. The grid content itself is new markup, so it
   uses --prog-* directly, same as program-detail.js's own CSS. */
/* Same #285 Phase 9c fix as #detail-modal's own comment explains --
   --text/--text-secondary/--bg/--border/--primary need their own
   redeclaration here too, or their var(--ink) etc resolves against
   :root's un-remapped value instead of this scope's. */
#program-compare-modal {
  --ink:          var(--prog-ink);
  --ink-soft:     var(--prog-ink-60);
  --ink-faint:    var(--prog-ink-45);
  --paper:        var(--prog-canvas);
  --surface:      var(--prog-surface);
  --surface-sunk: var(--prog-surface-2);
  --line:         var(--prog-rule);
  --navy:         var(--prog-accent-fill);
  --navy-dark:    var(--prog-accent);
  --navy-fill:    var(--prog-accent-fill);
  --navy-soft:    var(--prog-accent-bg);
  --good:         var(--prog-good);
  --good-dark:    var(--prog-good);
  --good-soft:    var(--prog-good-bg);
  --warn:         var(--prog-warn);
  --warn-dark:    var(--prog-warn);
  --warn-soft:    var(--prog-warn-bg);
  --text:           var(--ink);
  --text-secondary: var(--ink-soft);
  --bg:             var(--paper);
  --border:         var(--line);
  --primary:        var(--navy);
}
#program-compare-modal .modal-content {
  font-family: var(--prog-font-ui);
}

.pcmp-gone-note {
  font: 400 12.5px var(--prog-font-ui);
  color: var(--prog-warn);
  margin-bottom: 10px;
}
.pcmp-hide-toggle {
  display: flex;
  align-items: center;
  gap: 7px;
  font: 400 12.5px var(--prog-font-ui);
  color: var(--prog-ink-60);
  cursor: pointer;
  margin-bottom: 14px;
}

/* Sticky label column + horizontal scroll, per the brief's exact spec for
   many-column comparisons on narrow viewports. */
.pcmp-wrap {
  overflow-x: auto;
}
.pcmp-grid {
  display: grid;
  grid-template-columns: 150px repeat(var(--compare-cols, 1), minmax(160px, 1fr));
  border: 1px solid var(--prog-rule);
  min-width: max-content;
}
.pcmp-label {
  position: sticky;
  left: 0;
  z-index: 1;
  background: var(--prog-surface-2);
  padding: 10px 12px;
  font: 600 10px var(--prog-font-mono);
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--prog-ink-45);
  border-bottom: 1px solid var(--prog-rule);
  border-right: 1px solid var(--prog-rule);
  display: flex;
  align-items: center;
}
.pcmp-cell {
  padding: 10px 12px;
  font: 400 13px var(--prog-font-ui);
  color: var(--prog-ink);
  border-bottom: 1px solid var(--prog-rule);
  border-right: 1px solid var(--prog-rule-soft);
  display: flex;
  align-items: center;
  gap: 6px;
}
.pcmp-cell--best {
  background: var(--prog-accent-tint);
}
.pcmp-badge {
  font-size: 8px;
}
.pcmp-header-cell {
  position: relative;
  flex-direction: column;
  align-items: flex-start;
  gap: 3px;
  background: var(--prog-surface-2);
}
.pcmp-prog-link {
  font: 500 13.5px var(--prog-font-ui);
  color: var(--prog-ink);
  text-decoration: none;
  cursor: pointer;
}
.pcmp-prog-link:hover { text-decoration: underline; }
.pcmp-inst-link {
  font: 400 11.5px var(--prog-font-ui);
  color: var(--prog-accent);
  text-decoration: none;
  cursor: pointer;
}
.pcmp-inst-link:hover { text-decoration: underline; }
.pcmp-remove {
  position: absolute;
  top: 6px;
  right: 8px;
  background: none;
  border: none;
  color: var(--prog-ink-45);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
  padding: 2px;
}
.pcmp-remove:hover { color: var(--prog-warn); }

/* ── NMT calculator / /chances route (redesign #285 Phase 6) ──
   #calculator-view gets the same scoped custom-property remap
   #detail-modal/#program-compare-modal already use, repainting
   .calc-intro/.calc-form/.calc-subject-field/etc (the score-entry form,
   kept functionally unchanged -- see the plan's decision on why). Scoped
   to #calculator-view rather than the bare .calc-* classes since Hidden
   Gems used to share several of them for its own results list before
   becoming a Programs lens (#285 Phase 7) -- no longer a live concern
   (that view is gone), but the scoping is harmless to keep. */
/* Same #285 Phase 9c fix as #detail-modal's own comment explains. */
#calculator-view {
  --ink:          var(--prog-ink);
  --ink-soft:     var(--prog-ink-60);
  --ink-faint:    var(--prog-ink-45);
  --paper:        var(--prog-canvas);
  --surface:      var(--prog-surface);
  --surface-sunk: var(--prog-surface-2);
  --line:         var(--prog-rule);
  --navy:         var(--prog-accent-fill);
  --navy-dark:    var(--prog-accent);
  --navy-fill:    var(--prog-accent-fill);
  --navy-soft:    var(--prog-accent-bg);
  --good:         var(--prog-good);
  --good-dark:    var(--prog-good);
  --good-soft:    var(--prog-good-bg);
  --warn:         var(--prog-warn);
  --warn-dark:    var(--prog-warn);
  --warn-soft:    var(--prog-warn-bg);
  --text:           var(--ink);
  --text-secondary: var(--ink-soft);
  --bg:             var(--paper);
  --border:         var(--line);
  --primary:        var(--navy);
  font-family: var(--prog-font-ui);
}
#calculator-view .calc-intro h2 {
  font-family: var(--prog-font-ui);
  font-weight: 400;
  letter-spacing: -.015em;
}
#calculator-view .calc-submit-btn {
  background: var(--prog-accent-fill);
  border: none;
  color: #fff;
  font-family: var(--prog-font-ui);
}

/* New results-screen markup (brief §5.5): a colored-dot ranked list
   instead of the old card grid -- genuinely new structure, not a restyle
   of .calc-result-card, so these use --prog-* directly rather than going
   through the remap above (same reasoning as program-detail.js/
   program-compare.js's own new-markup CSS). */
.pchance-count {
  font: 400 13px var(--prog-font-ui);
  color: var(--prog-ink-60);
  margin-bottom: 10px;
}
.pchance-summary {
  display: flex;
  gap: 18px;
  margin-bottom: 14px;
  flex-wrap: wrap;
}
.pchance-summary__item--unknown { color: var(--prog-ink-60); }
.pchance-assessed-note {
  font: 400 12px var(--prog-font-ui);
  color: var(--prog-ink-60);
  margin: -8px 0 14px;
}
.pchance-summary__item {
  display: flex;
  align-items: center;
  gap: 6px;
  font: 500 13px var(--prog-font-ui);
  color: var(--prog-ink);
}
.pchance-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex: none;
  background: var(--prog-ink-45);
}
.pchance-dot--safe { background: var(--prog-good); }
.pchance-dot--likely { background: var(--prog-accent); }
.pchance-dot--reach { background: var(--prog-warn); }
.pchance-dot--unknown { background: var(--prog-ink-45); }

.pchance-list { border-top: 1px solid var(--prog-rule); }
.pchance-row {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 12px 4px;
  border-bottom: 1px solid var(--prog-rule);
  cursor: pointer;
}
.pchance-row:hover { background: var(--prog-surface-2); }
.pchance-row .pchance-dot { margin-top: 6px; }
.pchance-row__main { flex: 1; min-width: 0; }
.pchance-row__name {
  font: 500 13.5px var(--prog-font-ui);
  color: var(--prog-ink);
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}
.pchance-badge {
  font-size: 8px;
}
.pchance-badge.pchance-dot--safe { background: var(--prog-good-bg); color: var(--prog-good); }
.pchance-badge.pchance-dot--likely { background: var(--prog-accent-bg); color: var(--prog-accent); }
.pchance-badge.pchance-dot--reach { background: var(--prog-warn-bg); color: var(--prog-warn); }
/* The fourth verdict: "we could not assess this one". Deliberately the
   quietest of the four -- it is not a warning, and it must not compete
   with the three real verdicts -- but it is present, which an absent
   badge was not. */
.pchance-badge.pchance-dot--unknown {
  background: var(--prog-surface-2);
  color: var(--prog-ink-60);
  border: 1px solid var(--prog-rule);
}
.pchance-row__meta {
  font: 400 12px var(--prog-font-ui);
  color: var(--prog-ink-60);
  margin-top: 3px;
}
.pchance-row__price {
  white-space: nowrap;
  font-size: 13px;
  padding-top: 1px;
}
.pchance-row__fav {
  background: none;
  border: none;
  cursor: pointer;
  font-size: 16px;
  color: var(--prog-ink-45);
  line-height: 1;
  padding: 2px;
}

/* ── Ambient chance badges on the Programs view (web/programs-view.js) ──
   Cards: a small dot + label near the GEM badge. Table: a real YOUR
   CHANCE column, per the brief's own §5.5 text. */
.prog-card__chance {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font: 500 11px var(--prog-font-ui);
  color: var(--prog-ink-60);
}
.prog-table__chance {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font: 500 12px var(--prog-font-ui);
}

/* ── Hidden Gems lens (redesign #285 Phase 7, web/programs-view.js) ──
   /programs?lens=gems reuses the Programs view's own cards/table
   entirely -- these two classes are the only new visual surface: the
   header block explaining the lens, and each row's reason line. */
.prog-lens-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  padding: 16px 24px;
  background: var(--prog-accent-tint);
  border-bottom: 1px solid var(--prog-rule);
  /* A left rule in the accent colour, so the band reads as "a lens is
     active" rather than as an undifferentiated tinted strip -- the tint
     alone is nearly invisible in dark mode, where --prog-accent-tint is
     #121b2c against a #14171c page. */
  border-left: 3px solid var(--prog-accent);
  margin: 0 0 -1px;
}
/* Keeps the explanation at a readable measure instead of letting the
   button drift to the far edge of a 1300px row, which left the two halves
   looking unrelated. */
.prog-lens-header__text {
  min-width: 0;
}
.prog-lens-header__clear {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  white-space: nowrap;
}
.prog-lens-header__x {
  font-size: 11px;
  opacity: .7;
}
.prog-lens-header__title {
  font: 500 15px var(--prog-font-ui);
  color: var(--prog-ink);
  margin-bottom: 4px;
}
.prog-lens-header__desc {
  font: 400 12.5px var(--prog-font-ui);
  color: var(--prog-ink-60);
  max-width: 60ch;
}
.prog-gem-reason {
  font: 400 11.5px var(--prog-font-ui);
  color: var(--prog-good);
  margin-top: 4px;
}

/* ── Map (redesign #285 Phase 7, web/map.js) ──
   #map-view gets the same scoped custom-property remap #detail-modal/
   #program-compare-modal/#calculator-view already use, repainting the
   existing .map-toolbar/.map-legend/.map-result-card/.map-popup-* rules
   (all left structurally unchanged) without a second CSS pass over
   classes that already consume the right semantic tokens. Leaflet popups
   render as DOM descendants of #edu-map (itself inside #map-view), so the
   remap reaches them too. */
/* Same #285 Phase 9c fix as #detail-modal's own comment explains. */
#map-view {
  --ink:          var(--prog-ink);
  --ink-soft:     var(--prog-ink-60);
  --ink-faint:    var(--prog-ink-45);
  --paper:        var(--prog-canvas);
  --surface:      var(--prog-surface);
  --surface-sunk: var(--prog-surface-2);
  --line:         var(--prog-rule);
  --navy:         var(--prog-accent-fill);
  --navy-dark:    var(--prog-accent);
  --navy-fill:    var(--prog-accent-fill);
  --navy-soft:    var(--prog-accent-bg);
  --good:         var(--prog-good);
  --good-dark:    var(--prog-good);
  --good-soft:    var(--prog-good-bg);
  --warn:         var(--prog-warn);
  --warn-dark:    var(--prog-warn);
  --warn-soft:    var(--prog-warn-bg);
  --text:           var(--ink);
  --text-secondary: var(--ink-soft);
  --bg:             var(--paper);
  --border:         var(--line);
  --primary:        var(--navy);
  font-family: var(--prog-font-ui);
}

/* Two-column layout: map canvas + the new right panel (brief's 300px). */
.prog-map-layout {
  display: grid;
  grid-template-columns: 1fr 300px;
  gap: 16px;
  align-items: start;
}
.prog-map-canvas-wrap {
  position: relative;
}
.prog-map-panel {
  background: var(--prog-surface-2);
  border: 1px solid var(--prog-rule);
  padding: 16px;
  max-height: 560px;
  overflow-y: auto;
}
.prog-map-panel__divider {
  border-top: 1px solid var(--prog-rule);
  margin: 12px 0;
}
@media (max-width: 900px) {
  .prog-map-layout { grid-template-columns: 1fr; }
}

/* Logo-chip pins (pinIcon() in map.js) -- 20px logo square, white
   bordered chip, city + median fee label alongside, per brief §5.7. */
.prog-map-pin-wrap { background: none !important; border: none !important; }
.prog-map-pin {
  display: flex;
  align-items: center;
  gap: 5px;
  transform: translateY(-10px);
}
.prog-map-pin__chip {
  width: 20px;
  height: 20px;
  flex: none;
  background: #fff;
  border: 1px solid var(--prog-rule);
  box-shadow: 0 1px 3px rgba(0,0,0,.25);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.prog-map-pin__chip img { width: 100%; height: 100%; object-fit: contain; padding: 2px; box-sizing: border-box; }
.prog-map-pin__fallback {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  font: 600 8px var(--prog-font-mono);
  color: var(--prog-ink-45);
}
.prog-map-pin__label {
  font: 500 10px var(--prog-font-ui);
  color: var(--prog-ink);
  background: rgba(255,255,255,.92);
  padding: 1px 5px;
  white-space: nowrap;
  box-shadow: 0 1px 2px rgba(0,0,0,.15);
}
/* Occupied territory: hollow/outlined, distinct from the generic
   "blocked" gray fill -- brief's own occupied-territory framing. */
.prog-map-pin--occupied .prog-map-pin__chip {
  background: transparent;
  border: 1.5px dashed var(--prog-warn);
}
.prog-map-pin--occupied .prog-map-pin__fallback { color: var(--prog-warn); }
.prog-map-pin--blocked .prog-map-pin__chip { opacity: .55; }

/* Choropleth ramp legend (bottom-left, brief's exact spec) -- separate
   from the existing ownership/type legend, which keeps its own role. */
/* The map canvas gets its own positioned box so the choropleth legend's
   bottom:10px anchors to the MAP, not to .prog-map-canvas-wrap -- which is
   taller because it also holds #mapLegend, and previously put the legend on
   top of those rows. See the markup comment in index.html. */
.prog-map-canvas {
  position: relative;
}
.prog-map-choropleth-legend {
  position: absolute;
  left: 10px;
  bottom: 10px;
  z-index: 400;
  background: var(--prog-surface);
  border: 1px solid var(--prog-rule);
  padding: 8px 10px;
  min-width: 140px;
}
.prog-map-choropleth-legend__ramp {
  display: flex;
  height: 10px;
  margin: 5px 0 3px;
}
.prog-map-choropleth-legend__ramp span { flex: 1; }
.prog-map-choropleth-legend__range {
  display: flex;
  justify-content: space-between;
  font-size: 10px;
  color: var(--prog-ink-60);
}

/* Right panel: region bars (click adds to the map's own region filter --
   NOT programFilters, see the Phase 7 plan's scope-boundary note) and the
   SHOW ON MAP layer-toggle checkboxes. */
.prog-map-region-row {
  display: grid;
  grid-template-columns: 84px 1fr 52px;
  align-items: center;
  gap: 8px;
  padding: 5px 0;
  width: 100%;
  border: none;
  background: none;
  text-align: left;
  cursor: pointer;
  font: 400 11.5px var(--prog-font-ui);
  color: var(--prog-ink);
}
.prog-map-region-row--active .prog-map-region-row__label { color: var(--prog-accent); font-weight: 500; }
.prog-map-region-row__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.prog-map-region-row__bar { background: var(--prog-rule); height: 6px; }
.prog-map-region-row__bar span { display: block; height: 100%; background: var(--prog-accent-fill); }
.prog-map-region-row--active .prog-map-region-row__bar span { background: var(--prog-accent); }
.prog-map-region-row__value { text-align: right; font-size: 11px; }
.prog-map-toggle {
  display: flex;
  align-items: center;
  gap: 7px;
  font: 400 12.5px var(--prog-font-ui);
  color: var(--prog-ink);
  cursor: pointer;
  padding: 4px 0;
}

/* Popup photo/logo thumbnail row (buildPopup() in map.js). */
.map-popup-photo {
  width: 100%;
  height: 90px;
  object-fit: cover;
  margin-bottom: 6px;
}
.map-popup-logo {
  width: 24px;
  height: 24px;
  object-fit: contain;
  background: #fff;
  border: 1px solid var(--prog-rule);
  padding: 2px;
  flex: none;
}

/* ══════════════════════════════════════════════════════════════════════
   #285-redesign Phase 9c: Auth / Cabinet / Report modal restyle (brief
   §8 step 9's "move the auth/cabinet/bug-report modals last"). Same
   scoped base-token remap as #detail-modal above -- every rule inside
   these 3 modals already resolves color through --ink/--paper/--surface/
   --surface-sunk/--line/--good/--warn (or their --text/--border/etc
   aliases, which themselves resolve through those same base tokens; see
   #detail-modal's own comment on why remapping the BASE token is enough
   and the alias doesn't need touching), so one scoped block repaints
   inputs/buttons/status text/badges without any markup change.

   --accent/--primary are the one deliberate DIFFERENCE from #detail-
   modal's approach: sitewide, --accent aliases to --brass (the contract-
   education/private-ownership CATEGORY marker -- #detail-modal correctly
   leaves that alone, per its own comment, since the --prog-* system has
   no 3rd hue for that category). But these 3 modals only ever use
   --accent/--primary generically, as "the interactive action color" for
   Sign In/Save/Send buttons and links -- not as a category marker -- so
   leaving them aliased to brass would make this modal's primary actions
   render amber while every other primary action in the redesign is blue.
   Remapped straight to --prog-accent-fill/--prog-accent instead. */
/* Same #285 Phase 9c fix as #detail-modal's own comment explains --
   --text/--text-secondary/--bg/--border need their own redeclaration here
   too (--primary/--accent already ARE redeclared directly below, so
   those two don't need the same treatment). */
#login-modal, #cabinet-modal, #report-modal {
  --ink:          var(--prog-ink);
  --ink-soft:     var(--prog-ink-60);
  --paper:        var(--prog-canvas);
  --surface:      var(--prog-surface);
  --surface-sunk: var(--prog-surface-2);
  --line:         var(--prog-rule);
  --good:         var(--prog-good);
  --warn:         var(--prog-warn);
  --accent:       var(--prog-accent-fill);
  --primary:      var(--prog-accent-fill);
  --navy-soft:    var(--prog-accent-bg);
  --navy-dark:    var(--prog-accent);
  --text:           var(--ink);
  --text-secondary: var(--ink-soft);
  --bg:             var(--paper);
  --border:         var(--line);
}
#login-modal .modal-content,
#cabinet-modal .modal-content,
#report-modal .modal-content {
  font-family: var(--prog-font-ui);
}

/* #285 Phase 12: the same scoped remap, extended to the 5 modals every
   prior redesign phase left on old tokens -- Follow, My-follows, About,
   Legal, the old institution-level Compare modal (#compare-modal,
   distinct from the newer Program Compare route), and the Chart modal
   (#chart-modal, Phase 9c's own "always-dark visual language, out of
   scope" call at the time -- folded in now since this phase is the full
   pass, not just a font swap). Identical mechanism, no markup changes. */
#follow-modal, #my-follows-modal, #about-modal, #legal-modal,
#compare-modal, #chart-modal {
  --ink:          var(--prog-ink);
  --ink-soft:     var(--prog-ink-60);
  --paper:        var(--prog-canvas);
  --surface:      var(--prog-surface);
  --surface-sunk: var(--prog-surface-2);
  --line:         var(--prog-rule);
  --good:         var(--prog-good);
  --warn:         var(--prog-warn);
  --accent:       var(--prog-accent-fill);
  --primary:      var(--prog-accent-fill);
  --navy-soft:    var(--prog-accent-bg);
  --navy-dark:    var(--prog-accent);
  --text:           var(--ink);
  --text-secondary: var(--ink-soft);
  --bg:             var(--paper);
  --border:         var(--line);
}
#follow-modal .modal-content,
#my-follows-modal .modal-content,
#about-modal .modal-content,
#legal-modal .modal-content,
#compare-modal .modal-content,
#chart-modal .modal-content {
  font-family: var(--prog-font-ui);
}

/* Below: genuinely new component treatment (not just a variable remap) for
   the 3 elements flagged during Phase 9 research as needing more than a
   repaint -- the Google button, the cabinet avatar/activity rows, and the
   report screenshot-attach row. Square corners throughout (border-radius:
   0) to match the redesign's Swiss/institutional aesthetic (.prog-btn/
   .prog-input/.prog-card etc have no radius anywhere in this file) -- the
   OLD pre-restyle CSS for these had 6-16px radii, the one visual detail a
   pure variable remap can't fix on its own. Circular avatar is the one
   deliberate exception (a round profile photo isn't a "square vs rounded
   corners" question the same way a button/badge/row is). */
/* Scoped with the modal id prefix (not bare class selectors) so this wins
   over index.html's own same-specificity .follow-submit-btn etc rules --
   programs.css loads BEFORE index.html's main <style> block (see its own
   <link> position), so an equal-specificity bare-class override here would
   lose the cascade despite coming from "the new system". */
#login-modal .auth-google-btn,
#login-modal .follow-email-input,
#login-modal .follow-submit-btn,
#cabinet-modal .follow-email-input,
#cabinet-modal .follow-submit-btn,
#report-modal .report-select,
#report-modal .report-textarea,
#report-modal .report-attach-btn,
#report-modal .report-screenshot-preview,
#cabinet-modal .cabinet-role-badge,
#cabinet-modal .cabinet-activity-item {
  border-radius: 0;
}
#login-modal .auth-google-btn {
  border: 1px solid var(--prog-rule);
  font-weight: 500;
}
#login-modal .auth-google-btn:hover { background: var(--prog-surface-2); }
#cabinet-modal .cabinet-avatar,
#cabinet-modal .cabinet-avatar-placeholder {
  border: 1px solid var(--prog-rule);
}
#cabinet-modal .cabinet-role-badge {
  font: 600 10px var(--prog-font-mono);
  letter-spacing: .05em;
}
#cabinet-modal .cabinet-activity-item {
  border-color: var(--prog-rule);
}
#cabinet-modal .cabinet-activity-item .cabinet-activity-type {
  font: 400 11px var(--prog-font-mono);
}
#report-modal .report-attach-btn,
#report-modal .report-screenshot-preview {
  border-color: var(--prog-rule);
}
#report-modal .report-screenshot-preview img { border-radius: 0; }

/* ══════════════════════════════════════════════════════════════════════
   #285-redesign Phase 8: Mobile layouts (brief §5.8/§8 step 8)
   Single new breakpoint for all of it -- see the Phase 8 plan's own
   "Breakpoint" decision for why 640px (matches the OLD pre-redesign
   header/main-nav system's own mobile cutoff). Component styles first
   (hidden by default, shown only inside the media query below so nothing
   here affects desktop), then the media query itself.
   ══════════════════════════════════════════════════════════════════════ */

:root { --prog-tabbar-h: calc(56px + env(safe-area-inset-bottom, 0px)); }

/* Header mobile-menu button + dropdown -- hidden on desktop, see media
   query below. Dropdown is a small anchored panel (not a full sheet); the
   menu list itself is short (3 nav links + 4 action rows). */
.prog-header__menu-btn {
  display: none;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: none;
  background: none;
  color: var(--prog-ink-60);
  cursor: pointer;
  flex: none;
}
.prog-header__menu-btn .ic { width: 18px; height: 18px; }
.prog-header__mobile-menu {
  position: absolute;
  left: 8px;
  top: 52px;
  width: 232px;
  background: var(--prog-surface);
  border: 1px solid var(--prog-rule);
  box-shadow: 0 8px 28px rgba(0,0,0,.2);
  z-index: 97;
  padding: 6px;
}
.prog-header__mobile-menu-item {
  display: block;
  width: 100%;
  text-align: left;
  font: 400 13.5px var(--prog-font-ui);
  color: var(--prog-ink);
  padding: 10px 10px;
  border: none;
  background: none;
  cursor: pointer;
}
.prog-header__mobile-menu-item:hover { background: var(--prog-surface-2); }
.prog-header__mobile-menu-divider { height: 1px; background: var(--prog-rule); margin: 4px 2px; }

/* Bottom tab bar -- global chrome, hidden on desktop. Sits at the true
   viewport bottom (z-index 95: above .status-bar's 50, below modals' 100
   and .compare-bar's 110 -- see index.html's own z-index comments). */
.prog-tabbar {
  display: none;
  position: fixed;
  left: 0; right: 0; bottom: 0;
  height: var(--prog-tabbar-h);
  padding-bottom: env(safe-area-inset-bottom, 0px);
  background: var(--prog-surface);
  border-top: 1px solid var(--prog-rule);
  z-index: 95;
}
.prog-tabbar__item {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  border: none;
  background: none;
  color: var(--prog-ink-45);
  font: 400 10px var(--prog-font-ui);
  cursor: pointer;
}
.prog-tabbar__item .ic { width: 20px; height: 20px; }
.prog-tabbar__item.active { color: var(--prog-accent); }

/* Mobile filter-summary bar -- entry point into the filter sheet below.
   Hidden on desktop; the sidebar rail is always visible there instead. */
.prog-filter-summary {
  display: none;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 16px;
  border-bottom: 1px solid var(--prog-rule);
  background: var(--prog-surface-2);
}
.prog-filter-summary__digest {
  font: 400 12.5px var(--prog-font-ui);
  color: var(--prog-ink-60);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.prog-filter-summary__count { color: var(--prog-accent); font-weight: 600; }

/* #programsFilterRail's mobile-only "done" footer -- sticky inside the
   sheet, hidden on desktop where the rail is always-visible chrome, not
   something you "close". */
.prog-rail__mobile-footer {
  display: none;
  position: sticky;
  bottom: 0;
  margin: 8px -20px -20px;
  padding: 12px 20px;
  padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
  background: var(--prog-surface-2);
  border-top: 1px solid var(--prog-rule);
}
.prog-rail__mobile-footer .prog-btn { width: 100%; }

/* Compact mobile result row (_renderProgramsMobileRows()) -- denser than
   .prog-card (no photo band), replaces both cards and table below the
   breakpoint. */
.prog-mrow-list { border-top: 1px solid var(--prog-rule); }
.prog-mrow {
  position: relative;
  display: flex;
  gap: 10px;
  padding: 13px 16px;
  border-bottom: 1px solid var(--prog-rule-soft);
}
/* Same hitarea-stacking reasoning as .prog-table-row .prog-checkbox above. */
.prog-mrow__check { position: relative; z-index: 1; flex: none; margin-top: 3px; }
.prog-mrow__body { flex: 1; min-width: 0; }
.prog-mrow__top {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
}
.prog-mrow__name { font: 500 13.5px var(--prog-font-ui); color: var(--prog-ink); }
.prog-mrow__price { font-size: 14px; font-weight: 600; flex: none; }
.prog-mrow__inst {
  font: 400 12px var(--prog-font-ui);
  color: var(--prog-ink-60);
  margin-top: 3px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.prog-mrow__meta {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 6px;
  font-size: 11px;
  color: var(--prog-ink-45);
  flex-wrap: wrap;
}
.prog-mrow__chance { display: inline-flex; align-items: center; gap: 4px; }

@media (max-width: 1000px) {
  /* #285 Phase 9: raised from 640px to 1000px. A real bug, found by the
     e2e suite's rewritten header-overlap.spec.js: the FULL desktop header
     (wordmark + 7 nav items + icon cluster + lang + sign-in, all fixed-
     width flex children with no shrink/wrap) genuinely doesn't fit in
     640-960px -- confirmed live, the sign-in button's right edge landed
     ~215px past the viewport edge at 700px, i.e. off-screen and
     unclickable, not merely visually tight. 1000px was chosen with margin
     below the smallest width that's actually been confirmed to fit
     (1024px). The alternative (a 3rd, tablet-specific header layout for
     641-1000px) is real design work out of this pass's scope -- widening
     the existing, already-complete mobile treatment to also cover this
     range is a correct, if not pixel-optimal, fix: everything below still
     functions fully, just with more headroom than a true phone has.

     Header: wordmark + language + overflow menu only, per brief's exact
     52px mobile-header spec. Icon-cluster actions (follows/NMT/theme/
     sign-in) fold into the overflow menu -- see
     _renderHeaderMobileMenuContent() in index.html -- rather than being
     silently dropped. */
  .prog-header { height: 52px; gap: 10px; padding: 0 12px; }
  .prog-header__menu-btn { display: inline-flex; }
  .prog-header__nav,
  .prog-header__icon-btn,
  #headerSignInBtn,
  .header-avatar-btn { display: none; }
  .prog-header__right { gap: 10px; }
  .prog-header__wordmark-main { font-size: 14.5px; }

  .prog-tabbar { display: flex; }

  /* Everything fixed-bottom needs to clear the new tab bar -- extends the
     exact stacking precedent .compare-bar/#programCompareBar already use
     for each other (index.html:1653) with one more offset layer.

     #285 Phase 9 fix: these lost the cascade the whole time this block has
     existed. programs.css loads BEFORE index.html's own <style> block (see
     that file's own <link> order), and .compare-bar/.report-bug-bubble/
     .scroll-top-btn/.status-bar's BASE `bottom` rules live in that LATER
     block as bare, unconditional class selectors -- equal specificity to
     these, later in the cascade, so they always won regardless of this
     media query matching. Confirmed live: computed `bottom` on mobile was
     stuck at each element's base (unshifted) value, meaning the report-bug
     bubble, scroll-to-top button, and compare bar have been sitting BEHIND
     the bottom tab bar (and effectively unclickable) since Phase 8 shipped.
     `body` is prepended to each selector purely to add one element
     selector's worth of specificity and win the tie -- same fix shape as
     the #login-modal/#cabinet-modal/#report-modal border-radius bug found
     in Phase 9c. */
  body .compare-bar { bottom: calc(20px + var(--prog-tabbar-h)); }
  /* This one already matched index.html's selector EXACTLY (same classes,
     same id) -- a true specificity tie needs more than a bare `body`
     prefix (that's already present on both sides), hence `html` too. */
  html body.has-compare-bar #programCompareBar.active { bottom: calc(132px + var(--prog-tabbar-h)); }
  body .report-bug-bubble { bottom: calc(20px + var(--prog-tabbar-h)); }
  body .scroll-top-btn { bottom: calc(74px + var(--prog-tabbar-h)); }
  body .status-bar { bottom: var(--prog-tabbar-h); }
  .pcmp-tray { bottom: var(--prog-tabbar-h); }

  /* Programs view: filter-summary bar replaces the always-visible rail;
     the rail itself becomes a bottom sheet (see #programsFilterRail rules
     below) instead of sitting in the .prog-layout grid.

     #285 Phase 11: #institutionsFilterRail (Institutions/Admissions'
     own sidebar rail) shares this exact bottom-sheet treatment -- same
     combined-selector pattern already used elsewhere in this file (e.g.
     `#login-modal, #cabinet-modal, #report-modal`) rather than cloning
     the block, since the mechanics (fixed position, slide up/down,
     .prog-rail--sheet-open) are identical either way. */
  .prog-filter-summary { display: flex; }
  .prog-layout, .inst-layout { display: block; }
  #programsFilterRail, #institutionsFilterRail {
    position: fixed;
    left: 0; right: 0; bottom: 0;
    max-height: 82vh;
    overflow-y: auto;
    z-index: 96;
    border: none;
    border-top: 1px solid var(--prog-rule);
    box-shadow: 0 -8px 28px rgba(0,0,0,.22);
    transform: translateY(100%);
    /* Handles the CLOSE direction (class removed -> snaps back to this
       base transform) -- slideUp below handles OPEN via a real keyframe
       animation per the plan's decision; removing .prog-rail--sheet-open
       still animates smoothly because this transition applies to any
       computed-style change, animation-driven or not. */
    transition: transform .25s ease;
  }
  /* Reuses the existing @keyframes slideUp primitive (index.html:1889),
     already defined for modals but unused here until now -- see the
     Phase 8 plan's "Filter sheet" decision. */
  #programsFilterRail.prog-rail--sheet-open, #institutionsFilterRail.prog-rail--sheet-open {
    animation: slideUp .28s ease forwards;
  }
  .prog-rail__mobile-footer { display: block; }

  /* No mobile form of table view -- renderProgramsResults() always picks
     the compact-row renderer at this width regardless of programsSubView,
     so the toggle itself has nothing to control here. */
  .prog-view-toggle { display: none; }
}

/* ══════════════════════════════════════════════════════════════════════
   #285-redesign Phase 10: Institutions view + shared filter panel
   restyle. Same scoped base-token remap as #detail-modal/#calculator-view/
   #map-view/the auth modals above -- renderCard() (index.html:7703-7900)
   and the old "Фільтри" panel (#filtersPanel, index.html:3902-3958) both
   already paint entirely through --ink/--surface/--surface-sunk/--line/
   --navy* / --good* / --warn* (or their --text/--border/etc aliases), INCLUDING
   the handful of colors renderCard() bakes into inline style="" attributes
   on generated pills (e.g. index.html:7738,7786-7790,7809) -- those resolve
   var() against the live inherited custom-property chain exactly like any
   stylesheet rule, so this remap reaches them with zero renderCard() edits.
   See #detail-modal's own comment above for the full mechanism and the
   #285 Phase 9c alias gotcha (base tokens AND their --text/--border/etc
   alias names both need redeclaring here, same as every prior block).

   #mainContent (the search card + #filtersToggleBtn + #filtersPanel +
   #activeFilterChips), #tabsContainer (category tabs), and
   #resultsContainer (card grid + the sort bar renderResults() injects
   inline) are SIBLINGS in the DOM (confirmed directly, index.html:3883/
   3966/3970), not nested under one common ancestor -- hence the combined
   selector below, same shape as the `#login-modal, #cabinet-modal,
   #report-modal` block. #resultsContainer is also the exact node
   admissions.js's renderAdmissionTimeline() writes into (see its own
   comment there) and #mainContent/#tabsContainer stay on-screen for the
   Admissions view too (setView() never hides them for 'admissions') -- so
   this one remap repaints Admissions along with Institutions, since they
   are structurally the same containers today, not a separate pass.

   --font-display/--font-mono ARE real custom properties (index.html:156,
   158 -- not hardcoded stacks), so they're remapped directly here too,
   same as any other base token -- reaches .card h3 and .info-pill.fee's
   monospace price figures with no separate per-selector override needed.

   --brass/--brass-soft/--brass-ink/--brass-fill/--brass-on stay unmapped,
   same precedent as every prior block -- the fee pill and ownership
   badges keep their existing amber tone; no --prog-* equivalent hue
   exists for that category marker. */
/* #285 Phase 11: #institutionsFilterRail joins this same remap -- it's a
   NEW element (didn't exist for Phase 10 to have scoped this to), but its
   content (.direction-chip, .hint-bubble, .saved-searches, etc, all
   defined in index.html's own <style>) resolves through the exact same
   old semantic tokens as everything else already covered here. */
#mainContent, #tabsContainer, #resultsContainer, #institutionsFilterRail {
  --ink:          var(--prog-ink);
  --ink-soft:     var(--prog-ink-60);
  --ink-faint:    var(--prog-ink-45);
  --paper:        var(--prog-canvas);
  --surface:      var(--prog-surface);
  --surface-sunk: var(--prog-surface-2);
  --line:         var(--prog-rule);
  --navy:         var(--prog-accent-fill);
  --navy-dark:    var(--prog-accent);
  --navy-fill:    var(--prog-accent-fill);
  --navy-soft:    var(--prog-accent-bg);
  --good:         var(--prog-good);
  --good-dark:    var(--prog-good);
  --good-soft:    var(--prog-good-bg);
  --warn:         var(--prog-warn);
  --warn-dark:    var(--prog-warn);
  --warn-soft:    var(--prog-warn-bg);
  --text:           var(--ink);
  --text-secondary: var(--ink-soft);
  --bg:             var(--paper);
  --border:         var(--line);
  --primary:        var(--navy);
  --font-display: var(--prog-font-ui);
  --font-mono:    var(--prog-font-mono);
  /* #285 Phase 10 follow-up: the color remap above shipped alone first and
     LOOKED unchanged to a real user, because color wasn't the visible tell
     -- shape was. --radius/--radius-sm are real custom properties (used by
     .card/.search-card/.badge/.filters-panel/etc, index.html:173-174) so
     zeroing them here, same mechanism as every other token above, matches
     the redesign's square "Swiss/institutional" aesthetic that .prog-btn/
     .prog-input/.prog-card already use everywhere (border-radius:0, no
     exception -- confirmed live). Circular elements (spinners, avatar
     dots, legend dots, the slider thumb) all hardcode 50% directly rather
     than going through these tokens, so they're unaffected. */
  --radius:    0;
  --radius-sm: 0;
}

/* ══════════════════════════════════════════════════════════════════════
   #285 Phase 10 follow-up: controls for the filter groups the Programs
   rail was missing (funding, competition band) and the always-visible
   sort bar Cards view never had. Same square/monospace-label language as
   the rest of the rail -- .prog-seg is a single-choice segmented control,
   used where options are mutually exclusive and checkboxes would wrongly
   imply you can pick several. */
.prog-rail__seg-group { margin-top: 14px; }
.prog-seg {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}
.prog-seg__btn {
  flex: 1 1 auto;
  padding: 5px 8px;
  border: 1px solid var(--prog-rule);
  background: var(--prog-surface);
  color: var(--prog-ink-60);
  font-family: var(--prog-font-ui);
  font-size: 11px;
  font-weight: 600;
  line-height: 1.3;
  cursor: pointer;
  white-space: nowrap;
  transition: background .12s, color .12s, border-color .12s;
}
.prog-seg__btn:hover {
  border-color: var(--prog-accent);
  color: var(--prog-accent);
}
.prog-seg__btn--active {
  background: var(--prog-accent-fill);
  border-color: var(--prog-accent-fill);
  color: #fff;
}
/* The "All" reset row inside every facet popover. Its absence from the
   region popover was a reported bug -- once a region was ticked there was
   no way back to "all regions" short of Reset all, which also wipes the
   price range and every must-have. */
.prog-region-popover__row--clear {
  font-weight: 700;
  border-bottom: 1px solid var(--prog-rule-soft);
}
.prog-region-popover__row--on { color: var(--prog-accent); }

.prog-sortbar {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  padding: 10px 24px;
  border-bottom: 1px solid var(--prog-rule-soft);
}
.prog-sortbar .prog-seg__btn { flex: 0 0 auto; }
.prog-sortbar:empty { display: none; }
/* The remaining Institutions/Admissions pill shapes below do NOT route
   through --radius/--radius-sm -- they hardcode a literal border-radius
   (20px/24px/14px/10px/6px pill values) directly in their own rule, so the
   token remap above can't reach them. Same "genuinely needs an explicit
   override, not just a variable remap" situation Phase 9c already hit for
   the auth-modal Google button/avatar/screenshot row -- listed here rather
   than folded into the token block above because these are per-selector
   overrides, not aliases. */
#mainContent .filters-toggle-btn,
#mainContent .filter-count-badge,
#mainContent .active-filter-chip,
#mainContent .direction-chip,
#mainContent .search-mode-btn,
#tabsContainer .tab,
#resultsContainer .direction-tag,
#resultsContainer .details-btn,
#resultsContainer .compare-btn,
#resultsContainer .inst-avatar,
#resultsContainer .inst-initials,
#resultsContainer .adm-chip,
/* #285 Phase 11 bug fix: the must-have chips (.direction-chip --
   postgradChip/dormitoryChip/etc) moved from #mainContent's old filter
   panel into #institutionsFilterRail when the sidebar rail shipped, but
   this rule (written before the rail existed) was never updated to
   follow them -- they silently went back to their old 20px pill radius,
   reported live ("buttons has rounded style") on a screenshot of exactly
   this rail section. */
#institutionsFilterRail .direction-chip {
  border-radius: 0;
  /* Pre-existing bug, found by the e2e gate 2026-09-04 and confirmed by
     hit-testing: in the rail these chips are full-width blocks, so their
     centre falls wherever the label text happens to end -- and that is
     exactly where the inline .hint sits. Its <button> calls
     stopPropagation(), so a tap on the MIDDLE of a filter chip silently
     did nothing. Not caused by the P6 target-size change: measured at the
     old 13x16 trigger too, the centre was still inside it.

     Making the chip a flex row and pushing the hint to the far edge means
     the centre is always label -- the chip toggles -- and the hint keeps a
     predictable, conventional position. On content-width chips elsewhere
     there is no free space, so margin-left:auto changes nothing there. */
  display: flex;
  align-items: center;
  gap: 6px;
}
#institutionsFilterRail .direction-chip .hint {
  margin-left: auto;
}

/* ── Lens bar ───────────────────────────────────────────────────────────
   The five search-family destinations (Institutions / Programs / Hidden
   Gems / My chances / Admissions) as one switcher, matching mobile's
   SearchScreen mode chips. Map and Shortlist are deliberately not here --
   they are separate destinations on both platforms.

   Shares .prog-seg__btn's visual language (1px rule, no radius, accent
   fill when active) rather than inventing a third chip style: the app
   already has segmented controls and this IS one, just at page scope.
   Sized a step up from .prog-seg__btn because these switch the whole view
   rather than a filter within it. */
.lens-bar {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  /* Matches #mainContent exactly (max-width 1320 + 16px padding, centred),
     so the chips line up with the content beneath them. The first version
     used max-width:var(--prog-maxw, 1280px) with 24px padding -- but
     --prog-maxw does not exist anywhere in this codebase, so it silently
     fell back to 1280 and pushed the row 28px right of everything else at
     wide viewports. Measured on production at 1358px: chips at x=56,
     content at x=28. Do not reintroduce a max-width that isn't this one. */
  max-width: 1320px;
  margin: 0 auto;
  padding: 12px 16px 0;
}
.lens-chip {
  padding: 7px 14px;
  border: 1px solid var(--prog-rule);
  border-radius: 0;
  background: var(--prog-surface);
  color: var(--prog-ink-60);
  font-family: var(--prog-font-ui);
  font-size: 13px;
  font-weight: 600;
  line-height: 1.3;
  cursor: pointer;
  white-space: nowrap;
  transition: background .12s, color .12s, border-color .12s;
}
.lens-chip:hover {
  border-color: var(--prog-accent);
  color: var(--prog-accent);
}
.lens-chip.active {
  background: var(--prog-accent-fill);
  border-color: var(--prog-accent-fill);
  color: #fff;
}
/* Keyboard focus must stay visible on the active chip too, where the
   accent fill would otherwise swallow a default outline. */
.lens-chip:focus-visible {
  outline: 2px solid var(--prog-accent);
  outline-offset: 2px;
}
@media (max-width: 640px) {
  /* Same horizontal-scroll treatment the tab row uses: wrapping five
     chips onto three lines costs more vertical space than a phone can
     spare above the results. */
  .lens-bar {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
    padding: 8px 16px 0;
    max-width: none;
    -webkit-mask-image: linear-gradient(to right, #000 88%, transparent);
    mask-image: linear-gradient(to right, #000 88%, transparent);
  }
  .lens-bar::-webkit-scrollbar { display: none; }
  .lens-chip { flex: 0 0 auto; font-size: 12px; padding: 6px 12px; }
}
/* #285 audit B13: the calculator's submit button is gated on the form
   being answerable, so it needs a real disabled state and a place to say
   why. The reason sits under the button rather than replacing the result
   area, so it reads as a precondition rather than as an answer. */
.calc-submit-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
.calc-submit-reason {
  margin-top: 8px;
  font: 400 12px var(--prog-font-ui);
  color: var(--prog-ink-60);
  line-height: 1.5;
}
.calc-submit-reason:empty { display: none; }

/* #285 audit P6: .prog-btn--tertiary zeroes its horizontal padding, which
   is right for an inline text button but left this one -- whose whole
   label is a single "✕" glyph -- 10px wide and 35px tall. Hard with a
   mouse, impossible with a thumb, and well under WCAG 2.5.8's 24px floor.
   The glyph stays the size it was; only the target grows. */
.prog-price-clear {
  min-width: 32px;
  min-height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* #285 audit P6: the hint triggers are 16x16 and sit INSIDE clickable
   .direction-chip elements, so a near-miss toggled the filter instead of
   opening the tip -- the chip swallows the stray pixels. Their own
   onclick already stops propagation on a direct hit; this widens the
   target so a near-miss is a hit. */
.direction-chip .hint-trigger {
  min-width: 24px;
  min-height: 24px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* Calculator scope line -- states what the answer was filtered to, in both
   the filtered and unfiltered case. Subordinate to the form above it: this
   is a statement of fact about the results, not another control. */
#calculator-view .calc-scope {
  font: 400 13px/1.5 var(--prog-font-ui);
  color: var(--prog-ink-60);
  margin: 10px 0 0;
  padding-left: 10px;
  border-left: 2px solid var(--prog-rule);
}
#calculator-view #calcMaxPrice {
  width: 120px;
  font-family: var(--prog-font-mono);
}

/* Empty-result suggestions. Sits below the "no programmes" line as help,
   not as an error: bordered and quiet rather than warn-coloured, because
   an empty answer here usually means a filter is too tight, not that the
   applicant did anything wrong. */
#calculator-view .calc-hints {
  border: 1px solid var(--prog-rule);
  background: var(--prog-surface-2);
  padding: 12px 14px;
  margin-top: 12px;
}
#calculator-view .calc-hints__title {
  font: 600 13px/1.4 var(--prog-font-ui);
  color: var(--prog-ink);
  margin-bottom: 6px;
}
#calculator-view .calc-hints ul { margin: 0; padding-left: 18px; }
#calculator-view .calc-hints li {
  font: 400 13px/1.55 var(--prog-font-ui);
  color: var(--prog-ink-60);
  margin-bottom: 4px;
}
#calculator-view .calc-hints li:last-child { margin-bottom: 0; }

/* Occupied-territory key on the choropleth legend. Crimea and Sevastopol
   have no fee data (no active institutions are registered there) but they
   are Ukrainian territory -- shown in a muted red rather than dropped into
   the generic "no data" grey. */
.prog-map-choropleth-legend__occupied {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 6px;
  font: 400 10px/1.3 var(--prog-font-ui);
  color: var(--prog-ink-60);
}
.prog-map-choropleth-legend__occupied-swatch {
  width: 12px; height: 10px; flex: none;
  background: #c2705e; opacity: .55;
  outline: 1px dashed var(--prog-rule); outline-offset: -1px;
}

/* Calculator summary counts are controls now, matching mobile: each one
   filters the list to the subset it names. */
#calculator-view .pchance-summary__item {
  border: 1px solid transparent;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
  padding: 4px 8px;
}
#calculator-view .pchance-summary__item:hover { border-color: var(--prog-rule); }
#calculator-view .pchance-summary__item.is-active {
  border-color: var(--prog-accent);
  background: var(--prog-accent-bg);
}
/* Gems get their own colour rather than the accent: they answer a different
   question from safe/likely/reach -- not "can you get in" but "is this
   better value than it looks" -- so they should not read as a fourth
   verdict band. Same light yellow as mobile. */
#calculator-view .pchance-summary__item--gem { color: #7A5B10; }
#calculator-view .pchance-summary__item--gem.is-active {
  border-color: #7A5B10;
  background: #FDF3D0;
}
#calculator-view .pchance-badge--gem {
  background: #FDF3D0;
  color: #7A5B10;
  border: 1px solid #7A5B10;
  margin-left: 6px;
}

/* Long-press-to-copy confirmation (charts.js's _showCopyToast). Mirrors
   the Android toast the mobile app shows for the same gesture. */
.copy-toast {
  position: fixed;
  left: 50%;
  bottom: 96px;
  transform: translateX(-50%) translateY(8px);
  z-index: 200;
  padding: 8px 16px;
  background: var(--prog-ink);
  color: var(--prog-surface);
  font-family: var(--prog-font-ui);
  font-size: 13px;
  line-height: 1;
  border-radius: 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity .15s ease, transform .15s ease;
}
.copy-toast.visible {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}
/* A row that copies on hold must not start a text selection under the
   finger while the timer runs -- otherwise Android shows its own
   selection handles on top of the copy. */
[data-copy] { -webkit-touch-callout: none; }
/* Held long enough to copy on release -- tells you the gesture registered
   before you let go, so a long press never feels like nothing happened. */
[data-copy].copy-holding { background: var(--prog-accent-tint); }
@media (prefers-reduced-motion: reduce) {
  .copy-toast { transition: none; }
}
