/* ============================================================
   SELF-HOSTED WEBFONTS — and why they are not loaded from Google
   ============================================================

   Every page used to open with:

     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
     <link href="https://fonts.googleapis.com/css2?family=Fraunces...">

   which sends the visitor's IP address to Google, in the United States, before
   the page has drawn and before anybody has agreed to anything. It sets no
   cookie, so it is not a cookie-banner problem — it is a GDPR Art. 6 problem,
   and it is the exact thing LG Munchen I decided on 20 January 2022
   (3 O 17493/20), which started a wave of German warning letters. `preconnect`
   makes it worse by opening the connection earlier still.

   The fix is to remove the problem rather than ask permission for it. These
   four files are the same two typefaces, served from our own origin. Nothing
   about the design changes; the family names below are the ones the rest of
   this file already asks for.

   Consequences worth knowing:
     - the site now has ZERO third-party origins (jsQR was vendored in the same
       change), so style-src / font-src drop to 'self' in utils/security.js
     - no DNS + TLS handshake to a second host before first paint
     - a licence question that is already answered: both are SIL OFL, which
       permits exactly this. The licences ship beside the files.

   Variable fonts, one file per family per range. Fraunces carries all four axes
   (opsz, wght, SOFT, WONK) because style.css uses SOFT and WONK on headings;
   italic is not shipped because nothing in the site sets it — check before
   adding a rule that does.

   Files came from @fontsource-variable/{fraunces,manrope} 5.3.0, which repack
   the upstream Google Fonts binaries unchanged.
   ============================================================ */

@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url(/fonts/fraunces-latin-full-normal.woff2) format('woff2-variations');
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url(/fonts/fraunces-latin-ext-full-normal.woff2) format('woff2-variations');
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
  font-family: 'Manrope';
  font-style: normal;
  font-display: swap;
  font-weight: 200 800;
  src: url(/fonts/manrope-latin-wght-normal.woff2) format('woff2-variations');
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
  font-family: 'Manrope';
  font-style: normal;
  font-display: swap;
  font-weight: 200 800;
  src: url(/fonts/manrope-latin-ext-wght-normal.woff2) format('woff2-variations');
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}

/* ============================================================
   Sejeli — design system
   Warm heritage palette. Light + dark themes via CSS variables.

   Brand palette
     Terracotta 600  #A44F31   primary / interactive
     Terracotta 500  #C06B4A   secondary, gradient end
     Terracotta 200  #EFD5CC   tints
     Canvas          #FAF6F4   page background
     Neutral 200     #E3DAD5   borders and rules

   Type
     Fraunces  — variable display serif (opsz + SOFT + WONK axes give it
                 warmth and a hand-cut feel; nothing about it reads generic)
     Manrope   — semi-geometric UI sans, distinctive terminals, very legible
   ============================================================ */

/* Terracotta palette, shared with the phone app (mobile/src/theme.js). Both
   surfaces read from the same ramp so the site and the app are recognisably one
   product — the previous lavender set is gone, values only: every token name
   below is unchanged, so no rule elsewhere in this file needed touching. */
:root, [data-theme="light"] {
  /* raw brand ramp — never overridden per theme, used for art + gradients */
  --brand-900: #3F1409;
  --brand-800: #592110;
  --brand-700: #7B3520;
  --brand-600: #A44F31;
  --brand-500: #C06B4A;
  --brand-200: #EFD5CC;
  --brand-100: #F7E7E1;
  --brand-50:  #FDF6F4;
  /* legacy aliases kept so older rules and inline art keep resolving */
  --lavender: #A44F31;
  --rose:     #C06B4A;
  --caramel:  #EFD5CC;
  --seashell: #FAF6F4;
  --petal:    #E3DAD5;

  --bg: #FAF6F4;
  --surface: #ffffff;
  --surface-2: #F7F3F1;
  --border: #E3DAD5;
  --border-soft: #EFE9E5;
  --text: #1C1512;
  --muted: #73665F;
  --accent: #A44F31;          /* brand-600 — 4.8:1 on the canvas */
  --accent-soft: #F7E7E1;
  --accent-contrast: #ffffff;
  --teal: #C06B4A;            /* legacy token: now the light end of the ramp */
  --ok: #2C6A44;        --ok-soft: #E7F1EA;
  --warn: #8A5712;      --warn-soft: #F7EEDF;
  --danger: #A81F42;    --danger-soft: #FBE7EC;
  --flag: #8A5712;      --flag-soft: #F7EEDF;
  --live: #A81F42;
  --shadow: 0 1px 2px rgba(63, 20, 9, .05), 0 6px 16px rgba(63, 20, 9, .05);
  --shadow-lg: 0 2px 6px rgba(63, 20, 9, .06), 0 16px 36px rgba(63, 20, 9, .08);

  --font-display: "Fraunces", "Iowan Old Style", Georgia, serif;
  --font-ui: "Manrope", ui-sans-serif, system-ui, "Segoe UI", sans-serif;
}

/* Dark is derived, not supplied. The redesign specifies light only; this
   inverts the neutral ramp and lifts the brand to brand-500, because brand-600
   drops under 4.5:1 on a dark canvas. Kept in step with mobile/src/theme.js —
   change both together. */
[data-theme="dark"] {
  --brand-900: #3F1409;
  --brand-800: #592110;
  --brand-700: #7B3520;
  --brand-600: #A44F31;
  --brand-500: #C06B4A;
  --brand-200: #EFD5CC;
  --brand-100: #F7E7E1;
  --brand-50:  #FDF6F4;
  --lavender: #C06B4A;
  --rose:     #EFD5CC;
  --caramel:  #EFD5CC;
  --seashell: #F7F3F1;
  --petal:    #453833;

  --bg: #1C1512;
  --surface: #2B211D;
  --surface-2: #453833;
  --border: #3B2E29;
  --border-soft: #332723;
  --text: #F7F3F1;
  --muted: #988780;
  --accent: #C06B4A;
  --accent-soft: #3A211A;
  --accent-contrast: #1C1512;
  --teal: #C06B4A;
  --ok: #6FB98C;        --ok-soft: #1B2A21;
  --warn: #D2A055;      --warn-soft: #2E2415;
  --danger: #E4788F;    --danger-soft: #331A20;
  --flag: #D2A055;      --flag-soft: #2E2415;
  --live: #E4788F;
  --shadow: 0 1px 3px rgba(0,0,0,.45);
  --shadow-lg: 0 10px 40px rgba(0,0,0,.55);
}

/* The page's theme decides how form controls draw, not the OS. Without this
   a laptop in OS dark mode with the app in light mode rendered <button>s in
   the dark scheme: white "buttontext" on a cream card, which is how the group
   names on the Groups tab vanished in the light theme (25 Aug). */
:root, [data-theme="light"] { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }

