:root{
  /* Slate ground, one blue, split by job. --accent is ink: links, focus
     rings, the active-tab rule, the sync dot, the gradient endpoint.
     --accent-fill is a surface under white text — white on --accent is
     3.2:1 and fails AA, which is the whole reason there are two.
     Ratios below are against --bg; tests/test_design_tokens.py asserts
     them against --surface and --surface-2 as well.

     --danger gets the same ink/fill split for the same reason: white on
     --danger is 3.35:1 and fails AA. --danger-fill is the surface for
     white-labelled danger buttons (Sign out, delete-confirm); --danger
     itself stays ink-only — text, borders, dots — where it clears AA. */
  --bg:#0f1216;
  --surface:#171b21;
  --surface-2:#1e242c;
  --border:#262c35;
  --border-str:#333b47;
  --text:#e6e9ee;        /* 15.43:1 */
  --muted:#8b95a3;       /*  6.19:1 */
  --accent:#4c8dff;      /*  5.87:1 */
  --accent-fill:#2f6fd8; /* white label on it: 4.79:1 */
  --ok:#3fb950;          /*  7.39:1 */
  --danger:#f85149;      /*  5.60:1 */
  --danger-fill:#d92d20; /* white label on it: 4.83:1 */

  /* The only gradient in the stylesheet. Seven display-text sites consume
     it through .gradient-text; its darkest stop is --accent at 5.40:1 on
     --surface, so the text it paints still clears AA. */
  --accent-light:#a8c7ff; /* the ramp's midpoint; reused by typingOrb's glow */
  --ramp-text:linear-gradient(135deg,#fff 0%,var(--accent-light) 50%,var(--accent) 100%);

  --fs-xs:.75rem; --fs-sm:.875rem; --fs-md:1rem;
  --fs-lg:1.125rem; --fs-xl:1.375rem; --fs-2xl:2rem;

  --sp-1:.25rem; --sp-2:.5rem; --sp-3:.75rem;
  --sp-4:1rem;   --sp-5:1.5rem; --sp-6:2rem;

  --r-sm:6px; --r-md:10px; --r-lg:16px; --r-pill:999px;

  --shadow:0 4px 16px rgba(0,0,0,.4);
  /* The drop-shadow half of glowPulse. Not --shadow: that keyframe composes
     this with an accent glow in one box-shadow, and the two halves need
     different alphas. Lower on light, where the same darkness reads heavier. */
  --shadow-glow:rgba(0,0,0,.55);

  /* The wash scale: four steps toward the ink, for hover states, hairlines
     and faint fills. Derived from --text via color-mix rather than declared
     as a literal, which is what makes them invert with the theme: on dark
     --text is near-white and a wash lightens; on light it is near-black and
     the same token darkens. Nothing about a rule that uses one has to know
     which theme is active.

     These replaced 52 rgba(255,255,255,...) literals spread across 18
     distinct alpha levels -- the same "styled in isolation and eyeballed"
     pattern the spacing scale fixed. Every one of those snapped to the
     nearest step here. */
  --wash-1:color-mix(in srgb, var(--text)  4%, transparent);
  --wash-2:color-mix(in srgb, var(--text)  8%, transparent);
  --wash-3:color-mix(in srgb, var(--text) 14%, transparent);
  --wash-4:color-mix(in srgb, var(--text) 25%, transparent);

  /* Overlay scrims. Literals per theme rather than color-mix over --bg: a
     scrim exists to dim what is behind it, and mixing over the ground would
     make it WHITE on the light theme. Light uses a lower alpha throughout,
     because the same darkness reads heavier over a light page.

     There are TWO of these, because a scrim serves two different jobs:

       --scrim       occludes. Modal, lightbox and the delete-confirm
                     overlay: the thing behind is finished with, and
                     competing with it is a distraction.
       --scrim-soft  dims but keeps context. The mobile sidebar drawer: you
                     are navigating FROM somewhere, and losing sight of
                     where costs more than the extra separation buys.

     This replaced five per-site values that had been eyeballed one at a
     time -- .del-overlay .9, #profile-modal .7, #lightbox .92,
     #lightbox-close .85, #sidebar-overlay rgba(0,0,0,.55). The first
     consolidation put all five on one token at .9, which was wrong: it read
     the drawer as a fifth backdrop. At .9 the page behind the drawer is
     effectively gone and the drawer feels like a modal. The drawer keeps
     its original .55, now as a token rather than a literal.

     Remaining disclosed dark-theme change, alongside .chat-item.active's
     text and the user-bubble code chip: #profile-modal's backdrop darkens
     from .7 to .9 (20 points). That one IS a backdrop, and .7 barely
     separated its foreground. #sidebar-overlay keeps the alpha it had; only
     its hue moves, from pure black to the same near-black navy every other
     scrim uses.

     Known follow-up, not fixed here: #lightbox-close is a button, not a
     backdrop, so its use of a scrim token is a semantic stretch. Separate
     issue. */
  --scrim:rgba(15,18,22,.9);
  --scrim-soft:rgba(15,18,22,.55);
  --dur:180ms; --dur-fast:120ms; --ease:cubic-bezier(.2,0,.2,1);

  --font-ui:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,
            'Helvetica Neue',Arial,'Noto Sans Hebrew',sans-serif;
  --font-mono:ui-monospace,'SF Mono',Menlo,Consolas,monospace;
}

/* The light theme. Dark is the default and lives in :root above; this block
   overrides only the tokens whose values contain a colour literal. Tokens
   built purely from var() references — the --wash-* scale — are deliberately
   absent: they resolve through --text and invert on their own, so giving
   them a value here would defeat the mechanism.

   There is no @media (prefers-color-scheme) rule by design. Dark is the
   default for every first-time visitor regardless of OS, and a guard in
   tests/test_design_surface.py enforces that absence. */
:root[data-theme="light"]{
  --bg:#f7f8fa;
  --surface:#ffffff;
  --surface-2:#eceff4;   /* darker than --surface here; see the note below */
  --border:#dde2e9;
  --border-str:#c3ccd8;
  --text:#1a1f26;        /* 15.59 / 16.56 / 14.37 */
  --muted:#55606d;       /*  6.02 /  6.40 /  5.55 */
  --accent:#1a56b8;      /*  6.44 /  6.85 /  5.94 */
  --ok:#116329;          /*  6.96 /  7.39 /  6.41 */
  --danger:#b42318;      /*  6.19 /  6.57 /  5.70 */

  /* --accent-fill and --danger-fill are deliberately absent: they are
     identical in both themes and inherit from :root. White sits on them
     either way, which is what keeps the user bubble and the primary and
     danger buttons pixel-identical across themes.

     One invariant goes vacuous here and that is fine: white on this
     --accent is 6.85:1, so the ink/fill split is not NEEDED on light. The
     two tokens still differ and --accent stays ink-only by convention.

     --surface-2 is darker than --surface in this theme and lighter than it
     in dark. Its job is "one step further from the ground", which is the
     same job in both directions. */

  /* The ramp's near stop. It stays lighter than --accent in BOTH themes
     (luminance .134 vs .103 here, .564 vs .278 on dark) -- what inverts is
     its relationship to --accent-fill, which is the same colour in both:
     on dark the near stop sits well above the fill (.564 vs .169), on light
     it sits below it (.134 vs .169). So beside the fill it is measured
     against, "light" in the name reads backwards here. It keeps the name
     because renaming it would break test_chart_palette's accent_light
     mirror and the Mini App pinning for no gain. */
  --accent-light:#2a63c4; /*  5.37 /  5.71 /  4.95 */
  --ramp-text:linear-gradient(135deg,var(--text) 0%,var(--accent-light) 50%,var(--accent) 100%);

  --shadow:0 4px 16px rgba(0,0,0,.10);
  --shadow-glow:rgba(0,0,0,.18);

  /* Both scrims sit lower on light than on dark, for the reason given in
     :root: the same darkness reads heavier over a light page. The gap
     between them stays proportional, so the drawer still dims less than a
     modal occludes. */
  --scrim:rgba(15,18,22,.55);
  --scrim-soft:rgba(15,18,22,.32);
}
*{box-sizing:border-box}
html,body{
  margin:0;height:100%;overflow:hidden;
  background-color:var(--bg);          /* solid fallback — kills white overscroll */
  overscroll-behavior:none;
}
body{
  font-family:var(--font-ui);
  color:var(--text);
  background:var(--bg);
  background-attachment:fixed;
}
#app{height:100vh;height:100dvh}
.hidden{display:none !important}
.muted{color:var(--muted)}
.gradient-text{
  background:var(--ramp-text);
  -webkit-background-clip:text;background-clip:text;color:transparent;
}
.glass{
  background:var(--surface);
  backdrop-filter:blur(14px);
  border:1px solid var(--wash-2);
  box-shadow:var(--shadow);
}

/* ---------- buttons / inputs ---------- */
.btn{
  border:none;border-radius:var(--r-md);padding:var(--sp-3) var(--sp-4);font-family:inherit;
  font-weight:600;font-size:var(--fs-md);cursor:pointer;transition:.15s transform,.15s filter;
}
.btn:hover{filter:brightness(1.08)} .btn:active{transform:translateY(1px)}
.btn-primary{background:var(--accent-fill);color:#fff}
.btn-ghost{background:transparent;color:var(--muted);border:1px solid var(--border)}
.btn-stop{
  background:color-mix(in srgb, var(--danger) 14%, transparent);color:var(--danger);border:1px solid color-mix(in srgb, var(--danger) 35%, transparent);
  display:inline-flex;align-items:center;justify-content:center;gap:var(--sp-1);
}
.btn-stop:hover{background:color-mix(in srgb, var(--danger) 22%, transparent)}
.btn-block{display:block;width:100%;margin-top:var(--sp-2)}
.btn-sm{padding:var(--sp-2) var(--sp-3);font-size:var(--fs-sm)}
.input{
  width:100%;padding:var(--sp-3) var(--sp-4);border-radius:var(--r-md);margin:var(--sp-1) 0 var(--sp-1);
  background:var(--surface-2);border:1px solid var(--border);
  color:var(--text);font-family:inherit;font-size:var(--fs-md);outline:none;
  /* <input> and <select> get different UA-default line-heights for the same
     font-size, so identical padding still computes to two different total
     heights — a <select class="input"> sitting next to a search box in a
     toolbar row visibly falls short. Pinning line-height here makes both
     elements' box height a pure function of shared tokens. */
  line-height:1.4;
}
.input:focus{border-color:var(--accent)}
/* A bare <select class="input"> keeps its OS-native dropdown chrome (arrow
   glyph, internal padding) even with every other .input rule applied, which
   is exactly why it reads as a foreign control sitting next to a matching
   text input. appearance:none strips that; the arrow comes back as a plain
   inline SVG using stroke="currentColor" so it inherits --text like the
   select's own label — no separate color token needed and it matches both
   themes automatically. */
select.input{
  appearance:none;-webkit-appearance:none;-moz-appearance:none;
  padding-right:var(--sp-6);cursor:pointer;
  background-repeat:no-repeat;background-position:right var(--sp-3) center;background-size:.8rem;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
}
select.input::-ms-expand{display:none}
a:focus-visible,button:focus-visible,input:focus-visible,
select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{
  outline:2px solid var(--accent);outline-offset:2px;
}
.field-label{display:block;margin-top:var(--sp-3);font-size:var(--fs-sm);color:var(--muted)}
.alert{background:color-mix(in srgb, var(--danger) 16%, transparent);border:1px solid var(--danger);
  color:var(--text);padding:var(--sp-2) var(--sp-3);border-radius:var(--r-md);margin:var(--sp-2) 0;font-size:var(--fs-sm)}

/* ---------- centered auth views ---------- */
.center-view{
  height:100vh;display:flex;align-items:center;justify-content:center;padding:var(--sp-4);
  position:relative;overflow:hidden;
}

/* animated decorative orbs behind the login card */
.center-view::before,.center-view::after{
  content:"";position:absolute;border-radius:50%;pointer-events:none;
  animation:orb 8s ease-in-out infinite alternate;
}
.center-view::before{
  width:420px;height:420px;top:-100px;left:-120px;
  background:radial-gradient(circle,color-mix(in srgb, var(--accent) 22%, transparent) 0%,transparent 70%);
  animation-duration:9s;
}
.center-view::after{
  width:360px;height:360px;bottom:-80px;right:-80px;
  background:radial-gradient(circle,color-mix(in srgb, var(--accent) 16%, transparent) 0%,transparent 70%);
  animation-duration:11s;animation-delay:-4s;
}
@keyframes orb{
  from{transform:translate(0,0) scale(1)}
  to{transform:translate(30px,20px) scale(1.12)}
}

.card{padding:var(--sp-6);border-radius:var(--r-lg)}
.auth-card{
  width:min(420px,100%);
  position:relative;z-index:1;
  animation:glowPulse 4.5s ease-in-out infinite;
  background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);
}
@keyframes glowPulse{
  0%,100%{box-shadow:0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent), 0 24px 60px var(--shadow-glow),
                     0 0 60px color-mix(in srgb, var(--accent) 10%, transparent)}
  50%{box-shadow:0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), 0 24px 60px var(--shadow-glow),
                 0 0 110px color-mix(in srgb, var(--accent) 28%, transparent)}
}

