:root {
  --brand-blue: #004E73;
  --neutral-ivory: #F9F5F0;
  --navy: #00253E;
  --aqua: #06FEFD;
  --stone: #CBC4C6;
  --bg: #F9F5F0;
  --panel: #ffffff;
  --border: #CBC4C6;
  --text: #00253E;
  --muted: #6b7a80;
}
/* Brand colors (--brand-blue/--neutral-ivory/--navy/--aqua/--stone/--bg/--panel/--border/--text/
   --muted) are NOT defined here anymore (2026-08-27) — they're per-instance, injected at runtime
   by server.js's own /style.css route (reads brandConfig.js, prepends a :root block built from
   config/brand.json before serving the rest of this file). Every rule below still just consumes
   var(--brand-blue) etc. exactly as before; only where those values come from changed. See
   CLAUDE.md's "Multi-tenant architecture" section — don't reintroduce a hardcoded :root color
   block here.
   Status/tier colors below stay shared across every instance — explicitly not client-identifying
   branding, just muted UI semantics (Cody's own framing when this was scoped, 2026-08-27). */

/* Cross-document (MPA) view transitions (2026-09-11) — cross-fades between full page loads on
   every same-origin navigation instead of a white flash. Verified live (real caniuse.com/MDN
   data pulled during this change, not training-data recall) and tested on this app before
   shipping: Chrome/Edge 126+ and Safari 18.2+ support it; Firefox doesn't yet (only the
   same-document API, not cross-document). Purely additive and degrades with zero effect on
   unsupporting browsers — no @supports guard needed, they just ignore the at-rule and keep
   today's instant-swap behavior. No token/config split needed the way dark mode's --accent
   will — this is a browser capability, not a brand color, so the same rule is correct for both
   instances as-is. */
@view-transition {
  navigation: auto;
}

:root {
  /* For a box nested INSIDE a .card/.sidebar-card (var(--panel)) that wants to read as a
     recessed "well" — a reference example, an AI answer preview, a read-only detail block —
     as opposed to var(--bg), which is the page's own outermost canvas color. In light mode
     these two are already close (cream page vs white card), so aliasing straight to var(--bg)
     here is pixel-identical to every nested box's old `background: var(--bg)`. In dark mode
     var(--bg) mixes in 70% black to sit well behind var(--panel) at the PAGE level (see that
     block's own comment) — reused for a box nested inside a panel, that gap reads as a
     near-black hole punched in an otherwise-dark card, not a subtle recess. This token gets its
     own, much closer-to-panel value there instead (2026-09-16, real user report: "the ai tools
     clearly has a black box"). */
  --panel-sunken: var(--bg);
  /* The 3-level "health" traffic light (compliance %, grading progress, quiz score ranges) —
     one source of truth instead of the same three hexes copy-pasted into page-local <style>
     blocks. Deliberately separate from the "correct/done" green (#1c7a3e, used for interactive-
     module correct answers and path-completion checkmarks) — that's a binary
     completion/correctness signal, not a 3-level gauge, so it keeps its own color. */
  --status-good: #2e6b2e;
  --status-warn: #8a6d00;
  --status-bad: #b3261e;
  /* The fourth state of that gauge: not yet graded. It is deliberately NOT a literal, because
     "pending" should read as the same quiet grey the instance already uses for secondary text
     rather than a colour of its own — so it is derived from --muted instead of picked.
     Derived, though, and not --muted itself. The pending badge is white text on this, and
     --muted is a TEXT token: its job is to be legible ON a surface, not to be one. On the
     committed neutral palette it happens to be dark enough (4.56:1) so nothing showed here, but
     on both real palettes white on raw --muted measures 4.45:1 and 3.47:1 — under AA, on a
     badge every rep sees. Found by the SQLite build, not by this repo's own test, which had
     --muted on
     its allowlist of tokens that may carry white text. Darkening by 28% takes those to 7.3:1
     and 6.0:1 while keeping each instance's own hue.
     Worth knowing before reusing this trick: it is not a guarantee. The mix clears AA only
     while --muted is darker than about #a8a8a8; a very light --muted would still fail, which is
     what test/scoreBadgeContrast.test.js's derivation check exists to catch. */
  --status-pending: color-mix(in srgb, var(--muted) 72%, black);
  /* Cohort leaderboard podium tiers (public/leaderboard.js) — separate from the status triad
     above since gold/silver/bronze is a ranking signal, not a good/warn/bad gauge. */
  --tier-gold: #b8862e;
  --tier-silver: #8c97a0;
  --tier-bronze: #a8672e;
  /* Elevation scale (2026-09-11, visual-polish pass item 1). Tinted by var(--text) — each
     instance's own darkest, most-legible color, already brand-differentiated (one deployed
     palette's is a dark navy, another's a dark warm gray) — NOT plain black. Two rounds of a
     neutral rgba(0,0,0,x) shadow were confirmed, via direct side-by-side comparison on live
     deploys, to read clearly on the near-monochrome palette (--panel and --bg are the literal
     same color there, so the shadow is the ONLY thing separating a card from the page) while
     genuinely washing out on the warm cream/white one — a neutral gray has far less hue-contrast
     against a warm background than a color with real hue to it does, even at equal darkness.
     color-mix(in srgb, var(--text) P%, transparent) is the standard way to get a translucent
     version of a custom-property color without a separate precomputed RGB-triplet variable —
     mixing with fully-transparent scales alpha to P% while preserving the source color's hue
     exactly. This makes the shadow genuinely per-instance (a navy-branded palette gets a cool
     navy-black that stands out against cream; a near-monochrome one gets the same near-black it
     always effectively had) without a new config field or a hardcoded substitute color for any
     brand — it's computed from a
     token that already exists, same principle as the dark-mode accent color plan.
     --shadow-sm: resting depth on every previously-flat bordered box (.card, .path-tile,
     .folder-card, .assignment-card, .subgallery-card, .stat-card, .sidebar-card).
     --shadow-hover: the lifted state for surfaces that are genuinely clickable — paired with a
     small translateY and a short transition (see each card's own hover rule) so the lift reads
     as motion, not a snap. --shadow-md reserved for a middle tier if a future surface needs one
     between resting and hover without being a full modal-weight shadow.
     Browser note: color-mix() is Baseline-widely-available (Chrome 111+/Safari 16.2+/Firefox
     113+); an unsupporting browser fails to parse the box-shadow value entirely and falls back to
     the flat pre-item-1 look, not a broken layout — an acceptable degradation given the app's
     other modern-CSS/JS assumptions. */
  --shadow-sm: 0 2px 4px color-mix(in srgb, var(--text) 30%, transparent), 0 8px 16px color-mix(in srgb, var(--text) 22%, transparent);
  --shadow-md: 0 4px 10px color-mix(in srgb, var(--text) 34%, transparent), 0 12px 28px color-mix(in srgb, var(--text) 26%, transparent);
  --shadow-hover: 0 16px 36px color-mix(in srgb, var(--text) 42%, transparent), 0 6px 14px color-mix(in srgb, var(--text) 30%, transparent);
  /* Spacing scale (2026-09-12, visual-polish pass item 3) — shared across every instance, same
     as the status/tier colors above: whitespace isn't brand identity. Existing padding/margin/gap
     values across the file were almost all raw pixels with no shared vocabulary (a survey turned
     up everything from 1px to 36px with no consistent scale behind it); this doesn't retrofit
     every one of those, only the ones this pass deliberately increases below (card padding,
     heading-to-content gaps, form fields/buttons, plus a few of the more common recurring values
     picked up along the way). Rule of thumb used for which raw values got left alone: anything
     that's a fine-grained fit (icon alignment, border offsets, single-purpose one-offs) stays a
     literal pixel value rather than being forced onto this coarser scale. */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 20px;
  --space-6: 24px;
  --space-7: 32px;
  /* Type scale (2026-09-12, visual-polish pass item 4) — a font-size survey turned up 44 uses
     of 13px, 44 of 12px, plus a long tail of near-duplicates that read as drift rather than
     intentional variation (13.5px, 11.5px — awkwardly between round numbers) and two different
     sizes doing the exact same "big emphasized number" job in different card types (24px vs
     26px). This scale backs the actual value changes made below (drifted sizes rounded onto it,
     the two number sizes unified, a new consistent subsection-heading tier, a distinct
     page-title tier) — it doesn't retrofit every one of the 20+ other font-size values in this
     file that already do the job they're meant to. */
  --text-xs: 11px;
  --text-sm: 12px;
  --text-base: 13px;
  --text-md: 14px;
  --text-lg: 15px;
  --text-xl: 20px;
  --text-2xl: 26px;
  --text-page-title: 24px;
  /* Light-mode defaults for the two dark-mode-only accent tokens (2026-09-12, visual-polish
     pass item 8) — --accent/--accent-aqua only get their real (lightened) values under
     :root[data-theme="dark"] below; without a default here, every rule migrated to reference
     them would resolve to nothing at all in light mode (the current, default theme), breaking
     every one of those ~40 call sites' everyday appearance. Defaulting to the exact same value
     as --brand-blue/--aqua means light mode is pixel-identical to before this token even
     existed — these two lines are pure plumbing, not a visual change. */
  --accent: var(--brand-blue);
  --accent-aqua: var(--aqua);
  /* Same plumbing pattern as --accent/--accent-aqua above, for the status-color system
     (2026-09-12, round-18 audit fix). --status-good/-warn/-bad themselves stay raw everywhere
     (dots, badges, score bars, pills — vivid background-fill roles that should stay exactly as
     saturated in dark mode as light) — these three new tokens exist only for the TEXT and
     status-card-background roles, which need to lighten/darken with the theme the same way
     --brand-blue's foreground role did. Light-mode defaults here match the existing raw
     colors/hexes exactly, so light mode is unaffected. */
  --status-good-text: var(--status-good);
  --status-warn-text: var(--status-warn);
  --status-bad-text: var(--status-bad);
  --status-good-card-bg: #eaf3ea;
  --status-warn-card-bg: #fff8e6;
  --status-bad-card-bg: #fbeaea;
  --status-good-card-border: #cfe3cf;
  --status-warn-card-border: #f0e0b0;
  --status-bad-card-border: #f0c0c0;
  /* Transcript highlight wash (2026-09-18) — the marker-pen tint behind a graded quote in a
     role-play transcript (views/grading-user.html). A separate token from --status-*-card-bg
     above: that one blends toward --panel to build an opaque tinted card, while this blends
     toward transparent, because it sits on top of running body text and has to let that text's
     own color through unchanged.
     Its own hue rather than var(--status-good/-bad), which is the one place in this file that
     deviates from reusing the status tokens, so: the status colors are deliberately dark and
     muted (#2e6b2e/#b3261e) for text and dots, and a wash of a dark hue reads as sludge rather
     than as a highlighter. These are the same semantic green/red at a brightness that works as
     a background wash. Still shared-across-instances UI semantics, not client branding — same
     footing as the status colors themselves (see this file's own header comment).
     Defined once, not per theme (the earlier version had a 26%/55% light/dark split): a bright
     hue at this strength lands mid-range on both a pale and a near-black panel, so one value
     holds. Measured on the rendered page — body text over it is 11:1 light, 6:1 (good) and
     8:1 (bad) dark, all clear of AA. A dark hue could not do this, which is why the earlier
     version needed the split. */
  --transcript-highlight-good: color-mix(in srgb, #22c55e 45%, transparent);
  --transcript-highlight-bad: color-mix(in srgb, #ef4444 45%, transparent);
}
/* Dark mode (2026-09-12, visual-polish pass item 8) — explicit toggle only, no
   prefers-color-scheme fallback (Cody's own call: "if someone wants it, they click it").
   Applies when JS stamps data-theme="dark" on <html> (see public/darkMode.js); default/absent
   is today's light theme, completely unaffected by anything below.

   The real problem this had to solve: --brand-blue is used in two conflicting roles across this
   file — 27 places as a BACKGROUND (header, primary buttons, thumbnails) with light text on
   top, and 52 as FOREGROUND (headings, stat numbers, icons, links) sitting on a light surface.
   In dark mode these pull opposite directions — background wants to stay dark so light text on
   it keeps reading; foreground needs to get much lighter to read against a dark panel. One
   redefinition of --brand-blue can't satisfy both, and a near-monochrome palette's version of
   the conflict is worse than a navy one's: its brand-blue is near-black already, with almost no
   headroom either direction.

   Resolution: --brand-blue, --aqua, --navy, and --neutral-ivory are NOT redefined here at all —
   every consumer still pointing at those raw tokens (every background usage, the certificate's
   own text/border, the brand logo's light backgrounds) is automatically theme-invariant, by
   construction, with no scoped exceptions to maintain. Two NEW tokens exist only in this block:
   --accent (brand-blue lightened, for the foreground role) and --accent-aqua (aqua lightened,
   for focus rings and the leaderboard self-ring from item 7) — call sites get migrated to these
   individually, not by redefining the tokens they already used.

   Numbers actually computed and verified before picking these ratios (not guessed):
     navy-branded:     --panel 1.57:1 vs --bg, --accent 8.15:1 vs --panel
     near-monochrome:  --panel 2.21:1 vs --bg, --accent 4.81:1 vs --panel (clears WCAG AA's
                       4.5:1; earlier, less-lightened ratios landed it at 2.6:1 and were rejected)
   --bg/--panel are derived from each instance's own --navy (its existing darkest neutral), not
   a flat gray shared by both — a navy palette's dark mode reads as a cool navy-black, a warm
   near-monochrome one's as a warm gray-black, matching their light-mode identities instead of
   converging into one generic dark theme. --text reuses --neutral-ivory directly (already the
   right near-white for both); --muted and --border are derived from it. */
:root[data-theme="dark"] {
  --bg: color-mix(in srgb, var(--navy) 30%, black 70%);
  --panel: color-mix(in srgb, var(--navy) 92%, white 8%);
  /* See --panel-sunken's own comment in the light-mode :root block above — a real blend of the
     two rather than aliasing straight to the (much darker) --bg, so a nested "well" box reads as
     a subtle recess against its own panel instead of a black hole. */
  --panel-sunken: color-mix(in srgb, var(--bg) 45%, var(--panel) 55%);
  --text: var(--neutral-ivory);
  /* 50% -> 75% (2026-09-12, round-18 audit fix) — 50% measured 4.1:1 against --panel on the
     navy-branded palette, just under WCAG AA's 4.5:1 for normal text; the near-monochrome one's
     much closer panel/ivory tones measured worse, 3.1:1. 75% clears both with real margin,
     7.24:1 and 4.92:1 — real numbers, computed, not estimated. --muted backs .muted/.stat-label/
     .stat-sub/.card-toggle-arrow and other pervasive secondary text, so this one token change
     fixes all of them at once. */
  --muted: color-mix(in srgb, var(--neutral-ivory) 75%, var(--panel) 25%);
  /* --status-pending has to be restated here, and this is the whole reason why: --muted is not
     a fixed colour. In light mode it is a dark grey; the line directly above redefines it as 75%
     IVORY, which is light. So the light-mode derivation (72% of --muted) inherits a light source
     in dark mode and stays light — white text on it measures 3.43:1 on this checkout's palette
     and 3.30:1 on a real one, under AA, on the same badge the light-mode fix had just brought to
     7.48:1.
     Shipped that way, and every contrast test passed, because every one of them resolved only
     the light :root — including the derivation check written one commit earlier specifically to
     catch a palette this checkout doesn't have. That is the third time a colour bug has hidden
     behind a test that measures one palette; test/scoreBadgeContrast.test.js now resolves this
     block too.
     45%, not 72%, because the source is light here rather than dark: 7.27:1 on this palette,
     7.07:1 and 7.05:1 on the two deployed palettes (measured in the SQLite build, which hit
     this first). Still
     a clear step up from --panel rather than blending into the card. */
  --status-pending: color-mix(in srgb, var(--muted) 45%, black);
  --border: color-mix(in srgb, var(--neutral-ivory) 15%, transparent 85%);
  --accent: color-mix(in srgb, var(--brand-blue) 25%, white 75%);
  --accent-aqua: color-mix(in srgb, var(--aqua) 25%, white 75%);
  /* Status-color text/card-background dark-mode variants (2026-09-12, round-18 audit fix).
     Raw --status-good/-warn/-bad text measured 3.21-3.26:1 against the dark panel (the grading
     grid) — below AA. 30% original + 70% white clears both instances with real margin: computed
     good 7.33/4.93 across the two deployed palettes, warn 7.84/5.27, bad 7.52/5.06 — bad needed
     the lowest ratio of the three to hold on the near-monochrome one, so all three use the same
     30% for one consistent
     rule rather than three different fractions.
     The card backgrounds (.stat-card.status-*) were flagged separately: a fixed pale hex
     (#eaf3ea/#fff8e6/#fbeaea) reads as a light-mode swatch floating in an otherwise-dark UI, and
     --muted text on top of it measured 2.63:1. 12% original + 88% panel keeps the same "tinted
     card" idea dark-mode appropriate while holding --muted (now 75%-mix, see above) at a real
     margin above 4.5:1 on both instances — computed worst case (near-monochrome, warn) 4.63:1;
     every other
     combination is higher. Deliberately a light tint, not a strong one: a bolder mix pushed the
     card noticeably closer to matching its own status text's contrast requirement, at the cost of
     barely reading as distinct from --panel — picked the safer, more conservative side of that
     tradeoff since legibility was the actual bug, not visual pop. */
  --status-good-text: color-mix(in srgb, var(--status-good) 30%, white 70%);
  --status-warn-text: color-mix(in srgb, var(--status-warn) 30%, white 70%);
  --status-bad-text: color-mix(in srgb, var(--status-bad) 30%, white 70%);
  --status-good-card-bg: color-mix(in srgb, var(--status-good) 12%, var(--panel) 88%);
  --status-warn-card-bg: color-mix(in srgb, var(--status-warn) 12%, var(--panel) 88%);
  --status-bad-card-bg: color-mix(in srgb, var(--status-bad) 12%, var(--panel) 88%);
  --status-good-card-border: color-mix(in srgb, var(--status-good) 35%, var(--panel) 65%);
  --status-warn-card-border: color-mix(in srgb, var(--status-warn) 35%, var(--panel) 65%);
  --status-bad-card-border: color-mix(in srgb, var(--status-bad) 35%, var(--panel) 65%);
}
/* Call-site classification (2026-09-12, visual-polish pass item 8 commit 2/3) — every
   var(--brand-blue)/var(--aqua) foreground usage was traced to what it actually sits on before
   deciding whether it migrates to --accent/--accent-aqua or stays on the raw, theme-invariant
   token. ~40 migrated (headings, stat numbers, icons, links, focus rings — anything sitting on
   --panel/--bg); ~45 stayed raw (anything sitting on a background that itself never changes:
   header.top, .page-tabs, .sidebar-page-links/.sidebar-group's own brand-blue fill, the
   certificate template's fixed --neutral-ivory paper, .brand-highlight's fixed --aqua block).
   Flagged rather than guessed at the time; both resolved after the toggle shipped and a live
   look was possible (2026-09-12, dark-mode fix round):
   - .sidebar-rail-link:hover and .small-btn:hover's fixed var(--neutral-ivory) background ->
     var(--bg), reusing the "recede to page background" hover idiom .submission-modal-close-btn
     already established elsewhere in this file, instead of a fixed bright-white flash.
   - input[type=checkbox]/[type=radio]:checked's fill moved raw var(--brand-blue) -> var(--accent)
     (confirmed live: raw brand-blue measured ~1.4:1 against the dark panel, nearly invisible) —
     its checkmark (::after) moved var(--neutral-ivory) -> var(--panel) to match, since the
     checkmark needs to invert opposite the fill in each mode, not just follow it.
   Also resolved, round 18's audit fix (2026-09-12): .stat-card.status-good/warn/bad's hardcoded
   pale hex backgrounds (#eaf3ea/#fff8e6/#fbeaea, along with every raw var(--status-good/-warn/
   -bad) TEXT usage across the file — the grading grid, delete buttons, upload/practice status
   labels, the graduation banner) now route through --status-*-text/-card-bg/-card-border, the
   same default-plus-dark-override pattern as --accent — see those tokens' own definitions above
   for the real computed numbers. --status-good/-warn/-bad themselves stay raw everywhere they're
   a background fill (dots, pills, score bars, badges) — same background-role-stays-raw rule as
   --brand-blue.
   Still pre-existing and out of scope for this pass, left as unrelated cleanup:
   - select's/.folder-icon-adjacent chevron backgrounds are literal hardcoded SVG data-URIs
     (stroke='%23004E73') — server.js's /style.css route already re-brands these per-instance via
     a runtime string-replace (pre-existing, not new), but that mechanism doesn't know about
     themes, so this one arrow stays light-mode-blue even in dark mode. Minor, cosmetic, not
     fixed here. */
* { box-sizing: border-box; }
body {
  margin: 0;
  /* Explicit base line-height (2026-09-12, visual-polish pass item 3) — previously unset, so
     every block of running text (card subtitles, FAQ answers, empty-state copy, submission
     feedback) fell back to the browser default (~1.2, effectively "normal"), which is what made
     paragraph-heavy areas feel dense. Elements that need a tight, fixed line-height for their own
     layout (stat values, badges, single-line labels) already set their own `line-height: 1` (or
     similar) explicitly throughout this file, which — being more specific than this inherited
     body-level value — is unaffected by this change.
     */
  line-height: 1.5;
  font-family: Arial, Helvetica, sans-serif; /* guide: Arial is the approved fallback for internal tools */
  background-color: var(--bg);
  color: var(--text);
  /* Subtle repeating texture inspired by the guide's "outlined shapes of the branded Vault
     icon" pattern (p.23) — a plain checkmark/V outline, not the actual logo file (which the
     guide says should never be edited), tiled at very low opacity so it reads as texture,
     not a graphic. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 96 96'%3E%3Cpath d='M14 34 L40 60 L82 12' stroke='%23004E73' stroke-width='5' fill='none' stroke-linecap='round' stroke-linejoin='round' opacity='0.035'/%3E%3C/svg%3E");
  background-repeat: repeat;
}
h1, h2, .brand-heading {
  font-family: "Golos Text", Arial, sans-serif; /* guide: Golos is primary typeface for headlines/subheads */
}
/* Real subsection-heading tier (2026-09-12, visual-polish pass item 4) — h4 is used all over
   this app (rep.html's "1. My Grades"/"2. Class Grades", assignments.html's interactive-module
   slide titles, practice.html's persona card titles, the rollup report's per-BU headers, ...)
   but previously had no single consistent style: some scopes had their own one-off treatment
   (.chart-box h4 at 13px brand-blue, .rollup-bu-header at 13px plain --text color, practice.html's
   own page-local .persona-card h4 override), and everything outside those scopes — including
   assignments.html's slide titles — fell through to the unstyled browser default. One real,
   platform-wide style now, clearly bigger/more present than the 11px muted-uppercase "eyebrow
   label" tier below (.sidebar-panel h4 etc. — a genuinely different role, a category divider,
   not a heading, so it deliberately keeps its own smaller/quieter treatment) and clearly lighter
   than the 20px uppercase h2 "card title" tier above it. The scoped rules that used to set their
   own size/color/weight for this now only override what's still genuinely page-specific
   (.rollup-bu-header's tighter margin, its own .muted state). */
h4 {
  margin: 0 0 var(--space-2);
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 700;
  font-size: var(--text-lg);
  color: var(--accent);
}
/* Platform-wide disabled-button treatment (2026-09-11) — there was no rule anywhere in this file
   giving a disabled <button>/<input type="button|submit"> any visual difference from an enabled
   one (a couple of pages had their own narrow one-off, e.g. assignments.html's
   `form.inline button:disabled`, but nothing global), so every disabled button in the app looked
   fully clickable. That's exactly what made practice.html's "Finish" button glitch read as
   unresponsive rather than busy: clicking it while genuinely disabled did nothing, with no visual
   cue the click didn't register, inviting repeat clicks. Opacity + cursor rather than trying to
   override every button variant's own background: this app has solid-color buttons
   (.roleplay-start-btn), outline/text buttons (.roleplay-end-btn), and icon-only buttons, and a
   single dimming treatment reads correctly on all of them without needing a per-component
   override. :disabled has higher specificity than a lone class selector (0,1,1 vs 0,1,0), so this
   correctly wins over each button's own class rule without `!important` or fighting the ordering
   concern the select/input block below already documents for its own, different reason. A page's
   own more specific selector (e.g. `form.inline button:disabled` above) still wins over this by
   normal cascade rules, so existing one-offs are unaffected. */
/* Transition lives on the base selector, not :disabled alone (2026-09-11, motion polish pass
   item 2) — a transition property only animates a change if it was already present on the
   element BEFORE the state flip, so enabling/disabling fades smoothly in both directions
   instead of snapping. Plain, unclassed buttons get their opacity transition from here; buttons
   with their own class (.small-btn, .primary-btn, .delete-btn) already declare it themselves
   alongside their own hover transitions, since a class selector's own `transition` value
   otherwise wins outright over this lower-specificity one instead of merging with it. */
button, input[type="button"], input[type="submit"] { transition: opacity 150ms ease; }
button:disabled, input[type="button"]:disabled, input[type="submit"]:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
/* --- Custom form controls — replace raw browser-default select/checkbox/
   radio appearance with something that actually matches the brand palette,
   applied globally so no per-page markup changes are needed.
   Deliberately placed here, immediately after the base reset and before every page-specific
   rule below — a bare `select`/`input[type="text"]` selector has the exact same specificity
   as plenty of existing rules like `.nav-search input` (class + type = attribute + type, same
   specificity tier), so without this ordering, whichever one happened to appear LATER in the
   file would silently win the tie and clobber the other's border/background/color. Keeping
   this block first means every later, more-specific-or-equal page rule reliably overrides it
   as intended, instead of the outcome depending on where in the file it happens to sit. */
/* Moved here from views/resources.html (2026-09-19). Same chevron as select's below, and the
   same reason it has to live in this file: the colour is baked into the SVG markup rather than
   read from a custom property, and /style.css's route is the only place that rewrites it for
   the instance being served. Inline in a view it rendered one client's blue everywhere. */
.move-to-toggle-arrow {
  flex-shrink: 0;
  width: 10px;
  height: 7px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23004E73' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-size: contain;
}

select {
  appearance: none;
  -webkit-appearance: none;
  background-color: var(--panel);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23004E73' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 10px center;
  padding-right: 30px !important;
  border: 1px solid var(--border);
  border-radius: 6px;
  /* 7px/7px/9px -> 8px/8px/12px (2026-09-12, visual-polish pass item 3) — "more room around
     form fields and buttons," Cody's own words. Kept on the spacing scale rather than hand-
     picking 10px for top/bottom; the horizontal side gets the bigger, more visible bump since
     that's what actually reads as "cramped" next to the dropdown arrow. */
  padding-top: var(--space-2);
  padding-bottom: var(--space-2);
  padding-left: var(--space-3);
  font-size: 13px;
  color: var(--text);
}
select:focus {
  outline: 2px solid var(--accent-aqua);
  outline-offset: 1px;
}
/* type="date" fell through this whole block until now (only input[type="text"] was covered)
   — both real date inputs in the app (assignments.html's Due Date, path.html's Deadline) were
   silently rendering with the browser's unstyled native date-picker chrome instead of matching
   every other field. type="number" (2026-09-16) had the exact same gap — Passing score/Max
   attempts on the assignment editor were rendering as an unstyled, always-white browser-default
   box next to every other themed field, invisible in light mode but a stray light box in dark
   mode. textarea (below) had the same gap for a different reason: form.inline's own
   textarea rule (search "legacy intrinsic width" in this file) only ever set layout properties,
   never background/color, so every plain <textarea> in the app (written-question prompts,
   acknowledgment statements, persona prompts, submission comments) was always browser-default
   white/black regardless of theme. */
/* The TYPE LIST is the whole story of this rule, and it is where the bug keeps coming from: an
   input whose type isn't named here gets no background or color at all and falls through to the
   browser's default, which is white with black text — invisible as a problem in light mode and
   glaring in dark. email/password/url were missing (2026-09-20, reported from the floor: the
   login page's two fields were white boxes on the dark card, and Settings' change-password
   field the same). The tell was that filling one turned it the card's blue: the autofill rule
   below is type-agnostic, so it was the only rule reaching these fields at all.
   test/themedInputs.test.js now fails on any text-entry type used in the app but absent here. */
input[type="text"], input[type="date"], input[type="number"],
input[type="email"], input[type="password"], input[type="url"], textarea {
  background-color: var(--panel);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: var(--space-2) var(--space-3); /* 7px 9px -> 8px 12px, matches select's own bump above */
  font-size: 13px;
  font-family: inherit;
  color: var(--text);
  box-sizing: border-box;
}
input[type="text"]:focus, input[type="date"]:focus, input[type="number"]:focus,
input[type="email"]:focus, input[type="password"]:focus, input[type="url"]:focus, textarea:focus {
  outline: 2px solid var(--accent-aqua);
  outline-offset: 1px;
}
/* Browser autofill (2026-09-19) — picking a saved value from Chromium's own dropdown repaints
   the field with its built-in pale background and near-black text. That styling is applied by
   the browser itself and beats `background-color` on this rule, so in dark mode a field turns
   white the instant someone autofills it while every field beside it stays dark. Reported from
   a simulation run, where it's most obvious (the page is dressed as an outside website, so a
   flashing white field reads as a broken page), but it was never specific to that page.
   background-color genuinely cannot win here; a large inset box-shadow is the standard way to
   cover the browser's own fill, and -webkit-text-fill-color is the matching override for the
   text, which ignores `color` under autofill for the same reason. The long background-color
   transition delays the browser's repaint indefinitely in engines that animate it rather than
   applying it outright. The focus outline is unaffected — it's an outline, not a box-shadow. */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active,
textarea:-webkit-autofill,
select:-webkit-autofill {
  -webkit-text-fill-color: var(--text);
  -webkit-box-shadow: 0 0 0 1000px var(--panel) inset;
  box-shadow: 0 0 0 1000px var(--panel) inset;
  caret-color: var(--text);
  transition: background-color 600000s 0s;
}
/* position:relative added (2026-09-13) so .notif-panel below can anchor to THIS element's own
   right edge instead of .notif-bell-wrap's — the bell wrap's position shifts depending on
   which other header-actions buttons are visible (the admin-only Edit Mode toggle in
   particular, which never fully hides even at mobile widths), which was pushing the panel
   off-screen for admin/super_admin viewers specifically. header.top's own right edge is stable
   regardless of what's inside it. No other rule relies on header.top being position:static —
   confirmed nothing besides .notif-panel used .notif-bell-wrap as a positioned ancestor either
   (.notif-dot positions off .notif-bell itself, a separate relatively-positioned element). */
header.top {
  position: relative;
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  padding: 16px 24px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
header.top h1 {
  margin: 0;
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 16px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}
/* Wraps the logo image + "Training Platform" suffix as h1's own first child (2026-09-07) —
   previously two bare children of h1 itself, spaced by h1's own gap:10px. Wrapping them costs
   nothing at desktop widths (this reproduces that exact same 10px gap internally, side by side,
   unchanged) but gives the mobile breakpoint below a single element to flip into a stacked
   layout, rather than having to restyle h1's overall flex direction (which also holds the
   hamburger/back/forward/refresh buttons nav.js inserts, and those need to stay in a row).
   nav.js's wireMobileNavToggle reads h1.firstChild purely positionally to know where to insert
   those buttons — it doesn't care what that first child actually is, so this needed no JS change. */
.brand-logo-stack { display: flex; align-items: center; gap: 10px; }
.brand-logo {
  height: 26px;
  width: auto;
  display: block;
}
.brand-logo-suffix {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.03em;
  opacity: 0.85;
  border-left: 1px solid rgba(249, 245, 240, 0.35);
  padding-left: 10px;
}
.brand-logo-large {
  height: 64px;
  width: auto;
  display: block;
  margin-bottom: 4px;
}
/* /brand/logo (as opposed to /brand/logo-white, used everywhere else) is drawn for a light
   background — it sits on --panel here (login/accept-invite/create-account's own .card), which
   goes dark in dark mode. A guaranteed-light plate behind it used to be the fix (2026-09-12);
   as of 2026-09-13 all three of these pages instead swap to the white-ink /brand/logo-white
   variant directly on the dark panel — the same way the header already avoids this problem,
   since that logo is drawn to sit on a dark surface and needs no plate at all. Both <img> tags
   on each page are real DOM elements (not a background-image swap), so alt text and print/no-JS
   rendering stay correct either way; the light one is what light mode has always shown,
   byte-for-byte. The old plate rule is gone — nothing renders a bare .brand-logo-large without
   one of the two subclasses below anymore. */
.brand-logo-large-dark { display: none; }
:root[data-theme="dark"] .brand-logo-large-light { display: none; }
:root[data-theme="dark"] .brand-logo-large-dark {
  display: block;
  background: none;
  padding: 0;
  border-radius: 0;
}
header.top .who {
  font-size: 13px;
  color: var(--neutral-ivory);
  display: flex;
  align-items: center;
  gap: 12px;
  opacity: 0.9;
}
header.top button {
  background: transparent;
  border: 1px solid var(--neutral-ivory);
  color: var(--neutral-ivory);
  padding: 6px 12px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 12px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 700;
}
/* Notification bell — appended directly as a child of header.top (see nav.js's
   wireNotificationBell for why it isn't inside .who), so it lands at the far right of the
   header at every width via header.top's own justify-content:space-between.
   NOT position:relative (2026-09-13) — .notif-panel below anchors to header.top itself now,
   not to this wrap, precisely because this wrap's own horizontal position isn't stable (it
   shifts left whenever the admin-only Edit Mode toggle sits to its right — see header.top's
   own comment). Leaving this as a plain flow element avoids it silently becoming a second,
   unused positioned ancestor that could confuse a future absolutely-positioned addition here. */
.notif-bell-wrap {
  margin-left: 12px;
}
.notif-bell {
  width: 36px;
  height: 36px;
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  position: relative;
}
.notif-bell:focus-visible { outline: 2px solid var(--aqua); outline-offset: 1px; }
/* .icon defaults to color:var(--brand-blue) (see the base rule far below) — invisible
   against this header's own brand-blue background, same bug already fixed for the hamburger
   (.mobile-nav-toggle) and Quick Talk mic icon elsewhere in this file. Explicit px on both the
   span and the svg, not just the svg — the base rule's width/height:100% doesn't reliably
   resolve to a real pixel value on an inline svg in a flex layout (same fix already applied to
   .mobile-nav-history-btn above). */
.notif-bell .icon { color: var(--neutral-ivory); width: 20px; height: 20px; }
.notif-bell .icon svg { width: 20px; height: 20px; }
/* Sits in the gap between the icon glyph (centered, ~8px inset) and the button's own border
   (0px) — not on top of either. */
/* General-purpose utility — visible only to assistive tech, not sighted users. Reused wherever
   a control's full meaning (an unread count, a lock reason) is otherwise conveyed by something
   a screen reader can't perceive (color/opacity alone, or a hover-only title tooltip). */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.notif-dot {
  position: absolute;
  top: 2px;
  right: 2px;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--status-bad);
  border: 1.5px solid var(--brand-blue);
}
/* Anchored to header.top (position:relative, see its own comment), not .notif-bell-wrap —
   right:0/top:calc(100%+8px) now resolve against the whole header bar's own stable right edge
   and height, so the panel opens in the same place regardless of which other buttons (the
   admin-only Edit Mode toggle, in particular) happen to sit next to the bell. */
.notif-panel {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  width: 320px;
  max-width: calc(100vw - 24px);
  max-height: 420px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
  z-index: 200;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  color: var(--text);
}
/* Without this, the panel never actually hides: [hidden]'s default display:none and this
   class's own display:flex have equal specificity, and this rule loads later in the
   cascade — so it always won regardless of the hidden attribute's true/false state. The extra
   attribute selector here (0,0,2,0) outranks the plain class rule above (0,0,1,0). */
.notif-panel[hidden] { display: none; }
.notif-panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 14px;
  border-bottom: 1px solid var(--border);
  font-weight: 700;
  font-size: 13px;
}
/* header.top button (0,0,1,2) otherwise beats a plain .notif-mark-all/.notif-item (0,0,1,0) —
   both buttons sit inside header.top (see nav.js's wireNotificationBell), so without this
   they silently inherit that rule's ivory text + ivory border, meant for the dark header bar,
   onto this panel's white background. Scoped two levels (0,0,2,0) is enough to win regardless
   of header.top button's extra element in its selector. */
.notif-panel .notif-mark-all {
  background: none;
  border: none;
  color: var(--accent);
  font-size: 12px;
  font-weight: 700;
  cursor: pointer;
  padding: 2px;
}
.notif-panel .notif-mark-all:hover { text-decoration: underline; }
.notif-panel .notif-mark-all:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.notif-list {
  overflow-y: auto;
  padding: 4px;
}
.notif-panel .notif-item {
  display: flex;
  flex-direction: column;
  gap: 2px;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  border-radius: 6px;
  padding: 8px 10px;
  cursor: pointer;
  font-family: Arial, Helvetica, sans-serif;
}
.notif-panel .notif-item:hover, .notif-panel .notif-item:focus-visible { background: var(--bg); }
.notif-item-unread { background: rgba(0, 78, 115, 0.07); }
/* Unread state was color/opacity-only before this (the background tint above) — this dot is a
   second, non-color signal so the distinction still reads for anyone who can't rely on that
   subtle tint (low vision, color deficiency, or a screen reader ignoring background color
   entirely, which is also why nav.js pairs this with a visually-hidden "(unread)" text node). */
.notif-item-unread .notif-item-title::before {
  content: "";
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--brand-blue);
  margin-right: 6px;
}
.notif-item-title { font-size: 13px; font-weight: 700; color: var(--text); }
.notif-item-body { font-size: 12px; color: var(--muted); }
.notif-item-time { font-size: 11px; color: var(--muted); }
.notif-list .empty-state { padding: 24px 12px; }
@media (max-width: 480px) {
  /* right:-8px removed (2026-09-13) — that offset compensated for .notif-bell-wrap having zero
     padding of its own; now that the panel anchors to header.top (whose own padding already
     insets it correctly, at every width, for every role), right:0 from the base rule already
     does the right thing here. Only the narrower width still needs a mobile-specific value. */
  .notif-panel { width: calc(100vw - 24px); }
}
.page-tabs {
  background: var(--navy);
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px;
  padding: 0 24px;
}
/* The tabs themselves are a separate flex row from .nav-search (nested inside .page-tabs) so
   the two can behave independently on mobile: the search bar still wraps to its own full-width
   line below (via its own width:100%/order:99 rule further down), while the tabs stay on one
   row and scroll horizontally instead of wrapping — a super_admin's 4th tab ("Admin") no longer
   drops to an orphaned second line below the other 3. */