* { box-sizing: border-box; }
html { font-size: 16px; }
body {
  margin: 0;
  font-family: var(--font-ui);
  background: var(--bg);
  color: var(--text);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

h1, h2, h3, .display {
  font-family: var(--font-display);
  font-variation-settings: "SOFT" 28, "WONK" 1;
  font-weight: 600;
  letter-spacing: -.015em;
}
body.font-small { font-size: .875rem; }
body.font-large { font-size: 1.125rem; }

/* ============================================================
   Layout shell — one markup, two layouts
   ------------------------------------------------------------
   app.js renders a rail (.sidenav), a top bar, the content, a tab bar and,
   on Profile, the footer links. Below 900px the page is the phone app: top
   bar, content, tab bar stuck to the bottom of the four roots. From 900px
   the rail takes over and the tab bar disappears — the four destinations,
   Requests, Settings, and the Help / Terms / Privacy links all sit in a
   column that never scrolls away, which is what "the footer is not fixed"
   was really asking for.
   ============================================================ */
:root {
  --rail-w: 15.5rem;
  --content-w: 74rem;
  --topbar-h: 3.4rem;
}

#app { min-height: 100dvh; display: flex; flex-direction: column; }
.app-body { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#content { flex: 1; width: 100%; max-width: var(--content-w); margin: 0 auto; padding: 1.25rem 1rem 4rem; }

/* ------------------------------------------------------------- the rail */
.sidenav { display: none; }

/* ---------------------------------------------------------- the tab bar
   The phone's four destinations, with the phone's icons, stuck to the bottom
   where a thumb is. Shown only on the four roots; a screen you navigated INTO
   hides it, because a tab bar under a pushed screen offers to throw away
   where you are. */
.tabbar {
  position: sticky; bottom: 0; z-index: 40;
  display: flex; gap: .15rem;
  border-top: 1px solid var(--border);
  background: var(--surface);
  padding: .3rem .4rem max(.3rem, env(safe-area-inset-bottom));
}
.tabbar-item {
  flex: 1; appearance: none; border: 0; background: none; cursor: pointer;
  display: flex; flex-direction: column; align-items: center; gap: .15rem;
  font: inherit; font-size: .7rem; font-weight: 700; color: var(--muted);
  padding: .4rem .3rem .35rem; border-radius: .6rem;
  transition: color .15s ease, background .15s ease;
}
.tabbar-item svg { opacity: .85; }
.tabbar-item.on { color: var(--accent); }
.tabbar-item.on svg { opacity: 1; }
@media (hover: hover) and (pointer: fine) { .tabbar-item:hover { background: var(--accent-soft); } }

/* ----------------------------------------------------------- the top bar */
.topbar {
  position: sticky; top: 0; z-index: 40;
  display: flex; align-items: center; gap: .6rem;
  min-height: var(--topbar-h);
  padding: .45rem 1rem;
  background: var(--bg);
  border-bottom: 1px solid var(--border);
}
/* One wordmark treatment, shared by the app topbar, the rail and the auth
   hero, matching .ld-logo-word on the landing page. Size still varies by
   context; the letterforms do not. */
.topbar .brand, .brand-hero h1, .sidenav-brand .brand-word {
  font-family: var(--font-display); font-weight: 600;
  font-variation-settings: "SOFT" 46, "WONK" 1;
  letter-spacing: -.035em;
}
.topbar .brand { display: flex; align-items: center; gap: .5rem; font-size: 1.15rem; }
.brand-hero h1 { font-size: 1.6rem; }
/* The real brand mark. The SPA renders <span class="brand-mark">S</span> from
   several places; font-size:0 hides the "S" the markup still contains. */
.brand-mark {
  display: inline-block;
  width: 2rem; height: 2rem;
  background: url("/logo-mark.png") center / contain no-repeat;
  font-size: 0; color: transparent;
  flex-shrink: 0;
}
.topbar .uni-name { color: var(--muted); font-size: .85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.topbar .spacer { flex: 1; }
.topbar .back-link {
  display: inline-flex; align-items: center; gap: .1rem;
  color: var(--accent); cursor: pointer; font-weight: 700; text-decoration: none;
  margin-left: -.35rem; padding: .2rem .4rem .2rem .1rem; border-radius: .5rem;
}
@media (hover: hover) and (pointer: fine) { .topbar .back-link:hover { background: var(--accent-soft); } }
.topbar .back-link + .brand { display: none; }

/* Below the rail breakpoint the top bar is the phone's header: brand, the
   bell and the gear. Log out lives on Profile and the theme under Settings,
   exactly where the phone keeps them. */
@media (max-width: 899.98px) {
  .topbar .narrow-logout, .topbar .icon-btn[data-action="__theme"] { display: none; }
}

/* ---------------------------------------------------------- page titles
   One treatment for the title of every screen, instead of nine inline
   font-sizes that had drifted between 1.2 and 1.3rem. */
.page-title {
  font-size: 1.45rem; margin: .1rem 0 1rem;
  font-variation-settings: "SOFT" 40, "WONK" 1; letter-spacing: -.025em; line-height: 1.15;
}
.row.between > .page-title { margin-bottom: 0; }
.row.between:has(> .page-title) { margin-bottom: 1rem; }
@media (min-width: 900px) { .page-title { font-size: 1.7rem; } }

/* The phone's SectionLabel: a small heading that groups without a card. */
.section-label {
  display: block; margin: 1.25rem 0 .5rem;
  font-size: .72rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase;
  color: var(--muted);
}
.section-label:first-child { margin-top: 0; }

/* On a phone the footer links live on Profile, as the phone's support link
   does — one tap away from anywhere, never under a long roster. */
.footer-narrow { padding-bottom: .5rem; }

/* ================================================================ ≥ 900px
   The rail. */
@media (min-width: 900px) {
  #app { flex-direction: row; }
  .app-body { min-height: 100dvh; }
  #content { padding: 1.75rem 2.25rem 4rem; }

  .sidenav {
    position: sticky; top: 0; align-self: flex-start;
    display: flex; flex-direction: column;
    width: var(--rail-w); flex: none; height: 100dvh;
    padding: 1.1rem .9rem 1rem;
    background: var(--surface);
    border-right: 1px solid var(--border);
    overflow-y: auto;
  }
  .sidenav-brand {
    display: flex; align-items: center; gap: .6rem;
    padding: .2rem .5rem; text-decoration: none; color: var(--text);
  }
  .sidenav-brand .brand-mark { width: 2.1rem; height: 1.9rem; }
  .sidenav-brand .brand-word { font-size: 1.45rem; }
  .sidenav-uni {
    display: block; padding: .15rem .55rem 0;
    font-size: .76rem; color: var(--muted); line-height: 1.3;
  }
  .sidenav-links { display: flex; flex-direction: column; gap: .15rem; margin-top: 1.25rem; }
  .sidenav-sep { height: 1px; background: var(--border); margin: 1rem .4rem .2rem; }
  .sidenav-links + .sidenav-sep + .sidenav-links { margin-top: .6rem; }
  .sidenav-item {
    position: relative;
    display: flex; align-items: center; gap: .7rem; width: 100%;
    appearance: none; border: 0; background: none; cursor: pointer; text-align: left;
    font: inherit; font-size: .95rem; font-weight: 600; color: var(--muted);
    padding: .55rem .65rem; border-radius: .65rem;
    transition: color .15s ease, background .15s ease;
  }
  .sidenav-item svg { flex: none; }
  .sidenav-item.on { color: var(--accent); background: var(--accent-soft); }
  @media (hover: hover) and (pointer: fine) {
    .sidenav-item:not(.on):hover { color: var(--text); background: var(--surface-2); }
  }
  .sidenav-item .bell-badge { position: static; margin-left: auto; }
  .sidenav-foot { margin-top: auto; padding-top: 1rem; display: flex; flex-direction: column; gap: .15rem; }
  .sidenav-foot .sidenav-item { font-size: .88rem; padding: .45rem .65rem; }
  .sidenav .app-footer {
    flex-direction: column; align-items: flex-start; gap: .2rem;
    padding: .9rem .65rem .2rem;
    border-top: 1px solid var(--border); margin-top: .6rem;
  }
  .sidenav .app-footer a { font-size: .78rem; }

  /* Everything the rail already carries leaves the top bar; the bar itself
     stays only when there is a way back to show. */
  .topbar { padding-left: 2.25rem; padding-right: 2.25rem; }
  .topbar:not(.has-back) { display: none; }
  .topbar .brand, .topbar .bell-btn, .topbar .narrow-logout,
  .topbar .icon-btn[data-action="__theme"], .topbar .icon-btn[data-action="__settings"] { display: none; }
  .topbar .uni-name { order: 3; margin-left: auto; }
  .topbar .spacer { display: none; }

  .tabbar, .footer-narrow { display: none; }
}

.boot-splash { display: flex; flex-direction: column; align-items: center; gap: 1rem; padding-top: 20vh; color: var(--muted); }
.boot-splash .brand-mark { width: 3.5rem; height: 3.5rem; }

/* ---------------- cards & sections ---------------- */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: .9rem;
  box-shadow: var(--shadow);
  padding: 1.1rem 1.2rem;
  margin-bottom: 1rem;
}
.card h2 { margin: 0 0 .75rem; font-size: 1.05rem; letter-spacing: -.01em; }
.card h3 { margin: 1rem 0 .5rem; font-size: .95rem; }
.card .sub { color: var(--muted); font-size: .85rem; margin: -.4rem 0 .8rem; }

.card.clickable { cursor: pointer; transition: transform .08s ease, box-shadow .12s ease; }
.card.clickable:hover { transform: translateY(-1px); box-shadow: var(--shadow-lg); }

.row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.row.between { justify-content: space-between; }
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; }

/* ---------------- auth screens ---------------- */
.auth-wrap { max-width: 24rem; margin: 8vh auto 0; }
.auth-wrap .brand-hero { display: flex; flex-direction: column; align-items: center; gap: .5rem; margin-bottom: 1.5rem; }
.auth-wrap .brand-hero .brand-mark { width: 4rem; height: 3.6rem; }
.role-switch { display: flex; border: 1px solid var(--border); border-radius: .7rem; overflow: hidden; margin-bottom: 1rem; }
.role-switch button {
  flex: 1; padding: .55rem; border: 0; background: transparent; color: var(--muted);
  font-weight: 600; cursor: pointer; font-size: .9rem;
}
.role-switch button.active { background: var(--accent); color: var(--accent-contrast); }

/* ---------------- forms ---------------- */
label.field { display: block; margin-bottom: .8rem; font-size: .85rem; font-weight: 600; color: var(--muted); }
label.field > span { display: block; margin-bottom: .25rem; }
input[type=text], input[type=email], input[type=password], input[type=number],
input[type=date], input[type=time], input[type=file], input[type=search], select, textarea {
  width: 100%; padding: .55rem .7rem;
  border: 1px solid var(--border); border-radius: .55rem;
  background: var(--surface); color: var(--text);
  font: inherit; font-size: .95rem;
  -webkit-appearance: none; appearance: none;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; }
input:disabled, select:disabled, textarea:disabled { opacity: .55; cursor: not-allowed; }
/* Browsers style type=search specially (native magnifier/clear icon, its own
   metrics) unless you opt out — without this it ignores the theme entirely and
   shows up as a plain grey OS control next to everything we do style. */
input[type=search]::-webkit-search-decoration,
input[type=search]::-webkit-search-cancel-button,
input[type=search]::-webkit-search-results-button,
input[type=search]::-webkit-search-results-decoration { -webkit-appearance: none; }
.checkbox-row { display: flex; align-items: center; gap: .5rem; font-size: .95rem; font-weight: 500; color: var(--text); margin: .4rem 0; cursor: pointer; }
.checkbox-row input { width: auto; }

/* One of several spellings of the student's own name, during registration.
   Given a real selected state rather than relying on the radio dot alone —
   this is the one choice in the flow that propagates everywhere afterwards. */
.name-option {
  border: 1px solid var(--border);
  border-radius: .5rem;
  padding: .6rem .7rem;
  margin-bottom: .5rem;
  cursor: pointer;
}
.name-option.selected {
  border-color: var(--accent);
  border-width: 2px;
  background: var(--accent-soft, transparent);
}

/* The notification bell.
   The badge is positioned on the button rather than inline so the topbar does
   not reflow when the count appears — a header that shifts sideways when data
   arrives is worse than one with a slightly odd gap. */
.bell-btn { position: relative; }
.bell-badge {
  position: absolute; top: -.15rem; right: -.15rem;
  min-width: 1.05rem; height: 1.05rem; padding: 0 .2rem;
  border-radius: 999px;
  background: var(--danger, #d33); color: #fff;
  font-size: .62rem; font-weight: 700; line-height: 1.05rem;
  text-align: center;
}
.bell-badge[hidden] { display: none; }

/* One row of the requests inbox: what it is on the left, the way in on the
   right, with the divider only between rows so the card edge stays clean. */
.inbox-row {
  display: flex; align-items: center; justify-content: space-between;
  gap: .8rem; padding: .7rem 0;
  border-top: 1px solid var(--border);
}
.inbox-row:first-of-type { border-top: 0; }

/* ------------------------------------------------------------ the inbox
   The hub: one tile per kind of thing waiting, with its count. Opening one
   shows that list alone. On a laptop the two sit side by side. */
.inbox-layout { display: grid; gap: 1.25rem; }
.hub { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .25rem; }
.hub-tile {
  display: flex; align-items: center; gap: .85rem; width: 100%; text-align: left;
  appearance: none; font: inherit; cursor: pointer; color: var(--text);
  background: var(--surface); border: 1px solid var(--border); border-radius: .85rem;
  padding: .8rem .9rem;
  transition: border-color .15s ease, background .15s ease, transform 140ms var(--ease-out-strong);
}
.hub-tile:active { transform: scale(.99); }
@media (hover: hover) and (pointer: fine) { .hub-tile:hover { border-color: var(--brand-500); } }
.hub-tile.on { border-color: var(--accent); background: var(--accent-soft); }
.hub-ic { position: relative; flex: none; color: var(--muted); }
.hub-tile.has .hub-ic { color: var(--text); }
.hub-badge {
  position: absolute; top: -.5rem; right: -.6rem;
  min-width: 1.1rem; height: 1.1rem; padding: 0 .28rem; border-radius: 999px;
  font-size: .64rem; font-weight: 800; line-height: 1.1rem; text-align: center; color: #fff;
  background: var(--danger);
}
.hub-badge.warn { background: var(--warn); }
.hub-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .05rem; }
.hub-text strong { font-size: .95rem; color: var(--muted); }
.hub-tile.has .hub-text strong, .hub-tile.on .hub-text strong { color: var(--text); }
.hub-text small { font-size: .78rem; color: var(--muted); line-height: 1.35; }
.hub-go { flex: none; color: var(--muted); }

.inbox-hint { padding: 1rem .25rem; }
.inbox-list .card { box-shadow: none; background: var(--bg); margin-bottom: .6rem; }

/* One row: what it is on the left, the way in on the right. The whole row
   is the button. */
.inbox-row {
  display: flex; align-items: center; justify-content: space-between; gap: .8rem; width: 100%;
  appearance: none; font: inherit; text-align: left; color: var(--text); cursor: pointer;
  background: none; border: 0; border-top: 1px solid var(--border);
  padding: .7rem .2rem;
}
.inbox-row.static { cursor: default; }
.inbox-row:first-of-type { border-top: 0; }
.inbox-row > div { min-width: 0; line-height: 1.4; }
.inbox-row > div > strong { font-size: .98rem; margin-right: .3rem; }
.inbox-row > div > .small { display: block; margin-top: .1rem; }
@media (hover: hover) and (pointer: fine) { .inbox-row:not(.static):hover { background: var(--surface-2); } }

.empty-state {
  display: flex; flex-direction: column; align-items: center; gap: .3rem;
  padding: 1.6rem 1rem; margin-top: .75rem; text-align: center; color: var(--muted);
  border: 1px dashed var(--border); border-radius: .9rem;
}
.empty-state svg { color: var(--ok); margin-bottom: .2rem; }
.empty-state strong { color: var(--text); }
.empty-state span { font-size: .85rem; max-width: 28rem; }

@media (max-width: 899.98px) {
  /* Hub, then list — one at a time, like the phone. */
  .inbox-layout.open .inbox-hub { display: none; }
  .inbox-layout:not(.open) .inbox-detail { display: none; }
}
@media (min-width: 900px) {
  .inbox-layout { grid-template-columns: 19rem minmax(0, 1fr); align-items: start; }
  .inbox-hub { position: sticky; top: 1.5rem; }
}

.card-accent-warn { border-left: 3px solid var(--warn); }
.card h3:empty { display: none; }

/* --------------------------------------------------------------- home */
.home-grid { display: grid; gap: 1rem; }
.home-course { font-size: 1.12rem; margin: .35rem 0 0; }
.home-live { border-left: 3px solid var(--live); background: var(--accent-soft); }
.home-count { font-size: 1.2rem; font-variant-numeric: tabular-nums; }
.meter { height: .55rem; border-radius: 999px; background: var(--border); overflow: hidden; margin-top: .4rem; }
.meter i { display: block; height: 100%; background: var(--accent); border-radius: 999px; transition: width 600ms var(--ease-out-strong); }
.list-card { padding-top: .2rem; padding-bottom: .2rem; }
.list-row { display: flex; align-items: center; justify-content: space-between; gap: .8rem; padding: .7rem 0; border-top: 1px solid var(--border); }
.list-row:first-child { border-top: 0; }
.home-side .btn.ghost { margin-top: .2rem; }
@media (min-width: 900px) {
  .home-grid { grid-template-columns: minmax(0, 1.6fr) minmax(16rem, 1fr); align-items: start; }
  /* Width is there: the Start button sits beside the session, not under it. */
  .home-later { display: flex; align-items: center; gap: 1rem; }
  .home-later > .row { flex: 1; }
  .home-later > .btn { margin-top: 0 !important; flex: none; }
}

/* A whole row as a button — group rows, session rows. Inherits the page's
   type and colour explicitly, so the UA button styles can never leak in. */
.row-btn {
  display: flex; align-items: center; justify-content: space-between; gap: .8rem; width: 100%;
  appearance: none; font: inherit; color: inherit; text-align: left; cursor: pointer;
  background: none; border: 0; border-radius: .6rem;
  padding: .6rem .4rem;
  transition: background-color 140ms var(--ease-out-strong);
}
@media (hover: hover) and (pointer: fine) { .row-btn:hover { background: var(--surface-2); } }
.row-btn .hub-go { flex: none; }
.row-btn + .row-btn { border-top: 1px solid var(--border-soft); }

/* Session status on a row: a stripe, so a cancelled one reads at a glance. */
.sess-row.cancelled strong { text-decoration: line-through; color: var(--muted); }
.sess-row .reason { color: var(--danger); }

/* The session sheet's reason chips — the phone's CANCEL_REASONS. */
.chips { display: flex; flex-wrap: wrap; gap: .45rem; margin: .4rem 0 .8rem; }
.chip {
  appearance: none; font: inherit; cursor: pointer;
  font-size: .85rem; font-weight: 600; padding: .4rem .8rem; border-radius: 999px;
  background: var(--surface-2); color: var(--text); border: 1px solid var(--border);
}
.chip.on { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }

/* Inbox: the detail pane swaps a skeleton for the list; the hub stays put. */
.inbox-skel { padding: .2rem 0; }

/* --------------------------------------------------------- week nav + calendar */
.week-nav { position: relative; display: flex; align-items: center; gap: .25rem; }
.cal-btn { gap: .4rem; }
.cal-backdrop { position: fixed; inset: 0; z-index: 45; }
.cal-pop {
  position: absolute; right: 0; top: calc(100% + .4rem); z-index: 46;
  width: 20rem; padding: .8rem .9rem .7rem;
  background: var(--surface); border: 1px solid var(--border); border-radius: 1rem;
  box-shadow: var(--shadow-lg);
  animation: combo-in 140ms var(--ease-out-strong);
}
.cal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .5rem; }
.cal-head strong { font-family: var(--font-display); font-size: 1.05rem; letter-spacing: -.01em; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: .15rem; }
.cal-dow { text-align: center; font-size: .66rem; font-weight: 800; letter-spacing: .06em; color: var(--muted); padding: .2rem 0 .3rem; }
.cal-day {
  position: relative; appearance: none; font: inherit; cursor: pointer;
  height: 2.2rem; border: 0; border-radius: .5rem; background: none; color: var(--text);
  font-size: .86rem; font-variant-numeric: tabular-nums;
}
.cal-day.other { color: var(--muted); opacity: .55; }
.cal-day.has::after { content: ''; position: absolute; left: 50%; bottom: .28rem; width: .28rem; height: .28rem; border-radius: 50%; background: var(--accent); transform: translateX(-50%); }
.cal-day.today { font-weight: 800; color: var(--accent); }
.cal-day.sel { background: var(--accent-soft); border-radius: 0; }
.cal-day.sel:nth-child(7n + 1) { border-radius: .5rem 0 0 .5rem; }
.cal-day.sel:nth-child(7n) { border-radius: 0 .5rem .5rem 0; }
@media (hover: hover) and (pointer: fine) { .cal-day:hover { background: var(--surface-2); } .cal-day.sel:hover { background: var(--brand-200); } }
[data-theme="dark"] .cal-day.sel:hover { background: var(--accent-soft); filter: brightness(1.3); }
@media (max-width: 899.98px) {
  .cal-pop { position: fixed; left: 50%; top: 50%; right: auto; transform: translate(-50%, -50%); width: min(92vw, 20rem); }
  .cal-backdrop { background: rgba(10, 14, 30, .35); }
}