.brand-logo{
  text-align:center;margin-bottom:var(--sp-4);color:var(--accent);
}
.brand-logo .icon{
  display:inline-block;
  animation:float 3s ease-in-out infinite;
  filter:drop-shadow(0 0 14px color-mix(in srgb, var(--accent) 45%, transparent));
}
@keyframes float{
  0%,100%{transform:translateY(0)}
  50%{transform:translateY(-8px)}
}
.brand-big{
  font-size:var(--fs-2xl);font-weight:600;margin-bottom:var(--sp-1);text-align:center;
}
.brand-tagline{
  text-align:center;font-size:var(--fs-sm);color:var(--muted);margin-bottom:var(--sp-4);
}
.fineprint{font-size:var(--fs-xs);color:var(--muted);margin-top:var(--sp-4);line-height:1.5}

/* Google btn */
.btn-google{
  display:flex;align-items:center;justify-content:center;gap:var(--sp-2);
  background:#fff;color:#1f1f1f;border-radius:var(--r-md);padding:var(--sp-3);
  font-weight:600;font-size:var(--fs-md);border:none;cursor:pointer;width:100%;
  margin-top:var(--sp-2);transition:.15s filter;
}
.btn-google:hover{filter:brightness(.96)}
.btn-google img{width:20px;height:20px}

/* The refusal notice. A block inside the card rather than a toast: there is
   no next step inside the app to fade back to, and it has to survive a
   reload. text-align:start because the auth card centres everything else. */
.login-refused{
  text-align:start;background:var(--surface-2);border:1px solid var(--border);
  border-radius:var(--r-md);padding:var(--sp-4);margin-bottom:var(--sp-4);
}
.login-refused-title{
  font-size:var(--fs-md);font-weight:600;color:var(--text);margin-bottom:var(--sp-2);
}
.login-refused-body{font-size:var(--fs-sm);color:var(--muted);line-height:1.5}
.login-refused-body + .login-refused-body{margin-top:var(--sp-3)}
.login-refused-body a{color:var(--accent)}

/* ---------- main layout ---------- */
#view-main{display:grid;grid-template-columns:280px 1fr;height:100vh;height:100dvh;gap:0}
/* height:100% + overflow:hidden is load-bearing, not tidying — mirrors .content
   just below, which already needs the same fix for the same reason. A grid
   item's height from align-items:stretch (the default) isn't reliably treated
   as a DEFINITE height for a flex child's min-height:auto resolution, so
   .chat-list's flex:1;overflow-y:auto silently stopped constraining once there
   were enough chats to matter: the list grew past the viewport instead of
   scrolling, taking .sidebar with it (clipped bottom, no way to reach it,
   since html/body themselves are overflow:hidden by design further up this
   file). Never surfaced with a handful of test chats or on mobile, where this
   breakpoint's .sidebar switches to position:fixed with its own explicit
   height instead of a grid track — a different mechanism that happens not to
   hit the same bug, not evidence the underlying cause was mobile-specific. */
.sidebar{margin:var(--sp-3);border-radius:var(--r-lg);padding:var(--sp-4);display:flex;flex-direction:column;gap:var(--sp-3);height:100%;overflow:hidden}
.brand{display:flex;align-items:center;gap:var(--sp-2);font-size:var(--fs-xl);font-weight:600;color:var(--accent)}
/* Wraps rather than overflowing. The basis must be auto, not flex:1's 0%: with a
   zero basis every tab's hypothetical size is 0, so they all get packed onto one
   line and only then forced up to min-content — which spills past the 280px
   sidebar instead of wrapping. At content basis the line breaks honestly, so the
   admin's fourth tab drops to its own row rather than hanging off the edge.
   min-width is left at auto deliberately: making tabs shrinkable would ellipsize
   the labels onto one cramped row instead. */
.tabs{display:flex;flex-wrap:wrap;gap:var(--sp-2);margin-top:var(--sp-1)}
.tab{flex:1 1 auto;padding:var(--sp-2);border-radius:var(--r-md);border:1px solid var(--border);
  background:transparent;color:var(--muted);font-family:inherit;font-weight:600;
  white-space:nowrap;cursor:pointer}
.tab.active{
  background:var(--surface-2);color:var(--text);border-color:var(--border-str);
  border-inline-start:2px solid var(--accent);
}
.sync-status{
  position:relative;display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-2) var(--sp-3);margin-top:var(--sp-2);
  border-radius:var(--r-md);background:var(--wash-1);border:1px solid var(--wash-2);
  font-size:var(--fs-xs);color:var(--muted);opacity:1;transition:opacity .4s ease;
}
.sync-status.fading{opacity:0}
.sync-status.done{color:var(--ok);border-color:color-mix(in srgb, var(--ok) 25%, transparent)}
#sync-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:default}
.sync-dot{
  width:7px;height:7px;border-radius:50%;background:var(--accent);flex-shrink:0;
  animation:sync-pulse 1.1s ease-in-out infinite;
}
.sync-status.done .sync-dot{background:var(--ok);box-shadow:0 0 7px var(--ok);animation:none}
@keyframes sync-pulse{
  0%,100%{transform:scale(1);opacity:1}
  50%{transform:scale(1.7);opacity:.45}
}

/* hover tooltip listing courses currently in flight — CSS-only visibility,
   JS only ever needs to keep its content in sync with the latest poll */
.sync-tooltip{
  position:absolute;bottom:calc(100% + .5rem);left:0;min-width:200px;max-width:260px;
  background:var(--surface);border:1px solid var(--wash-3);
  border-radius:var(--r-md);padding:var(--sp-2) var(--sp-3);box-shadow:var(--shadow);z-index:30;
  opacity:0;pointer-events:none;transform:translateY(4px);
  transition:.15s opacity,.15s transform;
}
.sync-status:not(.hidden):hover .sync-tooltip:not(.hidden){opacity:1;pointer-events:auto;transform:translateY(0)}
.sync-tooltip-title{
  font-weight:600;color:var(--muted);font-size:var(--fs-xs);text-transform:uppercase;
  letter-spacing:.04em;margin-bottom:var(--sp-1);
}
.sync-tooltip-course{
  padding:var(--sp-1) 0;font-size:var(--fs-sm);color:var(--text);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}

/* min-height:0 explicitly, matching .pane-scroll's own comment on the same
   rule elsewhere in this file: a flex item's automatic min-height is content-
   based unless this is set, letting it grow past its flex-basis instead of
   deferring to overflow-y. Belt-and-suspenders alongside .sidebar's fix above —
   this alone wasn't sufficient while .sidebar's own height was indefinite. */
.chat-list{flex:1;min-height:0;overflow-y:auto;margin-top:var(--sp-2);display:flex;flex-direction:column;gap:var(--sp-1)}
/* flex-shrink:0 is load-bearing, not tidiness. .chat-list is a flex column, so
   these rows are flex items that shrink by default — and because .chat-item sets
   overflow:hidden (for the ellipsis), its min-height:auto resolves to 0, letting
   them squeeze arbitrarily small instead of overflowing into a scroll. Once the
   list is taller than the sidebar (a phone's short viewport + ~20 chats), every
   row compressed to ~55% of its natural height and its own overflow:hidden
   clipped the text mid-glyph — the "cut/shrunk/smeared" rows reported on mobile.
   Desktop only escaped it by having the vertical room to not need shrinking. */