.page-tabs-scroll {
  display: flex;
  align-items: center;
  gap: 4px;
}
.nav-search {
  margin-left: auto;
  position: relative;
  padding: 8px 0;
}
.nav-search input {
  width: 220px;
  padding: 6px 10px;
  border: 1px solid rgba(249, 245, 240, 0.3);
  border-radius: 6px;
  background: rgba(249, 245, 240, 0.08);
  color: var(--neutral-ivory);
  font-size: 12px;
  box-sizing: border-box;
}
.nav-search input::placeholder { color: rgba(249, 245, 240, 0.55); }
.nav-search input:focus {
  outline: 2px solid var(--aqua);
  outline-offset: 1px;
  background: rgba(249, 245, 240, 0.15);
}
.nav-search-results {
  position: absolute;
  top: calc(100% + 4px);
  right: 0;
  width: 300px;
  max-height: 360px;
  overflow-y: auto;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 12px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
  z-index: 100;
}
.page-tab {
  display: inline-block;
  padding: 10px 16px;
  color: var(--neutral-ivory);
  opacity: 0.7;
  text-decoration: none;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 700;
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  border-bottom: 3px solid transparent;
}
.page-tab.active {
  opacity: 1;
  border-bottom-color: var(--aqua);
}
.page-tab:hover { opacity: 1; }
.page-tab:focus-visible { outline: 2px solid var(--aqua); outline-offset: -2px; }
@media (max-width: 700px) {
  .nav-search { margin-left: 0; width: 100%; order: 99; }
  .nav-search input { width: 100%; }
  .nav-search-results { width: 100%; }
  .page-tabs { padding: 0 12px; }
  /* nowrap + horizontal scroll instead of wrap: on a narrow phone, 4 tabs (Home/Sales Rep/
     District Manager/Admin, a super_admin's full set) plus normal padding don't fit one row —
     wrapping dropped the last tab onto its own orphaned line below the rest ("the admin falls
     below the rest... they all should sit next to one another," Cody's exact words). Scrolling
     keeps every tab on the same line and reachable with a swipe, same pattern already used for
     wide tables/diagrams elsewhere in the app, rather than shrinking text until it's unreadable. */
  .page-tabs-scroll {
    flex-wrap: nowrap;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    width: 100%;
    /* Same CSS-only scroll-shadow trick as .grading-grid-wrap — without this there was no
       visual cue that "Admin" (a super_admin's 4th tab) sits off-screen to the right until
       you happened to swipe and find it. */
    background:
      linear-gradient(to right, var(--navy) 30%, rgba(255, 255, 255, 0)),
      linear-gradient(to left, var(--navy) 30%, rgba(255, 255, 255, 0)) 100% 0,
      linear-gradient(to right, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0)),
      linear-gradient(to left, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0)) 100% 0;
    background-repeat: no-repeat;
    background-color: var(--navy);
    background-size: 24px 100%, 24px 100%, 10px 100%, 10px 100%;
    background-attachment: local, local, scroll, scroll;
  }
  /* Moved after the base .page-tab rule above (2026-09-07 fix) — both selectors are plain
     ".page-tab" with identical specificity, and CSS breaks a specificity tie in favor of
     whichever rule is textually LATER in the stylesheet, regardless of media-query nesting. This
     block used to sit BEFORE the base rule, so the base rule's padding:10px 16px/font-size:13px
     silently won at every width, including mobile — the intended smaller mobile size never
     actually applied. white-space:nowrap survived (the base rule never sets that property, so
     there was nothing to lose the tie on), which is why tabs still scrolled instead of wrapping;
     but at the desktop padding/font-size, the visible edge of that scroll area landed mid-label
     more easily, reading as a cut-off word. Same "later rule silently wins" bug class as
     header.top .header-logout-btn's own comment describes — verify a real narrow-viewport
     render if this is ever touched again, not just a read of the CSS in isolation. */
  .page-tab {
    flex: 0 0 auto;
    white-space: nowrap;
    padding: 10px 12px;
    font-size: 12px;
  }
}

main {
  max-width: 640px;
  margin: 40px auto;
  padding: 0 20px;
}
/* Pages with a sidebar (everything but /login) wrap <aside> + <main> in .layout instead
   of leaving main centered in a narrow column with empty space on either side. */
.layout {
  display: flex;
  align-items: flex-start;
  gap: 24px;
  max-width: 1320px;
  margin: 0 auto;
  padding: 24px 24px 48px;
}
.layout main {
  flex: 1;
  /* Without this, a flex item's default min-width:auto lets its content (a wide table, an
     unwrapped row) refuse to shrink below its own natural size — the same bug documented on
     .category-manage-column below, here forcing the whole main column wider than the mobile
     viewport instead of letting its contents wrap/scroll within it. */
  min-width: 0;
  max-width: 820px;
  margin: 0;
  padding: 0;
}
/* Collapsing the sidebar frees ~260px — handed to main as a wider (not unlimited) cap, so
   grids/videos/tables genuinely get more room without plain paragraph text (FAQ answers,
   assignment instructions) stretching out to an uncomfortable reading width. */
.layout.sidebar-collapsed main {
  max-width: 1100px;
}
/* Three standalone cards (page-links / Quick Resources / FAQ+Settings) stacked with real
   gaps between them, not one continuous box with internal divider lines — same "distinct
   card, visible page background between" language every other multi-card page already uses. */
/* Not position:sticky (removed 2026-08-26, Cody's own ask, applied platform-wide after the same
   fix on vault-recorder) — sticky let this column detach from the page and re-stick at a
   different scroll depth than main once its own content ran shorter/longer than main's, so the
   two visibly scrolled out of sync with each other on some pages. Plain static flow instead:
   sidebar and main now always move together at the same rate. */
.sidebar {
  width: 280px;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  gap: 16px;
  /* Needed as a positioning context for .sidebar-collapse-toggle's position: absolute below —
     2026-08-26's sticky removal (see comment above) took this away as an unintended side effect,
     since sticky also happened to establish that context; nothing had ever set it explicitly.
     relative has no visual effect here on its own (no offset properties set), so this doesn't
     reintroduce the independent-scrolling bug that commit fixed. */
  position: relative;
}
.layout.sidebar-collapsed .sidebar {
  width: 48px;
}
.sidebar-card {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  /* 16px -> 20px (2026-09-12, visual-polish pass item 3) — smaller bump than .card's, since this
     column is narrower and shares its own 16px gap between cards (below); still real breathing
     room, not the same jump. */
  padding: var(--space-5);
  box-shadow: var(--shadow-sm);
}
/* Round button straddling the sidebar's right edge (half in, half in the gutter before main),
   vertically centered on the first card/icon (not its top edge) — reference: Cody's screen
   recording of a similar content panel's collapse control. A direct child of .sidebar, not
   nested in .sidebar-full/.sidebar-rail-links, so it stays in the same spot and clickable in
   both states regardless of which of those two is showing. Centered separately per state below
   since the first page-link card (36px tall) and the first rail icon (40px tall) aren't the
   same height. */
.sidebar-collapse-toggle {
  position: absolute;
  top: 23px;
  right: -12px;
  width: 24px;
  height: 24px;
  /* Centers the .icon span inside the circle — see that rule's own comment below for why
     this button moved off a raw HTML-entity glyph onto the same icon system every other
     icon-only button in this app already uses. */
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--brand-blue);
  border: none;
  border-radius: 50%;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
  cursor: pointer;
  padding: 0;
  z-index: 1;
}
.sidebar-collapse-toggle:hover { background: var(--navy); }
.sidebar-collapse-toggle:focus-visible { outline: 2px solid var(--aqua); outline-offset: 1px; }
/* .icon defaults to color:var(--brand-blue), same as this button's own background — same
   fix already applied everywhere else an icon sits on a brand-blue surface (.notif-bell,
   .header-logout-btn, .quick-talk-btn, .pwa-install-trigger). This button used a raw
   &rsaquo;/&lsaquo; HTML entity instead of renderIcon() until 2026-08-27 — a punctuation glyph
   isn't a symmetric shape the way an SVG icon is, so container-level centering (however
   correct) could never make it look properly centered; switching to the same arrow_left/
   arrow_right chevrons already used for the mobile nav's back/forward buttons fixes it at the
   source instead of compensating for the glyph with a margin nudge. .icon's own base rule
   already centers the svg inside itself; this button's own flex rule above centers that whole
   span inside the circle. */
.sidebar-collapse-toggle .icon { color: var(--neutral-ivory); width: 14px; height: 14px; }
.layout.sidebar-collapsed .sidebar-collapse-toggle {
  top: 8px;
}
.sidebar-rail-links {
  display: none;
  flex-direction: column;
  gap: 6px;
}
.layout.sidebar-collapsed .sidebar-rail-links {
  display: flex;
}
/* Wraps the three cards that used to be .sidebar's own direct flex children — needs the same
   flex-column + gap so collapsing/expanding it doesn't collapse the spacing between them too. */
.sidebar-full {
  display: flex;
  flex-direction: column;
  gap: 16px;
  min-width: 0;
}
.layout.sidebar-collapsed .sidebar-full {
  display: none;
}
.sidebar-rail-link {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 40px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--accent);
}
/* var(--neutral-ivory) -> var(--bg) (2026-09-12, dark-mode fix) — was a fixed bright-white flash
   on hover regardless of theme; .sidebar-rail-link's own resting background is --panel (dark in
   dark mode), so --bg reuses the same "recede to page background" hover idiom already established
   by .submission-modal-close-btn:hover elsewhere in this file, instead of a new hardcoded value.
   border-color also moved --brand-blue -> --accent to match the icon's own already-migrated color
   (1.4:1 raw brand-blue on a dark panel border was the same missed-site pattern as .path-tile). */
.sidebar-rail-link:hover { border-color: var(--accent); background: var(--bg); }
.sidebar-rail-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.sidebar-rail-link .icon { width: 18px; height: 18px; }
.sidebar-page-links {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.sidebar-page-links a {
  display: block;
  padding: 10px 14px;
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  text-decoration: none;
  border-radius: 8px;
  font-size: 13px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.01em;
  text-align: center;
}
.sidebar-page-links a:focus-visible { outline: 2px solid var(--aqua); outline-offset: 1px; }
/* Sidebar link groups (2026-09-06, Library/Audio) — one solid brand-blue card per group, same
   fill/shape/type treatment as every flat .sidebar-page-links link, so collapsed it reads as
   "just another tab," not a divider or a different kind of control (2026-09-06 revision: the
   first pass gave the header its own outlined/white style sitting above a separate list, which
   read as two things instead of one container — Cody's own correction). The header and its
   expanded body share this one background + border-radius + overflow:hidden, so the items
   inside visually read as nested INSIDE the tab rather than a second list underneath it, not
   just logically grouped under it. .active (the current page is one of this group's own
   members) gets the same aqua accent nav.js's own .page-tab.active already uses, adapted to a
   left border since this is a vertical stack, not a horizontal tab row — the fill stays
   identical either way, since "collapsed, it should look like any other single tab" has to hold
   whether or not you're currently inside it. */
.sidebar-group {
  background: var(--brand-blue);
  border-radius: 8px;
  overflow: hidden;
  border-left: 3px solid transparent;
}
.sidebar-group.active { border-left-color: var(--aqua); }
/* position:relative + a centered label, not flex + space-between — the label has to be
   dead-center like every other .sidebar-page-links tab; the arrow is pinned to the right edge
   independently via absolute positioning instead of claiming its own flex slot, which is what
   was pushing the label left of true center. */
.sidebar-group-header {
  position: relative;
  display: block;
  width: 100%;
  padding: 10px 14px;
  background: none;
  border: none;
  color: var(--neutral-ivory);
  font-size: 13px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.01em;
  text-align: center;
  cursor: pointer;
}
.sidebar-group-header:hover { opacity: 0.9; }
.sidebar-group-header:focus-visible { outline: 2px solid var(--aqua); outline-offset: 1px; }
/* The group header's caret was removed 2026-09-19 (see groupHeaderHtml in sidebar.js) — the
   section opening is its own affordance. Its rules are gone with it; aria-expanded still carries
   the state for assistive tech. */
/* Nested inside the same blue card as the header (not a separate box below it) — nested links
   are a lighter tint of that same blue plus a left indent, so they read as contained by the
   header above them, distinct from a flat top-level link but still clearly one clickable row
   each. Both groups can be open at once (no accordion): the sidebar itself scrolls, so two open
   groups cost nothing that closing one would buy back.

   No max-height cap and no overflow here (2026-09-19). There used to be `max-height: 200px;
   overflow-y: auto`, so that an expanded group could never push the rest of the sidebar around
   — but the cost was that a group taller than 200px grew its own inner scrollbar, which Setup
   hit the moment it held four links (Integrations, Recording Software, Simulations, Personality
   Assessment). A scroll region nested inside a sidebar that already scrolls is worse than a
   long list, and nobody asked for one; the group simply takes the height it needs now.

   The open/close animation still works: getOpenTargetHeight (public/sidebar.js) reads the
   CSS-authored cap if there is one and falls back to the element's real scrollHeight when
   there isn't — which is now the case, so a group animates to exactly its own content height.
   The transition below is what that function drives; it is not tied to the cap. */
.sidebar-group-body {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 0 10px 8px;
  transition: max-height 220ms ease;
}
/* No margin-left here — the indent comes entirely from the parent's own symmetric left/right
   padding above, so both sides stay equal (a stray margin-left here previously made the left
   gap wider than the right). */
.sidebar-group-body a {
  background: rgba(255, 255, 255, 0.14);
}
.sidebar-group-body a:focus-visible { outline: 2px solid var(--aqua); outline-offset: 1px; }
.sidebar-group-body a:hover { background: rgba(255, 255, 255, 0.22); }
/* Admin-only inline edit toggle for the page-links stack — sits right in the sidebar
   (Cody's ask: not buried in a separate card elsewhere on the page). Deliberately quiet/
   muted, not styled like the blue action buttons above it, so it doesn't read as "the next
   thing to click" for non-editing use. */
.sidebar-edit-links-btn {
  background: none;
  border: 1px dashed var(--border);
  color: var(--muted);
  padding: 6px 10px;
  border-radius: 8px;
  font-size: 11px;
  font-family: "Golos Text", Arial, sans-serif; font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  cursor: pointer;
  width: 100%;
}
.nav-link-edit-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 2px;
}
/* Edit mode stays flat (no group-aware drag-and-drop, Cody's own call) but still shows which
   group each row belongs to, so the Library/Audio structure is visible while reordering even
   though dragging itself just works on the plain flat order underneath. */