/* --------------------------------------------------------- week grid
   Seven columns on a laptop: Monday to Sunday, each a stack of sessions.
   Narrow screens keep the vertical day list. */
.week-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: .5rem; align-items: start; }
.week-day { background: var(--surface); border: 1px solid var(--border); border-radius: .8rem; min-height: 14rem; padding: .55rem .5rem .6rem; }
.week-day.today { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.week-day-head { display: flex; align-items: baseline; justify-content: space-between; gap: .3rem; padding: 0 .2rem .45rem; border-bottom: 1px solid var(--border-soft); margin-bottom: .45rem; }
.week-day-head strong { font-size: .82rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.week-day.today .week-day-head strong { color: var(--accent); }
.week-day-head span { font-family: var(--font-display); font-size: 1.15rem; font-weight: 600; letter-spacing: -.02em; }
.week-item {
  display: block; width: 100%; text-align: left; appearance: none; font: inherit; color: inherit; cursor: pointer;
  background: var(--surface-2); border: 1px solid transparent; border-left: 3px solid var(--accent); border-radius: .5rem;
  padding: .4rem .5rem; margin-bottom: .4rem;
  transition: border-color 140ms var(--ease-out-strong), transform 140ms var(--ease-out-strong);
}
.week-item:active { transform: scale(.98); }
@media (hover: hover) and (pointer: fine) { .week-item:hover { border-color: var(--accent); } }
.week-item .t { display: block; font-size: .72rem; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--muted); }
.week-item .c { display: block; font-size: .82rem; font-weight: 700; line-height: 1.25; margin: .1rem 0; overflow: hidden; text-overflow: ellipsis; }
.week-item .m { display: block; font-size: .7rem; color: var(--muted); line-height: 1.3; }
.week-item.lab { border-left-color: var(--brand-500); }
.week-item.live { border-left-color: var(--live); background: var(--accent-soft); }
.week-item.cancelled { border-left-color: var(--border); opacity: .7; }
.week-item.cancelled .c { text-decoration: line-through; }
.week-empty { font-size: .75rem; color: var(--muted); text-align: center; padding: 1.2rem .2rem; }
@media (max-width: 899.98px) { .week-grid { display: none; } }
@media (min-width: 900px) { .week-list { display: none; } }

, as opposed to one that is broken.
   Greyed and with the not-allowed cursor so the state reads before the person
   clicks into it and wonders why nothing types. The explanatory line beneath
   carries the reason — the styling alone only says "no", never "why". */
input.locked, input[readonly] {
  background: var(--border);
  color: var(--muted);
  cursor: not-allowed;
}

.inline-fields { display: flex; gap: .6rem; flex-wrap: wrap; }
.inline-fields label.field { flex: 1; min-width: 8rem; }

/* ---------------- tutorial ---------------- */
.tut { position: relative; padding-top: .3rem; }
.tut h2 { margin: .2rem 0 .5rem; }
.tut p { color: var(--muted); font-size: .92rem; }
.tut-skip { position: absolute; top: -.3rem; right: 0; font-size: .82rem; color: var(--muted); text-decoration: none; }
.tut-skip:hover { color: var(--text); }
.tut-dots { display: flex; gap: .35rem; justify-content: center; margin: 1rem 0 0; }
.tut-dot { width: .4rem; height: .4rem; border-radius: 999px; background: var(--border); }
.tut-dot.on { background: var(--accent); }

/* ---------------- info icon ---------------- */
.info-icon { font-size: .85rem; padding: .1rem .3rem; vertical-align: middle; }

/* ---------------- footer ---------------- */
.app-footer {
  display: flex; flex-wrap: wrap; justify-content: center; gap: .35rem 1.1rem;
  padding: 1.25rem 1rem 1.5rem;
}
.app-footer a { color: var(--muted); font-size: .8rem; text-decoration: none; }
.app-footer a:hover { color: var(--accent); text-decoration: underline; }

/* ---------------- feedback / support ----------------
   The kind picker. These are the first thing in the flow and the only decision
   the person has to make on their own, so they are large, clearly separated,
   and describe the outcome rather than naming a category. */
.fb-choice {
  display: block; width: 100%; text-align: left;
  padding: .8rem .9rem; border-radius: .7rem; cursor: pointer;
  background: var(--surface); border: 1px solid var(--border);
  font: inherit; color: var(--text);
  transition: border-color 160ms var(--ease-out-strong),
              background-color 160ms var(--ease-out-strong),
              transform 160ms var(--ease-out-strong);
}
.fb-choice-title { display: block; font-weight: 700; font-size: .95rem; }
.fb-choice-blurb { display: block; color: var(--muted); font-size: .82rem; margin-top: .15rem; }
/* Hover only where hovering is a real thing. On a touch screen :hover sticks
   after a tap, leaving the last-pressed option looking selected. */
@media (hover: hover) and (pointer: fine) {
  .fb-choice:hover { border-color: var(--accent); background: var(--accent-soft); }
}
.fb-choice:active { transform: scale(.985); }
.fb-choice:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.fb-ref {
  font-size: 1.5rem; font-weight: 800; letter-spacing: .16em;
  color: var(--accent); font-variant-numeric: tabular-nums;
}

/* ---------------- password show/hide ---------------- */
.pw-wrap { position: relative; }
.pw-wrap input { padding-right: 2.4rem; }
.pw-wrap .pw-toggle { position: absolute; right: .1rem; top: 50%; transform: translateY(-50%); }

/* ---------------- university picker ---------------- */
.uni-add-link { display: inline-block; margin: -.4rem 0 .8rem; font-size: .82rem; }

/* Searchable combobox. `position: relative` on the wrapper so the list hangs
   off the input rather than pushing the rest of the form down as you type —
   a field that reflows the page on every keystroke is unusable on a phone. */
.combo { position: relative; margin-bottom: .8rem; }
.combo-input {
  width: 100%; padding: .55rem .7rem;
  border: 1px solid var(--border); border-radius: .55rem;
  background: var(--surface); color: var(--text);
  font: inherit; font-size: .95rem;
}
.combo-input:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; }
.combo-input:disabled { opacity: .6; }