.chat-item{padding:var(--sp-2) var(--sp-3);border-radius:var(--r-md);cursor:pointer;font-size:var(--fs-sm);flex-shrink:0;
  color:var(--muted);border:1px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chat-item:hover{background:var(--wash-1)}
.chat-item.active{background:var(--surface-2);color:var(--text);border-color:var(--accent)}
/* The Telegram thread is one real chat, but it is not a conversation the
   student started here. Pinned styling (accent edge, icon, a tag) keeps it
   from reading as just another row someone titled "Telegram". */
.chat-item-telegram{
  display:flex;align-items:center;gap:var(--sp-2);
  color:var(--text);font-weight:600;
  background:color-mix(in srgb, var(--accent) 14%, transparent);
  border-color:color-mix(in srgb, var(--accent) 45%, transparent);
}
.chat-item-telegram .chat-item-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
.chat-item-telegram .chat-item-tag{
  flex-shrink:0;font-size:var(--fs-xs);font-weight:600;letter-spacing:.04em;
  text-transform:uppercase;color:var(--accent);
}
.chat-item-telegram svg{flex-shrink:0;color:var(--accent)}

/* sidebar footer — name + role more prominent */
.sidebar-foot{
  border-top:1px solid var(--wash-2);padding-top:var(--sp-3);
  display:flex;align-items:center;justify-content:space-between;gap:var(--sp-2);
}
.who{display:flex;flex-direction:column;gap:var(--sp-1);min-width:0}
.who-name{
  font-weight:600;font-size:var(--fs-md);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.badge{
  display:inline-block;font-size:var(--fs-xs);padding:var(--sp-1) var(--sp-2);border-radius:var(--r-pill);
  background:var(--accent-fill);color:#fff;
  letter-spacing:.03em;width:fit-content;
}

.content{padding:var(--sp-3) var(--sp-3) var(--sp-3) 0;height:100vh;height:100dvh;overflow:hidden}
.pane{height:100%;display:flex;flex-direction:column}
/* The scrolling body of a pane whose content runs long. Used by Files and
   Admin — it lives here next to .pane rather than under the files-tab heading
   it was first written for, so that tuning one doesn't silently retune the
   other. */
.pane-scroll{flex:1;min-height:0;overflow-y:auto;padding:var(--sp-2)}
.pane-title{margin:var(--sp-1) var(--sp-2) var(--sp-4)}

/* ---------- welcome splash ---------- */
#welcome-splash{
  position:fixed;inset:0;z-index:600;
  background:radial-gradient(ellipse at 50% 40%,var(--surface) 0%,var(--bg) 70%);
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--sp-4);
  animation:splashFade 2.8s ease forwards;
}
@keyframes splashFade{
  0%{opacity:0} 15%{opacity:1} 75%{opacity:1} 100%{opacity:0;pointer-events:none}
}
.splash-icon{
  color:var(--accent);animation:float 2s ease-in-out infinite;
  filter:drop-shadow(0 0 20px color-mix(in srgb, var(--accent) 45%, transparent));
}
.splash-name{
  font-size:var(--fs-2xl);font-weight:600;text-align:center;
}
.splash-role{
  font-size:var(--fs-md);padding:var(--sp-1) var(--sp-4);border-radius:var(--r-pill);
  background:var(--accent-fill);color:#fff;
}