.nav-link-edit-group-tag {
  flex-shrink: 0;
  background: var(--neutral-ivory);
  border: 1px solid var(--border);
  color: var(--muted);
  padding: 2px 6px;
  border-radius: 999px;
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}
.nav-link-edit-row .drag-handle {
  cursor: grab;
  color: var(--muted);
  font-size: 14px;
}
.nav-link-edit-input {
  flex: 1;
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 6px 8px;
  font-size: 12px;
  font-family: "Golos Text", Arial, sans-serif;
}
.sidebar-tabs {
  display: flex;
  border-bottom: 1px solid var(--border);
  margin-bottom: 12px;
}
.sidebar-tab {
  flex: 1;
  background: none;
  border: none;
  padding: 8px 4px;
  font-size: 11px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--muted);
  cursor: pointer;
  border-bottom: 2px solid transparent;
}
.sidebar-tab.active {
  color: var(--accent);
  border-bottom-color: var(--accent);
}
.sidebar-panel input[type="text"],
.sidebar-panel input[type="password"],
.sidebar-panel select {
  width: 100%;
  padding: 7px 9px;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 13px;
  box-sizing: border-box;
}
.sidebar-results {
  margin-top: 10px;
  max-height: 320px;
  overflow-y: auto;
}
.sidebar-result {
  display: block;
  padding: 6px 0;
  font-size: 13px;
  color: var(--accent);
  text-decoration: none;
  border-bottom: 1px solid var(--border);
}
.sidebar-faq-item {
  border-bottom: 1px solid var(--border);
  padding: 8px 0;
  font-size: 13px;
}
.sidebar-faq-item p { margin: 4px 0; color: var(--muted); }
/* Eyebrow-label tier (2026-09-12, visual-polish pass item 4) — deliberately separate from the
   h4 "subsection heading" tier above: this is a small uppercase category divider, not a
   heading, so a real h4 element used this way still opts back out of that tier's size/color/
   case entirely, on purpose. .sidebar-panel h4 and .sidebar-quick-resources h4 previously
   duplicated the exact same declaration at 12px; .subgallery-section-title (own rule, below)
   duplicated it again at 13px — three copies of one role, two of them a px off from the third
   for no reason. Merged into one shared rule at 11px (matches .section-label/.stat-card
   .stat-label, the other members of this same tier), plus each selector's own genuinely
   different layout need. */
.sidebar-panel h4, .sidebar-quick-resources h4 {
  margin: 0 0 var(--space-2);
  font-size: var(--text-xs);
  font-family: "Golos Text", Arial, sans-serif; font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
}
/* Quick Resources — its own standalone card, between the page-links card and the FAQ/
   Settings card. Capped at 6 pins (see MAX_QUICK_RESOURCES in sidebar.js and
   MAX_PINS_PER_PAGE in routes/pins.js), so every item gets a fixed-size row rather than a
   scrolling list. */
.sidebar-quick-resources h4 {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.qr-add-btn {
  background: none;
  border: 1px solid var(--border);
  color: var(--accent);
  width: 20px;
  height: 20px;
  border-radius: 50%;
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}
.qr-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 0;
}
.qr-item a {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1;
  min-width: 0;
  text-decoration: none;
  color: inherit;
}
/* Same visual language as .assignment-card-thumb (Vault logo on a Vault Blue tile, small icon
   badge) — just square and small enough for 4 of them to stack in a 280px sidebar. */
.qr-thumb {
  position: relative;
  width: 36px;
  height: 36px;
  flex-shrink: 0;
  border-radius: 6px;
  background: var(--brand-blue); /* guide: reversed logo only approved on Vault Blue, not Navy */
  display: flex;
  align-items: center;
  justify-content: center;
}
.qr-thumb img { width: 60%; object-fit: contain; }
.qr-thumb .icon {
  position: absolute;
  bottom: -3px;
  right: -3px;
  background: rgba(255, 255, 255, 0.92);
  border-radius: 50%;
  width: 16px;
  height: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--brand-blue);
}
.qr-thumb .icon svg { width: 10px; height: 10px; }
.qr-title {
  font-size: 12px;
  font-weight: 700;
  color: var(--text);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.qr-unpin-btn {
  background: none;
  border: none;
  color: var(--muted);
  font-size: 14px;
  cursor: pointer;
  flex-shrink: 0;
  padding: 2px 4px;
}
.qr-add-form select { margin-bottom: 6px; }
/* Hamburger drawer toggle (see nav.js's wireMobileNavToggle) — a plain icon button hidden at
   desktop widths, where the sidebar already sits beside main and needs no toggle at all. */
.mobile-nav-toggle {
  display: none;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: none;
  border-radius: 8px;
  background: none;
  color: var(--neutral-ivory);
  cursor: pointer;
  flex-shrink: 0;
}
.mobile-nav-toggle:hover { background: rgba(255, 255, 255, 0.12); }
.mobile-nav-toggle:focus-visible { outline: 2px solid var(--aqua); outline-offset: 1px; }
/* .icon's own base rule (`color: var(--brand-blue)`) is a real declared value, not an
   inherited one — it wins over color set on this button even though ivory would otherwise
   inherit fine, which is exactly what made the hamburger glyph render as dark-navy-on-navy
   (invisible) against this header's own background. Has to be set here explicitly. */
.mobile-nav-toggle .icon { width: 26px; height: 26px; color: var(--neutral-ivory); }
/* Same explicit-px-on-the-svg fix as .mobile-nav-history-btn below — the base .icon svg
   rule sizes via width/height:100% of the span, which doesn't reliably resolve to a real pixel
   value on an inline <svg> inside this button's inline-flex layout (measured 100% literally,
   never actually resolving). Bigger + bolder than the icon set's shared default, same ask as
   the back/forward arrows: "the 3 lines in the burger icon a little bigger too." Button itself
   stays 36px (unchanged) — plenty of room. */
.mobile-nav-toggle .icon svg { width: 26px; height: 26px; stroke-width: 2.75; }
/* Back/forward: unlike the hamburger (drawer toggle only makes sense once the sidebar goes
   off-canvas, i.e. mobile-only), these are useful everywhere — a PWA/standalone install (see
   pwaInstall.js) has no browser chrome of its own at ANY width, and even in an ordinary
   desktop browser tab they save a reach up to the address bar. Overrides .mobile-nav-toggle's
   own `display: none` unconditionally rather than only inside the mobile media query below. */
.mobile-nav-history-btn { display: inline-flex; width: 36px; height: 36px; }
/* Bumped up from 18px/stroke-width 2 (the icon set's shared default) — "the arrows themself
   are also very small," Cody's exact words, comparing them against the hamburger glyph right
   next to them. Matches the hamburger's own 22px box; stroke-width is overridden here only
   (not in icons.js) since every other icon in the set still wants the shared thin-line weight. */
.mobile-nav-history-btn .icon { width: 22px; height: 22px; }
/* Explicit px on the svg itself, not just the wrapping span — the base .icon svg rule
   sizes it via width/height:100% of the span, which measured out lopsided (10px x 22px) here
   specifically, likely a flex percentage-resolution quirk from this button's layout. Fixed
   pixels sidestep it rather than chasing the exact cause. */
.mobile-nav-history-btn .icon svg { width: 22px; height: 22px; stroke-width: 2.75; }
/* Sidebar's own Log Out row (see sidebar.js) — its own card, same as every other section of
   the sidebar, with a bit of extra breathing room above it (on top of the normal 16px
   .sidebar-full gap) so it doesn't read as glued to the FAQ/Settings card right above. Hidden
   by default (desktop) since .header-logout-btn already covers desktop from the header — re-shown
   below the app's mobile breakpoint (see the max-width:900px media query), where the sidebar is
   the only Log Out control and .header-logout-btn is hidden instead. */
.sidebar-logout-card {
  display: none;
  margin-top: 8px;
}
/* Lives in .sidebar-logout-card, inside the general .sidebar-card (background: var(--panel),
   theme-tracking) — not .sidebar-group's own separately-invariant blue card above. Same fix as
   .error: raw #b3261e goes low-contrast against a dark panel in dark mode. */
.sidebar-logout-btn {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  background: none;
  border: none;
  padding: 0;
  color: var(--status-bad-text);
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 700;
  font-size: 14px;
  cursor: pointer;
}
.sidebar-logout-btn:hover { opacity: 0.8; }
/* var(--brand-blue) -> var(--accent) (2026-09-12, dark-mode fix, found live) — sits inside
   .sidebar-card (--panel), same missed-site pattern as .path-tile above. */
.sidebar-logout-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.sidebar-logout-btn .icon { width: 18px; height: 18px; }
/* Shared right-side wrapper the bell and the desktop Log Out button both live in — see
   nav.js's getHeaderActionsWrap for why (keeps header.top's own justify-content:space-between
   down to two visible children, so the bell and this button hug each other instead of getting
   pushed apart). */
.header-actions {
  display: flex;
  align-items: center;
  gap: 10px;
}
/* Desktop convenience copy of the sidebar's Log Out button (.sidebar-logout-btn above), placed
   next to the notification bell per Cody's request. Hidden below the app's mobile breakpoint
   (see the media query near .mobile-nav-toggle) — the sidebar's copy already covers narrow
   viewports, and the sidebar itself is off-canvas there, so this would be the only way to reach
   the header otherwise. Selector scoped as "header.top .header-logout-btn" (0,0,2,1), not the
   plain class alone — same specificity fix already applied to .notif-panel .notif-mark-all above,
   needed here for the same reason: "header.top button" (0,0,1,2) otherwise beats a plain
   ".header-logout-btn" (0,0,1,0) and silently keeps its wider padding. */
header.top .header-logout-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  height: 36px;
  width: max-content;
  background: transparent;
  border: 1px solid var(--neutral-ivory);
  color: var(--neutral-ivory);
  padding: 0 14px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 700;
  line-height: 1;
  white-space: nowrap;
}
header.top .header-logout-btn:hover { opacity: 0.85; }
header.top .header-logout-btn:focus-visible { outline: 2px solid var(--aqua); outline-offset: 1px; }
/* .icon defaults to color:var(--brand-blue) — invisible against this header's own
   brand-blue background, same bug already fixed for .notif-bell .icon above and for
   .mobile-nav-toggle/the Quick Talk mic icon elsewhere in this file. Without this, the icon
   renders with zero visible pixels but still takes up its 17px + gap of layout space, which is
   exactly the empty-looking gap Cody flagged to the left of the "Log Out" text. */
.header-logout-btn .icon { color: var(--neutral-ivory); width: 17px; height: 17px; flex-shrink: 0; }
.header-logout-btn .icon svg { width: 17px; height: 17px; }
/* Read-Only (default) matches the Log Out button's own transparent/ivory-border look, so a
   super_admin who never touches this reads it as "just another header control," not an alarm.
   .editing flips to a solid status-warn fill specifically so it's impossible to miss while it's
   on — the whole point of defaulting to read-only is that editing should be a deliberate,
   visible state, not something left on by accident between sessions. */
.edit-mode-toggle {
  display: inline-flex;
  align-items: center;
  height: 36px;
  width: max-content;
  background: transparent;
  border: 1px solid var(--neutral-ivory);
  color: var(--neutral-ivory);
  padding: 0 14px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 13px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  white-space: nowrap;
}
.edit-mode-toggle:hover { opacity: 0.85; }
.edit-mode-toggle:focus-visible { outline: 2px solid var(--aqua); outline-offset: 1px; }
/* Text-only at desktop (unchanged look) — the icon nav.js now always renders alongside the
   label is only actually shown once the max-width:480px block below flips this and hides the
   text instead. Kept in the DOM at every width (not conditionally rendered in JS) so a pure CSS
   media query is what decides which one shows, not a JS viewport check that can't react to a
   resize/rotate. */
.edit-mode-toggle .icon { display: none; }
.edit-mode-toggle.editing {
  background: var(--status-warn);
  border-color: var(--status-warn);
  color: var(--navy);
}
/* header.top overflow fix (2026-09-07) — at real iPhone widths, h1's hamburger + back + forward
   + refresh buttons (36px each) plus the logo/suffix plus header.top's own right-side bell (and,
   for a super_admin, the edit-mode toggle right after it) added up to roughly 445px of required
   width, well past a 375-430px phone. Stacking the logo/suffix (rather than shrinking text alone)
   is what actually recovers the width the suffix text used to claim sitting side-by-side with the
   logo; the button/icon shrinks recover the rest. Reuses the existing 480px breakpoint already
   used elsewhere in this file (.notif-panel) rather than picking a new one.
   Placed here, AFTER .mobile-nav-toggle/.mobile-nav-history-btn/.edit-mode-toggle's own base
   rules above (not grouped with the other logo/header rules near the top of this file) —
   deliberately, per the same "later same-specificity rule wins" cascade bug fixed on .page-tab
   elsewhere in this file: this block's overrides share exact selectors with those base rules
   (e.g. `.edit-mode-toggle .icon`), so it has to come after them in source order to actually win
   at matching widths, regardless of the @media wrapper. */
@media (max-width: 480px) {
  header.top { padding: 10px 12px; }
  header.top h1 { gap: 4px; font-size: 13px; }
  .brand-logo-stack { flex-direction: column; align-items: flex-start; gap: 1px; }
  .brand-logo { height: 16px; }
  .brand-logo-suffix {
    font-size: 9px;
    border-left: none;
    border-top: 1px solid rgba(249, 245, 240, 0.35);
    padding-left: 0;
    padding-top: 1px;
  }
  .mobile-nav-toggle, .mobile-nav-history-btn { width: 30px; height: 30px; }
  .mobile-nav-toggle .icon, .mobile-nav-toggle .icon svg { width: 20px; height: 20px; }
  .mobile-nav-history-btn .icon, .mobile-nav-history-btn .icon svg { width: 17px; height: 17px; }
  /* .notif-bell (2026-09-07) — missed in the original header-overflow pass: its own base rule
     (36px, far above in this file) was never touched by that fix, so it stayed at the desktop
     size while every other header button shrank to 30-32px here, making it visibly larger than
     its five neighbors. .notif-bell's base rule sits well before this block (~line 161), so
     unlike the cascade bug fixed elsewhere in this file, no reordering was needed — this override
     already wins at matching widths. Sized to match .mobile-nav-toggle/.mobile-nav-history-btn's
     30px box; icon shrunk to 17px to match .mobile-nav-history-btn's icon for the same reason. */
  .notif-bell { width: 30px; height: 30px; }
  .notif-bell .icon, .notif-bell .icon svg { width: 17px; height: 17px; }
  /* Icon-only — the text label (a bare <span>, not .icon) is dropped from layout AND the
     accessibility tree at this width; btn.setAttribute("aria-label", "Edit mode") in nav.js is
     what keeps the control's accessible name intact once that happens. Shape (pencil vs lock,
     set in nav.js) is what actually carries the on/off state at icon size — the .editing
     background-color swap is additive, not the only signal. .icon defaults to
     color:var(--brand-blue), invisible against this transparent-background button sitting on
     the header's own brand-blue — same fix as .header-logout-btn .icon above.
     32px/16px -> 30px/17px (2026-09-12, found via frame-by-frame) — this button got its own
     icon-only mobile treatment separately from .notif-bell/.mobile-nav-toggle/.mobile-nav-
     history-btn above, so it drifted from their shared 30px box instead of matching it; the icon
     shrank to compensate, but the box itself still read as visibly larger than its neighbors.
     Matched to .notif-bell exactly, box and icon both — the two sit right next to each other in
     the header row, so of every other header icon button, the bell is the one this needs to line
     up with. Padding was never actually the issue: both already computed the same "6px 12px"
     live (inherited from header.top button, absorbed by the fixed box via box-sizing:border-box
     and each button's own flex centering) — confirmed live, not just read off the rule. */
  .edit-mode-toggle { width: 30px; height: 30px; padding: 0; justify-content: center; }
  .edit-mode-toggle span:not(.icon) { display: none; }
  .edit-mode-toggle .icon { display: inline-flex; color: var(--neutral-ivory); width: 17px; height: 17px; }
  .edit-mode-toggle .icon svg { width: 17px; height: 17px; }
}
/* Sits under the tab row inside .page-tabs (var(--navy) background) — see nav.js's renderNav.
   Not a second nav surface: the tab bar above it already lets you jump to another role or back
   to Leadership, this is just a status line plus one redundant shortcut. */
.view-as-banner {
  width: 100%;
  padding: 8px 24px;
  background: rgba(255, 255, 255, 0.08);
  color: var(--neutral-ivory);
  font-size: 13px;
}
.view-as-banner a { color: var(--neutral-ivory); text-decoration: underline; }
/* Name/role and the header's own Log Out control both moved out — Log Out now lives in the
   sidebar (see .sidebar-logout-card above) at every width, and the plain name/role text
   wasn't worth the header space it took once nothing next to it needed the room reclaimed. */
header.top .who { display: none; }
/* Dims the page behind the open drawer and gives it something to tap to dismiss — sits under
   the drawer (950) but above everything else on the page. */
.mobile-nav-overlay {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(0, 37, 62, 0.5);
  z-index: 940;
}
body.mobile-nav-open .mobile-nav-overlay { display: block; }

@media (max-width: 900px) {
  /* The hamburger itself is the one piece of this that's genuinely mobile-only — it toggles
     the sidebar between fixed-beside-main (desktop) and off-canvas drawer (this breakpoint),
     which is meaningless once the sidebar already sits beside main with room to spare. */
  .mobile-nav-toggle { display: inline-flex; }
  /* Desktop-only extra (see .header-logout-btn's own comment above) — narrow viewports keep
     using the sidebar's Log Out button, same as before this existed. Selector must match
     "header.top .header-logout-btn" (0,0,2,1), not the plain class — a plain ".header-logout-btn"
     here is weaker than the base rule's specificity and silently loses to it regardless of this
     media query, which is exactly what happened before this comment was added: the button never
     actually hid on mobile despite this rule looking correct in isolation. */
  header.top .header-logout-btn { display: none; }
  /* Mirror of the rule above — below this breakpoint the header's copy is hidden, so the
     sidebar's own Log Out (see .sidebar-logout-card above, hidden by default) becomes the
     only control again. */
  .sidebar-logout-card { display: block; }
  .layout { flex-direction: column; }
  .layout main { width: 100%; }
  /* Off-canvas drawer instead of the old behavior of stacking the sidebar full-width below
     main — that put every link to another section of the app (Resources, Assignments,
     Grading, Practice, ...) at the very bottom of whatever page you were on, reachable only
     by scrolling all the way past the page's own content first. Fixed positioning takes the
     sidebar out of the normal document flow entirely, so main no longer needs the order/width
     tricks the stacked layout depended on — it just fills the column on its own. */
  .sidebar {
    position: fixed;
    top: 0;
    left: 0;
    bottom: 0;
    width: min(85vw, 320px);
    max-width: 320px;
    background: var(--bg);
    box-shadow: 4px 0 24px rgba(0, 37, 62, 0.35);
    padding: 16px;
    margin: 0;
    transform: translateX(-100%);
    transition: transform 0.25s ease;
    z-index: 950;
    overflow-y: auto;
  }
  body.mobile-nav-open .sidebar { transform: translateX(0); }
  @media (prefers-reduced-motion: reduce) {
    .sidebar { transition: none; }
  }
  /* The collapse-to-a-rail idea only makes sense when the sidebar sits beside main and
     collapsing hands that width back — in the drawer it's just taking up its own fixed
     panel, so force the full content regardless of a collapsed preference saved at a wider
     viewport, and hide the toggle that drives that desktop-only behavior. */
  .layout.sidebar-collapsed .sidebar { width: min(85vw, 320px); }
  .layout.sidebar-collapsed .sidebar-full { display: flex; }
  .layout.sidebar-collapsed .sidebar-rail-links { display: none; }
  .sidebar-collapse-toggle { display: none; }
}
/* "All Folders" (or just a library with a lot of folders) can produce far more tiles than fit
   on screen, so past a max height the grid scrolls internally instead of pushing the rest of
   the page down — same convention as .roleplay-bucket-list/.user-section-body. Small padding
   (not 0) keeps a card's own border/hover outline from getting clipped by the scroll edge.
   scrollbar-gutter: stable reserves the scrollbar's width whether or not it's actually showing
   — see .assignment-card-grid's comment for why that matters for auto-fill's column count. */
.folder-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 12px;
  max-height: 640px;
  overflow-y: auto;
  scrollbar-gutter: stable;
  padding: 2px;
}
.folder-card {
  display: flex;
  flex-direction: column;
  /* var(--neutral-ivory) -> var(--panel) (2026-09-13, dark-mode fix, found live) — neutral-ivory
     is theme-invariant (see the dark-mode block's own comment), so this card stayed a
     washed-out cream box on /resources in dark mode while every sibling card (.assignment-card,
     .stat-card, .subgallery-card) already used the theme-aware --panel and correctly went dark.
     Text inside (.folder-card-open uses --accent, .count uses --muted) is unaffected by this —
     both tokens are already the ones verified to have real contrast against --panel in dark
     mode (see :root[data-theme="dark"]'s own computed-ratio comment), the same pair every other
     --panel-background card already relies on. */
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: var(--space-5) var(--space-4); /* 18px 14px -> 20px 16px, 2026-09-12 visual-polish pass item 3 */
  text-align: center;
  /* Anchors .folder-select's absolutely-positioned checkbox to this card, not the page —
     added alongside the multi-select "move to another view" feature (2026-08-25). */
  position: relative;
  box-shadow: var(--shadow-sm);
  transition: box-shadow 150ms ease, transform 150ms ease;
}
.folder-card:hover { border-color: var(--brand-blue); box-shadow: var(--shadow-hover); transform: translateY(-2px); }
/* Real <button> now does the "open this folder" work (keyboard/screen-reader accessible,
   unlike the plain clickable <div> this used to be) — reset to look identical to the old
   div-based card, since .folder-card (the outer shell) still carries all the visual styling.
   flex: 1 lets it grow to fill whatever height the CSS Grid row stretches the card to, so the
   Rename/Delete row below it lands at the same bottom edge on every card in the row regardless
   of how many lines a given folder name wraps to. Also a flex column itself (not just block)
   so the "N documents" count inside it can be pinned to the bottom via margin-top: auto below
   — otherwise the count would still land at a different height per card based on how many
   lines the folder name wrapped to, even with the button itself uniformly sized. */
.folder-card-open {
  display: flex;
  flex-direction: column;
  align-items: center;
  flex: 1;
  width: 100%;
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  cursor: pointer;
  text-align: center;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 700;
  font-size: inherit;
  color: var(--accent);
}
.folder-card-open:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.folder-card-actions { display: flex; gap: 6px; justify-content: center; margin-top: 6px; }
/* min-height reserves room for 2 lines even when a folder's count fits on 1 (e.g. plain "15
   documents" vs the longer "0 documents, 10 assignments" some folders show) — otherwise a
   1-line count's text starts lower than a 2-line count's on the same row, even though both
   are bottom-anchored via margin-top: auto above. */
.folder-card .count { display: block; font-size: 11px; line-height: 1.3; min-height: calc(2.6em + 4px); color: var(--muted); font-weight: 400; margin-top: auto; padding-top: 4px; text-transform: none; }
.folder-icon { font-size: 28px; display: block; margin-bottom: 6px; }
.folder-icon.icon { display: block; margin: 0 auto 6px; }
.todo-item, .pin-item {
  border-bottom: 1px solid var(--border);
  padding: 10px 0;
}
.todo-item:last-child, .pin-item:last-child { border-bottom: none; }
/* No background of its own (sits on the dashboard card's theme-tracking panel) — same
   dark-mode-contrast fix as .error/.sidebar-logout-btn/.category-chip-delete:hover above. */
.todo-item.overdue { color: var(--status-bad-text); }
.todo-item .due { font-size: 12px; text-transform: uppercase; letter-spacing: 0.02em; font-family: "Golos Text", Arial, sans-serif; font-weight: 700; }
/* Bordered-box-with-internal-scroll for a list that can grow long — the same treatment as
   .resource-picker/.cohort-member-picker (border + radius + padding + max-height + overflow),
   this app's dominant convention for this exact problem (also: roleplay-bucket-list, grading's
   user-section-body), used here instead of a "View More" button (that pattern exists too, but
   only in one place — subgallery.js's submission galleries — so this is the more common one to
   match). Padding is 0 (unlike resource-picker's 8-10px) since .todo-item already carries its
   own vertical padding/border-bottom per row; adding box padding too would double up the gap
   at the top and bottom. */
.todo-list-box {
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 0 10px;
  max-height: 320px;
  overflow-y: auto;
}
/* rep.html/manager.html's #todoToggle button uses inline style="all:unset" to strip default
   button chrome so it can look like a plain clickable header — `all` resets every property
   including outline, which silently killed the focus ring along with everything else. Restored
   here rather than in the inline style itself, since :focus-visible can't be expressed inline. */