.combo-list {
  position: absolute; z-index: 30; left: 0; right: 0; top: 100%;
  margin: .25rem 0 0; padding: .25rem; list-style: none;
  max-height: 15rem; overflow-y: auto;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: .6rem; box-shadow: var(--shadow-lg);
  animation: combo-in 140ms var(--ease-out-strong);
}
/* Never from scale(0) — it would appear out of nothing. A short rise reads as
   the list arriving beneath the field, which is where it is. */
@keyframes combo-in { from { opacity: 0; transform: translateY(-4px); } }

.combo-option {
  padding: .5rem .6rem; border-radius: .45rem; cursor: pointer;
  font-size: .92rem; line-height: 1.35;
}
.combo-option.active { background: var(--accent-soft); color: var(--accent); }
@media (hover: hover) and (pointer: fine) {
  .combo-option:hover { background: var(--surface-2); }
}
.combo-option mark {
  background: transparent; color: var(--accent); font-weight: 700;
}
.combo-option.active mark { color: inherit; text-decoration: underline; }

.combo-empty { padding: .6rem; font-size: .85rem; color: var(--muted); }
.combo-note { margin: .3rem 0 0; min-height: 1rem; color: var(--muted); }
.combo-ok { color: var(--ok); font-weight: 700; }

@media (prefers-reduced-motion: reduce) { .combo-list { animation: none; } }