/* ---------- chat ---------- */
.messages{
  flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;
  padding:var(--sp-2);display:flex;flex-direction:column;gap:var(--sp-3);
}
.jump-latest-anchor{position:relative;height:0}
.jump-latest{
  position:absolute;bottom:var(--sp-3);left:50%;transform:translateX(-50%);z-index:5;
  padding:var(--sp-1) var(--sp-3);border-radius:var(--r-pill);font:inherit;font-size:var(--fs-sm);
  color:var(--text);background:var(--surface);border:1px solid var(--border-str);
  box-shadow:var(--shadow);cursor:pointer;animation:fadeIn .15s ease;
}
.jump-latest:hover{background:var(--surface-2)}
.msg{max-width:76%;padding:var(--sp-3) var(--sp-4);border-radius:var(--r-lg);line-height:1.5;font-size:var(--fs-md);white-space:pre-wrap;word-wrap:break-word}
.msg.user{align-self:flex-end;background:var(--accent-fill);color:#fff;border-bottom-right-radius:var(--r-sm)}
.msg.assistant{align-self:flex-start;background:var(--surface);border:1px solid var(--wash-2);border-bottom-left-radius:var(--r-sm)}
.artifact-thumbs{display:flex;flex-wrap:wrap;gap:var(--sp-2);margin-top:var(--sp-2)}
.artifact-thumb{
  /* max-width/height + auto sizing (not a fixed box) so the image's own
     aspect ratio decides its footprint — object-fit:cover on a forced
     square used to crop tall PDF-page screenshots top/bottom, which was
     the real readability problem, not just the size. */
  max-width:220px;max-height:280px;width:auto;height:auto;
  object-fit:contain;border-radius:var(--r-md);background:#fff;
  border:1px solid var(--wash-3);cursor:pointer;
  transition:.15s transform,.15s box-shadow,.15s border-color;
}
.artifact-thumb:hover{transform:scale(1.04);box-shadow:var(--shadow);
  border-color:var(--accent)}
.msg code{
  background:rgba(0,0,0,.25);padding:var(--sp-1) var(--sp-2);border-radius:var(--r-sm);
  font-size:.85em;font-family:var(--font-mono);color:var(--text);
  /* Both are left-to-right content that routinely sits inside a Hebrew
     (RTL) paragraph. Without isolation the bidi algorithm reorders the run
     against the surrounding text — "f(x) = 0." renders as ".0 = f(x)", and
     a formula's operators land in an order that states something different
     from what was written. isolate is the standard fix and, unlike
     dir="ltr" alone, also stops the run from perturbing the Hebrew around it. */
  unicode-bidi:isolate;
}
/* The user bubble's ground is --accent-fill, which does not invert with the
   theme, so the chip inside it does not either. .msg code's color:var(--text)
   is near-white on dark (6.10:1 over the composited chip) but near-black on
   light (2.23:1) -- the one place where keeping the fills dark costs a rule.

   Scoped to `:not(pre) > code` so it reaches inline chips only. A bare
   `.msg.user code` also matches a FENCED block's code element, which does not
   sit on the bubble at all: enhanceCodeBlocks() wraps every <pre> in an opaque
   .code-block on --surface-2 (#eceff4 on light). White there is 1.15:1, and
   `.msg pre code:not(.hljs)` cannot save it because a highlighted element
   carries .hljs by definition -- leaving only the hljs stylesheet's own
   (0,1,0) base colour, which a bare (0,2,1) `.msg.user code` outranks.

   `:not(pre) > code` rather than `:not(pre code) code`: the child combinator
   says exactly what is meant -- a code element whose own parent is not a pre
   -- and needs only a type selector inside :not(), which is Selectors 3 and
   has no compatibility question anywhere in this project's declared range. */
.msg.user :not(pre) > code{color:#fff}
/* KaTeX output gets the same treatment: typeset maths is always LTR, and
   these spans are inline inside whatever paragraph the model wrote. */
.msg .katex, .msg .katex-display{direction:ltr;unicode-bidi:isolate}
/* code-block wrapper: built in JS (enhanceCodeBlocks) around each <pre> so
   it can carry a header row (language + copy) that plain markdown can't. */
.code-block{
  margin:var(--sp-2) 0;border-radius:var(--r-md);overflow:hidden;
  border:1px solid var(--wash-2);background:var(--surface-2);
}
.code-block-head{
  display:flex;align-items:center;justify-content:space-between;gap:var(--sp-2);
  padding:var(--sp-2) var(--sp-3);background:var(--wash-1);
  border-bottom:1px solid var(--wash-2);
}
.code-block-lang{
  font-family:var(--font-mono);font-size:var(--fs-xs);color:var(--muted);
  text-transform:uppercase;letter-spacing:.06em;
}
.code-copy-btn{
  display:inline-flex;align-items:center;gap:var(--sp-1);background:transparent;border:none;
  color:var(--muted);font-family:var(--font-mono);font-size:var(--fs-xs);cursor:pointer;
  padding:var(--sp-1) var(--sp-2);border-radius:var(--r-sm);transition:.12s background,.12s color;
}
.code-copy-btn:hover{color:var(--text);background:var(--wash-2)}
.code-copy-btn.copied{color:var(--ok)}
.msg pre{margin:0;padding:var(--sp-3) var(--sp-4);overflow-x:auto;background:transparent}
.msg pre code{
  background:transparent !important;padding:0;font-size:var(--fs-sm);font-family:var(--font-mono);
  line-height:1.55;
}
/* plain (non-highlighted) code — no .hljs class means no theme color, so
   fall back to something readable instead of default black-on-dark */
.msg pre code:not(.hljs){color:var(--text)}
.msg p{margin:var(--sp-1) 0}
.msg p:first-child{margin-top:0}
.msg p:last-child{margin-bottom:0}
.msg ul,.msg ol{margin:var(--sp-1) 0;padding-inline-start:var(--sp-5)}
.msg li{margin:var(--sp-1) 0}
.msg h1,.msg h2,.msg h3{margin:var(--sp-2) 0 var(--sp-1);font-size:var(--fs-md);font-weight:600;color:var(--text)}
.msg strong{font-weight:600;color:var(--text)}
.msg blockquote{border-inline-start:3px solid var(--accent);margin:var(--sp-2) 0;padding:var(--sp-1) var(--sp-3);color:var(--muted)}
/* var(--text) and var(--accent) are chosen for a dark ground -- 3.93:1 and
   1.50:1 on --accent-fill, both under AA. A user-sent message renders
   through the same marked.parse() as an assistant reply (see app.js:1354,
   app.js:1683, app.js:1880), so headings/emphasis/blockquotes can land
   inside a .msg.user bubble, not just .msg.assistant. Restore the bubble's
   own solid white (4.79:1 on --accent-fill) rather than a translucent one --
   white at 90% is 4.21:1 and at 85% is 3.92:1, both still under AA. */
.msg.user h1,.msg.user h2,.msg.user h3,.msg.user strong{color:#fff}
.msg.user blockquote{border-inline-start-color:#fff;color:#fff}
.msg table{border-collapse:collapse;width:100%;font-size:var(--fs-sm);margin:var(--sp-2) 0}
.msg th,.msg td{border:1px solid var(--wash-3);padding:var(--sp-1) var(--sp-2);text-align:start}
/* A translucent black tint (the same trick .msg code uses two rules up)
   self-adapts to whichever bubble sits behind it, instead of a fixed dark
   token that reads as a clean fill on --surface but as a hole punched
   through the --accent-fill user bubble. */
.msg th{background:rgba(0,0,0,.25);font-weight:600}
.msg .tools{margin-top:var(--sp-2);display:flex;flex-wrap:wrap;gap:var(--sp-1)}
.tool-chip{
  display:inline-flex;align-items:center;gap:var(--sp-1);
  padding:var(--sp-1) var(--sp-2);border-radius:var(--r-pill);
  background:var(--wash-1);border:1px solid var(--wash-2);
  color:var(--muted);font-size:var(--fs-xs);line-height:1.4;
}
.tool-chip svg{color:var(--ok);flex-shrink:0}   /* echoes the same check used mid-reply in .typing-log-line */

/* ---------- quiz mode ---------- */
.quiz-card{
  margin-top:var(--sp-3);padding:var(--sp-3) var(--sp-4);border-radius:var(--r-md);
  background:var(--surface-2);border:1px solid var(--wash-2);
}
.quiz-question{font-weight:600;margin-bottom:var(--sp-3)}
.quiz-choices{display:flex;flex-direction:column;gap:var(--sp-2)}
.quiz-choice-btn{text-align:start}
.quiz-choice-btn:disabled{cursor:default;opacity:.75}
.quiz-choice-correct{
  background:color-mix(in srgb, var(--ok) 16%, transparent)!important;border-color:var(--ok)!important;
  color:var(--text)!important;opacity:1!important;
}
.quiz-choice-wrong{
  background:color-mix(in srgb, var(--danger) 14%, transparent)!important;border-color:var(--danger)!important;
  color:var(--text)!important;opacity:1!important;
}
.quiz-explanation{margin-top:var(--sp-3);font-size:var(--fs-sm);color:var(--muted);line-height:1.5}
.quiz-verdict{font-weight:600;margin-inline-end:var(--sp-1)}
.quiz-verdict-correct{color:var(--ok)}
.quiz-verdict-wrong{color:var(--danger)}
.empty-hint{margin:auto;text-align:center;color:var(--muted);max-width:440px}
.empty-hint h3{color:var(--text)}
.hero-greet{font-size:var(--fs-lg);color:var(--muted);letter-spacing:.02em}
.hero-name{
  font-size:var(--fs-2xl);font-weight:600;line-height:1.15;margin:var(--sp-1) 0 var(--sp-2);
  animation:heroIn .7s ease both;
}
.hero-badge{
  display:inline-block;font-size:var(--fs-sm);padding:var(--sp-1) var(--sp-3);border-radius:var(--r-pill);
  background:var(--surface-2);border:1px solid var(--border);color:var(--muted);
  margin-bottom:var(--sp-4);
  animation:heroIn .7s .15s ease both;
}
@keyframes heroIn{
  from{opacity:0;transform:translateY(10px)}
  to{opacity:1;transform:translateY(0)}
}
.chip{display:inline-block;margin:var(--sp-1);padding:var(--sp-2) var(--sp-3);border-radius:var(--r-pill);
  background:var(--wash-2);border:1px solid var(--wash-3);cursor:pointer;font-size:var(--fs-sm);
  font-family:inherit;color:inherit}
.chip:hover{background:var(--surface-2)}

/* animated thinking indicator */
.typing{
  align-self:flex-start;display:flex;align-items:flex-end;gap:var(--sp-2);
  padding:var(--sp-2) var(--sp-4);font-size:var(--fs-sm);color:var(--muted);max-width:100%;
}
.typing-dots{display:flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0}
.typing-orb{
  width:10px;height:10px;border-radius:50%;
  animation:typingOrb 1.8s ease-in-out infinite;
}
@keyframes typingOrb{
  0%,100%{background:var(--accent);box-shadow:0 0 6px 1px color-mix(in srgb, var(--accent) 55%, transparent);transform:scale(.78)}
  33%{background:var(--accent-light);box-shadow:0 0 7px 2px color-mix(in srgb, var(--accent) 50%, transparent);transform:scale(1)}
  66%{background:var(--accent-fill);box-shadow:0 0 7px 2px color-mix(in srgb, var(--accent) 45%, transparent);transform:scale(.78)}
}
.msg .typing{align-self:auto;padding:var(--sp-1) 0 0;margin-top:var(--sp-1)}
.msg .msg-body:empty + .typing{padding-top:0}
.tail-text{white-space:pre-wrap}
.stable-md:empty,.tail-text:empty{display:none}
.typing-body{display:flex;flex-direction:column;gap:var(--sp-1);min-width:0}
.typing-log{display:flex;flex-direction:column;gap:var(--sp-1)}
.typing-log-line{
  display:flex;align-items:center;gap:var(--sp-1);font-size:var(--fs-xs);color:var(--muted);
  opacity:0;transform:translateY(4px);transition:.25s ease opacity,.25s ease transform;
  font-family:var(--font-mono);
}
.typing-log-line.in{opacity:.55;transform:translateY(0)}
.typing-log-line svg{color:var(--ok);flex-shrink:0}
.typing-log-line span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.typing-active{display:flex;align-items:center;gap:var(--sp-1);min-width:0}
.typing-text{
  transition:opacity .3s ease;font-family:var(--font-mono);font-size:var(--fs-sm);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.typing-cursor{
  color:var(--accent);animation:blink .8s step-end infinite;font-weight:400;
}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}

.composer{display:flex;align-items:flex-end;gap:var(--sp-2);padding:var(--sp-2);border-radius:var(--r-lg);margin-top:var(--sp-2);
  background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow)}
/* min-width:0 is load-bearing, not tidying. A flex item defaults to
   min-width:auto, and an <input>'s intrinsic width comes from its size
   attribute (~20 characters) — so flex:1 could grow this box but never shrink
   it below that. On a phone the input therefore held its ground and pushed the
   button off the right edge instead; the button only became visibly clipped
   once it swapped from "Send" to the wider "◼ Stop", which is why it looked
   like a Stop-button bug and never reproduced on a desktop's slack width.
   Still applies now that this is a <textarea> — same flex-shrink trap. */
.composer-input{flex:1;min-width:0;padding:var(--sp-3) var(--sp-4);border-radius:var(--r-md);background:var(--surface-2);
  border:1px solid var(--wash-3);color:var(--text);font-family:inherit;font-size:var(--fs-md);outline:none;
  resize:none;line-height:1.4;max-height:8.5rem;overflow-y:auto}
/* Never let the button itself absorb the squeeze: its label is two words at
   most, and shrinking it just clips the text. */
#send-btn{flex-shrink:0;white-space:nowrap}
.composer-input:focus{border-color:var(--accent)}
.dev-banner{margin-top:var(--sp-2);font-size:var(--fs-xs);color:var(--muted);text-align:center}

/* ---------- attachments (issue #3) ---------- */
#attach-btn{flex-shrink:0;padding:var(--sp-2);display:flex;align-items:center;justify-content:center}
#composer-attachment{display:flex;margin-top:var(--sp-2)}
.attachment-chip{display:inline-flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-3);
  border-radius:var(--r-md);background:var(--surface-2);border:1px solid var(--border);
  color:var(--text);font-size:var(--fs-sm);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.attachment-chip-remove{background:none;border:none;color:var(--muted);cursor:pointer;font-size:var(--fs-md);
  line-height:1;padding:0 0 0 var(--sp-1)}
.attachment-chip-remove:hover{color:var(--text)}

/* ---------- study mode ---------- */
.study-mode-wrap{position:relative;margin-top:var(--sp-3);display:flex;align-items:center}

/* ---------- context counter / compact ---------- */
.context-badge{
  display:inline-flex;align-items:center;gap:var(--sp-2);margin-inline-start:auto;
  padding:var(--sp-1) var(--sp-3);border-radius:var(--r-pill);font-size:var(--fs-xs);color:var(--muted);
  background:var(--wash-1);border:1px solid transparent;white-space:nowrap;
  cursor:default;transition:.15s background,.15s border-color,.15s color;
  font-family:inherit;line-height:inherit;
}
.context-badge.warn{color:var(--danger);border-color:color-mix(in srgb, var(--danger) 30%, transparent);background:color-mix(in srgb, var(--danger) 8%, transparent)}
.context-badge.critical{
  color:var(--danger);border-color:color-mix(in srgb, var(--danger) 55%, transparent);background:color-mix(in srgb, var(--danger) 14%, transparent);
  animation:context-critical-pulse 1.4s ease-in-out infinite;
}
.context-badge.clickable{cursor:pointer;border-color:var(--wash-3)}
.context-badge.clickable:hover{border-color:var(--accent);color:var(--text);background:var(--wash-2)}
.context-badge.warn.clickable:hover,.context-badge.critical.clickable:hover{border-color:var(--danger);color:var(--danger)}
.context-badge.compacting{pointer-events:none;color:var(--muted)}
.context-badge.done{
  color:var(--ok);border-color:color-mix(in srgb, var(--ok) 35%, transparent);background:color-mix(in srgb, var(--ok) 9%, transparent);
}
@keyframes context-critical-pulse{
  0%,100%{box-shadow:0 0 0 color-mix(in srgb, var(--danger) 0%, transparent)}
  50%{box-shadow:0 0 10px color-mix(in srgb, var(--danger) 50%, transparent)}
}
.context-cap{display:inline-flex;line-height:1}
.context-text{font-family:var(--font-mono);font-size:var(--fs-xs)}
.context-dot{
  width:6px;height:6px;border-radius:50%;background:var(--accent);flex-shrink:0;
  animation:sync-pulse 1.1s ease-in-out infinite;
}

.compact-divider{
  display:flex;align-items:center;gap:var(--sp-2);margin:var(--sp-4) var(--sp-1);
  color:var(--muted);font-size:var(--fs-xs);font-weight:600;
  text-transform:uppercase;letter-spacing:.05em;
}
.compact-divider::before,.compact-divider::after{content:"";flex:1;height:1px;background:var(--wash-2)}
.compact-divider span{display:inline-flex;align-items:center;gap:var(--sp-1);white-space:nowrap}

.study-mode-pill{
  display:inline-flex;align-items:center;gap:var(--sp-2);max-width:100%;
  background:var(--wash-1);border:1px solid var(--wash-3);
  border-radius:var(--r-pill);padding:var(--sp-2) var(--sp-3);cursor:pointer;font-family:inherit;
  font-size:var(--fs-sm);font-weight:600;color:var(--muted);
  transition:.15s background,.15s border-color,.15s color,.15s box-shadow;
}
.study-mode-pill:hover{border-color:var(--accent);color:var(--text)}
.study-mode-pill.open{border-color:var(--accent);color:var(--text)}
.study-mode-pill.active{
  background:var(--accent-fill);border-color:transparent;
  color:#fff;
}
.study-mode-cap{display:inline-flex;line-height:1;opacity:.75}
.study-mode-pill.active .study-mode-cap,.study-mode-pill:hover .study-mode-cap{opacity:1}
/* Two lines, not one ellipsized line: the degree in parentheses is the part
   that distinguishes lookalike course titles, so clipping the end hides it. */
#study-mode-text{
  max-width:min(70vw, 26rem);overflow:hidden;white-space:normal;line-height:1.3;
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;text-align:start;
}
.study-mode-clear{
  display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;
  border-radius:50%;background:rgba(255,255,255,.22);font-size:var(--fs-xs);
  margin-inline-start:var(--sp-1);flex-shrink:0;
}
.study-mode-clear:hover{background:rgba(255,255,255,.4)}

/* A column: the search box stays at the top and only the list scrolls. */
.study-course-panel{
  position:absolute;bottom:calc(100% + .55rem);left:0;width:min(340px,90vw);
  max-height:min(400px,55vh);display:flex;flex-direction:column;overflow:hidden;
  border-radius:var(--r-lg);padding:var(--sp-2);z-index:20;
  animation:study-panel-in .16s ease;
  background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);
}
@keyframes study-panel-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.study-course-group-label{
  margin:var(--sp-2) var(--sp-2) var(--sp-1);font-size:var(--fs-xs);font-weight:600;letter-spacing:.05em;
  text-transform:uppercase;color:var(--muted);
}
.study-course-group-label:first-child{margin-top:var(--sp-1)}
.study-course-row{
  display:flex;align-items:center;gap:var(--sp-2);width:100%;padding:var(--sp-2);border-radius:var(--r-md);
  background:transparent;border:none;color:var(--text);font-family:inherit;font-size:var(--fs-sm);
  text-align:start;cursor:pointer;transition:.12s background;
}
.study-course-row:hover{background:var(--wash-2)}
.study-course-row.selected{background:color-mix(in srgb, var(--accent) 18%, transparent)}
.study-course-dot{width:6px;height:6px;border-radius:50%;background:var(--ok);flex-shrink:0}
.study-course-row.inactive .study-course-dot{background:var(--muted);opacity:.55}
.study-course-search{
  flex-shrink:0;width:100%;margin:0 0 var(--sp-1);padding:var(--sp-2) var(--sp-3);
  border-radius:var(--r-md);border:1px solid var(--border);background:var(--surface-2);
  color:var(--text);font-family:inherit;font-size:var(--fs-sm);
}
.study-course-search::placeholder{color:var(--muted)}
.study-course-list{flex:1;min-height:0;overflow-y:auto}
.study-course-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:var(--sp-1)}
.study-course-name{white-space:normal;line-height:1.35}
/* The degree is context, not the thing being picked: smaller and muted so the
   course name is what the eye lands on. */