#todoToggle:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* Same all:unset-strips-the-focus-ring gap as #todoToggle above, on cohorts.html's Certificate
   Bank expand/collapse toggle (.cert-bank-toggle, built inline in JS). */
.cert-bank-toggle:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* Home page's Announcement/Highlight picture crop-position slider (home.html) — previously
   the plain unstyled OS default in every browser, with no visible focus ring at all for a
   keyboard user tabbing to it. Track/thumb rules are duplicated per vendor prefix on purpose —
   browsers ignore an entire rule if any selector in a comma-separated list is unsupported, so
   -webkit-/-moz- pairs have to stay as fully separate rules, not combined. */
.highlight-image-position-slider {
  -webkit-appearance: none;
  appearance: none;
  height: 4px;
  border-radius: 2px;
  background: var(--border);
  outline-offset: 4px;
}
.highlight-image-position-slider:focus-visible {
  outline: 2px solid var(--accent);
}
.highlight-image-position-slider::-webkit-slider-runnable-track {
  height: 4px;
  border-radius: 2px;
  background: var(--border);
}
.highlight-image-position-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 16px;
  height: 16px;
  margin-top: -6px;
  border-radius: 50%;
  background: var(--brand-blue);
  border: 2px solid var(--panel);
  cursor: pointer;
}
.highlight-image-position-slider::-moz-range-track {
  height: 4px;
  border-radius: 2px;
  background: var(--border);
}
.highlight-image-position-slider::-moz-range-thumb {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--brand-blue);
  border: 2px solid var(--panel);
  cursor: pointer;
}
.small-btn {
  background: none; border: 1px solid var(--border); color: var(--muted);
  /* 3px 9px -> 5px 12px (2026-09-12, visual-polish pass item 3) — still the compact/secondary
     button, just less cramped; not bumped as far as .primary-btn below. */
  padding: 5px var(--space-3); border-radius: 6px; font-size: 11px; cursor: pointer;
  transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, opacity 150ms ease;
}
/* Neither of these two buttons had any hover feedback at all before this (2026-09-11, motion
   polish pass item 2) — cursor:pointer was the only signal something was clickable. */
/* var(--neutral-ivory) -> var(--bg) (2026-09-12, dark-mode fix) — same fixed-bright-white-flash
   problem and same fix as .sidebar-rail-link:hover above (border-color/color here were already
   dark-mode-safe tokens, only the background was hardcoded). */
.small-btn:hover { background: var(--bg); border-color: var(--muted); color: var(--text); }
/* Same box model as .small-btn, just the danger color — previously defined independently
   (and slightly differently: 4px 10px padding, 12px font) in both resources.html and
   assignments.html. */
.delete-btn {
  background: none; border: 1px solid var(--border); color: var(--status-bad-text);
  padding: 5px var(--space-3); border-radius: 6px; font-size: 11px; cursor: pointer; /* matches .small-btn's own bump, 2026-09-12 */
  transition: background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
/* var(--status-bad-card-bg) (theme-aware, exact same pixel value as the old #fbeaea in light
   mode) — same fixed-pale-hover-flash bug already fixed for .small-btn:hover above, missed here:
   .delete-btn's own text is var(--status-bad-text), which lightens in dark mode, and this
   hardcoded background never did, so hovering went low-contrast in dark mode. */
.delete-btn:hover { background: var(--status-bad-card-bg); border-color: var(--status-bad); }
/* Borderless "x" unassign glyph, shared by manage-users.html's cohort-path list and
   path.html's assigned-cohorts list — previously the exact same inline style duplicated in
   both files. */
.unassign-x-btn {
  border: none; background: none; color: var(--status-bad-text); cursor: pointer; font-size: 12px;
}
/* Touch-target sizing — only applies when the primary input has no hover and a coarse
   pointer (a phone/tablet); desktop mouse users see zero change. A mobile audit measured
   these exact classes at ~20px tall with single-digit gaps between them in real use (the
   reorder Move Up/Down buttons, which are plain .small-btn instances; Manage Users' row
   actions — Deactivate/Reset Password/Resend Invite, also .small-btn; the icon-only cohort/
   path unassign "x"; the Active/Deactivated/Audit Log section toggles) — well under both
   Apple/Google's ~44px guidance and WCAG's 24px minimum, and in Deactivate's case sitting
   right next to a destructive action. The `form.inline button` compound selectors are
   repeated here because that combination already outranks a bare `.small-btn`/`.delete-btn`
   (see the specificity note below on line ~709) — without duplicating it, this override
   would silently lose inside any form.inline. */
@media (hover: none) and (pointer: coarse) {
  .small-btn, .delete-btn,
  form.inline button.small-btn, form.inline button.delete-btn {
    padding: 11px 15px;
    font-size: 13px;
    min-height: 40px;
  }
  .unassign-x-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 40px;
    min-height: 40px;
    font-size: 18px;
  }
  /* `body` prefix bumps specificity above the base `.user-section-toggle { padding: 0; }`
     rule, which is defined later in this file at equal specificity and would otherwise win
     by source order regardless of this media query. */
  body .user-section-toggle {
    padding: 10px 0;
  }
}
form.inline {
  display: flex;
  flex-direction: column;
  gap: var(--space-4); /* 8px -> 16px, 2026-09-12 visual-polish pass item 3 — more room between stacked fields */
}
form.inline input, form.inline select, form.inline textarea {
  padding: var(--space-2) var(--space-3); /* 8px 10px -> 8px 12px, 2026-09-12 visual-polish pass item 3 */
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 13px;
  font-family: inherit;
}
/* A <textarea> uniquely carries a legacy intrinsic width (the default cols="20" HTML behavior)
   that overrides flexbox's normal align-items:stretch the way <input>/<select> don't — every
   textarea here (Acknowledgment statement, personaPrompt, written questions, etc.) was rendering
   at that narrow intrinsic width instead of filling its .field-label column like every other
   field, wrapping/overlapping the label text above it. box-sizing so the padding above doesn't
   push it past 100%. */
form.inline textarea {
  width: 100%;
  box-sizing: border-box;
}
label.field-label {
  display: flex;
  flex-direction: column;
  gap: var(--space-2); /* 2px -> 8px, 2026-09-12 visual-polish pass item 3 — label sat almost flush against its own field */
  font-size: 12px;
}

/* Compact filter-bar row (Role/Status/Owner/BU-style dropdowns above a list or table) —
   promoted from compliance.html's own inline styles (2026-08-20) once grading.html needed the
   exact same look in 4 separate places. Every .filter-field shares one fixed width, which is
   what actually keeps a wrapped row looking like a straight grid instead of a ragged one — the
   bug this fixed: grading.html's 4 filter groups previously each had their own ad hoc sizing
   (some using flex-basis, some none at all), so wrapped rows never lined up with each other and
   Team Compliance/Path Grading Grid crammed their filters into the same flex row as the section's
   own <h2>, wrapping unpredictably against the heading instead of forming a clean row of their
   own. Every .filter-row here is now its own row, below the heading, using this one class.

   The single filter-row class in the app (2026-09-14 — a short-lived .report-filter-row/
   .report-filter-field duplicate existed for one pass, scoped to grading.html's Grading Trend
   card and compliance.html's Filter/Compliance Trend cards while their Split checkbox still
   made those two rows' controls inconsistent with each other; folded back in here once the
   checkbox was gone, since the rule bodies were never actually different from this class's own
   — a second class for an identical rule set was exactly the kind of duplication that caused the
   original inconsistency). Backs every filter-row/filter-field markup in the app: grading.html's
   4 rows and compliance.html's 3, manage-users.html's own Owner/BU row, and, via
   public/listFilterBar.js, the Search/Type/Sort bar on assignments.html, script-roleplays.html,
   practice.html, cohorts.html, manage-users.html, resources.html, and teaching-library.html
   (resources.html also appends its own "Move To" field onto a listFilterBar.js row using this
   same class on a plain <div> — .filter-field's rules are tag-agnostic, keyed off the class only,
   so that's a supported use, not a special case). */
.filter-row { display: flex; gap: 16px; flex-wrap: wrap; }
.filter-field { font-size: 12px; display: flex; flex-direction: column; gap: 6px; width: 150px; min-width: 0; } /* 3px -> 6px, 2026-09-12 visual-polish pass item 3 — kept smaller than .field-label's own bump since this is a deliberately compact filter-bar row */
.filter-field select, .filter-field input {
  width: 100%; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}

/* Other Tracks disclosure (assignments.html, 2026-09-14, Change C) — a native <details>, not a
   tab row: closed by default, one clickable label, existing theme tokens only. The content
   inside (.filter-row of plain .small-btn links) already has its own wrap/gap handling from that
   shared class, so nothing extra is needed here for narrow widths — the summary's own text wraps
   like any other inline text at 375px. Custom marker (replacing the native disclosure triangle)
   so the open/closed state reads the same across browsers instead of varying default markers. */
.other-tracks-disclosure {
  margin-top: 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 14px;
}
.other-tracks-disclosure summary {
  cursor: pointer;
  font-size: 13px;
  font-weight: 700;
  color: var(--text);
  list-style: none;
}
.other-tracks-disclosure summary::-webkit-details-marker { display: none; }
.other-tracks-disclosure summary::before {
  content: "▸";
  display: inline-block;
  width: 1em;
  margin-right: 4px;
  color: var(--muted);
  transition: transform 150ms ease;
}
.other-tracks-disclosure[open] summary::before { transform: rotate(90deg); }

form.inline button {
  align-self: flex-start;
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  border: none;
  padding: var(--space-3) var(--space-5); /* 8px 16px -> 12px 20px, 2026-09-12 visual-polish pass item 3 — matches .primary-btn's own bump below */
  border-radius: 6px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  text-transform: uppercase;
  cursor: pointer;
  font-size: 12px;
}
/* `form.inline button` (0,1,1) outranks plain `.small-btn`/`.delete-btn` (0,1,0), so every
   Cancel/small button living inside a form.inline was silently rendering as the full primary
   blue button instead of the small outlined one — true everywhere this combination occurs
   (home.html, cohorts.html, practice.html, script-roleplays.html, assignments.html), not just
   the "wrong-looking" ones caught by eye. Fixed at the source with a selector that outranks it
   (0,2,1), rather than patching each call site. */
form.inline button.small-btn, form.inline button.delete-btn {
  align-self: auto;
  background: none;
  border: 1px solid var(--border);
  padding: 5px var(--space-3); /* 3px 9px -> 5px 12px, matches the standalone .small-btn/.delete-btn bump above, 2026-09-12 */
  border-radius: 6px;
  font-family: inherit;
  font-weight: normal;
  text-transform: none;
  font-size: 11px;
}
/* Same visual weight as `form.inline button`, usable standalone — several primary confirm
   actions (Assign, Save Members, Add) live in a plain flex row rather than a form.inline (that
   row layout is deliberately horizontal, which form.inline's flex-column would break), so they
   need this rather than the form-scoped rule. */
.primary-btn {
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  border: none;
  padding: var(--space-3) var(--space-5); /* 8px 16px -> 12px 20px, 2026-09-12 visual-polish pass item 3 */
  border-radius: 6px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  text-transform: uppercase;
  cursor: pointer;
  font-size: 12px;
  transition: opacity 150ms ease;
}
/* No hover feedback at all before this (2026-09-11, motion polish pass item 2) — a plain
   opacity dip rather than a second hardcoded background color, so it works correctly against
   both instances' own --brand-blue without needing a per-instance "hover blue" token. */
.primary-btn:hover { opacity: 0.88; }
form.inline button.small-btn { color: var(--muted); }
form.inline button.delete-btn { color: var(--status-bad-text); }
/* Same small-uppercase-eyebrow look as .stat-card .stat-label and practice.html's own
   .practice-group-label (radio-picker subheaders) — promoted here, generic name, for
   clustering related cards/fields elsewhere (manage-users.html card groups, assignments.html
   editor field groups) instead of copy-pasting page-local CSS a third time. */
.section-label {
  font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted);
  font-family: 'Golos Text', Arial, sans-serif; font-weight: 700; margin: 20px 0 4px;
}
.section-label:first-child { margin-top: 0; }
/* Fade-in on appearance (2026-09-11, motion polish pass item 2) — a short fade + slight rise,
   not a showy entrance. Deliberately scoped to .card/.stat-card only, NOT the fetch-populated
   grid classes from item 1 (.path-tile, .folder-card, .assignment-card, .subgallery-card) — those
   get their whole grid rebuilt via innerHTML after a fetch (a path's content after adding an
   assignment, a folder grid after a bulk move), which would replay this on every unrelated card
   in the grid, not just the one that actually changed — "twitchy," not smooth (Cody's own
   framing). .card/.stat-card, by contrast, are server-rendered once per page load and never
   wholesale-rebuilt afterward — the roster refresh after a bulk action, for instance, only ever
   replaces .user-row content INSIDE the already-existing "Users" .card, never recreates the .card
   element itself, so this never replays there either.
   .sidebar-card explicitly EXCLUDED (2026-09-12, found via frame-by-frame) — unlike .card/
   .stat-card, the sidebar renders client-side via renderSidebar, after main content, as part of
   the sidebar-concurrency work earlier in this pass. Animating it in made a now-fast sidebar
   (~400ms) read as arriving late relative to the main content's own fade, which finishes first
   and sits solid while the sidebar visibly animates in beside it — the animation itself was the
   "slow" cue, not any actual latency. It should just be there once rendered, not fade in. */
@keyframes card-fade-in {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: translateY(0); }
}
.card, .stat-card {
  animation: card-fade-in 200ms ease-out;
}
.card {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  /* 24px -> 32px (2026-09-12, visual-polish pass item 3) — Cody's own read: "the cards feel
     tight, content sits close to the edges." This is the main content container platform-wide,
     so it gets the biggest bump of any card type. */
  padding: var(--space-7);
  box-shadow: var(--shadow-sm);
}
.card h2 {
  margin-top: 0;
  /* Explicit heading-to-content gap (2026-09-12, visual-polish pass item 3) — previously relied
     on the browser's default paragraph top-margin for whatever came next, which read as
     noticeably tighter than this. .card h2 + p below zeroes the immediately-following
     subtitle's own top margin so this value is the only one setting the gap, not a collapse
     between two competing margins. */
  margin-bottom: var(--space-5);
  color: var(--accent);
  text-transform: uppercase;
  font-size: var(--text-xl);
}
.card h2 + p {
  margin-top: 0;
}
/* Page-title tier (2026-09-12, visual-polish pass item 4) — every h2 on a page previously
   rendered identically whether it was the page's own subject (the first card — "Sales Rep,"
   "Resource Library," "Manage Users") or the sixth stacked subsection below it, so a page read
   as a stack of equal-weight boxes with no clear subject. This targets the first card's own
   heading specifically: bigger, bolder, a touch more letter-spacing, everything else (color,
   uppercase, margin) inherited unchanged from .card h2 above. Purely structural selector, no
   HTML changes — verified against the real markup of all 21 pages that render a sidebar+main
   layout: main's first child is always a .card, and that card's own (sole) h2 is always the
   page's real title, whether it's a plain heading (home.html, path.html, recording-software.html)
   or wrapped in a .card-toggle button for the collapsible pattern (every other page) — the
   descendant selector below reaches the h2 either way. Scoped to .layout main specifically, not
   bare main, so it can't also match the login page's own centered .layout-less main. */
.layout main > .card:first-child h2 {
  font-size: var(--text-page-title);
  font-weight: 800;
  letter-spacing: 0.01em;
}
/* Collapsible cards, platform-wide (2026-09-06 — see public/collapsibleCards.js). Promoted here
   from the Compliance pilot's own page-local <style> block once every page started using the
   same treatment, rather than duplicating these three rules in ~20 view files. The button wraps
   the real <h2> rather than replacing it, so .card h2's own brand-blue/uppercase styling above
   applies completely unchanged — this is purely a bigger click target and an open/closed
   indicator around a heading that was already there. */
/* width:100% is load-bearing here, not decoration — a <button> keeps its browser-default
   shrink-to-fit sizing even once display:flex is set on it (unlike a div, which stretches to
   its container automatically), so without it the whole title+arrow flex row only ever grows
   as wide as its content and the arrow lands right after the title instead of at the card's
   right edge. flex:1 below covers the one case where .card-toggle itself sits inside an outer
   flex row alongside a sibling control (rep.html's My Progress track <select>) — flex-basis:0%
   from that shorthand overrides width for sizing there, so the two rules don't fight. */
.card-toggle {
  display: flex; align-items: center; gap: 8px;
  width: 100%;
  background: none; border: none; padding: 0; margin: 0; cursor: pointer; font: inherit; text-align: left;
  flex: 1; min-width: 0;
}
.card-toggle h2 { flex: 1; min-width: 0; }
.card-toggle-arrow { color: var(--muted); font-size: 16px; margin-left: 10px; flex-shrink: 0; transition: transform 200ms ease; }
/* Rotate instead of swap (2026-09-11, motion polish pass item 2) — same reasoning as
   .sidebar-group-header's own identical rule above. */
.card-toggle[aria-expanded="true"] .card-toggle-arrow { transform: rotate(180deg); }
/* Slide instead of snap (2026-09-11, motion polish pass item 2) — animateSectionToggle (public/
   sidebar.js) drives every collapsible body's open/close via a temporary inline max-height
   instead of flipping display alone; this is what actually makes that transition instead of
   an instant jump. No max-height of its own here (unlike .sidebar-group-body/.user-section-body
   below, which are deliberately capped-and-scrollable) — a card body opens to its own real
   content height, measured fresh via scrollHeight at open time. */
.card-body {
  transition: max-height 220ms ease;
  /* Heading-to-content gap for the platform-wide collapsible pattern (2026-09-12, visual-polish
     pass item 3) — most cards wrap their h2 in .card-toggle (a <button>), so the h2's own
     margin-bottom above doesn't reliably create visible space below the toggle row (it's a flex
     item inside the button, not a normal block sibling of what follows). This margin on the
     content wrapper itself is what actually produces the gap for that pattern; margin doesn't
     affect scrollHeight, so it doesn't interact with animateSectionToggle's open/close
     measurement (motion polish pass item 2). */
  margin-top: var(--space-4);
}
/* "Consider It Covered" brand banner — sits above any video-recording widget's action
   controls, below the title/mode selection. Originally added to assignments.html only (Video
   Submission / AI Role Play assignments), confirmed by Cody after a visual check on one
   screen; promoted here (was duplicated in that page's own <style> block) once Practice with
   AI's live recording session needed the exact same treatment, so both stay in sync instead
   of drifting the way other page-local copies have before. */
.assignment-cover-banner {
  background: var(--brand-blue); color: var(--neutral-ivory);
  font-family: 'Golos Text', Arial, sans-serif; font-weight: 800; text-transform: uppercase;
  letter-spacing: 0.03em; text-align: center; font-size: 22px;
  padding: 22px 16px; border-radius: 10px; margin-bottom: 12px;
}
.assignment-cover-banner:empty {
  display: none;
}
/* Base rule for the "muted" convention used app-wide (span/div/p, any context) — the two
   scoped rules below only ever narrowed the font-size for their specific spot, but never
   actually supplied this base color, so every class="muted" outside .card/.subgallery-info/
   .empty-state rendered in default text color instead of grey. */
.muted {
  color: var(--muted);
}
.card p.muted {
  color: var(--muted);
  font-size: 14px;
}
.nav-links {
  display: flex;
  gap: 12px;
  margin-top: 16px;
}
.nav-links a {
  display: inline-block;
  padding: 10px 16px;
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  text-decoration: none;
  border-radius: 8px;
  font-size: 14px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.01em;
}
form.login {
  display: flex;
  flex-direction: column;
  gap: var(--space-4); /* 12px -> 16px, 2026-09-12 visual-polish pass item 3 */
  margin-top: 16px;
}
form.login label {
  font-size: 13px;
  font-weight: 700;
  /* var(--navy) -> var(--text) (2026-09-12, dark-mode fix) — found live: this sits on .card
     (--panel), which goes dark in dark mode, and --navy is exactly the raw color --panel is
     itself derived from — near-invisible label text. --text is the token already built for
     "legible foreground on --panel/--bg in either mode" and equals --navy in light mode for
     Vault, so light mode is unaffected. */
  color: var(--text);
}
form.login input {
  padding: var(--space-3); /* 10px 12px -> 12px, 2026-09-12 visual-polish pass item 3 */
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 14px;
}
form.login input:focus {
  outline: 2px solid var(--accent-aqua); /* Aqua used sparingly, as an accent, per brand usage rule */
  outline-offset: 1px;
}
form.login button {
  margin-top: var(--space-2);
  padding: var(--space-3) var(--space-5); /* 10px 16px -> 12px 20px, matches .primary-btn, 2026-09-12 */
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  border: none;
  border-radius: 6px;
  font-size: 14px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.01em;
  cursor: pointer;
}
.forgot-password-link {
  display: inline-block;
  margin-top: 12px;
  font-size: 13px;
  font-weight: 700;
  color: var(--accent);
  text-decoration: none;
}
.forgot-password-link:hover { text-decoration: underline; }
/* Microsoft sign-in (2026-09-24) — a secondary button above the password form: bordered on
   --panel rather than filled --brand-blue, so the password button stays the primary action
   until an instance turns passwords off, at which point this is the only button left. */
.sso-signin {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin-top: 16px;
  padding: var(--space-3) var(--space-5);
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--panel);
  color: var(--text);
  font-family: "Golos Text", Arial, sans-serif;
  font-size: 14px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.01em;
  text-decoration: none;
}
.sso-signin:hover { background: var(--bg); border-color: var(--muted); }
.sso-signin[hidden], .sso-divider[hidden], #passwordSignIn[hidden] { display: none; }
.sso-divider {
  margin: 12px 0 0;
  text-align: center;
  font-size: 12px;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.forgot-password-box {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.forgot-password-box input {
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 14px;
}
.forgot-password-box button {
  padding: 9px 14px;
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  border: none;
  border-radius: 6px;
  font-size: 13px;
  font-weight: 700;
  cursor: pointer;
}
.error {
  /* Not a brand color — semantic error red is a system/utility color, kept outside the brand
     palette. var(--status-bad-text), not the raw #b3261e/var(--status-bad) — this class has no
     background of its own, so it sits directly on whatever theme-tracking panel/page background
     is behind it (every login/signup-adjacent page loads darkMode.js too), and raw status-bad
     measured below AA against a dark panel (see the round-18 audit comment on --status-*-text
     above) — exactly the contrast var(--status-bad-text)'s dark-mode lightening exists to fix.
     Identical value to the old hardcoded hex in light mode. */
  color: var(--status-bad-text);
  font-size: 13px;
}
.test-accounts {
  margin-top: 20px;
  padding: 12px 14px;
  background: var(--neutral-ivory);
  border: 1px solid var(--stone);
  border-radius: 8px;
  font-size: 12px;
  color: var(--navy);
  line-height: 1.6;
}

/* Brand tagline treatment (guide p.10): Vault Blue text, Golos Extra Bold, all caps, on
   Neutral Ivory only — with the payoff word called out in a rectangular Aqua block. Used
   sparingly (guide: "limit to once per layout") — not something to repeat on every page. */
.brand-tagline {
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  /* var(--brand-blue) -> var(--accent) (2026-09-12, dark-mode fix, found live) — the guide's
     "on Neutral Ivory only" above describes the certificate's paper, which stays fixed; the two
     non-certificate uses (login card, home's welcome card) sit on --panel, which goes dark in
     dark mode, and brand-blue-on-dark-panel measured 1.4:1 — nearly invisible. .certificate-
     tagline below re-pins the certificate context back to raw --brand-blue so that fixed-ivory
     treatment is untouched by this. */
  color: var(--accent);
  font-size: 15px;
  margin: 4px 0 0;
}
.brand-tagline:empty {
  display: none;
}
.brand-highlight {
  background: var(--aqua);
  color: var(--brand-blue); /* matches the guide's own tagline mockup (p.10/21) — the
    generic accessibility grid on p.14 actually X's every color-on-Aqua text pairing,
    including this one, so the specific tagline example is the more authoritative spec here */
  padding: 1px 7px;
  border-radius: 2px;
}

/* Quick Talk — floating always-on voice button (Rep/Manager pages only). */
.quick-talk-btn {
  position: fixed;
  right: 24px;
  bottom: 24px;
  width: 58px;
  height: 58px;
  border-radius: 50%;
  border: none;
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  font-size: 24px;
  cursor: pointer;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
  z-index: 500;
  display: flex;
  align-items: center;
  justify-content: center;
}
.quick-talk-btn:hover { filter: brightness(1.1); }
.quick-talk-btn:focus-visible { outline: 2px solid var(--aqua); outline-offset: 2px; }
/* .icon defaults to brand-blue, same as this button's own background — without this
   override the mic icon renders invisible (blue-on-blue) instead of the ivory it should be. */
.quick-talk-btn .icon { color: var(--neutral-ivory); width: 26px; height: 26px; }
.quick-talk-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 37, 62, 0.45);
  z-index: 600;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
}
.quick-talk-panel {
  background: var(--panel);
  border-radius: 12px;
  width: 100%;
  max-width: 480px;
  max-height: 80vh;
  display: flex;
  flex-direction: column;
  padding: 18px;
}
.quick-talk-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.quick-talk-header h3 {
  margin: 0;
  color: var(--accent);
  font-family: "Golos Text", Arial, sans-serif;
}
.quick-talk-close {
  background: none;
  border: none;
  font-size: 22px;
  line-height: 1;
  color: var(--muted);
  cursor: pointer;
  padding: 0 4px;
}
/* Had no focus styling at all (2026-09-16 bugfix) — the panel calls closeBtn.focus()
   programmatically on open (public/quicktalk.js), so every open left the browser's raw default
   outline visibly ringing the button with no author override, unlike every other close button in
   the app. Same :focus-visible-only pattern as .file-preview-close-btn/.submission-modal-close-btn
   below — shows the ring for real keyboard focus, not for the mouse-click/programmatic-focus case
   this button hits on every single open. */