/* ---------------- buttons ---------------- */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
  padding: .5rem 1rem; border-radius: .6rem; border: 1px solid transparent;
  font: inherit; font-size: .92rem; font-weight: 600; cursor: pointer;
  background: var(--accent); color: var(--accent-contrast);
  transition: filter .1s ease;
}
.btn:hover { filter: brightness(1.08); }
/* Press feedback. Every pressable thing should visibly acknowledge the press —
   without it the button feels like it might not have heard you, which is when
   people tap a second time. Gated so a touch device never gets a stuck hover. */
.btn { transition: filter .1s ease, transform 160ms var(--ease-out-strong); }
.btn:active { transform: scale(.97); }
.btn:disabled:active { transform: none; }
.icon-btn { transition: background-color 140ms var(--ease-out-strong), transform 140ms var(--ease-out-strong); }
.icon-btn:active { transform: scale(.94); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border); }
.btn.ghost { background: transparent; color: var(--accent); }
.btn.danger { background: var(--danger); color: #fff; }
.btn.warn { background: var(--warn); color: #fff; }
.btn.small { padding: .3rem .7rem; font-size: .82rem; border-radius: .5rem; }

/* A button that reads as a link — for the secondary way out of a flow, where a
   full button would compete with the primary action it sits under. */
.btn-link {
  border: 0; background: none; padding: 0; cursor: pointer;
  font: inherit; font-weight: 600; color: var(--accent);
  text-decoration: underline; text-underline-offset: 2px;
}
.btn-link:hover { color: var(--brand-700); }
.btn.block { width: 100%; }
.btn.big { padding: .9rem 1.5rem; font-size: 1.05rem; border-radius: .8rem; }

.icon-btn {
  background: transparent; border: 0; cursor: pointer; color: var(--muted);
  font-size: 1.1rem; padding: .3rem .45rem; border-radius: .5rem;
}
.icon-btn:hover { background: var(--surface-2); color: var(--text); }

/* ---------------- pills & badges ---------------- */
.pill {
  display: inline-flex; align-items: center; gap: .3rem;
  padding: .12rem .6rem; border-radius: 999px;
  font-size: .75rem; font-weight: 700; letter-spacing: .02em;
}
.pill.ok       { background: var(--ok-soft); color: var(--ok); }
.pill.warn     { background: var(--warn-soft); color: var(--warn); }
.pill.danger   { background: var(--danger-soft); color: var(--danger); }
.pill.flag     { background: var(--flag-soft); color: var(--flag); }
.pill.neutral  { background: var(--surface-2); color: var(--muted); }
.pill.accent   { background: var(--accent-soft); color: var(--accent); }
.pill.live { background: var(--live); color: #fff; }
.pill.live::before {
  content: ""; width: .5rem; height: .5rem; border-radius: 50%;
  background: #fff; animation: pulse 1.2s infinite;
}
@keyframes pulse { 50% { opacity: .3; } }

/* ---------------- tabs ---------------- */
.tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--border); margin-bottom: 1rem; overflow-x: auto; }
.tabs button {
  padding: .55rem .9rem; border: 0; background: transparent; color: var(--muted);
  font: inherit; font-weight: 600; font-size: .92rem; cursor: pointer;
  border-bottom: 2px solid transparent; white-space: nowrap;
}
.tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }

/* ---------------- tables ---------------- */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
th, td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: var(--surface-2); }

/* ---------------- collapsible tiles ---------------- */
.tile { border: 1px solid var(--border); border-radius: .7rem; margin-bottom: .6rem; overflow: hidden; }
.tile-head {
  display: flex; align-items: center; gap: .6rem;
  padding: .7rem .9rem; cursor: pointer; background: var(--surface);
}
.tile-head:hover { background: var(--surface-2); }
.tile-arrow { margin-left: auto; transition: transform .15s ease; color: var(--muted); }
.tile-arrow.open { transform: rotate(90deg); }
.tile-body { display: none; padding: .6rem .9rem .9rem; background: var(--surface); border-top: 1px solid var(--border); }
.tile-body.open { display: block; }

/* ---------------- toggle switch ---------------- */
.switch-row { display: flex; align-items: center; gap: .6rem; margin: .6rem 0; }
.switch-row .lbl { font-size: .9rem; font-weight: 600; }
.switch { position: relative; width: 2.6rem; height: 1.45rem; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch .knob {
  position: absolute; inset: 0; border-radius: 999px;
  background: var(--surface-2); border: 1px solid var(--border);
  cursor: pointer; transition: background .15s ease;
}
.switch .knob::after {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 1.05rem; height: 1.05rem; border-radius: 50%;
  background: var(--surface); box-shadow: 0 1px 2px rgba(0,0,0,.25);
  transition: transform .15s ease;
}
.switch input:checked + .knob { background: var(--accent); border-color: var(--accent); }
.switch input:checked + .knob::after { transform: translateX(1.15rem); }

/* ---------------- toasts ---------------- */
#toasts { position: fixed; bottom: 1.2rem; left: 50%; transform: translateX(-50%); z-index: 100; display: flex; flex-direction: column; gap: .5rem; width: min(92vw, 26rem); }
.toast {
  padding: .7rem 1rem; border-radius: .7rem; box-shadow: var(--shadow-lg);
  background: var(--surface); border: 1px solid var(--border);
  font-size: .92rem; display: flex; gap: .5rem; align-items: flex-start;
  animation: toast-in .18s ease;
}
.toast.error { border-left: 4px solid var(--danger); }
.toast.success { border-left: 4px solid var(--ok); }
.toast.info { border-left: 4px solid var(--accent); }
@keyframes toast-in { from { opacity: 0; transform: translateY(.5rem); } }

/* ---------------- modal ---------------- */
.modal-backdrop {
  position: fixed; inset: 0; background: rgba(10, 14, 30, .45); z-index: 60;
  display: flex; align-items: center; justify-content: center; padding: 1rem;
  /* The backdrop itself must never scroll. Without this a drag that starts on
     the dimmed area moves the page underneath, which on a phone reads as the
     modal refusing to scroll. */
  overscroll-behavior: contain;
  touch-action: none;
}
.modal {
  background: var(--surface); border-radius: 1rem; box-shadow: var(--shadow-lg);
  width: 100%; max-width: 30rem; padding: 1.3rem;
  animation: toast-in .15s ease;

  /* ==== SCROLLING, AND WHY THESE THREE LINES ARE NOT INTERCHANGEABLE ======
   *
   * Hossam, 26 Aug, on an iPhone in Safari: "scrolling through sometimes would
   * make some glitches or make me scroll the website in the background rather
   * than the wizard."
   *
   * 1. `overscroll-behavior: contain` stops SCROLL CHAINING. A scroll gesture
   *    that reaches the top or bottom of this panel is, by default, handed to
   *    the document behind it -- so a flick inside a modal keeps going and
   *    moves the page you cannot see. This is the actual bug he hit.
   *
   * 2. `touch-action: pan-y` re-enables vertical dragging INSIDE the panel,
   *    which the backdrop's `touch-action: none` would otherwise suppress for
   *    everything within it.
   *
   * 3. `100dvh` rather than `vh`. On iOS Safari `vh` is measured against the
   *    viewport with the toolbars HIDDEN, so 88vh is taller than what you can
   *    actually see while the toolbar is up -- the bottom of a wizard step,
   *    including its buttons, sits below the fold with no way to reach it.
   *    `dvh` follows the visible area. The `vh` line stays first as the
   *    fallback for anything that does not know `dvh`.
   * ==================================================================== */
  max-height: 88vh;
  max-height: 88dvh;
  overflow-y: auto;
  overscroll-behavior: contain;
  touch-action: pan-y;
  -webkit-overflow-scrolling: touch;
}

/* The document is frozen while a modal is open -- ui.js sets `top` to the
   negative of the scroll position, so the page stays visually where it was
   instead of jumping to the top behind the dim. `overflow: hidden` alone does
   not hold on iOS Safari, which is why this is position-based. */
body.modal-open {
  position: fixed;
  left: 0; right: 0;
  width: 100%;
  overflow: hidden;
}

/* ---- the wizard takes the whole screen on a phone ----
   Seven steps inside a centred card is a window within a window: the card
   scrolls, the page behind it scrolls, and the two are indistinguishable
   under a thumb. On a narrow screen it becomes the screen, which is also what
   the phone app does. */
@media (max-width: 700px) {
  .modal-backdrop.wizard { padding: 0; align-items: stretch; }
  .modal-backdrop.wizard > .modal {
    max-width: none; border-radius: 0;
    max-height: 100vh;
    max-height: 100dvh;
    min-height: 100vh;
    min-height: 100dvh;
    padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
    animation: none;
  }
}
.modal h2 { margin-top: 0; }

/* ---------------- live monitor ---------------- */
.monitor-head { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }
.countdown { font-variant-numeric: tabular-nums; font-weight: 700; }
.qr-box { text-align: center; padding: 1rem; }
.qr-box img { width: min(70vw, 20rem); border-radius: .8rem; background: #fff; padding: .6rem; }
.stat-strip { display: flex; gap: .8rem; flex-wrap: wrap; margin: .8rem 0; }
.stat {
  flex: 1; min-width: 5.5rem; text-align: center; padding: .6rem;
  background: var(--surface-2); border-radius: .7rem;
}
.stat .n { font-size: 1.4rem; font-weight: 800; display: block; }
.stat .l { font-size: .72rem; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }

/* ---------------- student check-in ---------------- */
.checkin-hero { text-align: center; padding: 1.5rem 1rem; }
.checkin-hero .big-time { font-size: 1.6rem; font-weight: 800; font-variant-numeric: tabular-nums; }
/* The viewport clips the zoomed preview so scaling magnifies rather than
   overflowing the card. */
.qr-viewport {
  position: relative; width: 100%; max-width: 22rem; margin: 0 auto;
  aspect-ratio: 3 / 4; overflow: hidden; border-radius: .8rem;
  background: #000;
}
#qr-video {
  width: 100%; height: 100%; object-fit: cover; display: block;
  transform-origin: center center;
  transition: transform 120ms var(--ease-out-strong);
}
/* Where to point. Without a target people aim at the whole screen and wonder
   why nothing happens. */
.qr-reticle {
  position: absolute; inset: 22%;
  border: 2px solid rgba(255,255,255,.85); border-radius: .6rem;
  box-shadow: 0 0 0 100vmax rgba(0,0,0,.28);
}
.qr-zoom {
  display: flex; align-items: center; gap: .6rem;
  max-width: 22rem; margin: .6rem auto 0;
}
.qr-zoom input[type=range] { flex: 1; }
.qr-zoom span:last-child { min-width: 2.6rem; text-align: right; font-variant-numeric: tabular-nums; }

@media (prefers-reduced-motion: reduce) { #qr-video { transition: none; } }

/* ---------------- ad slot placeholder ---------------- */
.ad-slot {
  border: 1.5px dashed var(--border); border-radius: .8rem;
  padding: 1.2rem; text-align: center; color: var(--muted); font-size: .85rem;
  background: var(--surface-2); margin-bottom: 1rem;
}

/* ---------------- today timeline ----------------
   A three-column row: when, what, and a chevron. The time column is fixed width
   and tabular so the times line up vertically — a ragged left edge is what makes
   a list of times feel like a wall of text instead of a schedule. */
.today-row {
  display: flex; align-items: center; gap: .75rem;
  padding: .7rem .4rem; cursor: pointer;
  border-bottom: 1px solid var(--border-soft);
  border-radius: .5rem;
  transition: background-color 140ms var(--ease-out-strong), transform 140ms var(--ease-out-strong);
}
.today-row:last-child { border-bottom: 0; }
.today-row:active { transform: scale(.995); }
@media (hover: hover) and (pointer: fine) {
  .today-row:hover { background: var(--surface-2); }
}

.today-time {
  flex: none; width: 3.6rem; text-align: right;
  display: flex; flex-direction: column; line-height: 1.25;
  font-variant-numeric: tabular-nums;
}
.today-what { flex: 1; min-width: 0; }
.today-go { flex: none; color: var(--muted); font-size: 1.1rem; }

/* The live row is the only thing on the page that should pull the eye. */
.today-row.live {
  background: var(--accent-soft);
  border-left: 3px solid var(--live);
  border-bottom-color: transparent;
}

@media (prefers-reduced-motion: reduce) { .today-row { transition: none; } }

/* ---------------- misc ---------------- */
.empty { text-align: center; color: var(--muted); padding: 2rem 1rem; }
.muted { color: var(--muted); }
.small { font-size: .82rem; }
.mt { margin-top: 1rem; }
.mb0 { margin-bottom: 0; }
.danger-text { color: var(--danger); }
/* Search is the field people reach for before they've looked at anything else
   on the page, so it reads as its own kind of control — pill-shaped and
   roomier than a normal form field — rather than just another text box.
   Used for every search/filter input across the app (roster search, student
   export search, admin user/university search), so the look is one decision
   made once here, not something re-invented per screen. */
/* input.searchbar (element + class), not just .searchbar — this needs to
   reliably beat the themed `input[type=search]` rule above, which carries the
   same specificity weight (one attribute/class + one element). Losing that
   tie was the earlier bug: the base rule's `background: var(--surface)`
   shorthand won and silently reset background-repeat back to the browser
   default, which tiled the icon across the whole bar instead of showing it
   once. Using all-longhand background properties here too, so nothing in
   this rule can ever reset a sibling property by accident again. */
input.searchbar {
  width: 100%; margin-bottom: .8rem;
  padding: .75rem 1.1rem; padding-left: 2.65rem;
  border: 1px solid var(--border); border-radius: 999px;
  background-color: var(--surface);
  background-repeat: no-repeat;
  background-position: 1rem center;
  background-size: 1rem 1rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236f5c74' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
  color: var(--text); font: inherit; font-size: 1rem;
}
[data-theme="dark"] input.searchbar {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b39fb0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
}
input.searchbar::placeholder { color: var(--muted); }
input.searchbar:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; }
/* The next session, first thing on a course. One card, two halves: what and
   when on the left, the single right action on the right. */
.next-hero {
  display: flex; align-items: center; justify-content: space-between; gap: 1.25rem; flex-wrap: wrap;
  border-left: 4px solid var(--accent); padding: 1.1rem 1.3rem; margin-bottom: 1.25rem;
}
.next-hero.today { background: var(--accent-soft); }
.next-hero.live { border-left-color: var(--live); background: var(--accent-soft); }
.next-hero-main { min-width: 0; flex: 1 1 18rem; }
.next-hero-main h2 { margin: 0 0 .15rem; font-size: 1.35rem; }
.next-hero-main p { margin: 0; font-size: .95rem; }
.next-hero-actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; flex: none; }
@media (max-width: 640px) { .next-hero-actions { width: 100%; } .next-hero-actions .btn { flex: 1; } }
hr.sep { border: 0; border-top: 1px solid var(--border); margin: 1rem 0; }