.study-course-degree{font-size:var(--fs-xs);color:var(--muted);line-height:1.3;white-space:normal}
.study-course-check{color:var(--accent);font-size:var(--fs-sm);flex-shrink:0}
.study-course-empty,.study-course-loading{padding:var(--sp-4) var(--sp-2);color:var(--muted);
  font-size:var(--fs-sm);text-align:center}

/* ---------- gallery ---------- */
/* No overflow/flex/min-height here: this used to be its own pane's scroll
   container, but it now sits inside .pane-scroll, which already scrolls.
   Keeping them gave a scrollbox inside a scrollbox and a grid that wouldn't
   grow with its content. */
.gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:var(--sp-4);padding:var(--sp-2)}
.gallery-item{border-radius:var(--r-lg);overflow:hidden;background:var(--surface);border:1px solid var(--wash-2);position:relative}
.gallery-item img{width:100%;display:block}
.gallery-meta{padding:var(--sp-2) var(--sp-3);display:flex;justify-content:space-between;align-items:center;gap:var(--sp-2)}
.gallery-meta .t{font-size:var(--fs-sm);font-weight:600}
.icon-btn{
  display:inline-flex;align-items:center;justify-content:center;
  background:transparent;border:none;color:var(--muted);cursor:pointer;
}
.icon-btn:hover{color:var(--danger)}

/* ---------- files tab ---------- */
.pane-subtitle{margin:var(--sp-4) var(--sp-2) var(--sp-2);font-size:var(--fs-sm);color:var(--muted);
  font-weight:600;letter-spacing:.02em;text-transform:uppercase}
.pane-subtitle:first-of-type{margin-top:var(--sp-1)}
.pane-subtitle-row{display:flex;align-items:baseline;justify-content:space-between;gap:var(--sp-2)}
.pane-subtitle-row .pane-subtitle{margin-bottom:var(--sp-2);flex:1}
.pane-subtitle-action{
  background:transparent;border:none;cursor:pointer;font-family:inherit;
  font-size:var(--fs-xs);font-weight:600;color:var(--accent);padding:var(--sp-1) var(--sp-2);
  white-space:nowrap;
}
.pane-subtitle-action:hover{text-decoration:underline}
.material-course{margin:var(--sp-2) 0}
.material-course:first-child{margin-top:0}
.material-course-heading{
  display:flex;align-items:center;gap:var(--sp-2);width:100%;
  background:transparent;border:none;cursor:pointer;padding:var(--sp-2);
  /* start, not left: the course name inside gets an explicit dir from
     resolveDir(), and a hardcoded left would override it for Hebrew names. */
  font-family:inherit;font-size:var(--fs-sm);font-weight:600;color:var(--text);text-align:start;
}
.material-chevron{color:var(--muted);transition:transform .18s ease;font-size:var(--fs-xs)}
.material-course.collapsed .material-chevron{transform:rotate(-90deg)}
.material-course-name{flex:1}
.material-course-count{
  font-size:var(--fs-xs);background:var(--wash-2);border-radius:var(--r-pill);
  padding:var(--sp-1) var(--sp-2);
}
.material-course-body{
  display:flex;flex-direction:column;gap:var(--sp-2);padding:var(--sp-1) var(--sp-2) 0;
  overflow:hidden;
}
.material-course.collapsed .material-course-body{display:none}
/* ---------- schedules (#5) ---------- */
.server-clock{margin:0 var(--sp-2) var(--sp-3);font-size:var(--fs-sm)}
.link-btn{background:none;border:none;padding:0;color:var(--accent);text-decoration:underline;
  font:inherit;cursor:pointer}
.schedule-form{background:var(--surface);border:1px solid var(--wash-2);border-radius:var(--r-md);
  padding:var(--sp-4);margin:var(--sp-2)}
.schedule-form textarea.input{resize:vertical;min-height:3.5rem}
.schedule-form-row{display:flex;gap:var(--sp-3);flex-wrap:wrap;margin-top:var(--sp-3)}
.schedule-form-row > div{flex:1;min-width:9rem}
.schedule-form-actions{display:flex;gap:var(--sp-3);margin-top:var(--sp-3)}
.schedules-list{display:flex;flex-direction:column;gap:var(--sp-2);padding:var(--sp-2)}
.schedule-row{display:flex;align-items:center;justify-content:space-between;gap:var(--sp-3);
  padding:var(--sp-3) var(--sp-4);border-radius:var(--r-md);background:var(--surface);
  border:1px solid var(--wash-2)}
.schedule-row-main{min-width:0;flex:1}
.schedule-row-prompt{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.schedule-row-meta{font-size:var(--fs-xs);margin-top:var(--sp-1)}
.schedule-row-actions{display:flex;gap:var(--sp-2);flex-shrink:0}
.schedule-row-actions .btn{padding:var(--sp-1) var(--sp-3);font-size:var(--fs-sm);white-space:nowrap}

/* ---------- admin: users (merged with the former allowed-emails page) ---------- */
/* A sort control small enough to ride on a section heading: link-coloured
   text, no box of its own, with the same chevron the boxed selects get so
   it still reads as a menu rather than a label. text-transform is reset
   because .pane-subtitle upper-cases its own text and the option labels
   are sentence case. */
.subtitle-select{
  appearance:none;-webkit-appearance:none;-moz-appearance:none;
  background:transparent;border:none;padding:0 var(--sp-4) 0 0;margin-inline-start:var(--sp-2);
  color:var(--accent);font-family:inherit;font-size:var(--fs-xs);font-weight:600;
  text-transform:none;letter-spacing:normal;cursor:pointer;
  background-repeat:no-repeat;background-position:right center;background-size:.7rem;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
}
.subtitle-select:hover{text-decoration:underline}
.subtitle-select::-ms-expand{display:none}
#pane-users .pane-subtitle-row{align-items:center;margin-top:var(--sp-3)}
#users-removed-section,#users-invited-section,#users-requests-section{margin-top:var(--sp-4)}
.admin-roster-restore{
  flex-shrink:0;background:transparent;border:1px solid var(--wash-3);cursor:pointer;
  padding:var(--sp-1) var(--sp-2);color:var(--muted);border-radius:var(--r-sm);
  font-family:inherit;font-size:var(--fs-xs);
}
.admin-roster-restore:hover{color:var(--text);border-color:var(--accent)}
.admin-roster-row.removed .admin-presence{background:var(--muted);box-shadow:none;opacity:.5}
.admin-roster-row.removed .user-row-name,
.admin-roster-row.removed .admin-roster-email,
.admin-roster-row.removed .user-row-meta{opacity:.55}

/* ---------- assignments (restored) ---------- */
.pending-status{
  display:flex;align-items:center;gap:var(--sp-2);width:100%;margin-top:var(--sp-2);
  padding:var(--sp-2) var(--sp-3);border-radius:var(--r-md);border:1px solid var(--wash-3);
  background:color-mix(in srgb, var(--danger) 12%, var(--surface));color:var(--text);
  font-size:var(--fs-sm);cursor:pointer;text-align:start;
}
.pending-status:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.pending-status.quiet{background:var(--surface-2);border-color:var(--wash-3);color:var(--muted)}
.pending-dot{width:8px;height:8px;border-radius:50%;background:var(--danger);flex-shrink:0}
.pending-status.quiet .pending-dot{background:var(--muted)}

.assignments-list{display:flex;flex-direction:column;gap:var(--sp-1);padding:var(--sp-2)}
.pending-item{
  display:flex;align-items:center;gap:var(--sp-2);border-radius:var(--r-md);
  transition:var(--dur-fast) background;
}
.pending-item:hover,.pending-item:focus-within{background:var(--wash-2)}
.pending-item-info{
  flex:1;min-width:0;display:flex;flex-direction:column;align-items:flex-start;
  gap:var(--sp-1);background:none;border:none;padding:var(--sp-2) var(--sp-3);cursor:pointer;
  font-family:inherit;text-align:start;
}
.pending-item-info:focus-visible{outline:2px solid var(--accent);outline-offset:-2px;border-radius:var(--r-md)}
.pending-item-name{font-size:var(--fs-sm);color:var(--text);font-weight:600}
.pending-item-meta{font-size:var(--fs-xs);color:var(--muted)}
.pending-item-meta.overdue{color:var(--danger)}
.pending-item.inactive{opacity:.55}
.pending-item.inactive .pending-item-name{color:var(--muted);font-weight:400}
.pending-item.inactive:hover{opacity:.8}
.pending-item.submitted .pending-item-name{color:var(--muted)}
.pending-item-check{color:var(--ok);flex-shrink:0;padding-inline-end:var(--sp-3)}
.pending-group-label{
  margin:var(--sp-3) var(--sp-3) var(--sp-1);font-size:var(--fs-xs);font-weight:600;
  letter-spacing:.04em;text-transform:uppercase;color:var(--muted);
}
.pending-item-action{
  flex-shrink:0;margin-inline-end:var(--sp-2);opacity:0;background:none;border:none;
  color:var(--muted);padding:var(--sp-1);border-radius:var(--r-md);cursor:pointer;
  display:flex;transition:var(--dur-fast) opacity,var(--dur-fast) background,var(--dur-fast) color;
}
.pending-item:hover .pending-item-action{opacity:.8}
.pending-item-action:hover{opacity:1;background:var(--wash-2);color:var(--text)}
.pending-item-action:focus-visible{opacity:1;outline:2px solid var(--accent);outline-offset:1px}
.pending-item-action:disabled{opacity:.25;cursor:default}

.assignment-course{margin:0 var(--sp-2) var(--sp-4);font-size:var(--fs-sm)}
.assignment-status-card{
  display:flex;flex-wrap:wrap;align-items:flex-start;gap:var(--sp-4);
  background:var(--surface);border:1px solid var(--wash-2);border-radius:var(--r-lg);
  padding:var(--sp-4);margin:0 var(--sp-2) var(--sp-4);
}
.assignment-due-block{min-width:110px}
.assignment-due-label{font-size:var(--fs-xs);font-weight:600;letter-spacing:.04em;
  text-transform:uppercase;color:var(--muted)}
.assignment-due-value{font-size:var(--fs-xl);font-weight:600;color:var(--text);margin-top:var(--sp-1)}
.assignment-due-value.overdue{color:var(--danger)}
.assignment-due-value.closed{color:var(--muted)}
.assignment-due-sub{font-size:var(--fs-sm);margin-top:var(--sp-1)}
.assignment-pills{display:flex;flex-direction:column;gap:var(--sp-2);flex:1;min-width:140px}
.assignment-pill{
  display:inline-flex;align-items:center;gap:var(--sp-2);width:fit-content;
  padding:var(--sp-1) var(--sp-3);border-radius:var(--r-pill);font-size:var(--fs-sm);
}
.assignment-pill::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
.assignment-pill.ok{color:var(--ok);background:color-mix(in srgb, var(--ok) 15%, transparent)}
.assignment-pill.danger{color:var(--danger);background:color-mix(in srgb, var(--danger) 15%, transparent)}
.assignment-pill.muted{color:var(--muted);background:var(--wash-2)}
.assignment-moodle-link{
  display:inline-flex;align-items:center;gap:var(--sp-2);color:var(--muted);
  border:1px solid var(--wash-3);border-radius:var(--r-md);padding:var(--sp-1) var(--sp-3);
  font-size:var(--fs-sm);text-decoration:none;transition:var(--dur-fast) color,var(--dur-fast) border-color;
}
.assignment-moodle-link:hover{color:var(--text);border-color:var(--accent)}
.assignment-intro{margin:0 var(--sp-2) var(--sp-4);white-space:pre-wrap;line-height:1.5}
.assignment-intro.empty{color:var(--muted);font-style:italic}
.assignment-ask-btn{margin:var(--sp-5) var(--sp-2) 0}

#assignment-submit-section{margin:0 var(--sp-2) var(--sp-4);display:flex;flex-direction:column;gap:var(--sp-2)}
#assignment-submit-file{display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-2) var(--sp-3);
  background:var(--surface-2);border:1px solid var(--wash-2);border-radius:var(--r-md)}