.quick-talk-close:focus-visible { outline: 2px solid var(--accent-aqua); outline-offset: 2px; }
.quick-talk-note {
  font-size: 12px;
  margin: 8px 0 0;
}
.quick-talk-messages {
  /* Hidden per request — no visible running transcript. The underlying message list is
     still tracked in JS (still needed for the conversation-history API calls), this just
     stops rendering it. Errors are surfaced via .quick-talk-status instead so failures
     aren't silently invisible. */
  display: none;
}
.quick-talk-status {
  margin-top: 8px;
  font-size: 13px;
  font-weight: 600;
  color: var(--accent);
}
.qt-bubble {
  max-width: 85%;
  padding: 8px 12px;
  border-radius: 12px;
  font-size: 13px;
  line-height: 1.4;
  white-space: pre-wrap;
}
.qt-bubble.rep { align-self: flex-end; background: var(--brand-blue); color: var(--neutral-ivory); border-bottom-right-radius: 3px; }
/* var(--neutral-ivory) -> var(--panel), explicit color: var(--text) added (2026-09-12, round-18
   audit fix) — identical bug to the one found and fixed in the view files' own .rp-bubble.persona
   (same role-play chat bubble shape, this one's just the sidebar Quick Talk widget's copy): a
   fixed-light background with no explicit text color meant the bubble's text inherited
   var(--text), which correctly flips to ivory in dark mode — ivory-on-ivory, invisible. Flagged
   by the fork that swept the view files (out of its scope, this file wasn't) as the same bug,
   unfixed, in the one file that was supposedly already fully migrated. */
.qt-bubble.persona { align-self: flex-start; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-bottom-left-radius: 3px; }
.qt-bubble.typing { align-self: flex-start; color: var(--muted); font-size: 12px; font-style: italic; }
.qt-bubble.error { align-self: stretch; background: #fdecea; border: 1px solid #f5c6c2; color: #b3261e; }

/* Training dashboard widgets — compliance/grade progress bars, on Rep + Manager pages. */
.progress-widget-stats {
  display: flex;
  gap: 24px;
  margin: 10px 0 14px;
}
.progress-stat { text-align: center; }
.progress-stat .num {
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  font-size: var(--text-2xl); /* was already 26px; tokenized to match .stat-card .stat-value below, same role in a different card type, 2026-09-12 visual-polish pass item 4 */
  color: var(--accent);
  display: block;
}
.progress-stat .label {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--muted);
  font-family: "Golos Text", Arial, sans-serif; /* guide: all-caps labels are Golos Bold, not body's Arial */
  font-weight: 700;
}
.progress-bar-track {
  background: var(--panel-sunken);
  border: 1px solid var(--border);
  border-radius: 6px;
  height: 10px;
  overflow: hidden;
}
.charts-row {
  display: flex;
  flex-direction: column;
  gap: 16px;
  margin-top: 10px;
}
.chart-box {
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 12px;
}
/* .chart-box h4's own size/color/font rule removed (2026-09-12, visual-polish pass item 4) —
   fully redundant with the new base h4 rule above (same margin, same brand-blue, same Golos
   Text); the box itself still needs its own layout below. */
/* .chart-box .chart-canvas-wrap removed (2026-09-13) — a fixed-height, position:relative sizing
   box for an absolutely-positioned <canvas> from an earlier, since-abandoned charting attempt
   (see the Team Compliance/My Grades conversion-to-table comment below). Nothing in the
   codebase ever used this class; the trend chart added the same day needs a horizontally
   scrolling, variable-width SVG instead (.trend-chart-scroll below), which is a different
   enough shape that stretching this rule to fit would have been a worse fit than a new one.
   .charts-row/.chart-box themselves are still live — My Grades/Class Grades/Grade Trend on
   rep.html all sit inside them today — only this one dead sub-rule is gone. */
/* Trend chart (2026-09-13) — hand-rolled SVG, no charting library (see the audit that preceded
   this feature: none exists in this project and none is being added). Horizontally scrolling,
   same contained-overflow pattern as scorm-files.html's own wide-table fix, so a chart with many
   weeks never forces the page itself wider than the viewport at 375px — the box scrolls, the
   page doesn't. Colors are drawn inline in the SVG itself (trendChart.js) via var(--accent) etc.,
   not here — this file only styles the legend underneath it. */
.trend-chart-scroll {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}
.trend-chart-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 16px;
  margin-top: 8px;
  font-size: 12px;
  color: var(--muted);
}
.trend-chart-legend-metrics { margin-top: 2px; }
.trend-chart-legend-item {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.trend-chart-swatch {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  flex-shrink: 0;
  display: inline-block;
}
/* Avg Score's own legend key — a plain solid swatch, no dash, so the shape difference between
   this and Pass Rate's key below is real, not just implied by adjacent labels. */
.trend-chart-line-sample {
  width: 16px;
  height: 0;
  border-top: 2px solid var(--accent);
  display: inline-block;
}
.trend-chart-line-sample-dashed {
  border-top-style: dashed;
  border-top-color: var(--accent-aqua);
}
/* A single resolved week (see trendChart.js's renderTrendStat) has nothing to draw a line
   between, so it renders as plain stats instead — one .trend-chart-stat-group per series (per
   split group, or just one for "All"). Reuses .progress-stat/.num/.label as-is (same big-number/
   small-label look already used for the "My Progress"/Team Compliance stat rows on these same
   pages) rather than new number styling; wrap is what actually matters at 375px when split
   resolves to several groups stacking. */
.trend-chart-stat-row {
  display: flex;
  flex-wrap: wrap;
  gap: 18px 28px;
  padding: 4px 0 8px;
}
.trend-chart-stat-group {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.trend-chart-stat-group-label {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  font-weight: 700;
  color: var(--muted);
}
.trend-chart-stat-values {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}
/* "Showing 5 of 32 business units — ranked by change" — renders whenever split had to drop
   sub-groups to stay at the cap (trendChart.js's selectTrendGroups/omittedNoteHtml). Sits between
   the chart/stat area and the legend in both line and stat mode. */
.trend-chart-omitted-note { margin: 6px 0 0; font-size: 12px; }
/* The "compare specific sub-groups" picker (a native <select multiple>) — wider than the shared
   150px .filter-field default so real labels (BU/owner/rep names) stay legible rather than
   clipping immediately; still wraps to its own line in the row's flex-wrap at 375px like every
   other filter field. Applied alongside .filter-field on the Grading Trend and Compliance Trend
   cards' Compare Specific pickers — cascade order (this rule comes after .filter-field above in
   the source) is what lets its 220px win over the shared 150px without a combined selector. */
.trend-group-picker-field { width: 220px; }
.trend-group-picker-field select[multiple] { height: auto; }
/* Person/name-by-metric table look — originally local to grading.html (Path Grading Grid +
   Team Compliance), now shared: rep.html/manager.html's "My Grades"/"Class Grades" boxes
   reuse it too, on the same reasoning as the Team Compliance conversion this mirrors (a bar
   chart is fine for spotting an outlier, but "what's the exact number" is what a cell answers
   better than a bar's height can). Promoted here once a third page needed it, same threshold
   this app uses for centralizing anything page-local (see the status-color triad above).

   max-height + overflow: a growing row count shouldn't make the whole page longer — a
   consistent number of rows stays visible (~10-12), the rest reachable by scrolling inside the
   box. The header row is pinned (position: sticky; top: 0) so column labels stay visible while
   scrolling down. The first column is pinned left too (for wide grids with many columns);
   z-index layers it (3) above the plain sticky-top header cells (1) so the top-left corner
   cell, sticky on BOTH axes at once, renders above both directions. */
/* Scroll-shadow affordance (CSS-only, no JS) — on a wide grid (this can run 15+ assignment
   columns, ~19 screens' worth on a phone per a mobile audit), there was previously no visual
   cue that anything was scrolled off to the right. Two gradients pinned to the scroll content
   ("local") fade the edge into the panel background as you approach it; two more pinned to
   the viewport ("scroll") cast a faint shadow that only shows while there's still unscrolled
   content on that side — both pairs cancel out naturally once you reach either end. */
.grading-grid-wrap {
  overflow: auto;
  max-height: 400px;
  margin-top: 14px;
  background:
    linear-gradient(to right, var(--panel) 30%, rgba(255, 255, 255, 0)),
    linear-gradient(to left, var(--panel) 30%, rgba(255, 255, 255, 0)) 100% 0,
    linear-gradient(to right, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0)),
    linear-gradient(to left, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0)) 100% 0;
  background-repeat: no-repeat;
  background-color: var(--panel);
  background-size: 40px 100%, 40px 100%, 14px 100%, 14px 100%;
  background-attachment: local, local, scroll, scroll;
}
/* Tightens cell padding/font specifically on touch devices — doesn't shrink the number of
   columns, but does meaningfully cut the total distance a coarse-pointer user has to swipe
   through on a grid this wide. */
@media (hover: none) and (pointer: coarse) {
  /* `body` prefix bumps specificity above the base rules just below, which are defined later
     in this file at equal specificity and would otherwise win by source order regardless of
     this media query (same fix as the .user-section-toggle touch override above). */
  body .grading-grid-table { font-size: 12px; }
  body .grading-grid-table th, body .grading-grid-table td { padding: 6px 8px; }
}
/* border-collapse: collapse silently breaks position: sticky on th/td in Chromium — a known
   engine quirk. Separate + border-spacing: 0 keeps the collapsed look while sticky still works. */
.grading-grid-table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 13px; }
.grading-grid-table th, .grading-grid-table td {
  border: 1px solid var(--border); padding: 7px 12px; text-align: center; white-space: nowrap;
}
.grading-grid-table th.grading-grid-person-col, .grading-grid-table td.grading-grid-person-col {
  text-align: left; position: sticky; left: 0; background: var(--panel); z-index: 1;
}
/* Sticky top goes on <thead> itself, not individual th/td — sticky positioning on table cells
   doesn't stick in Chromium, the same declaration on the row group does.
   z-index 2 (> the sticky body column's 1) so the header row paints above a scrolled-past body
   row's sticky-left cell. th.grading-grid-person-col bumps to 3 on top of that so the one
   corner cell (sticky on both axes) sits above its own header row's siblings too. */
.grading-grid-table thead { position: sticky; top: 0; z-index: 2; }
.grading-grid-table th { background: var(--panel); }
.grading-grid-table th.grading-grid-person-col { z-index: 3; }
.grading-grid-title-row th { font-weight: 700; color: var(--accent); font-size: 12px; }
/* .my-grades-table (rep.html/manager.html's own "My Grades" and "Class Grades" tables) is
   always exactly 3 columns, unlike the wide multi-column grading grids this class is
   otherwise shared with, where sticky-left + horizontal scroll is the correct pattern. Here
   the shared .grading-grid-table's `white-space: nowrap` on every cell meant a long
   assignment/rep name pushed the whole table wider than a phone screen, scrolling Date/Score
   off the right edge with no visual cue anything was hidden ("doesnt show any grades just
   these titles," Cody's exact words). Letting the name column wrap instead keeps every
   column on-screen without needing to swipe. */
.my-grades-table td.grading-grid-person-col,
.my-grades-table th.grading-grid-person-col { white-space: normal; }
.grading-grid-cell-done { color: var(--status-good-text); font-weight: 700; }
.grading-grid-cell-pending { color: var(--status-warn-text); }
.grading-grid-cell-missing { color: var(--muted); }
.grading-grid-cell-bad { color: var(--status-bad-text); font-weight: 700; }
.progress-bar-fill {
  background: var(--brand-blue);
  height: 100%;
  border-radius: 6px 0 0 6px;
  transition: width 0.3s ease;
}
/* Upload progress (uploadWithProgress, public/util.js) — same track/fill look as
   .progress-bar-track/.progress-bar-fill above, kept as its own pair since the "processing"
   state needs a pulse animation .progress-bar-fill itself doesn't have. */
.upload-progress { margin-top: 4px; }
.upload-progress-label { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.upload-progress-label.stalled { color: var(--status-warn-text); }

/* Unsaved-changes indicator for the Practice page's per-row "teach" cards (difficulty, DISC,
   rubric — views/practice.html's wireTeachRowDirtyTracking) — reuses the existing
   .practice-teach-status span rather than a separate badge element. */
.practice-teach-status.dirty { color: var(--status-warn-text); font-weight: 700; }
.upload-progress-track {
  background: var(--panel-sunken);
  border: 1px solid var(--border);
  border-radius: 6px;
  height: 10px;
  overflow: hidden;
}
.upload-progress-fill {
  background: var(--brand-blue);
  height: 100%;
  border-radius: 6px;
  transition: width 0.2s ease;
}
.upload-progress-fill.processing {
  animation: upload-progress-pulse 1.4s ease-in-out infinite;
}
@keyframes upload-progress-pulse {
  0%, 100% { opacity: 0.55; }
  50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .upload-progress-fill.processing { animation: none; }
}
.cohort-member-row {
  display: grid;
  grid-template-columns: 140px 1fr 100px;
  align-items: center;
  gap: 12px;
  padding: 8px 0;
  border-bottom: 1px solid var(--border);
}
.cohort-member-row:last-child { border-bottom: none; }
.cohort-member-name { font-size: 13px; font-weight: 700; }
.cohort-member-stats { font-size: 12px; color: var(--muted); text-align: right; }
.cohort-select-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 12px;
  flex-wrap: wrap;
}
.cohort-select-row select { padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; }
.cohort-manage-box {
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}
.cohort-member-picker {
  max-height: 220px;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 10px;
  margin: 8px 0;
  font-size: 13px;
}
.cohort-member-picker label { display: block; margin-bottom: 5px; }

/* Shared "pick some resources" checkbox list — reused on assignments.html, practice.html
   (persona + Quick Talk), fieldpro.html, and script-roleplays.html. Previously unstyled
   everywhere except a one-off local copy on assignments.html; now global so every picker
   looks and scrolls the same way. Resources are grouped by category (public/util.js's
   resourcePickerHtml()), same alphabetical grouping resources.html's own folder view uses. */
.resource-picker {
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 8px 10px;
  max-height: 220px;
  overflow-y: auto;
}
.resource-picker label {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  padding: 3px 0;
  font-weight: normal;
}
.resource-picker .hint { font-size: 12px; color: var(--muted); margin: 0 0 6px; }
.resource-picker-group { margin-bottom: 10px; }
.resource-picker-group:last-child { margin-bottom: 0; }
.resource-picker-group-title {
  font-size: 11px;
  font-family: "Golos Text", Arial, sans-serif;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
  font-weight: 700;
  margin-bottom: 2px;
  padding-top: 6px;
  border-top: 1px solid var(--border);
}
.resource-picker-group:first-child .resource-picker-group-title { padding-top: 0; border-top: none; }

/* The read-only list of resources attached to an assignment (views/assignments.html's
   resourcesSectionHtml, .resources-view) — was completely unstyled, so an assignment with a
   long list (e.g. a full set of Teaching Library narrations) just pushed the card's height out
   indefinitely instead of scrolling. Same bordered-box-with-internal-scroll convention as
   .resource-picker above (unconditional, same as that one — the empty-state message sits fine
   inside a bordered box too, same as .resource-picker's own "Loading resources…" case). */
.resources-view {
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 4px 10px;
  max-height: 260px;
  overflow-y: auto;
}
.resource-link-row { padding: 4px 0; }

/* Manage Users card (manager.html, admin only) — also used by Grading's Sales Reps/District
   Managers/Other and Signature-Based Submissions module sections (see .user-section-body
   comment below). Each toggle+body pair sits in its own bordered .user-section box so several
   of these stacked in one card read as clearly separated groups, not headings running
   together with only margin between them. */
.user-section {
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 12px;
  margin-top: 10px;
}
.user-section:first-child { margin-top: 0; }
.user-section-toggle {
  /* Now a real <button> (was a plain clickable div) — reset browser button chrome so it
     still looks like a plain text heading. Golos, not "inherit" — this all-caps label is a
     Golos Bold callout per the brand guide, not body copy. display:flex + the label/arrow
     split into two children (2026-09-06) — the label text used to run straight into the
     arrow character in the same text node, so the arrow read as trailing punctuation right
     after the label instead of a control sitting at the row's own right edge. */
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  background: none;
  border: none;
  padding: 0;
  text-align: left;
  font-family: "Golos Text", Arial, sans-serif;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--accent);
  font-weight: 700;
  margin: 0 0 6px;
  cursor: pointer;
}
.user-section-toggle-arrow { flex-shrink: 0; transition: transform 200ms ease; }
/* Rotate instead of swap (2026-09-11, motion polish pass item 2) — same reasoning as
   .sidebar-group-header's own identical rule above. */
.user-section-toggle[aria-expanded="true"] .user-section-toggle-arrow { transform: rotate(180deg); }
/* Same fixed-height, internally-scrolling treatment as the Grading page's tables — shared by
   Manage Users' Active/Deactivated roster and Grading's Sales Reps/District Managers/Other
   submission sections (both use this class), so a growing headcount scrolls inside the box
   instead of making the whole page longer. The Audit Log section overrides this inline with
   its own max-height, unaffected by this rule. */
.user-section-body { margin-bottom: 4px; max-height: 400px; overflow-y: auto; transition: max-height 220ms ease; }
/* A divider between the toggle heading and whatever's expanded below it — same treatment
   views/video-library.html's own sections use (a scoped copy of this same idea, needed there
   because that page's toggle sits inside its own header row alongside Rename/Delete buttons).
   The adjacent-sibling selector only matches when .user-section-body directly follows
   .user-section-toggle (Manage Users' Audit Log + role groups, Grading's Signature-Based
   Submissions rows) — it does not match video-library.html's own markup, where a
   .video-section-header wrapper sits between the two, so this and that page's scoped rule
   never both fire on the same element. */
.user-section-toggle + .user-section-body {
  border-top: 1px solid var(--border);
  margin-top: 10px;
  padding-top: 10px;
}
/* Always info-row-then-actions-row, never side by side — with flex-wrap alone, whether the
   actions shared the name's line or dropped below depended on how much text happened to fit
   (a manager's longer "Regional Owner" label tipped that row over while "Owner" rep rows
   stayed put), so otherwise-identical rows ended up with their controls sitting at different
   heights ("some things are adjusted down," Cody's exact words). Stacking unconditionally
   makes every row the same shape regardless of name/role/label length. */
.user-row {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  padding: 8px 0;
  border-bottom: 1px solid var(--border);
}
.user-row:last-child { border-bottom: none; }
/* Pending signup rows sit inside their own already-scrolling box (.user-section-body), so the
   plain divider-line look .user-row uses elsewhere (relying on the card's own edge to bound
   it) reads as bare text with nothing to separate one request from the next — each one gets
   its own full border instead, the same boxed-rectangle treatment as the section-toggle
   buttons above it. Compound selector so it always wins over .user-row's border-bottom instead
   of depending on source order. */
.user-row.pending-signup-row {
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 12px;
  margin-bottom: 8px;
}
.user-row.pending-signup-row:last-child { margin-bottom: 0; }
/* Same boxed-rectangle treatment as .pending-signup-row above, generalized for any .user-row
   list where the plain divider-line look reads as too bare for distinct standalone items
   (Cody's call, on the Paths & Certificates page's Manage Paths list and Certificate Bank) —
   matches the boxed-card convention (.path-tile, .folder-card, etc.) used elsewhere in the
   app more than the flat-list convention does. Opt-in per list, not a blanket replacement for
   the plain .user-row look most other lists still intentionally use. */
.user-row.boxed-row {
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 12px;
  margin-bottom: 8px;
}
.user-row.boxed-row:last-child { margin-bottom: 0; }
.user-row.boxed-row:hover { border-color: var(--accent); }
.user-row .user-info { font-size: 13px; }
.user-row .user-info .email { color: var(--muted); font-size: 12px; }
.user-row .user-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.user-row .user-actions label { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 4px; font-weight: normal; }
/* Roster row layout (2026-09-12): three stacked rows under the read-only summary line, not
   two side-by-side clusters (that attempt still ended up at inconsistent widths across two
   lines with the cohort box jammed in beside them — Cody's own words). A single divider under
   the summary line, then every plain field (Role/Track/Title/Owner/BU) at one consistent small
   size on ONE line, then Cohorts alone on its own row where it has room to be wider, then
   Deactivate/Reset Password/Resend Invite pushed bottom-right via justify-content:flex-end on
   their own row. */
.user-fields-row { display: flex; flex-wrap: wrap; gap: 10px; padding-top: 8px; margin-top: 8px; border-top: 1px solid var(--border); }
.user-field { display: flex; flex-direction: column; gap: 2px; }
.user-field-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); font-weight: 600; }
/* Fixed, shared width (2026-09-12) — "same size as each other," Cody's own words — rather
   than each field sizing to its own longest option text, which is what produced the
   inconsistent widths this replaces. A long Owner/leader name just clips in the closed state;
   the dropdown itself still shows the full option list. */
.user-field select, .user-field .muted { font-size: 12px; padding: 3px 4px; width: 108px; }
.user-cohorts-row { margin-top: 8px; }
.user-cohorts-field { display: flex; flex-direction: row; align-items: flex-start; gap: 6px; }
.user-danger-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; margin-top: 8px; }
/* Bulk-select checkbox (2026-09-11) sits beside the name/info block, not stacked above it the
   way .user-row's own column layout would otherwise put it — this one sub-row is the exception,
   a plain flex row nested inside the column-flow parent. */
.user-row-top { display: flex; align-items: flex-start; gap: 8px; width: 100%; }
.user-row-select { padding-top: 2px; }
/* select[multiple] (the roster row's own Cohorts control) needs its own reset — the global
   `select { appearance:none; background-image: <arrow>; ...}` rule up top is written for a
   single-line dropdown and looks broken on a real multi-row listbox (a dropdown arrow floating
   over a tall box, padding sized for one line of text). Kept minimal — just undoing what doesn't
   apply here, not a redesign. */
.user-row .cohort-select {
  appearance: auto;
  background-image: none;
  padding: 2px 4px;
  font-size: 12px;
  /* Wide (2026-09-12) — this control has its own dedicated row now (.user-cohorts-row), with
     room to spare, instead of being squeezed beside same-size single-value fields. */
  min-width: 320px;
  width: auto;
  max-width: 100%;
}
/* The path-name link in each Paths & Certificates row had no styling at all, so it fell back
   to the plain browser default: blue for a link you'd never clicked, purple once you had —
   which Cody noticed and reasonably assumed was intentional (it happened to line up with
   which paths he'd personally opened before). Explicitly setting :visited too, not just the
   base color, so it can never drift back to purple regardless of click history. */
#pathList a, #pathList a:visited { color: var(--accent); text-decoration: none; }
#pathList a:hover { text-decoration: underline; }
/* Same bare-link bug as #pathList above, just never checked for elsewhere — any of these
   linking out to a resource/assignment/path the admin had personally clicked before rendered
   purple instead of matching the rest of the app, since nothing here overrode the browser's
   own default link/visited colors. */
#motwContent a, #motwContent a:visited,
#cohortAssignedPathsList a, #cohortAssignedPathsList a:visited,
.sub-row-body a, .sub-row-body a:visited { color: var(--accent); text-decoration: none; }
#motwContent a:hover, #cohortAssignedPathsList a:hover, .sub-row-body a:hover { text-decoration: underline; }
.password-reset-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 6px; }
.password-reset-row input { padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; }

/* Submission galleries — "My Submissions" and "Vault Submissions" on Rep/Manager pages.
   Thumbnail-forward cards, segmented into sections by submission type. */
.subgallery-section { margin-top: 18px; }
.subgallery-section:first-child { margin-top: 0; }
.subgallery-section-title {
  font-size: var(--text-xs); /* 13px -> 11px, matches .sidebar-panel h4/.section-label's own eyebrow-tier size, 2026-09-12 visual-polish pass item 4 */
  font-family: "Golos Text", Arial, sans-serif;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
  font-weight: 700;
  margin: 0 0 var(--space-2);
}
.subgallery-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 14px;
}
/* .subgallery-type-select previously had its own bolder/uppercase treatment and a larger
   custom chevron — intentionally distinct at the time, later flagged as a "worth a decision"
   inconsistency since every other native <select> in the app (including this one's own
   sibling dropdowns elsewhere) uses the plain global `select {}` style instead. Cody's call:
   match everything else. No rule needed here anymore — it just inherits the global style. */
.subgallery-card {
  /* Explicit flex column, not plain block — a bare <button> left as display:block still
     centers its content vertically in some browsers once the grid stretches it taller than
     its content (e.g. a card next to a taller sibling whose title wraps to 2 lines), which
     opened a gap above the thumbnail instead of the thumbnail sitting flush at the top. */
  display: flex;
  flex-direction: column;
  align-items: stretch;
  width: 100%;
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
  background: var(--panel);
  cursor: pointer;
  padding: 0;
  margin: 0;
  font: inherit;
  text-align: left;
  color: inherit;
  box-shadow: var(--shadow-sm);
  transition: box-shadow 150ms ease, transform 150ms ease;
}
.subgallery-card:focus-visible { outline: 2px solid var(--accent-aqua); outline-offset: 2px; }
.subgallery-card:hover, .subgallery-card:focus-visible { box-shadow: var(--shadow-hover); transform: translateY(-2px); }
.subgallery-thumb-wrap {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 10;
  background: var(--navy);
  display: flex;
  align-items: center;
  justify-content: center;
}
/* Sits absolutely over the icon underneath, invisible until JS confirms a real preview
   frame is actually painted (see wireGallerySection's "seeked" handler) — otherwise the
   loading/decoding window shows as a blank gap instead of the icon placeholder. */
.subgallery-thumb-wrap video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; opacity: 0; }
.subgallery-thumb-wrap video.subgallery-thumb-ready { opacity: 1; }
.subgallery-thumb-icon { font-size: 32px; }
/* Wraps 1-2 badges (AI score, and DM score once a manager confirms) — plain inline flex row
   by default (e.g. in the modal header); pinned to the thumbnail's corner and stacked
   vertically only when nested inside a card's thumbnail. flex-shrink:0 (2026-09-07) — in the
   modal header this sits next to .submission-modal-title, which has its own min-width:0 so it
   can ellipsis; without an explicit flex-shrink:0 here, this badge wrapper could get squeezed
   by the same flex algorithm instead of the title claiming all the shrinkage. */