@media (max-width: 640px) {
  #content { padding: .8rem .6rem 4rem; }
  .card { padding: .9rem; }
  .topbar .uni-name { display: none; }
}

/* ============================================================
   Motion + loading primitives
   ------------------------------------------------------------
   Following Emil Kowalski's framework, the key question is how
   often a user sees each of these:

   · Screen navigation — tens of times a day. So this is
     deliberately tiny: 170ms, a 6px lift, no scale. Enough to
     stop the content snapping into place, not enough to make
     you wait. Anything showier would become an irritation by
     the fifth click.
   · Wizard steps — a handful of times, and directional, so
     they get a real slide that says which way you moved.
   · Spinners — a faster spin reads as a faster app, even when
     the wait is identical.

   Everything animates transform/opacity only, and all of it is
   disabled under prefers-reduced-motion at the end of the file.
   ============================================================ */

:root {
  --ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
  /* iOS-style drawer curve (via Ionic). Leaves fast, decelerates for a long
     time — the closest thing CSS has to momentum without a spring. */
  --ease-glide: cubic-bezier(0.32, 0.72, 0, 1);
}

/* ---------------- screen + tab transitions ----------------
   Two distinct motions, because they mean different things:

   .nav-fwd / .nav-back — you moved. Slides in from the side you came from, so
     going right along the tab bar and coming back feel different. 260ms on a
     strong ease-out: fast enough off the mark to feel responsive, with a long
     tail so it settles rather than stops.

   .content-fade — the same screen replaced its spinner with real data. That is
     not a move, so it must not slide; a second slide would imply a navigation
     that never happened. Short cross-fade only.

   `clip` rather than `hidden` on the x-axis: it stops the slide producing a
   horizontal scrollbar without creating a scroll container, which would break
   the sticky top bar. */
body { overflow-x: clip; }

/* Distance and duration are what make this read as a glide rather than a
   flicker. 22px over 260ms was technically animating but too quiet to
   register — the eye caught the opacity change and not the movement. 44px
   over 340ms on a long-tailed ease-out is clearly a slide, and still short
   enough not to sit between you and the page.

   The curve matters as much as the numbers: this one covers most of the
   distance in the first third and then eases out for a long time, which is
   what "settling into place" feels like. A linear or symmetric curve at the
   same duration feels mechanical. */
#content.nav-fwd  { animation: nav-in-right 340ms var(--ease-glide) both; }
#content.nav-back { animation: nav-in-left  340ms var(--ease-glide) both; }
#content.content-fade,
#tab-content.content-fade { animation: content-fade 170ms var(--ease-out-strong) both; }

@keyframes nav-in-right { from { opacity: 0; transform: translate3d(44px, 0, 0); } }
@keyframes nav-in-left  { from { opacity: 0; transform: translate3d(-44px, 0, 0); } }
@keyframes content-fade { from { opacity: 0; transform: translate3d(0, 4px, 0); } }

/* Tab bar: the active pill should track your click rather than jump. */
.tabs button { transition: color 180ms var(--ease-out-strong), background-color 180ms var(--ease-out-strong); }
.tabs button:active { transform: scale(0.97); }

/* ---------------- skeleton ---------------- */
.skeleton {
  position: relative; overflow: hidden;
  background: var(--surface-2); border-radius: .5rem;
}
.skeleton::after {
  content: ''; position: absolute; inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--surface) 70%, transparent), transparent);
  animation: sk-sweep 1.3s infinite;
}
@keyframes sk-sweep { to { transform: translateX(100%); } }
.skeleton-row { display: flex; align-items: center; gap: .75rem; padding: .55rem 0; }
.skeleton-row .sk-avatar { width: 1.9rem; height: 1.9rem; border-radius: 50%; flex: none; }
.skeleton-row .sk-lines { flex: 1; display: flex; flex-direction: column; gap: .35rem; }
.skeleton-row .sk-line { height: .6rem; }
.skeleton-row .sk-pill { width: 3.5rem; height: 1.1rem; border-radius: 99px; flex: none; }