.assignment-submit-final{display:flex;align-items:center;gap:var(--sp-2);font-size:var(--fs-sm);
  color:var(--muted);cursor:pointer}

.files-list{display:flex;flex-direction:column;gap:var(--sp-2);padding:var(--sp-2)}
.empty-files{margin:auto;text-align:center;max-width:380px;padding:var(--sp-6) var(--sp-4);
  display:flex;flex-direction:column;align-items:center;gap:var(--sp-4)}
.empty-files-icon{color:var(--muted);opacity:.6}
.empty-files .btn{margin-top:var(--sp-2)}
.file-row{
  display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) var(--sp-4);
  border-radius:var(--r-md);background:var(--surface);
  border:1px solid var(--wash-2);color:var(--text);
  text-decoration:none;transition:.15s background,.15s border-color;
  -webkit-user-select:none;user-select:none;position:relative;overflow:hidden;
}
.file-row:hover{background:var(--surface-2);border-color:var(--accent)}
.file-icon{
  display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;
  width:30px;height:30px;border-radius:var(--r-md);background:var(--wash-2);
}
.file-icon.ext-pdf{color:#ff8a8a;background:color-mix(in srgb, var(--danger) 12%, transparent)}
.file-icon.ext-doc{color:var(--accent);background:color-mix(in srgb, var(--accent) 14%, transparent)}
.file-icon.ext-sheet{color:#6fe0a8;background:color-mix(in srgb, var(--ok) 14%, transparent)}
.file-icon.ext-slide{color:#f0b84c;background:rgba(240,184,76,.14)}
.file-icon.ext-image{color:#ec7fd6;background:rgba(236,127,214,.14)}
.file-icon.ext-video{color:#ff7a5c;background:rgba(255,122,92,.14)}
.file-icon.ext-archive{color:#4dd0c4;background:rgba(77,208,196,.14)}
.file-icon.ext-code{color:#7dd3fc;background:rgba(125,211,252,.14)}
.file-icon.ext-generic{color:var(--muted)}
.file-name{flex:1;font-size:var(--fs-sm);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-chip{
  display:inline-flex;align-items:center;gap:var(--sp-2);margin-top:var(--sp-2);
  padding:var(--sp-2) var(--sp-3);border-radius:var(--r-md);background:var(--wash-2);
  border:1px solid var(--accent);color:var(--text);text-decoration:none;
  font-size:var(--fs-sm);max-width:100%;overflow:hidden;text-overflow:ellipsis;
}
.file-chip:hover{background:var(--surface-2)}


/* ---------- admin tab ---------- */
.admin-stats-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));
  gap:var(--sp-3);max-width:760px;margin-bottom:var(--sp-4)}
.admin-stat-tile{background:var(--surface);border:1px solid var(--wash-2);
  border-radius:var(--r-md);padding:var(--sp-3) var(--sp-4)}
/* A tile can render as a <button> (renderStatTiles, when given an onClick) —
   reset the UA button defaults (centered text, system font) so it is pixel-
   identical to the plain <div> tiles beside it; only the cursor gives away
   that it does something. */
button.admin-stat-tile{display:block;width:100%;text-align:start;font:inherit;cursor:pointer}
.admin-stat-val{font-size:var(--fs-xl);font-weight:600;color:var(--text);font-family:var(--font-mono)}
/* Row wrapper so the badge sits inline beside the value as a DOM sibling —
   not nested inside .admin-stat-val, which would corrupt its accessible name
   and its textContent (see renderStatTiles). */
.admin-stat-val-row{display:flex;align-items:center}
.admin-stat-label{font-size:var(--fs-xs);color:var(--muted);margin-top:var(--sp-1)}
/* The pending-request count rides on the Users tile rather than taking a
   tile of its own: a tile that appears and disappears reflows a grid read
   for steady numbers, and an always-present one spends a permanent slot on
   what is almost always a zero. #fff on --accent-fill is 4.79:1 — that pair
   is what --accent-fill exists for. */
.admin-stat-badge{
  display:inline-block;margin-inline-start:var(--sp-2);
  background:var(--accent-fill);color:#fff;border-radius:var(--r-pill);
  padding:0 var(--sp-2);font-size:var(--fs-xs);font-weight:600;line-height:1.6;
}
.admin-panel{padding:var(--sp-5);border-radius:var(--r-lg);max-width:520px;
  background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow)}
.admin-panel .btn{margin-top:var(--sp-4)}
.admin-progress-bar{height:8px;border-radius:var(--r-sm);background:var(--wash-2);
  overflow:hidden;margin-top:var(--sp-4)}
.admin-progress-fill{height:100%;background:var(--accent);
  transition:width .3s ease}
#admin-status{margin-top:var(--sp-2);font-size:var(--fs-sm)}
.admin-results{max-height:260px;overflow-y:auto;margin-top:var(--sp-2);display:flex;
  flex-direction:column;gap:var(--sp-1)}
.admin-row{padding:var(--sp-2);border-radius:var(--r-md);background:var(--surface);
  font-size:var(--fs-sm)}
.admin-row.err{color:var(--danger)}

/* ---------- inline delete overlay ---------- */
.del-overlay{
  position:absolute;inset:0;z-index:10;
  background:var(--scrim);backdrop-filter:blur(6px);
  border-radius:inherit;
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--sp-3);
  animation:fadeIn .15s ease;
}
.del-label{color:#fff;font-weight:600;font-size:var(--fs-sm);letter-spacing:.01em}
.del-actions{display:flex;gap:var(--sp-2)}
.del-cancel{
  border:1px solid var(--wash-4);border-radius:var(--r-md);padding:var(--sp-2) var(--sp-3);
  background:var(--wash-2);color:var(--text);
  font-family:inherit;font-weight:600;font-size:var(--fs-sm);cursor:pointer;
  transition:.12s background;
}
.del-cancel:hover{background:var(--wash-3)}
.del-ok{
  border:none;border-radius:var(--r-md);padding:var(--sp-2) var(--sp-3);
  background:var(--danger-fill);color:#fff;
  font-family:inherit;font-weight:600;font-size:var(--fs-sm);cursor:pointer;
  transition:.12s filter;
}
.del-ok:hover{filter:brightness(1.1)}
.del-ok:disabled{opacity:.6;cursor:default}
/* file rows are one line tall — lay the overlay out horizontally so it fits */
.file-row .del-overlay{flex-direction:row;gap:var(--sp-3);padding:0 var(--sp-4)}
.file-row .del-label{font-size:var(--fs-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file-row .del-cancel,.file-row .del-ok{padding:var(--sp-1) var(--sp-3);font-size:var(--fs-xs)}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}

/* ---------- profile modal ---------- */
#profile-modal{
  position:fixed;inset:0;z-index:650;background:var(--scrim);
  display:flex;align-items:center;justify-content:center;padding:var(--sp-4);
  backdrop-filter:blur(8px);animation:fadeIn .18s ease;
  /* The card is taller than a phone once Moodle and Telegram are both shown.
     Without a scrollport the connect button sits past the bottom edge, and the
     tour step that points at it would highlight empty space. */
  overflow-y:auto;
}
.profile-card{
  width:min(400px,100%);padding:var(--sp-6) var(--sp-6) var(--sp-5);border-radius:var(--r-lg);
  display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);
  position:relative;animation:profileIn .28s cubic-bezier(.2,.9,.3,1.2) both;
  background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);
}
@keyframes profileIn{
  from{opacity:0;transform:translateY(18px) scale(.96)}
  to{opacity:1;transform:translateY(0) scale(1)}
}
.modal-x{
  position:absolute;top:12px;right:12px;
  width:34px;height:34px;border-radius:50%;border:1px solid var(--wash-3);
  background:var(--surface-2);color:var(--muted);font-size:var(--fs-md);cursor:pointer;
  display:flex;align-items:center;justify-content:center;
}
.modal-x:hover{color:#fff;background:var(--accent-fill)}
.profile-avatar{
  width:84px;height:84px;border-radius:50%;
  display:flex;align-items:center;justify-content:center;
  font-size:var(--fs-2xl);font-weight:600;color:#fff;letter-spacing:.02em;
  background:var(--accent-fill);
  margin-bottom:var(--sp-1);
}
.profile-name{
  font-size:var(--fs-xl);font-weight:600;text-align:center;
}
.profile-email{font-size:var(--fs-sm);color:var(--muted)}
.profile-stats{
  display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-2);width:100%;margin-top:var(--sp-4);
}
.stat{
  background:var(--surface-2);border:1px solid var(--wash-2);
  border-radius:var(--r-lg);padding:var(--sp-3) var(--sp-2);text-align:center;
  transition:.15s border-color;
}
.stat:hover{border-color:var(--accent)}
.stat-num{
  font-size:var(--fs-xl);font-weight:600;font-family:var(--font-mono);
}
.stat-label{font-size:var(--fs-xs);color:var(--muted);margin-top:var(--sp-1);letter-spacing:.03em}
.profile-since{font-size:var(--fs-xs);color:var(--muted);margin-top:var(--sp-3)}
.theme-toggle{
  display:flex;width:100%;margin-top:var(--sp-4);
  border:1px solid var(--border);border-radius:var(--r-md);overflow:hidden;
}
.theme-choice{
  flex:1;padding:var(--sp-2) var(--sp-3);border:none;background:transparent;
  color:var(--muted);font-family:inherit;font-weight:600;font-size:var(--fs-sm);
  cursor:pointer;transition:var(--dur-fast) background,var(--dur-fast) color;
}
.theme-choice:hover{background:var(--wash-2);color:var(--text)}
.theme-choice[aria-pressed="true"]{background:var(--accent-fill);color:#fff}
.btn-danger{
  background:var(--danger-fill);color:#fff;
  box-shadow:var(--shadow);
}
.who{cursor:pointer;border-radius:var(--r-md);padding:var(--sp-1);margin:calc(var(--sp-1)*-1) calc(var(--sp-1)*-1);
  transition:.15s background}
.who:hover{background:var(--surface-2)}

/* queued (not yet sent) user messages */
.msg.queued{opacity:.5}
.msg.queued::after{
  content:"queued";display:block;font-size:var(--fs-xs);text-align:right;
  margin-top:var(--sp-1);opacity:.85;letter-spacing:.08em;text-transform:uppercase;
}

/* ---------- lightbox ---------- */
#lightbox{
  position:fixed;inset:0;z-index:700;background:var(--scrim);
  display:flex;align-items:center;justify-content:center;padding:var(--sp-4);
  cursor:zoom-out;backdrop-filter:blur(6px);
}
#lightbox img{
  max-width:100%;max-height:100%;border-radius:var(--r-md);
  box-shadow:var(--shadow);
}
#lightbox-close{
  position:fixed;top:16px;right:16px;z-index:701;
  width:42px;height:42px;border-radius:50%;border:1px solid var(--wash-4);
  background:var(--scrim);color:#fff;font-size:var(--fs-lg);cursor:pointer;
  display:flex;align-items:center;justify-content:center;
}
#lightbox-close:hover{background:var(--accent-fill)}