.subgallery-score-badges { display: flex; gap: 4px; flex-shrink: 0; }
.subgallery-thumb-wrap .subgallery-score-badges {
  position: absolute;
  top: 8px;
  right: 8px;
  flex-direction: column;
  align-items: flex-end;
}
/* white-space:nowrap (2026-09-07) — this badge is absolutely positioned with no explicit width
   (shrink-to-fit sizing), so when the available space between its `right` offset and the
   container's edge is narrower than its natural content width (e.g. assignments.html's
   graded-examples grid — see .example-score-grid's own comment below — squeezing thumbnails
   down on a phone), the shrink-to-fit algorithm was forcing the box narrower and the text was
   wrapping onto 2 lines instead of just staying on one line. */
.subgallery-score-badge {
  padding: 3px 9px;
  border-radius: 20px;
  font-size: 12px;
  font-weight: 800;
  color: #fff;
  white-space: nowrap;
}
.subgallery-score-badge.score-high { background: var(--status-good); }
.subgallery-score-badge.score-mid { background: var(--status-warn); }
.subgallery-score-badge.score-low { background: var(--status-bad); }
.subgallery-score-badge.score-pending { background: var(--status-pending); }
/* Practice sessions only (views/practice.html) — deliberately one flat color regardless of the
   score's value, never the red/yellow/green pass-fail treatment above: a practice score isn't a
   pass/fail signal, and reusing that coloring here undercut the whole point of keeping practice
   visually distinct from a graded submission.
   That reasoning still holds; the color was wrong. It was var(--aqua), which on one deployed
   palette is a bright cyan — white text on it measures 1.26:1, against WCAG AA's 4.5:1. The
   number was effectively invisible (2026-09-20, reported from the floor). --brand-blue instead:
   8.98:1 and 19.03:1 on the two deployed palettes, 11.37:1 on the neutral default, and still
   nothing like the
   good/warn/bad set, so a practice score is as distinct from a graded one as it ever was.
   Not --accent: this is a BACKGROUND under white text, so it must stay dark in dark mode, the
   same way --status-good/warn/bad do. --accent is the ink token and lightens. */
.subgallery-score-badge.score-practice { background: var(--brand-blue); }

/* Same shape as .subgallery-score-badge, generic name — for a status callout that isn't a
   submission score (e.g. path.html's completion banner: "Awaiting grading" / "Certified"). */
.status-pill {
  display: inline-block;
  padding: 3px 10px;
  border-radius: 20px;
  font-size: 11px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: #fff;
}
.status-pill.status-warn { background: var(--status-warn); }
.status-pill.status-good { background: var(--status-good); }

/* Shared status-dot / stat-card traffic-light rules — previously copy-pasted verbatim into
   page-local <style> blocks in compliance.html, compliance-user.html, and grading.html. */
.status-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 8px; background: var(--border); }
.status-dot.status-good { background: var(--status-good); }
.status-dot.status-warn { background: var(--status-warn); }
.status-dot.status-bad { background: var(--status-bad); }
/* Visible companion to the dot (compliance.html's roster list) — the dot's color alone isn't a
   reliable signal for colorblind readers or anyone scanning on a phone (no hover), so the same
   status now also shows as real text, not just a title-attribute tooltip. */
.sub-person-pct { font-size: 12px; font-weight: 600; margin-right: 6px; width: 42px; flex: 0 0 42px; text-align: right; }
.sub-person-pct.status-good { color: var(--status-good-text); }
.sub-person-pct.status-warn { color: var(--status-warn-text); }
.sub-person-pct.status-bad { color: var(--status-bad-text); }
/* Base .stat-card look, promoted here 2026-08-20 once compliance.html became a second real
   consumer (was previously local-only to compliance-user.html). */
.stat-card { border: 1px solid var(--border); border-radius: 8px; padding: var(--space-4); box-shadow: var(--shadow-sm); } /* 14px -> 16px, 2026-09-12 visual-polish pass item 3 */
.stat-card .stat-label { font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); margin-bottom: 6px; font-family: 'Golos Text', Arial, sans-serif; font-weight: 700; }
.stat-card .stat-value { font-size: var(--text-2xl); font-weight: 800; color: var(--accent); line-height: 1.1; } /* 24px -> 26px, matches .progress-stat .num's same "big emphasized number" role, 2026-09-12 visual-polish pass item 4 */
.stat-card .stat-sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
/* Hardcoded hex -> tokens (2026-09-12, round-18 audit fix) — see the --status-*-card-bg/-border
   tokens' own comment for the real, computed dark-mode numbers. Light mode is pixel-identical
   (the tokens' light-mode defaults are these exact hex values). */
.stat-card.status-good { background: var(--status-good-card-bg); border-color: var(--status-good-card-border); }
.stat-card.status-good .stat-value { color: var(--status-good-text); }
.stat-card.status-warn { background: var(--status-warn-card-bg); border-color: var(--status-warn-card-border); }
.stat-card.status-warn .stat-value { color: var(--status-warn-text); }
.stat-card.status-bad { background: var(--status-bad-card-bg); border-color: var(--status-bad-card-border); }
.stat-card.status-bad .stat-value { color: var(--status-bad-text); }
.subgallery-info { padding: var(--space-3); } /* 10px -> 12px, 2026-09-12 visual-polish pass item 3 */
.subgallery-info strong { display: block; color: var(--accent); font-size: 14px; }
.subgallery-info span.muted { font-size: 12px; }

/* --- Submission detail modal (public/subgallery.js), opened on card click --- */
.submission-modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 37, 62, 0.55);
  z-index: 900;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}
.submission-modal-panel {
  background: var(--panel);
  border-radius: 14px;
  width: 100%;
  max-width: 1100px;
  max-height: 90vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  box-shadow: 0 24px 64px rgba(0, 37, 62, 0.35);
}
.submission-modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 22px;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
  gap: 16px;
}
.submission-modal-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
/* Two-line title: a bold primary line (the thing that matters most for whoever's looking —
   the assignment name for a rep viewing their own work, the rep's name for a manager browsing
   the public gallery) plus an optional muted secondary line underneath, instead of the old
   single flat "Name · Assignment" string with no hierarchy between the two. */
.submission-modal-title-text { min-width: 0; }
.submission-modal-title-text strong {
  display: block;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  font-size: 18px;
  color: var(--accent);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.submission-modal-title-text span.muted {
  display: block;
  font-size: 13px;
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.submission-modal-close-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: none;
  border: none;
  font-size: 22px;
  line-height: 1;
  color: var(--muted);
  cursor: pointer;
  flex-shrink: 0;
  transition: background 0.15s ease, color 0.15s ease;
}
.submission-modal-close-btn:hover { background: var(--bg); color: var(--text); }
.submission-modal-close-btn:focus-visible { outline: 2px solid var(--accent-aqua); outline-offset: 2px; }
.submission-modal-body {
  display: flex;
  /* nowrap, not wrap: with wrapping enabled, a flex line is allowed to grow taller than the
     container's own assigned cross size to fit whichever child (video or feedback) has more
     content — that's what was actually cutting the video's control bar off. A long AI-feedback
     paragraph made the feedback column's natural height balloon, wrap let the row stretch to
     match it, and the whole row then got silently clipped by this box's old overflow: hidden,
     taking the video's controls with it even though the video itself was short. nowrap forces
     the row to respect this container's real height at desktop widths, so each side can scroll
     its own overflow instead (see .submission-modal-feedback-section's own overflow-y below) —
     the layout switches to an explicit stacked column below the breakpoint instead. */
  flex-wrap: nowrap;
  overflow-y: auto;
  overflow-x: hidden;
  flex: 1;
  min-height: 0;
}
.submission-modal-video-wrap {
  flex: 1 1 55%;
  background: var(--navy);
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 280px;
}
.submission-modal-video-wrap video {
  width: 100%;
  height: 100%;
  max-height: 80vh;
  object-fit: contain;
}
/* Below this width there isn't room for video + feedback side by side (each needs its own
   280px+ min-width) — switch to an explicit stacked column instead of leaving it to flex-wrap,
   which (see .submission-modal-body above) doesn't reliably respect this container's own
   height once it's allowed to wrap. Video gets a fixed, aspect-ratio-correct height (auto,
   capped) instead of the height: 100% used for the desktop stretch-to-fill behavior, since a
   stacked video-wrap no longer has a definite parent height for that percentage to resolve
   against; feedback keeps its own internal scroll below it, and the whole body still scrolls
   as the backstop if both together still don't fit the modal's 90vh cap. */
@media (max-width: 700px) {
  .submission-modal-body { flex-direction: column; }
  .submission-modal-video-wrap { flex: none; max-height: 42vh; }
  .submission-modal-video-wrap video { height: auto; max-height: 42vh; }
  .submission-modal-feedback { flex: none; }
}
/* One shared scroll region for the whole feedback column (AI Feedback, Manager Feedback, and
   — only when isMine — Comments), instead of forcing AI/Manager into two independently-
   scrolling 50/50-height halves regardless of how much content either actually has. That old
   split-scroll setup is what made this page look inconsistent submission to submission: a
   short AI note next to a long comment thread meant one side sat mostly empty while the other
   scrolled internally, and the reply box (previously nested inside Manager Feedback's own half)
   could end up buried below a scroll a rep had no reason to expect. Every submission now gets
   the same behavior: sections stack at their natural height, the column scrolls as one unit if
   it doesn't fit. */
.submission-modal-feedback {
  flex: 1 1 45%;
  display: flex;
  flex-direction: column;
  min-width: 280px;
  min-height: 0;
  background: var(--panel-sunken);
  overflow-y: auto;
}
.submission-modal-feedback-section {
  padding: 18px 22px;
}
.submission-modal-feedback-section + .submission-modal-feedback-section {
  border-top: 1px solid var(--border);
}
/* AI gets the aqua accent (the brand guide reserves Aqua as a secondary/accent color — a
   natural fit for the platform's "tech-assisted" feedback), Manager Feedback stays anchored in
   the primary Vault Blue — the two labels are otherwise identical in weight/size, just the
   icon + heading color differ, so they read as a deliberate pairing rather than a coincidence
   of two headings that both happen to say "Feedback." */
.submission-modal-feedback-title {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 12px;
  font-family: "Golos Text", Arial, sans-serif;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  font-weight: 800;
  color: var(--accent);
  margin: 0 0 10px;
}
.submission-modal-feedback-title .icon { font-size: 15px; }
.submission-modal-feedback-title.is-ai .icon { color: var(--accent-aqua); }
.submission-modal-feedback-section p { line-height: 1.55; margin: 0; }
.submission-modal-feedback-section .empty-state { padding: 8px 0; }
/* Rep's own follow-up comment thread, shown under the manager's actual grade feedback — a
   dashed divider (rather than the solid one already separating the AI/Manager sections)
   marks it as a related-but-distinct addendum, not a continuation of the manager's feedback
   text itself. Only appears once populated (see openSubmissionModal's isMine branch). */
#submissionModalComments:not(:empty) {
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px dashed var(--border);
}
/* Two-way thread inside #submissionModalComments — a manager's note keeps the same left-border
   treatment grading-user.html's own admin-side comment list already uses; a rep's own reply
   gets a distinct accent (status-good, not brand-blue) purely so the two voices are visually
   separable at a glance in a mixed thread, not because "good" carries any judgment here. */
.submission-comment-item {
  border-left: 3px solid var(--accent);
  padding: 4px 10px;
  margin-bottom: 8px;
  font-size: 13px;
}
.submission-comment-item.submission-comment-mine { border-left-color: var(--status-good-text); }
.submission-comment-author { font-weight: 700; font-size: 12px; }
.submission-comment-time { font-size: 11px; }
.submission-comment-body { padding: 2px 0 0; }
.submission-reply-row {
  display: flex;
  gap: 6px;
  align-items: flex-start;
  margin-top: 10px;
}
.submission-reply-row textarea {
  flex: 1;
  padding: 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-family: Arial, Helvetica, sans-serif;
  font-size: 13px;
  resize: vertical;
}
.submission-reply-error:not([hidden]) { margin-top: 6px; }

/* Quiz per-question review — promoted here from views/assignments.html's own <style> block
   (2026-09-10) once openSubmissionModal (public/subgallery.js) became a second real consumer:
   assignments.html shows this right after a quiz submit (renderQuizReviewHtml), the modal now
   shows the identical persisted snapshot later (see quiz_results_json's own comment in
   db/bootstrap.js). Same classes, same look, in whichever page renders it. */
/* Was a hardcoded always-ivory card (var(--neutral-ivory)) — technically "theme-invariant by
   design" per CLAUDE.md's note on raw tokens, but in practice that just meant a stray white box
   sitting inside an otherwise all-dark UI in dark mode, the same complaint as every other fix in
   this sweep. var(--panel) instead — tracks the theme like every other card, and as a side
   effect this also fixes .small-btn (e.g. "Remove Question") inside this card: its own
   color:var(--muted) was already correct for a var(--panel) background, it only looked broken
   because this specific card was forced to stay ivory around it. */
.quiz-question-block {
  background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
  padding: 16px 18px; margin-bottom: 14px;
}
.module-kc-feedback { display: none; margin-top: 12px; padding: 12px 14px; border-radius: 8px; font-size: var(--text-md); line-height: 1.5; } /* 13.5px -> 14px, 2026-09-12 visual-polish pass item 4 */
.module-kc-feedback.show { display: block; }
/* .incorrect-fb right below already uses var(--status-bad-text) for both border and color —
   this one was the odd one out, still the raw completion green (#1c7a3e is deliberately its
   own color family outside the --status-good/warn/bad triad, per this file's own comment above
   on .quiz-question-block's neighbor rules, so it never got routed through that token system's
   dark-mode migration). var(--status-good-text) is the closest existing dark-mode-safe token. */
.module-kc-feedback.correct-fb { background: rgba(28, 122, 62, 0.08); border: 1px solid #1c7a3e; color: var(--status-good-text); }
.module-kc-feedback.incorrect-fb { background: rgba(179, 38, 30, 0.08); border: 1px solid var(--status-bad-text); color: var(--status-bad-text); }

/* Compact assignment cards — assignments.html. A collapsed tile per assignment (thumbnail +
   title); clicking expands the full instructions/submit-form content in place below it. */
/* "All Folders" (or just a large bucket/Path day) can produce far more cards than fit on
   screen, so past a max height the grid scrolls internally instead of pushing the rest of the
   page down — same convention as .roleplay-bucket-list/.folder-grid. Small padding (not 0)
   keeps a card's own border from getting clipped by the scroll edge. scrollbar-gutter: stable
   reserves the scrollbar's width whether or not it's actually showing — without it, a folder
   with enough cards to trigger the scrollbar loses ~15px of usable width right as auto-fill is
   deciding column count, which can silently drop a filtered "many cards" view (e.g. All
   Folders) to one fewer column than a "few cards, no scrollbar" view of the exact same width. */
.assignment-card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 14px;
  max-height: 640px;
  overflow-y: auto;
  scrollbar-gutter: stable;
  padding: 2px;
}
.assignment-card {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
  background: var(--panel);
  box-shadow: var(--shadow-sm);
  transition: box-shadow 150ms ease, transform 150ms ease;
}
.assignment-card:hover, .assignment-card:has(.assignment-card-header:focus-visible) {
  box-shadow: var(--shadow-hover);
  transform: translateY(-2px);
}
/* flex: 1 lets the header grow to fill whatever height the grid row stretches the card to
   (same fix as .folder-card-open), so the Edit/Duplicate row below it lands at the same
   bottom edge on every card in the row regardless of how many lines a given title wraps to. */
.assignment-card-header {
  cursor: pointer;
  flex: 1;
}
.assignment-card-header:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.assignment-card-thumb {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 10;
  background: var(--brand-blue); /* guide: reversed logo only approved on Vault Blue, not Navy */
  display: flex;
  align-items: center;
  justify-content: center;
}
.assignment-card-thumb img { width: 70%; object-fit: contain; }
/* Badge sits on the card's own thumbnail image, not the page panel, so its near-white circle is
   deliberately invariant like .qr-thumb .icon's identical pattern below — but unlike that one,
   this never overrode the icon's own color, so it fell through to .icon's shared `color:
   var(--accent)` (theme-tracking, lightens in dark mode) sitting on this always-near-white
   circle — washed out in dark mode. var(--brand-blue) (invariant) matches .qr-thumb .icon's own
   fix for the same shape. */
.assignment-card-icon {
  position: absolute;
  bottom: 8px;
  right: 8px;
  font-size: 18px;
  background: rgba(255, 255, 255, 0.88);
  border-radius: 50%;
  width: 30px;
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--brand-blue);
}
.assignment-card-title {
  padding: var(--space-3); /* 10px -> 12px, 2026-09-12 visual-polish pass item 3 */
  font-weight: 700;
  color: var(--accent);
  font-size: 14px;
}
/* Same treatment as .folder-card-actions — a centered small-button row under the card's own
   content, admin-only. flex-wrap (2026-09-12 bug fix): up to 4 buttons (Up/Down/Edit/Duplicate)
   share this row, and .small-btn's own padding (5px 12px, bumped from 3px 9px by the 2026-09-12
   visual-polish pass) plus "Duplicate" being the longest label add up to wider than the grid's
   160px-minimum card at nowrap — verified live at 173px cards: buttons measured 197.8px combined,
   24.6px wider than the card, centered by justify-content so it clipped evenly off BOTH edges
   (not a title-length problem — every card overflowed by the same amount regardless of title).
   flex-wrap lets a row that doesn't fit drop to two lines instead of clipping, so this stays
   correct no matter how .small-btn's own padding changes in the future — scoping a fixed padding
   number here would just re-break on the next unrelated small-btn tweak, same lesson as
   #platformStatsRow/#compliancePeopleCard elsewhere in style.css. flex-wrap alone left Duplicate
   dropping to its own line on every card (2026-09-12 follow-up) — Up/Down went icon-only (border/
   background/padding stripped, fixed 16x16 box) to buy back enough width that all 4 controls fit
   on one line even at the grid's 160px floor: measured natural row width 152.9px -> 127.9px,
   comfortably under the ~134px content budget at 160px (verified: 0/54 cards wrap in either
   theme). flex-wrap stays in place as the safety net for any future width squeeze. */
.assignment-card-actions {
  display: flex; flex-wrap: wrap; gap: 3px; justify-content: center;
  padding: 0 var(--space-3) var(--space-3);
}
.assignment-card-actions .compact-edit-btn,
.assignment-card-actions .compact-duplicate-btn { padding: 3px 6px; font-size: 10px; }
/* Standalone, not scoped under .assignment-card-actions (2026-09-13) — five other reorder
   locations (path.html's per-item/week/day move buttons, script-roleplays.html, sidebar.js's
   page-link editor) all still rendered plain bordered .small-btn boxes for Move Up/Down, the
   same wider treatment this page had before the fix above. Unscoping lets every one of them
   adopt this exact same 16x16 icon-only box by adding these two class names alongside whatever
   class their own JS click handlers already bind to (never renamed — see each page's own
   comment). Cascade note: same specificity as .small-btn (one class each), so this still has to
   come after .small-btn's own definition in this file to win ties — it does, and did before this
   change too. */
.compact-move-up-btn,
.compact-move-down-btn {
  background: none; border: none; padding: 0;
  width: 16px; height: 16px; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 12px; color: var(--muted); border-radius: 4px;
  transition: background-color 150ms ease, color 150ms ease;
}
.compact-move-up-btn:hover,
.compact-move-down-btn:hover {
  background: var(--bg); color: var(--text);
}
.compact-move-up-btn:focus-visible,
.compact-move-down-btn:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 1px;
}
/* Disabled state (first/last item in a list) falls through to the global button:disabled rule
   (opacity: 0.5) — no need to redeclare it here, same as every other button in the app. */
.assignment-card-expanded {
  border-top: 1px solid var(--border);
  padding: var(--space-4); /* 14px -> 16px, 2026-09-12 visual-polish pass item 3 */
}

/* Script Role-Plays — drag handle on each card's header (title row only), used for
   reordering within a category bucket. A narrow grab target so it never fights with
   clicking the audio player or Edit/Remove buttons below it. */
.roleplay-card-header { cursor: grab; }
.roleplay-card-header:active { cursor: grabbing; }
.drag-handle { color: var(--muted); font-size: 14px; }
/* A category can hold any number of role-plays, so past a max height the list scrolls
   internally instead of pushing the rest of the page down — same convention as the Grading
   page's Sales Reps/District Managers/Other lists. Taller than those (each entry here is a
   full audio player + transcript toggle, not a single row) so at least 2 full cards are
   visible before scrolling kicks in. */
.roleplay-bucket-list { max-height: 640px; overflow-y: auto; padding: 2px; }
/* Each role-play its own bordered card — previously just ran together with no border between
   entries, relying on the Download/Show transcript row alone to imply a break. Scoped to
   .roleplay-bucket-list rather than bare .sub-block — that class is shared with
   grading-user.html and fieldpro.html, which have their own (different, partial) .sub-block
   styling; a bare rule here would only partly be overridden by those pages' local <style>
   blocks (e.g. grading-user.html's own rule only sets border-bottom/padding, so this border's
   other 3 sides and border-radius would still leak through) rather than cleanly co-existing. */
.roleplay-bucket-list .sub-block {
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 12px 14px;
  margin-top: 12px;
}
.roleplay-bucket-list .sub-block:first-child { margin-top: 0; }

/* "Manage Categories" columns — assignments.html. Each category (plus a permanent
   Uncategorized column) holds small draggable chips; dropping a chip on a different
   column moves that assignment into it. */
.category-manage-columns {
  display: flex;
  gap: 14px;
  overflow-x: auto;
  padding-bottom: 4px;
}
.category-manage-column {
  flex: 0 0 200px;
  /* Without this, a flex item's default min-width:auto lets its content refuse to shrink
     below its own natural size, overriding flex-basis — exactly what let one column
     ("Interactive Modules", holding a chip titled "Salesperson Code of Conduct Interactive
     Module") render at 339px while every sibling stayed at the intended 204px. That single
     wide column was the actual reason Save/Delete looked "consistent" there and wrapped to
     a second line everywhere else — see .category-chip-label below for the deeper fix. */
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--panel);
  padding: 10px;
}
/* Name input and Save/Delete now always stack on their own rows — deterministic layout
   instead of flex-wrap's fragile "does it fit on one line" threshold, which is what let
   different columns render their buttons differently even at the same width. */
.category-manage-header {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-bottom: 8px;
}
.category-manage-header .category-name-input {
  width: 100%;
  box-sizing: border-box;
  padding: 4px 6px;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 13px;
}
.category-manage-header-actions { display: flex; gap: 4px; }
.category-manage-header-actions .small-btn { flex: 1; }
.category-manage-chips {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-height: 40px;
}
/* Flat single row (label + delete) — no Edit here anymore, so there's no second row to stack
   above/below (see the render function's own comment for why). */
.category-chip {
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--panel-sunken);
  padding: 6px 8px;
  font-size: 12px;
  cursor: grab;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
}
.category-chip:active { cursor: grabbing; }
.category-chip-label {
  /* flex:1 + min-width:0 is what actually makes the overflow/ellipsis below take effect — a
     flex item's default min-width:auto means it won't shrink below its own content's natural
     width no matter what overflow says, so a long unbroken title (confirmed live: this label's
     scrollWidth already equalled its clientWidth, meaning truncation was never firing at all)
     just pushed its whole chip, and the column containing it, wider instead of actually
     truncating. */
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.category-chip-delete {
  background: none;
  border: none;
  color: var(--muted);
  font-size: 14px;
  cursor: pointer;
  flex-shrink: 0;
  padding: 2px 4px;
}
/* .category-chip's own background is var(--bg) (theme-tracking) — same fix as .error above. */
.category-chip-delete:hover { color: var(--status-bad-text); }
.category-chip-delete:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.category-chip-move {
  flex-shrink: 0;
  max-width: 110px;
  font-size: 11px;
  padding: 2px 4px;
}

/* ===================================================================
   Design-system pass (2026-07-20) — brand-guide-grounded visual work:
   a real icon set instead of emoji, custom-styled form controls instead
   of raw browser defaults, a branded confirm dialog instead of the native
   confirm(), and a loading skeleton instead of plain "Loading…" text.
   =================================================================== */

/* --- Icon set (public/icons.js) — inline SVGs colored via currentColor,
   so `color` on this element (or an ancestor) sets the icon color. Per
   the brand guide, icons should read as one consistent set: same size,
   same stroke weight, kept to Vault Blue / Navy / Ivory / Aqua-on-dark. */
.icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1em;
  height: 1em;
  color: var(--accent);
  vertical-align: -0.15em;
}
.icon svg { width: 100%; height: 100%; display: block; }

input[type="checkbox"], input[type="radio"] {
  appearance: none;
  -webkit-appearance: none;
  width: 17px;
  height: 17px;
  border: 1.5px solid var(--border);
  background: var(--panel);
  display: inline-block;
  vertical-align: middle;
  position: relative;
  cursor: pointer;
  flex-shrink: 0;
}
input[type="checkbox"] { border-radius: 4px; }
input[type="radio"] { border-radius: 50%; }
/* var(--brand-blue) -> var(--accent) (2026-09-12) — this was flagged ambiguous in commit 2 (a
   checkbox's own fill reads as a background role everywhere else in this file) and resolved via
   a live look: raw brand-blue on the dark --panel measures ~1.4:1, so the box itself all but
   disappears, leaving a floating checkmark with no visible container. Treated as a foreground
   swatch instead — consistent with every other migrated site — rather than matching light mode's
   exact color. */
input[type="checkbox"]:checked, input[type="radio"]:checked {
  border-color: var(--accent);
  background: var(--accent);
}
/* var(--neutral-ivory) -> var(--panel) on both checkmarks below (2026-09-12) — paired with the
   fill above moving to --accent, the checkmark itself now needs to invert with it: --panel is
   light in light mode (near-identical to the ivory it replaces, against the dark brand-blue fill)
   and dark in dark mode (against --accent's now-lightened fill) — the same contrast pair as
   --accent-on-panel text elsewhere, just with the two roles swapped, so the same verified ratios
   apply (8.15:1 and 4.81:1 on the two deployed palettes). A plain var(--neutral-ivory)
   checkmark would have gone
   near-invisible against the lightened fill (measured ~1.4:1). */