/* ---------------- dot spinner ---------------- */
.dots { display: inline-flex; gap: .3rem; align-items: center; }
.dots i {
  width: .45rem; height: .45rem; border-radius: 50%;
  background: var(--accent); display: block;
  animation: dot-bounce 1.1s var(--ease-in-out-strong) infinite;
}
.dots i:nth-child(2) { animation-delay: .13s; }
.dots i:nth-child(3) { animation-delay: .26s; }
@keyframes dot-bounce {
  0%, 60%, 100% { transform: translateY(0); opacity: .45; }
  30%           { transform: translateY(-.32rem); opacity: 1; }
}

/* ---------------- ring spinner ---------------- */
.ring {
  width: 1.15rem; height: 1.15rem; flex: none;
  border: 2px solid color-mix(in srgb, var(--accent) 25%, transparent);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: ring-spin .7s linear infinite;   /* brisk: reads as quicker */
}
.ring.lg { width: 2.1rem; height: 2.1rem; border-width: 3px; }
@keyframes ring-spin { to { transform: rotate(360deg); } }

.load-state {
  display: flex; flex-direction: column; align-items: center; gap: .7rem;
  padding: 2.2rem 1rem; color: var(--muted); text-align: center;
}
.load-state .sub { font-size: .82rem; }

/* ---------------- wizard ---------------- */
.wiz-steps { display: flex; align-items: center; gap: .4rem; margin: 0 0 1.5rem; }
.wiz-step { display: flex; align-items: center; gap: .4rem; flex: 1; min-width: 0; }
.wiz-dot {
  width: 1.6rem; height: 1.6rem; border-radius: 50%; flex: none;
  display: flex; align-items: center; justify-content: center;
  font-size: .75rem; font-weight: 700;
  background: var(--surface-2); color: var(--muted);
  border: 1px solid var(--border);
  transition: background-color 260ms var(--ease-out-strong), color 260ms var(--ease-out-strong),
              border-color 260ms var(--ease-out-strong), transform 260ms var(--ease-out-strong);
}
.wiz-step.active .wiz-dot { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); transform: scale(1.1); }
.wiz-step.done .wiz-dot   { background: var(--ok-soft); color: var(--ok); border-color: var(--ok); }
.wiz-label { font-size: .78rem; font-weight: 600; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wiz-step.active .wiz-label { color: var(--text); }
.wiz-bar { flex: 1; height: 2px; background: var(--border); border-radius: 2px; overflow: hidden; }
.wiz-bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width 320ms var(--ease-out-strong); }
.wiz-step.done + .wiz-bar i, .wiz-bar.filled i { width: 100%; }

/* Directional step change — you should feel which way you moved. */

/* ---------------- wizard: timetable and rooms ----------------
   One card per group the Groups step created, and one row per weekly session
   slot. Styled entirely through the existing tokens so both themes follow
   without a second definition anywhere. */
.wiz-gcard {
  border: 1px solid var(--border); border-radius: .7rem;
  background: var(--surface-2); margin-bottom: .5rem; overflow: hidden;
}
.wiz-gcard.set { background: var(--accent-soft); border-color: var(--accent); }
.wiz-ghead {
  width: 100%; display: flex; align-items: center; gap: .55rem;
  padding: .7rem .8rem; background: none; border: 0; cursor: pointer;
  font-family: inherit; font-size: .95rem; color: var(--text); text-align: left;
}
.wiz-ghead .k {
  font-size: .62rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase;
  padding: .12rem .35rem; border-radius: .25rem;
  background: var(--surface); color: var(--muted); border: 1px solid var(--border);
}
.wiz-gcard.set .wiz-ghead .k { color: var(--accent); border-color: transparent; }
.wiz-ghead .n { font-weight: 700; letter-spacing: -.005em; }
.wiz-ghead .s { margin-left: auto; font-size: .8rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.wiz-gcard.set .wiz-ghead .s { color: var(--accent); font-weight: 700; }
.wiz-ghead .chev { font-size: 1.1rem; color: var(--muted); line-height: 1; width: 1rem; text-align: center; }
.wiz-ghead-sub {
  padding: 0 .8rem .7rem; font-size: .8rem; color: var(--muted);
  font-variant-numeric: tabular-nums; margin-top: -.35rem;
}
.wiz-gcard > .wiz-days,
.wiz-gcard > .wiz-slot,
.wiz-gcard > .inline-fields,
.wiz-gcard > p,
.wiz-gcard > button { margin-left: .8rem; margin-right: .8rem; }
.wiz-gcard > button:last-child { margin-bottom: .8rem; }
.wiz-days { display: flex; flex-wrap: wrap; gap: .3rem; margin-bottom: .3rem; }
.wiz-day {
  font-family: inherit; font-size: .78rem; font-weight: 600;
  padding: .38rem 0; width: 2.5rem; text-align: center;
  border: 1px solid var(--border); border-radius: .45rem;
  background: var(--surface); color: var(--muted); cursor: pointer;
}
.wiz-day.on { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast); }
.wiz-slot {
  display: flex; align-items: center; gap: .45rem; margin-bottom: .35rem;
  font-variant-numeric: tabular-nums;
}
.wiz-slot .d { font-size: .82rem; font-weight: 700; width: 2.2rem; }
.wiz-slot .to { font-size: .8rem; color: var(--muted); }
.wiz-slot input[type="time"] {
  font-family: inherit; font-size: .85rem; padding: .35rem .4rem;
  border: 1px solid var(--border); border-radius: .4rem;
  background: var(--surface); color: var(--text); min-width: 0; flex: 1;
}
.wiz-rrow {
  border: 1px solid var(--border); border-radius: .7rem;
  background: var(--surface-2); padding: .6rem .7rem; margin-bottom: .45rem;
}
.wiz-rrow.done { background: var(--ok-soft); border-color: var(--ok); }
.wiz-rhead { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; }
.wiz-rhead .who { font-size: .88rem; font-weight: 700; letter-spacing: -.005em; }
.wiz-rhead .when { font-size: .8rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.wiz-rhead .state { margin-left: auto; font-size: .74rem; font-weight: 700; color: var(--warn); }
.wiz-rrow.done .wiz-rhead .state { color: var(--ok); }
.wiz-rpick { display: flex; gap: .4rem; margin-top: .5rem; }
.wiz-rpick select { flex: 1; min-width: 0; }
.wiz-manual {
  margin-top: .5rem; padding-top: .5rem; border-top: 1px solid var(--border);
}

.wiz-panel { animation: wiz-fwd 260ms var(--ease-out-strong); }
.wiz-panel.back { animation: wiz-back 260ms var(--ease-out-strong); }
@keyframes wiz-fwd  { from { opacity: 0; transform: translateX(14px); } }
@keyframes wiz-back { from { opacity: 0; transform: translateX(-14px); } }

.wiz-actions { display: flex; justify-content: space-between; gap: .6rem; margin-top: 1.25rem; }

/* ---------------- reduced motion ---------------- */
@media (prefers-reduced-motion: reduce) {
  #content.nav-fwd, #content.nav-back, #content.content-fade { animation: none; }
  .tabs button { transition: none; }
  .skeleton::after { animation: none; }
  .dots i { animation: none; opacity: .7; }
  .ring { animation-duration: 1.6s; }          /* still communicates "working" */
  .wiz-panel, .wiz-panel.back { animation: none; }
  .wiz-dot, .wiz-bar i { transition: none; }
}