/* keep gallery/file items from selecting text on long-press */
.gallery-item{-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}
.gallery-item img{cursor:zoom-in}

::-webkit-scrollbar{width:9px}
::-webkit-scrollbar-thumb{background:color-mix(in srgb, var(--accent) 35%, transparent);border-radius:var(--r-md)}

/* ---------- first-run guide ---------- */
/* Above the profile modal and the lightbox (a replay starts from the
   profile), below toasts. --scrim-soft rather than --scrim: the page behind
   is the thing being taught, so it has to stay readable, only quieter. */
#guide{position:fixed;inset:0;z-index:800}
.guide-scrim{position:absolute;inset:0;width:100%;height:100%}
.guide-scrim-fill{fill:var(--scrim-soft)}
.guide-ring{
  position:fixed;pointer-events:none;border-radius:var(--r-md);
  box-shadow:0 0 0 2px var(--accent);
}
.guide-card{
  position:fixed;width:min(340px,calc(100vw - 32px));padding:var(--sp-4) var(--sp-5);
  background:var(--surface);border:1px solid var(--border-str);border-radius:var(--r-lg);
  box-shadow:var(--shadow);animation:fadeIn .18s ease;
}
.guide-count{font-size:var(--fs-xs);color:var(--muted);letter-spacing:.02em}
.guide-title{font-size:var(--fs-lg);margin:var(--sp-1) 0 var(--sp-2)}
.guide-body{font-size:var(--fs-sm);color:var(--text);line-height:1.55;margin:0}
.guide-actions{display:flex;align-items:center;gap:var(--sp-2);margin-top:var(--sp-4)}
.guide-actions-gap{flex:1}
.link-admin-note{
  font-size:var(--fs-sm);color:var(--muted);line-height:1.5;text-align:center;
  margin:calc(var(--sp-2)*-1) 0 var(--sp-4);
}

/* ---------- moodle notice ---------- */
/* Fixed and out of flow on purpose (see index.html's comment) — top-right on
   desktop so it never overlaps #menu-toggle, which sits top-left; on mobile
   it becomes a full-width bar below that button instead of beside it. Below
   the profile modal/guide/lightbox (z-index 650/800/700), above the sidebar
   overlay and menu toggle (99/200). */
#moodle-notice{
  position:fixed;top:16px;right:16px;z-index:500;
  max-width:min(380px,calc(100vw - 32px));
  display:flex;flex-direction:column;gap:var(--sp-3);
  padding:var(--sp-3) var(--sp-4);border-radius:var(--r-md);
  background:var(--surface);border:1px solid var(--danger);box-shadow:var(--shadow);
  animation:fadeIn .18s ease;
}
#moodle-notice-text{font-size:var(--fs-sm);color:var(--text);line-height:1.5}
.moodle-notice-actions{display:flex;gap:var(--sp-2);justify-content:flex-end}
@media(max-width:660px){
  #moodle-notice{left:16px;right:16px;top:64px;max-width:none}
}

/* ---------- mobile ---------- */
#menu-toggle{
  display:none;align-items:center;justify-content:center;position:fixed;top:14px;left:14px;z-index:200;
  background:var(--surface);border:1px solid var(--wash-3);border-radius:var(--r-md);
  color:var(--text);padding:var(--sp-2);cursor:pointer;
  box-shadow:var(--shadow);
}
#sidebar-overlay{
  display:none;position:fixed;inset:0;background:var(--scrim-soft);z-index:99;
}
@media(max-width:660px){
  #view-main{grid-template-columns:1fr}
  .sidebar{
    position:fixed;top:0;left:0;width:78%;max-width:300px;height:100vh;height:100dvh;
    margin:0;border-radius:0 var(--r-lg) var(--r-lg) 0;
    transform:translateX(-100%);transition:transform .25s ease;z-index:100;
    /* #menu-toggle sits fixed at top:14px/left:14px above everything (z-index:200) —
       also its close button while the sidebar is open, so it can't just be hidden.
       Push the sidebar's own content down past it instead of letting .brand land
       directly underneath at the same corner. */
    padding-top:64px;
  }
  #view-main.sidebar-open .sidebar{transform:translateX(0)}
  #sidebar-overlay.visible{display:block}
  .content{padding:var(--sp-3);padding-top:58px}
  #menu-toggle{display:flex}
  .msg{max-width:92%}
  .artifact-thumb{max-width:130px;max-height:170px}
}

/* Telegram connect row in the profile modal. Mirrors .profile-since spacing so
   it reads as part of the same stack rather than a bolted-on panel. */
#telegram-row { width: 100%; margin: var(--sp-3) 0 var(--sp-3); }
.telegram-state { font-size:var(--fs-xs); color: var(--muted); margin-bottom: var(--sp-2); text-align: center; }

/* The QR panel (issue #45). The symbol itself is black on white, decided in
   backend/app/telegram.py:link_qr and not here: a camera reads it, not a
   person, so it cannot follow the theme the way every other surface does.
   That is why this one plate opts out of the surface tokens — `#fff` is the
   same "opaque backing behind an image" exemption the artifact thumbnails
   take (tests/test_color_exemptions.py). The padding is the scanner's quiet
   zone in CSS: segno already draws four modules of it, and this keeps the
   white from ending exactly at the rounded corner. */
#telegram-qr {
  display:flex; flex-direction:column; align-items:center;
  gap:var(--sp-2); margin-top:var(--sp-3);
}
#telegram-qr-img {
  width:180px; height:180px; padding:var(--sp-2);
  background:#fff; border-radius:var(--r-md);
}
.telegram-qr-hint { font-size:var(--fs-xs); color:var(--text); text-align:center; }
/* tabular-nums so the countdown doesn't jitter its own width every second. */
.telegram-qr-note {
  font-size:var(--fs-xs); color:var(--muted);
  font-variant-numeric:tabular-nums;
}

/* ---------- admin: course access ----------
   The one thing this view knows that nothing else in the app does is whether a
   granted address has ever actually signed in — an outstanding invitation vs a
   real student — so that distinction, not the styling, is what carries here.
   Courses themselves render as cards (.admin-course-grid, further down),
   opening a dedicated detail page rather than expanding in place. */
.admin-access-intro{margin:0 var(--sp-2) var(--sp-3);max-width:62ch}
.admin-roster{display:flex;flex-direction:column;gap:var(--sp-1)}
.admin-roster-row{
  display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-2);border-radius:var(--r-sm);
}
.admin-roster-row:hover{background:var(--wash-1)}
/* Solid dot = this address has signed in. Hollow ring = granted, never seen —
   the state an admin needs after pasting a roster, and the reason this view
   exists rather than a flat list of addresses. */