input[type="checkbox"]:checked::after {
  content: "";
  position: absolute;
  left: 4px;
  top: 1px;
  width: 5px;
  height: 9px;
  border: solid var(--panel);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
input[type="radio"]:checked::after {
  content: "";
  position: absolute;
  left: 4px;
  top: 4px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--panel);
}
input[type="checkbox"]:focus-visible, input[type="radio"]:focus-visible {
  outline: 2px solid var(--accent-aqua);
  outline-offset: 1px;
}

/* --- Branded confirm dialog (public/confirmDialog.js), replacing native confirm() --- */
.confirm-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 37, 62, 0.45);
  z-index: 800;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
}
.confirm-panel {
  background: var(--panel);
  border-radius: 12px;
  max-width: 400px;
  width: 100%;
  padding: 20px;
}
.confirm-message {
  margin: 0 0 18px;
  color: var(--text);
  font-size: 15px;
  line-height: 1.5;
  white-space: pre-wrap;
}
.confirm-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}
.confirm-cancel-btn, .confirm-ok-btn {
  padding: 9px 16px;
  border-radius: 6px;
  font-size: 13px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.01em;
  cursor: pointer;
  border: none;
}
.confirm-cancel-btn {
  background: none;
  border: 1px solid var(--border);
  color: var(--muted);
}
.confirm-ok-btn {
  background: var(--brand-blue);
  color: var(--neutral-ivory);
}
.confirm-ok-btn.danger {
  /* Semantic error red — a system/utility color, not a brand color, same exception
     already used for .error elsewhere in this file. */
  background: #b3261e;
}

/* --- File preview overlay (public/filePreview.js) — replaces target="_blank" for every
   resource/submission file link app-wide. Same fixed-overlay/panel shape as .confirm-overlay
   above and .submission-modal-overlay below, just its own z-index tier (1010, above
   .certificate-overlay's 1000) since a file link can be clicked from inside another modal
   (a submission review, an assignment's attached resources) and needs to end up on top of it. */
.file-preview-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 37, 62, 0.55);
  z-index: 1010;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
}
.file-preview-panel {
  background: var(--panel);
  border-radius: 12px;
  width: 100%;
  max-width: 900px;
  height: 90vh;
  max-height: 900px;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.file-preview-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  flex-shrink: 0;
}
.file-preview-title {
  font-weight: 700;
  font-size: 14px;
  color: var(--text);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.file-preview-close-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: none;
  border: none;
  font-size: 22px;
  line-height: 1;
  color: var(--muted);
  cursor: pointer;
  flex-shrink: 0;
}
.file-preview-close-btn:hover { background: var(--panel-sunken); color: var(--text); }
.file-preview-close-btn:focus-visible { outline: 2px solid var(--accent-aqua); outline-offset: 2px; }
.file-preview-body {
  flex: 1;
  min-height: 0;
  display: flex;
}
/* PDF rendering (2026-09-16): an <iframe src="/resources/file/:id"> here used to be enough on
   desktop, but iOS Safari refuses to run its own multi-page PDF viewer inside an iframe at all —
   it shows page one as a flat image and nothing else, no scrolling, no other pages. Replaced
   with PDF.js (public/vendor/pdfjs/, lazy-loaded — see openFilePreview/renderPdfPages below)
   rendering every page to its own <canvas>, stacked in this scrollable column. Native scroll
   inside a plain overflow-y:auto div works identically on every platform, including iOS. */
.file-preview-pdf-pages {
  flex: 1;
  min-width: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  padding: 16px;
  background: var(--panel-sunken);
}
/* The placeholder slot (2026-09-16, virtualized rendering — see renderPdfPages) carries the
   shadow, not the canvas inside it — a rendered canvas sits flush inside its own already-shadowed
   slot, so there's never a doubled-shadow look once a page finishes rendering. White background
   so an unrendered slot already reads as "a blank page about to load," not an empty gap in the
   column, on the (usually brief) fast-scroll window before it's actually rendered. */
.file-preview-pdf-page-slot {
  background: #fff;
  box-shadow: var(--shadow-sm);
  max-width: 100%;
  /* Without this, an empty slot (no canvas rendered into it yet) collapses toward 0 height
     despite its own explicit inline height — a flex child's default min-height:auto means it
     won't shrink below its CONTENT's natural size, and an empty div's natural size is 0, so
     flex-shrink:1 (the default) happily squeezes it down to fit. Confirmed live: slots with an
     already-rendered canvas held their height fine (their child gave them real min-content
     size), every still-empty slot silently measured 0 despite "height:503px" sitting right on
     it — which collapsed the whole scrollable area and made most of a long document
     unreachable by scroll. flex-shrink:0 makes the explicit height authoritative regardless of
     what's (or isn't) inside. */
  flex-shrink: 0;
}
.file-preview-pdf-page {
  display: block;
  max-width: 100%;
}
.file-preview-pdf-loading {
  margin: auto;
  color: var(--muted);
  font-size: 13px;
}
/* PDF_DEBUG scaffolding — TEMPORARY, remove alongside the JS block of the same name in
   filePreview.js. Only ever created (and only ever visible) when the overlay is opened with
   ?pdfdebug=1 in the URL. Sits as .file-preview-panel's last flex child, so it takes its own row
   below the scrollable PDF body rather than overlapping it. Monospace/high-contrast/selectable so
   it's usable as an actual on-phone copy-paste readout, not just a visual indicator. */
.file-preview-debug-panel {
  flex-shrink: 0;
  max-height: 35vh;
  overflow-y: auto;
  background: #000;
  color: #0f0;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 10px;
  line-height: 1.4;
  padding: 6px 8px;
  white-space: pre-wrap;
  word-break: break-all;
  -webkit-user-select: text;
  user-select: text;
  border-top: 2px solid #0f0;
}
.file-preview-media-wrap {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 0;
  min-height: 0;
  background: #000;
  padding: 12px;
}
.file-preview-media {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
}
.file-preview-fallback {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  padding: 24px;
  text-align: center;
}

/* --- "Install the App" — same visual weight as the primary blue buttons (.primary-btn /
   form.inline button) everywhere it renders, on Home and in Settings alike, rather than its
   own distinct look — styled explicitly here (not just via class order) so it matches even
   standalone outside a form.inline, where nothing else would apply that look for it. Fires the
   real install prompt on any Chromium browser, or on iOS (no such API exists there) opens an
   instructions panel reusing .confirm-overlay/.confirm-panel as-is rather than inventing a
   second modal pattern. */
.pwa-install-trigger {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  border: none;
  padding: 8px 16px;
  border-radius: 6px;
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  font-size: 12px;
  text-transform: uppercase;
  cursor: pointer;
  /* Forces genuine shrink-to-fit sizing — its wrapping div (#pwaInstallMountSettings /
     #pwaInstallMount) is itself stretched full-width by the surrounding flex layout, and
     without this the button was inheriting that same full width with its icon+text left-
     anchored inside the extra space instead of centered. */
  width: fit-content;
  align-self: flex-start;
}
/* Placed after the base rule (not in the earlier touch-target media block) so it actually wins
   the cascade — same selector, same specificity, source order decides, and the base rule above
   used to come later in the file and silently override this padding bump while leaving
   min-height unopposed. "Install the App" is the one control whose entire purpose is a
   phone/tablet flow, so it's the least excusable one to leave under this app's own touch-target
   bar. */
@media (hover: none) and (pointer: coarse) {
  .pwa-install-trigger {
    min-height: 40px;
    padding: 11px 16px;
  }
}
/* .icon defaults to color:var(--brand-blue), same as this button's own background —
   without this override the download icon renders invisible (blue-on-blue), same bug already
   fixed for .header-logout-btn/.mobile-nav-toggle/.quick-talk-btn above. This is almost
   certainly what actually made the button look off-center/full of dead space in every prior
   screenshot: an invisible 15px icon + 6px gap sitting before the text, not a real layout bug. */
.pwa-install-trigger .icon { color: var(--neutral-ivory); width: 15px; height: 15px; }
.pwa-install-trigger svg { width: 15px; height: 15px; }
.pwa-install-panel {
  text-align: center;
}
.pwa-install-icon {
  width: 56px;
  height: 56px;
  border-radius: 12px;
  margin: 0 auto 14px;
  display: block;
}
.pwa-install-title {
  margin: 0 0 16px;
  color: var(--accent);
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  font-size: 15px;
}
.pwa-install-steps {
  list-style: none;
  margin: 0 0 18px;
  padding: 0;
  text-align: left;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.pwa-install-steps li {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 14px;
  color: var(--text);
  line-height: 1.4;
}
.pwa-install-steps svg {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  color: var(--accent);
}
.pwa-install-close-btn {
  width: 100%;
  padding: 10px;
  border-radius: 6px;
  border: none;
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  font-size: 13px;
  text-transform: uppercase;
  cursor: pointer;
}

/* --- Notification message editor (super_admin only, sidebar.js Settings panel) — reuses
   .confirm-overlay/.confirm-panel/.pwa-install-close-btn as-is, same precedent as the PWA
   install instructions panel above, just wider (a scrollable list of 11 title+body forms
   doesn't fit the shared 400px default) and left-aligned instead of centered. */
.notif-templates-panel {
  max-width: 480px;
  max-height: 85vh;
  display: flex;
  flex-direction: column;
}
.notif-templates-title {
  margin: 0 0 4px;
  color: var(--accent);
  font-family: "Golos Text", Arial, sans-serif;
  font-weight: 800;
  font-size: 15px;
}
.notif-templates-list {
  overflow-y: auto;
  margin: 14px 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.notif-template-row {
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 10px 12px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.notif-template-row h5 {
  margin: 0;
  font-size: 13px;
  color: var(--accent);
}
.notif-template-title-input, .notif-template-body-input {
  width: 100%;
  padding: 6px 9px;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 13px;
  font-family: inherit;
  resize: vertical;
}

/* --- Loading skeleton — replaces plain "Loading…" text with a subtle shimmering
   placeholder block in brand-neutral tones. Usage: '<div class="skeleton-line"></div>'
   (repeat for multiple lines/rows). Rolled out to the rest of the app's "Loading…" text
   placeholders (2026-09-12, visual-polish pass item 6) — this component already existed and was
   already used on 9 of 21 pages; the other 10 just hadn't been switched over yet. */
@keyframes skeleton-shimmer {
  0% { background-position: -200px 0; }
  100% { background-position: calc(200px + 100%) 0; }
}
.skeleton-line {
  height: 14px;
  border-radius: 4px;
  margin: 8px 0;
  /* Highlight stop was a hardcoded #e4dfe0 (fixed 2026-09-12, same pass) — same class of bug as
     item 1's original plain-black shadows: one fixed color can't be correct against both a warm
     cream panel and a near-black one. Checked against a real deployed near-monochrome config:
     its --stone (a neutral gray) paired with this hardcoded warm-cream highlight produced
     a mismatched warm-pink flash through an otherwise neutral palette. color-mix with --panel
     derives a correct lighter-than-stone highlight per instance instead — no new config field,
     same technique as the elevation tokens above. */
  background: linear-gradient(90deg, var(--stone) 0%, color-mix(in srgb, var(--stone) 35%, var(--panel) 65%) 50%, var(--stone) 100%);
  background-size: 200px 100%;
  animation: skeleton-shimmer 1.4s ease-in-out infinite;
  opacity: 0.5;
}
.skeleton-line.short { width: 40%; }
.skeleton-line.medium { width: 70%; }

/* --- Empty states — an icon over a message instead of a bare line of muted text.
   Usage: emptyState("inbox", "No announcements yet.") from icons.js.
   Visual treatment (2026-09-12, visual-polish pass item 5) — previously a small flat-gray icon
   (26px, --stone) with almost no presence, sitting in the middle of an otherwise-generous card
   like an afterthought. Bigger icon, a soft brand-blue circular backdrop behind it (color-mix
   with transparent, same alpha-from-a-token-color technique item 1's shadows used — no new
   hardcoded color, stays correct per-instance), and more generous padding matching item 3's
   whitespace pass. Paired with real copy (see icons.js's emptyState call sites) instead of a
   generic "No X yet." template. */
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-7) var(--space-4);
  text-align: center;
}
.empty-state .icon {
  font-size: 28px;
  padding: var(--space-4);
  border-radius: 50%;
  /* Both the backdrop tint and the icon itself track var(--accent), not var(--brand-blue) —
     2026-09-12, visual-polish pass item 8 — so the soft halo stays visually matched to whatever
     color the icon actually renders in, in both themes, rather than the halo silently going
     stale (still literally brand-blue-tinted) if only the icon color were migrated. */
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  color: var(--accent);
  /* Without this, the page's global `* { box-sizing: border-box; }` would subtract this padding
     from .icon's own 1em width/height instead of adding to it, squeezing the SVG down rather
     than growing a circular badge around it. */
  box-sizing: content-box;
}
.empty-state p.muted {
  margin: 0;
}

/* An empty state that is itself the control — currently the earned-library state on
   assignments.html, where "empty" has a real explanation behind it rather than just being an
   absence. A <button> so it's keyboard-reachable and announced as actionable, but styled to
   stay the same quiet block the non-clickable states are: the hint line below the message is
   what signals there's something to open, not a button-looking box. */
.empty-state-clickable {
  width: 100%;
  background: none;
  border: none;
  font: inherit;
  cursor: pointer;
  border-radius: 10px;
}
.empty-state-clickable:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.empty-state-clickable:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.empty-state-hint {
  font-size: 13px;
  font-weight: 600;
  color: var(--accent);
}

/* --- Small explanatory overlay ------------------------------------------------------------
   A short, text-only "here's what this means / here's your next step" card. Deliberately NOT
   .certificate-overlay/.certificate-panel, which are sized and themed for the certificate
   design (720px, print rules, a full framed document); this is the generic, narrow version for
   an explanation. Shares .certificate-close-btn, which is already generic chrome. */
.info-overlay {
  position: fixed; inset: 0; background: rgba(0,37,62,0.55);
  display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px;
}
.info-panel {
  background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
  padding: 22px 24px; max-width: 420px; width: 100%; position: relative;
  max-height: calc(100vh - 40px); overflow-y: auto; overflow-x: hidden;
}

/* --- My Paths tiles — rep.html/manager.html. Assigned courses (cohorts with Week/Day
   content) the logged-in person must complete, linking to /path/:id. */
.path-tile-row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
.path-tile {
  flex: 1;
  min-width: 220px;
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: var(--space-4); /* 14px -> 16px, 2026-09-12 visual-polish pass item 3 */
  text-decoration: none;
  color: inherit;
  display: block;
  background: var(--panel);
  box-shadow: var(--shadow-sm);
  transition: box-shadow 150ms ease, transform 150ms ease;
}
/* var(--brand-blue) -> var(--accent) on this whole tile (2026-09-12, dark-mode fix, found live)
   — .path-tile's own background is var(--panel) above, which goes dark in dark mode; brand-blue
   on the dark panel measured ~1.4:1, missed by the original call-site classification because
   .path-tile wasn't one of the ~79 sites actually traced background-by-background. */
.path-tile:hover { border-color: var(--accent); box-shadow: var(--shadow-hover); transform: translateY(-2px); }
.path-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Completed Paths tiles are <button>s, not <a>s (clicking opens a certificate overlay
   in place rather than navigating to the path page) — .path-tile's own border/padding/
   radius/background already win over the UA button defaults, just not font/text-align,
   which default to center/system-font on a real <button>. Scoped to the tag so the <a>
   usage elsewhere (My Paths) is untouched. */
button.path-tile { font: inherit; text-align: left; cursor: pointer; }
.path-tile .path-tile-name { font-weight: 700; color: var(--accent); }
.path-tile .path-tile-host { font-size: 12px; color: var(--muted); margin: 2px 0 8px; }
.path-tile .path-tile-progress { font-size: 12px; color: var(--muted); }
/* Completed Paths tiles only — a visible "open the certificate" affordance inside the
   already-clickable tile (a real nested <button> would be invalid HTML/bad a11y, so this
   is a styled label, not a second interactive element). */
.path-tile .path-tile-cta { font-size: 12px; font-weight: 700; color: var(--accent); margin-top: 8px; }
/* Next-path nudge (2026-09-13) — always last in the tile, below whichever of progress/cta
   rendered above it; smaller and lighter than either so it stays clearly subordinate. */
.path-tile .path-tile-next { font-size: 11px; color: var(--muted); margin-top: 6px; }

/* --- home.html's cross-track My Paths card (2026-09-13). Groups reuse .path-tile-row/.path-tile
   from above unchanged; only the track-label heading and the completed-count toggle are new. */
.home-path-track-group + .home-path-track-group { margin-top: 16px; }
.home-path-track-label { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); margin: 0 0 8px; }
.home-completed-paths { margin-top: 16px; }
.home-completed-paths-list { margin-top: 8px; }

/* --- Rich text (Quill) — editing chrome + static rendered output for the long-form
   content fields (assignment instructions/grading criteria, FAQ answers, resource/path
   descriptions, FieldPro notes, submission comments). Rendered output reuses Quill's own
   .ql-editor class (from the CDN stylesheet) for list/alignment styling, minus the
   editor-only border/background, so editing and display always look consistent. */
/* Background/text/border here were hardcoded to a fixed white/dark pair regardless of theme —
   fine in light mode (where var(--panel) IS white and var(--text) IS near-black anyway) but in
   dark mode it left a stray always-white box next to every other themed control on the same
   page (selects/text inputs already use var(--panel)/var(--text)/var(--border), see the `select`
   rule above — this mount was the one form surface not following that pattern). Using the same
   tokens instead makes it track the same dark-panel/light-text pairing as everything else in
   dark mode, and is pixel-identical to the old hardcoded values in light mode since --panel/
   --text already resolve to white/near-black there. */
.rich-editor-mount {
  background: var(--panel);
  border-radius: 6px;
  margin-bottom: 4px;
}
.rich-editor-mount .ql-toolbar {
  border-color: var(--border);
  border-radius: 6px 6px 0 0;
}
.rich-editor-mount .ql-container {
  border-color: var(--border);
  /* quill.snow.css ships two conflicting .ql-container.ql-snow rules — an earlier one zeroes
     the top border (so it doesn't double up with the toolbar's own bottom border at their
     shared seam), but a later one in the same file re-sets a full 1px border on all sides,
     silently winning and undoing that. With Quill's original opaque #ccc this was invisible
     (two solid gray lines look the same as one), but now that border-color above is a
     translucent var(--border), the doubled line at the seam visibly compounds into a brighter,
     thicker-looking edge than every other bordered box in the app. Re-asserting border-top: 0
     here (our own rule, after Quill's in the cascade) restores the single-border look. */
  border-top: 0;
  border-radius: 0 0 6px 6px;
  font-family: Arial, Helvetica, sans-serif;
  font-size: 14px;
}
.rich-editor-mount .ql-editor {
  color: var(--text);
}
.rich-editor-mount .ql-editor.ql-blank::before {
  color: var(--muted);
}
/* Quill's own quill.snow.css hardcodes toolbar icon strokes/fills to a fixed gray (#444) meant
   for a permanently-white toolbar — invisible against a dark panel background once the mount
   above follows the theme instead of staying hardcoded white. Re-pointing them at var(--text)
   (and the active/hover state at var(--accent), matching every other interactive icon in this
   app) keeps the toolbar legible in both themes instead of just light mode. */
.rich-editor-mount .ql-toolbar .ql-stroke {
  stroke: var(--text);
}
.rich-editor-mount .ql-toolbar .ql-fill {
  fill: var(--text);
}
.rich-editor-mount .ql-toolbar button:hover .ql-stroke,
.rich-editor-mount .ql-toolbar button:focus .ql-stroke,
.rich-editor-mount .ql-toolbar button.ql-active .ql-stroke,
.rich-editor-mount .ql-toolbar .ql-picker-label:hover .ql-stroke {
  stroke: var(--accent);
}
.rich-editor-mount .ql-toolbar button:hover .ql-fill,
.rich-editor-mount .ql-toolbar button:focus .ql-fill,
.rich-editor-mount .ql-toolbar button.ql-active .ql-fill,
.rich-editor-mount .ql-toolbar .ql-picker-label:hover .ql-fill {
  fill: var(--accent);
}
/* The picker DROPDOWN — the panel that opens under the alignment/size control — is the last
   piece of Quill's chrome that was still hardcoded for a permanently-white toolbar:
   quill.snow.css sets `.ql-picker-options { background-color: #fff }` with #444 label text and
   #ccc borders. In dark mode that opens as a white slab over a dark card (2026-09-20, reported
   from the floor). Same treatment the icons above already got — point it at the theme's own
   tokens. Every selector here ties or beats Quill's on specificity and style.css is loaded
   after quill.snow.css on every page that mounts an editor, which is the same thing the
   .ql-stroke/.ql-fill rules above already depend on.
   The hover/selected colour is Quill's own #06c — a hardcoded blue that is nobody's brand, and
   exactly the kind of literal the multi-tenant rule exists to keep out. --accent is what every
   other interactive element in this app uses, and it tracks the instance. */
.rich-editor-mount .ql-picker {
  color: var(--text);
}
.rich-editor-mount .ql-picker-options {
  background-color: var(--panel);
  border-radius: 6px;
}
.rich-editor-mount .ql-toolbar .ql-picker.ql-expanded .ql-picker-label,
.rich-editor-mount .ql-toolbar .ql-picker.ql-expanded .ql-picker-options {
  border-color: var(--border);
}
.rich-editor-mount .ql-toolbar .ql-picker.ql-expanded .ql-picker-label {
  color: var(--text);
}
.rich-editor-mount .ql-toolbar .ql-picker.ql-expanded .ql-picker-label .ql-stroke {
  stroke: var(--text);
}
.rich-editor-mount .ql-toolbar .ql-picker.ql-expanded .ql-picker-label .ql-fill {
  fill: var(--text);
}
.rich-editor-mount .ql-toolbar .ql-picker-item:hover,
.rich-editor-mount .ql-toolbar .ql-picker-item.ql-selected {
  color: var(--accent);
}
.rich-content.ql-editor {
  padding: 0;
  font-family: Arial, Helvetica, sans-serif;
  font-size: inherit;
  white-space: normal;
  /* Quill's own .ql-editor CSS sets height:100% + overflow-y:auto (meant for the live
     editor's fixed-height box). Left unset here, a display-only rich-content box inside any
     flex column (e.g. Home's Announcements/Highlight cards) sizes to whatever flex space is
     left over instead of its own content, clipping text behind a scrollbar and landing at a
     different height than a sibling box with different content. */
  height: auto;
  overflow-y: visible;
}
.rich-content.ql-editor p { margin: 0 0 6px; }

/* --- Certificate card — the actual visual design (double border navy/aqua, boxed "Certificate
   of Completion" eyebrow, real Vault logo, single V watermark, signature-style name line with
   diamond markers, two-column Date/Signer footer, "Consider it Covered" tagline). Shared here
   so the real per-rep certificate popup (path.html), the graduation reveal (assignments.html),
   the Completed Paths dashboard popup (rep.html/manager.html), and the Certificate Bank preview
   (cohorts.html) all render the identical design. The modal chrome (overlay, panel, close
   button) lives here too now — it was page-local to path.html at first, but every page above
   except cohorts.html builds a `.certificate-overlay` from JS and needs it to actually be a
   fixed-position modal instead of rendering inline whatever it's appended to. */
.certificate-frame { background: var(--navy); padding: 8px; border-radius: 4px; }
.certificate-inner {
  background: var(--neutral-ivory); border: 3px solid var(--aqua); border-radius: 2px;
  padding: 36px 44px; position: relative; overflow: hidden; text-align: center;
}
.certificate-watermark {
  position: absolute; top: 50%; left: 50%; width: 65%;
  transform: translate(-50%, -50%); pointer-events: none;
}
.certificate-content { position: relative; z-index: 1; }
.certificate-eyebrow-box {
  display: inline-block; border-top: 1.5px solid var(--brand-blue); border-bottom: 1.5px solid var(--brand-blue);
  padding: 4px 22px; margin: 0 auto 16px;
}
.certificate-eyebrow-box span {
  font-size: 11px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; color: var(--brand-blue);
  font-family: "Golos Text", Arial, sans-serif;
}
.certificate-logo { height: 50px; margin: 0 auto 16px; display: block; }
.certificate-headline {
  font-family: "Golos Text", Arial, sans-serif; font-weight: 800; color: var(--navy);
  margin: 0 0 14px; font-size: 42px; line-height: 1;
}
.certificate-certifies-line { margin: 0 0 4px; font-size: 14px; color: var(--brand-blue); }
.certificate-name-line {
  position: relative; border-bottom: 2px solid var(--brand-blue);
  margin: 0 auto 16px; padding-bottom: 6px; max-width: 420px;
}
.certificate-name-line::before, .certificate-name-line::after {
  content: "◆"; color: var(--brand-blue); position: absolute; bottom: -7px; font-size: 12px;
  /* was var(--aqua) — Aqua text on Neutral Ivory is a rejected pairing per the guide's
     accessibility grid; matches the adjacent border/eyebrow colors instead. */
}
.certificate-name-line::before { left: -18px; }
.certificate-name-line::after { right: -18px; }
.certificate-name {
  font-family: "Golos Text", Arial, sans-serif; font-weight: 700; font-size: 20px; color: var(--navy);
}
.certificate-body-text { margin: 0 0 24px; font-size: 13px; color: var(--brand-blue); line-height: 1.5; }
.certificate-footer-row { display: flex; justify-content: space-between; gap: 30px; margin-bottom: 20px; }
.certificate-footer-col { flex: 1; text-align: center; }
.certificate-footer-line {
  border-bottom: 1.5px solid var(--brand-blue); padding-bottom: 4px; margin-bottom: 4px;
  min-height: 20px; font-family: "Golos Text", Arial, sans-serif; font-weight: 700; color: var(--navy); font-size: 15px;
}
.certificate-footer-label {
  margin: 0; font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--brand-blue);
  font-family: "Golos Text", Arial, sans-serif;
}
/* Re-pins .brand-tagline's color back to raw --brand-blue here — the certificate's paper is
   fixed --neutral-ivory always (see the certificate cluster's own theme-invariant treatment
   above), so it must stay on the brand guide's literal color, not --accent's dark-mode lightened
   variant, regardless of which theme the surrounding page is in. */
.certificate-tagline { margin: 0; color: var(--brand-blue); }
/* Sits below the certificate frame, above the button row — messaging AROUND the certificate,
   never inside .certificate-inner, so it's never part of the downloaded/printed PDF (which
   renders only the certificate design itself, unaffected by this). Per-path admin copy
   (paths.graduation_message); the block is simply omitted when a path has none set. */
/* var(--brand-blue) -> var(--accent) below (2026-09-12, dark-mode fix, found live) — this block
   sits AROUND the certificate (comment above), on var(--bg) which goes dark in dark mode, not
   the certificate's own fixed ivory paper — another missed site from the original pass. */
.graduation-next-steps {
  background: var(--bg); border-left: 3px solid var(--accent); border-radius: 6px;
  padding: 12px 14px; margin-top: 14px; text-align: left; font-size: 13px; color: var(--text);
}
.graduation-next-steps .graduation-next-steps-label {
  display: block; font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--accent); margin-bottom: 4px; font-family: "Golos Text", Arial, sans-serif;
}
/* The first-time "you just graduated" moment (views/assignments.html's showGraduationReveal)
   reuses this whole certificate design as-is, just with this one extra celebratory line sitting
   above the frame — same visual, different context from path.html's plain "View Certificate". */
.graduation-reveal-banner {
  text-align: center;
  color: var(--status-good-text);
  font-size: 15px;
  margin-bottom: 10px;
}
.certificate-overlay {
  position: fixed; inset: 0; background: rgba(0,37,62,0.55);
  display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px;
}
.certificate-panel {
  background: var(--panel); border-radius: 12px; padding: 20px; max-width: 720px; width: 100%;
  text-align: center; position: relative;
  /* .certificate-overlay centers this with align-items + 20px padding on all sides, but with
     no height limit of its own, tall content (banner + certificate + next-steps + buttons all
     stacked) pushes past the viewport top/bottom with no way to reach the rest — no scrollbar
     appears on a flex-centered overflowing child by default. Capping height to the available
     space and scrolling internally keeps the whole panel reachable on any window size. */
  /* overflow-x explicit, not left to default (visible) — CSS auto-converts a visible x paired
     with a non-visible y into overflow-x: auto too, which was surfacing an unwanted horizontal
     scrollbar even though nothing here ever needs to scroll sideways. */
  max-height: calc(100vh - 40px); overflow-y: auto; overflow-x: hidden;
}
.certificate-close-btn {
  /* Was top/right: -6px (a badge floating just outside the panel's corner) — .certificate-panel
     now clips overflow (max-height/overflow-y/overflow-x above, for the shrink-to-fit zoom's
     safety net), which clips anything positioned outside its own box. Inset instead of negative
     so the button stays fully visible. */
  position: absolute; top: 8px; right: 8px; background: var(--panel); border: 1px solid var(--border);
  border-radius: 50%; width: 28px; height: 28px; font-size: 18px; color: var(--muted); cursor: pointer; line-height: 1;
}
@media print {
  body * { visibility: hidden; }
  .certificate-panel, .certificate-panel * { visibility: visible; }
  .certificate-panel { position: absolute; top: 40px; left: 50%; transform: translateX(-50%); box-shadow: none; }
  .no-print { display: none !important; }
}

/* Certificate Bank preview — a shrunk, non-interactive rendering of the same certificate-frame
   markup at its native 720px design width, scaled down with `zoom` (simplest way to shrink an
   absolute-px design proportionally; Chromium/Edge/Safari support it, which covers this app's
   actual users). */
.certificate-preview-wrap { zoom: 0.55; width: 720px; margin: 10px auto; pointer-events: none; }
/* At 0.55 this renders ~396px wide — fine anywhere above phone width, but wider than a
   375px screen itself, pushing the whole page into horizontal scroll. Shrink it further
   below ~480px so the preview (never interactive — pointer-events: none) fits the screen
   instead of overflowing it. */
@media (max-width: 480px) {
  .certificate-preview-wrap { zoom: 0.44; }
}
.rich-content.ql-editor p:last-child { margin-bottom: 0; }

/* Cohort Leaderboard (podium + ranked list) — public/leaderboard.js renders this shape.
   Additive on rep.html/manager.html (sits next to the existing Class Grades table, doesn't
   replace it); the primary view inside grading.html's new Cohort Leaderboards card. */
.leaderboard-podium {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 18px;
  flex-wrap: wrap;
  margin: 4px 0 18px;
  padding: 16px 8px 0;
}
.leaderboard-tier {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  width: 150px;
  padding: 14px 10px 16px;
  border-radius: 12px;
  background: var(--panel);
  border: 1px solid var(--border);
}
.leaderboard-tier-1 { order: 2; padding-top: 4px; border-color: var(--tier-gold); }
.leaderboard-tier-2 { order: 1; }
.leaderboard-tier-3 { order: 3; }
.leaderboard-tier-badge {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
}
.leaderboard-tier-badge svg { width: 100%; height: 100%; }
.leaderboard-tier-1 .leaderboard-tier-badge { color: var(--tier-gold); width: 60px; height: 60px; }
.leaderboard-tier-2 .leaderboard-tier-badge { color: var(--tier-silver); }
.leaderboard-tier-3 .leaderboard-tier-badge { color: var(--tier-bronze); }
.leaderboard-avatar-group {
  display: flex;
  margin-top: 2px;
}
.leaderboard-avatar {
  width: 32px;
  height: 32px;
  flex-shrink: 0;
  border-radius: 50%;
  /* Default/fallback fill — overridden per-person by .leaderboard-avatar-c2/-c3 below (JS
     assigns one of three deterministically, keyed by person id, so the same person always gets
     the same color everywhere). Kept here as the actual variant-1 value too, not just a
     fallback, so JS never has to add a class for the most common case. */
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  font-weight: 800;
  font-family: "Golos Text", Arial, sans-serif;
  border: 2px solid var(--panel);
  margin-left: -10px;
}
.leaderboard-avatar:first-child { margin-left: 0; }
/* Avatar color palette (2026-09-12, visual-polish pass item 7) — previously every avatar was
   this same solid brand-blue, distinguished only by initials; in the podium's overlapping
   avatar-group (people tied at the same score stack with a 10px overlap, separated by just a
   2px border) same-colored adjacent circles were hard to tell apart at a glance. Three variants,
   not more — a 5-variant version was tested first and rejected: on a near-monochrome palette,
   whose brand-blue/navy/aqua are all near-black already, two of the five mixes came out only
   13/441 RGB units apart (functionally identical), so shipping them would have been decoration
   that only technically differed. This 3-variant set was chosen by computing the actual
   resulting colors and their pairwise separation on BOTH deployed palettes' real tokens before
   picking ratios — minimum separation 60+/441 and 67+/441, both comfortably perceptible. --aqua
   is deliberately not
   part of this mix (or the self-ring below) as a background fill — it's flagged elsewhere in
   this file as "used sparingly, as an accent," and a solid avatar fill is the opposite of that;
   aqua is used instead for .leaderboard-avatar-self, which IS a sparing accent use. */
.leaderboard-avatar-c2 { background: var(--navy); }
.leaderboard-avatar-c3 { background: color-mix(in srgb, var(--stone) 45%, var(--navy) 55%); }
.leaderboard-avatar-overflow {
  /* Was var(--muted): the same defect as the Pending badge, on a different surface. This carries
     .leaderboard-avatar's own near-white ink, and --muted is a text colour — 4.07:1 on this
     checkout's palette in light mode, worse on both real ones, and worse again in dark where
     --muted is light. --status-pending is the derived "muted, but safe as a surface" value and
     is already correct in both themes. Reusing it rather than deriving a second one keeps a
     single place to fix if the derivation ever needs revisiting. */
  background: var(--status-pending);
  font-size: 11px;
}
/* The viewer's own avatar — previously nothing distinguished "you" in a longer ranked list,
   podium or row, so finding yourself meant reading every name. A ring, not a fill swap, so it
   layers over whichever of the three colors above this person already has rather than replacing
   their identity color. box-shadow (not an extra border) so it doesn't add to the element's own
   box size and throw off the avatar-group's -10px overlap math. */
.leaderboard-avatar-self {
  box-shadow: 0 0 0 2px var(--panel), 0 0 0 4px var(--accent-aqua);
}
.leaderboard-tier-name {
  font-weight: 700;
  font-size: 13px;
  text-align: center;
}
.leaderboard-tier-score {
  font-size: 20px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  color: var(--accent);
}
.leaderboard-participant-count {
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
  background: var(--panel-sunken);
  border-radius: 999px;
  padding: 2px 9px;
}
.leaderboard-list {
  max-height: 340px;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: 10px;
}
.leaderboard-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  border-bottom: 1px solid var(--border);
}
.leaderboard-row:last-child { border-bottom: none; }
.leaderboard-rank {
  width: 22px;
  flex-shrink: 0;
  text-align: right;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}
.leaderboard-row .leaderboard-avatar { margin-left: 0; }
.leaderboard-row-name {
  flex: 1 1 130px;
  min-width: 0;
  font-size: var(--text-md); /* 13.5px -> 14px, 2026-09-12 visual-polish pass item 4 */
  font-weight: 700;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.leaderboard-score-bar {
  flex: 2 1 120px;
  height: 8px;
  border-radius: 999px;
  background: var(--panel-sunken);
  overflow: hidden;
}
.leaderboard-score-bar-fill {
  height: 100%;
  border-radius: 999px;
}
.leaderboard-score-bar-fill.status-good { background: var(--status-good); }
.leaderboard-score-bar-fill.status-warn { background: var(--status-warn); }
.leaderboard-score-bar-fill.status-bad { background: var(--status-bad); }
.leaderboard-row-score {
  width: 42px;
  flex-shrink: 0;
  text-align: right;
  font-size: 13px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.leaderboard-unscored-section {
  margin-top: 4px;
  padding: 4px 12px 2px;
}
.leaderboard-unscored-section .leaderboard-row { padding-left: 0; padding-right: 0; }
.leaderboard-unscored-label {
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted);
  margin: 10px 0 4px;
}
@media (max-width: 640px) {
  /* 96px-min boxes + 10px gaps need ~308px; a 375px phone only has ~278px here after the
     card's own padding — min-width floors override flex-shrink, so the row was overlapping
     instead of reflowing. Reclaim the podium's own side padding and right-size everything to
     what's actually left, rather than widening boxes there isn't room to widen. */
  .leaderboard-podium { gap: 6px; padding: 14px 0 0; }
  .leaderboard-tier { width: 31%; min-width: 82px; padding: 10px 5px 12px; }
  .leaderboard-tier-1 .leaderboard-tier-badge { width: 40px; height: 40px; }
  .leaderboard-tier-badge { width: 32px; height: 32px; }
  .leaderboard-avatar { width: 26px; height: 26px; font-size: 10px; margin-left: -8px; }
  .leaderboard-tier-name { font-size: 11.5px; }
  .leaderboard-tier-score { font-size: 17px; }
  .leaderboard-participant-count {
    /* Stretched to the tier's own width (instead of sizing to its own text) so it wraps to a
       second line on a narrow box instead of rendering past the tier's border. Font shrunk +
       letter-spacing zeroed on top of that — at the base 11px/0.03em, "PARTICIPANTS" alone
       (12 characters) was still too wide for the ~70px available even after wrapping "N" onto
       its own line, so the word itself was cramped/overflowing the pill's padding. */
    align-self: stretch;
    box-sizing: border-box;
    text-align: center;
    white-space: normal;
    overflow-wrap: anywhere;
    line-height: 1.2;
    font-size: 8px;
    letter-spacing: 0;
    padding: 2px 2px;
  }
  /* Same reflow principle as the podium tiers above, applied to the ranked list below them
     (2026-09-14 fix) — .leaderboard-row-name and .leaderboard-score-bar (flex-basis 130px and
     120px) were both flex-shrinkable, so at 375px they got squeezed roughly proportionally once
     rank/avatar/score's fixed widths were subtracted — measured 54.59px for the name regardless
     of content, clipping a real name like "Alexandria Montgomery-Fitzgerald" (230px of actual
     text) down to a handful of characters. The bar is decorative — .leaderboard-row-score
     already states the exact percentage right next to it — so it's what gives up its space
     first; the name is the row's real identifier. Hiding the bar reclaims real width (the name
     absorbs 100% of it automatically via its own existing flex-grow, no other flex-grow sibling
     left to compete with) but that alone still wasn't enough to guarantee legibility for every
     real name — even "Ryan Everts Alexander" still clipped at the resulting ~115px. Letting the
     name wrap to 2 lines instead of single-line-truncating is what actually guarantees it never
     gets silently cut off, regardless of how long a real name turns out to be. */
  .leaderboard-score-bar { display: none; }
  .leaderboard-row-name { white-space: normal; overflow: visible; text-overflow: clip; }
}

/* --- BU-grouped rollup (Owner filter narrowed to one manager) — shared by compliance.html and
   grading.html's Team Compliance/Path Grading Grid/Cohort Leaderboards, promoted here from two
   verbatim-duplicated copies (2026-08-20 code review) so a future visual tweak only needs to
   happen once instead of drifting between the two pages. */
.rollup-owner-header {
  margin: 0 0 10px;
  font-size: 15px;
  font-family: 'Golos Text', Arial, sans-serif;
  font-weight: 800;
  color: var(--accent);
  text-transform: uppercase;
  letter-spacing: 0.02em;
}
.rollup-bu-header {
  /* font-size/weight/color removed (2026-09-12, visual-polish pass item 4) — redundant with the
     new base h4 rule (brand-blue Golos 700, same weight this already had); only the tighter
     margin is still a genuine override for this denser report context. */
  margin: 0 0 var(--space-1);
}
.rollup-bu-section { padding: 4px 0; }
.rollup-totals {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  margin-top: 8px;
  padding: 8px 10px;
  background: var(--neutral-ivory);
  border-radius: 6px;
  font-size: 12px;
  color: var(--muted);
}
.rollup-totals strong { color: var(--text); margin-right: 4px; }
.rollup-divider { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
.rollup-grand-total .rollup-totals {
  background: var(--brand-blue);
  color: var(--neutral-ivory);
  font-size: 13px;
}
.rollup-grand-total .rollup-totals strong { color: var(--neutral-ivory); }

/* --- Personality Assessment type colours (2026-09-19). One hue per DISC type, carrying
   IDENTITY only — never rank, never good/bad: there is no better or worse type to score, and a
   palette that implied one would contradict the whole instrument. Fixed order, never cycled;
   there are exactly four types and there will never be a fifth.

   Light and dark are separately chosen steps of the same four hues, not one set with an
   automatic filter over it: the light steps sit too light against a near-black surface for a
   categorical palette (the two warm ones fall outside the dark lightness band entirely). Both
   sets were checked, not eyeballed — same hue, same chroma floor, OKLCH L inside the mode's
   band, adjacent-pair separation under protan/deutan/tritan simulation, and WCAG contrast
   against that mode's own surface. Re-run that check before changing any of these eight
   values; the pair that runs closest is amber against green.

   They live here rather than in the scripts that draw the bars because the dark-mode override
   lives here — a JS-side hex map can't see which theme is active. */
:root {
  --pa-type-d: #2a78d6;
  --pa-type-i: #eb6834;
  --pa-type-s: #1baf7a;
  --pa-type-c: #eda100;
}
:root[data-theme="dark"] {
  --pa-type-d: #3280de;
  --pa-type-i: #d1521f;
  --pa-type-s: #139768;
  --pa-type-c: #ab7300;
}

/* --- Personality Assessment result, as shown inside the submission modal (2026-09-19).
   Deliberately its own `.pa-rv-*` namespace rather than reusing views/personality-take.html's
   `.pa-*` rules: those live in that page's own <style> block and never load here, and the two
   contexts are different widths (a full 680px page vs. one section of a modal column). The
   spread is a diverging bar chart centred on zero — colour carries type identity only, never
   rank or good/bad, and every bar is directly labelled with its own name so the chart never
   depends on colour alone to be read. Each bar's fill is set inline by subgallery.js to the
   matching --pa-type-* property above, so the theme decides the actual step. */
/* Only present in a manager's copy — see renderPersonalityReviewHtml. Same weight as the
   "For their manager" label further down, so the page reads as two labelled halves. */
.pa-rv-lens-label {
  margin: 0 0 8px;
  font-size: 12px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--muted);
}
.pa-rv-headline {
  font-size: 15px;
  font-weight: 700;
  line-height: 1.45;
  margin: 0 0 4px;
  color: var(--text);
}
.pa-rv-lede { font-size: 13px; line-height: 1.6; color: var(--text); margin: 10px 0 0; }

/* container-type, not a viewport media query: what decides whether the three-column layout
   fits is this chart's own width, and the same modal is wide on a desktop and narrow on a
   phone at the same viewport size depending on whether the body has stacked. */
/* Capped rather than full-bleed: this modal section runs close to 1000px wide when the
   submission has no video beside it, and a ±24-point scale stretched across that is mostly
   empty space with a band label stranded a long way from its own bar. */
.pa-rv-spread { margin: 14px 0 4px; max-width: 620px; container-type: inline-size; }
/* Fixed side columns, sized for the NARROWEST container this renders in rather than for the
   viewport: the feedback column is `flex: 1 1 45%; min-width: 280px` inside a modal that caps
   at 1100px, so the same chart has to work at roughly 300px of content width and at roughly
   1000px. A viewport media query is the wrong axis for that (a narrow column on a wide screen
   would keep the wide layout and overflow), so the two label columns are sized to fit their
   longest real string, with headroom — "Conscientiousness C" and "Balanced +12" — and the
   track simply takes whatever is left. The real floor is about 350px of content (a phone,
   where the modal body stacks and the feedback column runs nearly full-width). */
.pa-rv-row {
  display: grid;
  grid-template-columns: 146px 1fr 78px;
  align-items: center;
  gap: 8px;
  padding: 4px 0;
}
.pa-rv-name { font-size: 13px; font-weight: 600; color: var(--text); line-height: 1.3; }
.pa-rv-name .muted { font-weight: 700; }
/* min-width: 0 on both middle cells, so the row's `1fr` and the scale row's `1fr` resolve to
   the same width at any container size — the scale's text would otherwise give it a min-content
   floor the empty track doesn't have, and the two grids would drift apart as things got narrow. */
.pa-rv-track { position: relative; height: 18px; min-width: 0; }
/* The zero line the bars diverge from — recessive on purpose; it's the reference, not the data. */
.pa-rv-axis { position: absolute; left: 50%; top: -3px; bottom: -3px; width: 1px; background: var(--border); }
.pa-rv-bar { position: absolute; top: 3px; height: 12px; }
.pa-rv-bar.pos { left: 50%; border-radius: 0 4px 4px 0; }
.pa-rv-bar.neg { right: 50%; border-radius: 4px 0 0 4px; }
/* Band and raw score are one line, never two: a wrapped band label makes its row taller than
   the others and the four bars stop reading as one chart. (The score is present only in a
   manager's copy — see spreadFor's lens argument in personalityResult.js.) */
.pa-rv-band {
  font-size: 11px;
  font-weight: 700;
  text-align: right;
  color: var(--muted);
  white-space: nowrap;
}
.pa-rv-scale {
  display: grid;
  grid-template-columns: 146px 1fr 78px;
  gap: 8px;
  font-size: 10px;
  color: var(--muted);
  margin-top: 2px;
}
.pa-rv-scale div:nth-child(2) { display: flex; justify-content: space-between; min-width: 0; overflow: hidden; }

/* Below this there isn't room for label | track | band on one line — the track collapses to a
   stub and the chart stops being a chart. Stack instead: name and band on their own line, the
   bar full width underneath. DOM order is name, track, band, so the cells are placed
   explicitly rather than reordered in markup. */
@container (max-width: 380px) {
  .pa-rv-row {
    grid-template-columns: 1fr auto;
    row-gap: 1px;
    padding: 6px 0;
  }
  .pa-rv-name { grid-column: 1; grid-row: 1; }
  .pa-rv-band { grid-column: 2; grid-row: 1; }
  .pa-rv-track { grid-column: 1 / -1; grid-row: 2; }
  .pa-rv-scale { grid-template-columns: 1fr; }
  .pa-rv-scale div:nth-child(1), .pa-rv-scale div:nth-child(3) { display: none; }
}

.pa-rv-type {
  margin-top: 14px;
  padding: 12px 14px;
  background: var(--panel-sunken);
  border: 1px solid var(--border);
  border-radius: 8px;
}
.pa-rv-type h5 { margin: 0 0 6px; font-size: 14px; }
.pa-rv-type h5 .muted { font-weight: 700; }
.pa-rv-block + .pa-rv-block { margin-top: 10px; }
.pa-rv-block h6 {
  margin: 0 0 2px;
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--muted);
}
.pa-rv-block p { margin: 0; font-size: 13px; line-height: 1.6; color: var(--text); }
.pa-rv-note {
  font-size: 12px;
  line-height: 1.55;
  color: var(--muted);
  margin: 14px 0 0;
  padding-top: 12px;
  border-top: 1px solid var(--border);
}

/* A manager reading someone else's result gets everything above PLUS this. Visually set apart
   so it's obvious which half is the coaching sheet — the server omits the whole block for a
   rep viewing their own result, so this styling is never what's keeping it hidden. */
.pa-rv-manager {
  margin-top: 18px;
  padding-top: 14px;
  border-top: 2px solid var(--accent);
}
.pa-rv-manager > h5 {
  margin: 0;
  font-size: 12px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--accent);
}
.pa-rv-manager > .pa-rv-block { margin-top: 12px; }

/* The written half. Not inside the audit <details> with the card picks: those exist to show
   the working behind a score, these are content in their own right and shouldn't need a click
   to find. */
.pa-rv-written {
  margin-top: 18px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}
.pa-rv-written > h5 {
  margin: 0 0 10px;
  font-size: 12px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--muted);
}
.pa-rv-written .pa-rv-block + .pa-rv-block { margin-top: 12px; }
.pa-rv-written .pa-rv-block h6 { text-transform: none; letter-spacing: 0; font-size: 12px; line-height: 1.45; }

.pa-rv-audit { margin-top: 16px; }
.pa-rv-audit > summary {
  cursor: pointer;
  font-size: 12px;
  font-weight: 700;
  color: var(--muted);
  padding: 6px 0;
}
.pa-rv-audit > summary:hover { color: var(--text); }
.pa-rv-card {
  padding: 10px 0;
  border-top: 1px solid var(--border);
  font-size: 13px;
  line-height: 1.5;
}
.pa-rv-card strong {
  display: block;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--muted);
  margin-bottom: 4px;
}
.pa-rv-card p { margin: 0 0 3px; }
.pa-rv-other { font-size: 12px; }
.pa-rv-tag {
  display: inline-block;
  min-width: 42px;
  text-align: center;
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 1px 6px;
  border-radius: 4px;
  margin-right: 5px;
}
/* Most/least are two ends of one question, not a right and a wrong answer — so they take the
   neutral ink tokens, not the good/bad status colours a quiz review uses.
   --brand-blue, not --accent: --accent is lightened in dark mode (it's an ink token there, for
   text and borders), so a filled pill using it would put ivory text on a pale blue. --brand-blue
   keeps its value in both modes, which is why every other solid ivory-on-blue element in this
   file uses it. */
.pa-rv-tag.most { background: var(--brand-blue); color: var(--neutral-ivory); }
.pa-rv-tag.least { background: var(--border); color: var(--text); }


/* --- Personality Assessment authoring page (views/personality-assessment.html, super_admin
   only). The `.pa-edit-*` prefix keeps it clear of both `.pa-*` (the take page's own inline
   styles) and `.pa-rv-*` (the result view above). */

/* The lens switch. Two exclusive views of one saved object, not two tabs holding two documents
   — which is the whole reason this is a toggle and not two cards side by side: the rep's copy
   and the manager's copy are never meant to be read interleaved. */
/* Underline-and-ink, the same idiom as .sidebar-tab above, rather than a filled pill. A fill
   would have to come from a brand colour, and a brand colour is per-instance: the client-neutral
   default resolves --brand-blue to a dark grey that sits almost exactly on top of --panel in dark
   mode, which left the selected tab distinguishable only by the weight of its text. This reads
   the same on any palette. */
.pa-lens-switch {
  display: flex;
  border-bottom: 1px solid var(--border);
  margin-bottom: 12px;
}
.pa-lens-btn {
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  padding: 8px 16px;
  font-family: "Golos Text", Arial, sans-serif;
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--muted);
  cursor: pointer;
}
.pa-lens-btn:hover { color: var(--text); }
.pa-lens-btn.is-on { color: var(--accent); border-bottom-color: var(--accent); }
.pa-lens-btn:focus-visible { outline: 2px solid var(--accent-aqua); outline-offset: -2px; }

/* One card, one question, one type's write-ups — a visible box around each, because this form
   is long enough that an unbroken run of labelled textareas stops being readable. */
.pa-edit-row {
  margin-top: 14px;
  padding: 12px 14px;
  background: var(--panel-sunken);
  border: 1px solid var(--border);
  border-radius: 8px;
}
.pa-edit-row-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 8px;
}
.pa-edit-row-head strong { font-size: 13px; }
.pa-edit-row .field-label { margin-bottom: 10px; }
.pa-edit-row .field-label:last-child { margin-bottom: 0; }

/* Every problem at once, not one per save attempt (see the save handler's own comment). */
.pa-edit-errors {
  margin-top: 10px;
  padding: 10px 14px;
  border: 1px solid var(--status-bad);
  border-radius: 8px;
  font-size: 13px;
  line-height: 1.5;
}
.pa-edit-errors ul { margin: 6px 0 0; padding-left: 18px; }
.pa-edit-errors li { margin-bottom: 3px; }