.admin-presence{
  width:7px;height:7px;border-radius:50%;flex-shrink:0;
  background:var(--ok);box-shadow:0 0 5px 1px color-mix(in srgb, var(--ok) 45%, transparent);
}
.admin-roster-row.invited .admin-presence{
  background:transparent;border:1.5px solid var(--muted);box-shadow:none;
}
/* A refused address has, by definition, never signed in — the bare dot above
   (solid, glowing) claims the opposite. Same muted/no-glow de-emphasis
   .removed already uses for its dot, reused here for a different reason:
   .removed's is "used to count, doesn't anymore"; this one is "never did". */
.admin-roster-row.request .admin-presence{background:var(--muted);box-shadow:none;opacity:.5}
.admin-roster-email{
  flex:1;font-family:var(--font-mono);font-size:var(--fs-xs);color:var(--text);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:ltr;
}
.admin-roster-row.invited .admin-roster-email{color:var(--muted)}
.admin-roster-tag{
  flex-shrink:0;font-size:var(--fs-xs);letter-spacing:.05em;text-transform:uppercase;
  color:var(--muted);
}
.admin-roster-revoke{
  flex-shrink:0;background:transparent;border:none;cursor:pointer;padding:var(--sp-1);
  color:var(--muted);opacity:0;border-radius:var(--r-sm);line-height:1;
}
.admin-roster-row:hover .admin-roster-revoke{opacity:.75}
.admin-roster-revoke:hover{opacity:1;color:var(--danger);background:color-mix(in srgb, var(--danger) 12%, transparent)}
.admin-roster-revoke:focus-visible{opacity:1;outline:2px solid var(--accent);outline-offset:1px}
.admin-roster-empty{padding:var(--sp-2);color:var(--muted);font-size:var(--fs-sm)}
/* A roster is pasted, not typed one address at a time, so the field is a
   textarea and the button says how many it found. */
.admin-grant{display:flex;flex-direction:column;gap:var(--sp-2)}
.admin-grant textarea{
  width:100%;min-height:64px;resize:vertical;padding:var(--sp-2) var(--sp-3);border-radius:var(--r-md);
  background:var(--surface-2);border:1px solid var(--wash-3);color:var(--text);
  font-family:var(--font-mono);font-size:var(--fs-xs);outline:none;direction:ltr;
}
.admin-grant textarea:focus{border-color:var(--accent)}
.admin-grant-row{display:flex;gap:var(--sp-2);align-items:center}
.admin-grant-row .btn{flex-shrink:0}
.admin-grant-note{font-size:var(--fs-xs);color:var(--muted);flex:1}
.admin-grant-note.error{color:var(--danger)}
.admin-sync-heading{margin-top:var(--sp-2)}

/* ---------- admin: course cards + course-detail page ----------
   Courses are clickable rectangles now, not an expanding list — opening one
   goes to its own page (files, id, roster), the same way an assignment used
   to open its own detail page. */
.admin-course-grid{
  display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));
  gap:var(--sp-3);margin-bottom:var(--sp-5);
}
.admin-course-card{
  display:flex;flex-direction:column;gap:var(--sp-2);align-items:flex-start;
  padding:var(--sp-4);border-radius:var(--r-md);text-align:start;
  background:var(--wash-1);border:1px solid var(--wash-2);
  color:var(--text);font-family:inherit;cursor:pointer;
  transition:border-color .15s ease,background .15s ease,transform .1s ease;
}
.admin-course-card:hover{background:var(--wash-2);border-color:color-mix(in srgb, var(--accent) 35%, transparent)}
.admin-course-card:active{transform:scale(.99)}
.admin-course-card:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.admin-course-card-name{
  font-weight:600;font-size:var(--fs-sm);line-height:1.3;
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
}
.admin-course-card-count{font-family:var(--font-mono);font-size:var(--fs-xs);color:var(--muted)}
.admin-course-card-count .pending{color:var(--accent)}

.course-detail-back{
  display:inline-flex;align-items:center;gap:var(--sp-1);background:transparent;border:none;
  color:var(--muted);font-family:inherit;font-size:var(--fs-sm);cursor:pointer;
  padding:var(--sp-1);margin-bottom:var(--sp-3);
}
.course-detail-back:hover{color:var(--text)}
.course-detail-back:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.course-detail-name{margin-top:0;margin-bottom:var(--sp-1)}
.course-detail-meta{margin:0 var(--sp-2) var(--sp-4);font-family:var(--font-mono);font-size:var(--fs-xs)}
.course-add-heading{margin-top:var(--sp-5)}

/* Roster edit mode: a checkbox column that only exists while editing, and a
   bulk-action bar that appears the moment something is selected — mirrors
   .del-overlay's "the control for a destructive action should not be visible
   until it's relevant" logic used elsewhere in this app. */
.admin-roster-row{position:relative}
.admin-roster-row .roster-check{
  display:none;width:16px;height:16px;flex-shrink:0;accent-color:var(--accent);
}
.admin-roster.editing .roster-check{display:inline-block}
.admin-roster.editing .admin-roster-revoke{display:none}
.course-roster-bulkbar{
  display:flex;align-items:center;gap:var(--sp-3);margin:var(--sp-1) var(--sp-1) var(--sp-2);
  font-size:var(--fs-sm);
}

/* Collapsible section header — used for the course-detail file list, which
   defaults closed: file names are rarely what an admin came here to check,
   and a long list pushed the roster below the fold on the courses that most
   need managing. Reuses .material-chevron's rotate-on-collapse convention
   from the Files tab rather than inventing a second one. */
.section-toggle{
  width:100%;display:flex;align-items:center;gap:var(--sp-2);padding:0;margin:var(--sp-4) 0 var(--sp-2);
  background:transparent;border:none;color:var(--text);font-family:inherit;
  cursor:pointer;text-align:start;
}
.section-toggle .pane-subtitle{margin:0}
.section-toggle:hover .pane-subtitle{color:var(--text)}
.section-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* Driven by aria-expanded rather than a class on the chevron itself: the
   existing .material-chevron rotation rule is scoped under .material-course,
   which this button isn't one of. */
.section-toggle[aria-expanded="false"] .material-chevron{transform:rotate(-90deg)}
.section-toggle-count{font-family:var(--font-mono);font-size:var(--fs-xs)}
.admin-roster-intro{margin:var(--sp-1) var(--sp-2) var(--sp-3);max-width:60ch}

/* ---------- admin: course catalogue category groups ---------- */

/* Course catalogue grouped by category — each group reuses .section-toggle's
   collapse convention (see above) rather than a second one. The outer
   container stacks groups as blocks; .admin-course-grid (a CSS grid) only
   applies to the course cards WITHIN one group, not across groups. */
.admin-categories{display:flex;flex-direction:column}
.admin-category-group{margin-bottom:var(--sp-1)}
.admin-category-group .admin-course-grid{margin-bottom:var(--sp-5)}

/* ---------- admin: Users list + user-detail page ---------- */
.users-search{margin-bottom:var(--sp-4)}
.users-list{gap:var(--sp-1)}
/* The row itself is a plain div (.admin-roster-row, already flex) so a
   trailing remove/restore button can sit beside it; only the name/email/meta
   portion is the clickable part, reset back to look inline rather than like
   a button. */
.user-row-link{
  flex:1;min-width:0;display:flex;align-items:center;gap:var(--sp-2);
  background:transparent;border:none;padding:0;font-family:inherit;
  font-size:var(--fs-sm);color:inherit;cursor:pointer;text-align:start;
}
/* Name and activity are fixed-width chips pinned to the row's end; the
   email column (.admin-roster-email) is the one that stretches, so these
   two never inherit the row's slack. */
.user-row-name{
  flex-shrink:1;min-width:0;max-width:40%;font-weight:600;font-size:var(--fs-sm);color:var(--text);
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.user-row-meta{flex-shrink:0;font-size:var(--fs-xs);color:var(--muted);white-space:nowrap}

.study-mode-activity{display:flex;flex-direction:column;gap:var(--sp-1);margin-bottom:var(--sp-4)}
.study-mode-activity-row{
  display:flex;align-items:center;justify-content:space-between;gap:var(--sp-3);
  padding:var(--sp-1) var(--sp-2);border-radius:var(--r-sm);font-size:var(--fs-sm);
}
.study-mode-activity-row:hover{background:var(--wash-1)}

/* "Add courses"/"add categories": a scrollable checkbox checklist with a
   choose-all row on top, rather than a single-choice <select> — an admin
   granting a program's worth of access at once needs to select several. */
.admin-multiselect-all{
  display:flex;align-items:center;gap:var(--sp-2);font-size:var(--fs-sm);color:var(--muted);
  padding:var(--sp-1) var(--sp-1) var(--sp-2);
}
.admin-multiselect-all input{accent-color:var(--accent)}
.admin-multiselect-list{
  max-height:220px;overflow-y:auto;display:flex;flex-direction:column;gap:var(--sp-1);
  border:1px solid var(--wash-2);border-radius:var(--r-md);padding:var(--sp-1);
  background:var(--surface-2);
}
.admin-multiselect-row{
  display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) var(--sp-2);border-radius:var(--r-sm);
  font-size:var(--fs-sm);cursor:pointer;
}
.admin-multiselect-row:hover{background:var(--wash-1)}
.admin-multiselect-row input{accent-color:var(--accent);flex-shrink:0}
.admin-multiselect-row span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}

/* ---------- toast ---------- */
.app-toast{
  background:var(--surface-2);border:1px solid var(--border);
  color:var(--text);padding:var(--sp-3) var(--sp-4);
  border-radius:var(--r-md);box-shadow:var(--shadow);font-weight:600;
}

/* Everything that loops forever, in one place. The decorations are kept by
   choice; running them at a user who has asked for stillness is not part of
   that choice.

   This block has to be the LAST thing in the file. CSS resolves
   same-specificity rules by source order, and @media nesting doesn't change
   that -- an unguarded `.typing-cursor{animation:blink ... infinite}`
   declared anywhere after this block would silently win the cascade and
   re-enable the animation, guard text notwithstanding. That is exactly what
   happened before this fix: .typing-cursor, .context-badge.critical and
   .context-dot were all declared further down the file than this block used
   to sit. The @media (max-width:660px) block above touches no `animation`
   on any selector named here, so appending it after that block is safe. See test_every_infinite_animation_is_covered_by_reduced_motion in
   backend/tests/test_design_surface.py, which now checks source order too. */
@media (prefers-reduced-motion: reduce){
  .typing-orb{animation:none;background:var(--accent);box-shadow:none}
  .center-view::before,.center-view::after{animation:none}
  .auth-card{animation:none}
  .brand-logo .icon,.splash-icon{animation:none}
  .sync-dot,.context-dot{animation:none}
  .context-badge.critical{animation:none}
  .typing-cursor{animation:none;opacity:1}
  #welcome-splash{animation:none}
  .guide-card{animation:none}
}
