﻿/* MCNP-ISAP Attendance System — base stylesheet
   Shared by the auth pages and all three portals. theme.php injects :root
   overrides on top of this for dark mode and the user's accent colour. */

:root {
    /* Fallbacks; theme.php overrides these per user from palette.php. */
    --brand: #004085;
    --brand-deep: #001F3F;
    --brand-soft: #0059B8;
    --gold: #D4AF37;

    --admin-maroon: #004085;
    --primary-maroon: var(--admin-maroon);
    --primary-blue: #004085;
    --success: #1D6F42;
    --danger: #C0392B;
    --warning: #B7791F;

    --light-bg: #F4F6FA;
    --panel-bg: #FFFFFF;
    --glass-bg: #FFFFFF;
    --text-color: #1F2937;
    --text-muted: #6B7280;
    --border-color: #E6E9F0;
    --input-bg: #FFFFFF;
    --hover-bg: rgba(27, 59, 111, 0.05);
    /* The same tint as --hover-bg, flattened against --panel-bg. Needed
       wherever that tint has to actually cover what is behind it - a sticky
       column, say - because --hover-bg is 5% opaque and would let the page
       slide through underneath. */
    --hover-bg-solid: #F5F6FA;
    /* Banners run the accent out into near-black across their width. */
    --banner-dark: #15171E;
    --header-gradient: linear-gradient(100deg,
        var(--brand) 0%, var(--brand-deep) 45%, var(--banner-dark) 100%);

    --sidebar-w: 280px;
    --topbar-h: 78px;
    /* One height for everything sitting on a banner's toolbar row. The search
       field and the buttons beside it are different elements with different
       padding, so left to their own devices they each land at whatever height
       their content works out to and the row reads as ragged. Stated once here
       rather than tuned per control, which is how the two drift apart again. */
    --banner-control-h: 44px;
    /* The main column's own top padding. Shared, because the sticky banner
       holds this same distance from the topbar and masks exactly this much
       behind itself - three places that have to agree. */
    --main-pad-y: 26px;
    --radius: 12px;
    --shadow: 0 1px 3px rgba(16, 24, 40, 0.06), 0 6px 18px rgba(16, 24, 40, 0.05);
}

* { box-sizing: border-box; }

body {
    margin: 0;
    font-family: 'Segoe UI', Roboto, -apple-system, Arial, sans-serif;
    background: var(--light-bg);
    color: var(--text-color);
    -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4, h5, h6 { color: var(--text-color); margin: 0 0 10px; }
a { color: var(--brand); }

/* ================================================================
   Buttons and small controls
   ================================================================ */
.btn-primary-filled {
    width: 100%;
    padding: 11px 18px;
    background: var(--brand);
    color: #fff;
    border: 1px solid var(--brand);
    border-radius: 9px;
    font-weight: 600;
    font-size: 0.95rem;
    font-family: inherit;
    cursor: pointer;
    transition: filter 0.15s;
}
.btn-primary-filled:hover { filter: brightness(1.12); }
.btn-primary-filled:disabled { opacity: 0.5; cursor: not-allowed; }

.btn-ghost {
    background: transparent;
    border: 1px solid #fff;
    color: #fff;
    padding: 10px 18px;
    border-radius: 9px;
    font-weight: 600;
    cursor: pointer;
}
.btn-ghost:hover { background: rgba(255, 255, 255, 0.15); }

.quick-action-link {
    display: inline-block;
    background: var(--panel-bg);
    border: 1px solid var(--border-color);
    border-radius: 9px;
    padding: 9px 15px;
    color: var(--text-color);
    text-decoration: none;
    font-size: 0.9rem;
    font-family: inherit;
    transition: background 0.15s, border-color 0.15s;
}
.quick-action-link:hover { background: var(--hover-bg); border-color: var(--brand); }

.icon-btn {
    display: grid;
    place-items: center;
    width: 40px;
    height: 40px;
    border-radius: 10px;
    color: var(--brand);
    font-size: 1.15rem;
    text-decoration: none;
    transition: background 0.15s;
}
.icon-btn:hover { background: var(--hover-bg); }
.icon-btn--danger { color: var(--danger); }
.icon-btn--danger:hover { background: rgba(192, 57, 43, 0.08); }

/* ================================================================
   Forms
   ================================================================ */
.input-group { margin-bottom: 16px; }
.input-group label {
    display: block;
    font-size: 0.82rem;
    font-weight: 600;
    color: var(--text-muted);
    margin-bottom: 6px;
}
input, select, textarea {
    width: 100%;
    padding: 10px 12px;
    border: 1px solid var(--border-color);
    border-radius: 9px;
    background: var(--input-bg);
    color: var(--text-color);
    font-size: 0.94rem;
    font-family: inherit;
}
input:focus, select:focus, textarea:focus {
    outline: none;
    border-color: var(--brand);
    box-shadow: 0 0 0 3px rgba(27, 59, 111, 0.1);
}
input[readonly] { background: #F3F4F6; color: #9CA3AF; }
input[type="checkbox"], input[type="radio"] { width: auto; }

/* ================================================================
   Portal layout
   ================================================================ */
.portal { display: flex; min-height: 100vh; }
.portal__body {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
}
.portal__main { padding: var(--main-pad-y) 30px; flex: 1; }

/* A page that must fit the viewport. The main column stops growing with its
   content and the panel row takes whatever is left, so a long list scrolls
   inside its own panel rather than the whole page scrolling.

   overflow is auto rather than hidden on purpose: on a viewport too short for
   even the banner and tiles, this degrades to a scrollbar instead of clipping
   content out of reach. */
.portal__main--fit {
    display: flex;
    flex-direction: column;
    height: calc(100vh - var(--topbar-h));
    overflow: auto;
}
/* The banner and the tile row are read at a glance and are already as short as
   they get, so they keep their natural height. Without this they carry the
   default flex-shrink of 1 and the column squeezes *them* to fit - closing the
   gap under the banner and flattening the tiles - when the intent is for the
   panel row below to absorb the difference and scroll inside itself. */
.portal__main--fit > .page-banner,
.portal__main--fit > .alert,
.portal__main--fit > .stat-grid { flex-shrink: 0; }
.portal__main--fit .panel-grid { flex: 1; min-height: 0; }
.portal__main--fit .panel-grid > .glass-panel {
    display: flex;
    flex-direction: column;
    min-height: 0;
}

/* The scrolling region inside a fitted panel; min-height:0 is what lets a flex
   child shrink below its content instead of forcing its parent taller. */
/* overflow-x is stated rather than left to the rule that a non-visible value on
   one axis forces the other to auto - a table too wide for a phone has to
   scroll here, and that should not rest on a side effect. min-width:0 for the
   same reason the tracks above use minmax(0, ...): a flex child that keeps its
   automatic minimum widens its parent to fit its content instead of scrolling,
   and a panel wider than its page is how the column stops lining up. */
.panel-scroll {
    flex: 1;
    min-height: 0;
    min-width: 0;
    overflow-y: auto;
    overflow-x: auto;
}

/* ---------- Sidebar ---------- */
.sidebar {
    width: var(--sidebar-w);
    flex-shrink: 0;
    background: linear-gradient(180deg, var(--brand) 0%, var(--brand-deep) 100%);
    color: #fff;
    display: flex;
    flex-direction: column;
    position: sticky;
    top: 0;
    height: 100vh;
    overflow-y: auto;
}

.sidebar__identity {
    padding: 24px 20px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}

.identity-card {
    background: rgba(0, 0, 0, 0.18);
    border: 1px solid rgba(255, 255, 255, 0.09);
    border-radius: 14px;
    padding: 54px 16px 16px;
    text-align: center;
    position: relative;
    margin-top: 40px;
}
.identity-card__avatar {
    position: absolute;
    top: -40px;
    left: 50%;
    transform: translateX(-50%);
    width: 84px;
    height: 84px;
    border-radius: 50%;
    border: 1px solid var(--gold);
    background: var(--brand-deep);
    overflow: hidden;
    display: grid;
    place-items: center;
    font-size: 2rem;
    font-weight: 700;
    color: var(--gold);
}
.identity-card__avatar img { width: 100%; height: 100%; object-fit: cover; }
/* The portrait is an anchor to the profile, so it undoes what a link brings
   with it - a colour and an underline over a photo - and says it is one the way
   a control does, by answering the pointer. */
a.identity-card__avatar {
    color: inherit;
    text-decoration: none;
    cursor: pointer;
    transition: border-color 0.15s, box-shadow 0.15s;
}
a.identity-card__avatar:hover,
a.identity-card__avatar:focus-visible {
    border-color: #fff;
    box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.25);
    outline: none;
}
.identity-card__id {
    font-size: 0.82rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    color: rgba(255, 255, 255, 0.75);
}
.identity-card__name {
    font-size: 1.12rem;
    font-weight: 700;
    margin-top: 2px;
    line-height: 1.25;
}
/* Sized so the longest rank line - "CITE | YEAR LEVEL COORDINATOR" - still
   fits the card on one line; the ellipsis is the safety net if a future label
   runs longer still, since a wrapped rank reads as two separate fields. */
.identity-card__role {
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    color: var(--gold);
    margin-top: 5px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* What a coordinator's rank actually covers, e.g. "1st Year, BSIT". Sits
   under the rank line in plainer type so it reads as its detail, not a
   second title. */
.identity-card__assignment {
    margin-top: 3px;
    font-size: 0.78rem;
    font-weight: 600;
    color: rgba(255, 255, 255, 0.72);
}
/* One line. This sits at the foot of a card already carrying a portrait, a
   name, a rank and often an assignment - the term is a footnote to all of it,
   and the rule above is what sets it apart rather than any white space. */
.identity-card__term {
    margin-top: 10px;
    padding-top: 8px;
    border-top: 1px solid rgba(255, 255, 255, 0.12);
    font-size: 0.62rem;
    font-weight: 500;
    letter-spacing: 0.02em;
    line-height: 1.35;
    color: rgba(255, 255, 255, 0.5);
}
/* The grading period heads the block in the card's gold: it is the only part of
   the term that moves during a semester, and what every attendance row written
   today is stamped with. It takes the same size as the line beneath it - weight
   and colour carry the emphasis, so neither line costs more height than it
   needs. */
.identity-card__grading {
    color: var(--gold);
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
}
/* The calendar it sits inside, as its subtext. Quieter, and left in sentence
   case so the pair do not read as two headings. */
.identity-card__calendar {
    margin-top: 1px;
    color: rgba(255, 255, 255, 0.45);
}

/* ---------- Bottom bar ----------
   The portal's chrome for a screen held upright. Menu on the left, the account
   in the middle, the page you are on named at the right.

   Everything about it is defined here and only `display` is left to the media
   queries at the foot of the file, so the two triggers that turn it on - an
   upright screen, or a desktop window narrowed past the sidebar - cannot come
   to style it differently. The markup is emitted on every page; these rules
   decide whether it or the sidebar is the one in use. */
/* ---------- Back to top ----------
   Hidden on desktop, where the banner is sticky and never leaves. On a phone it
   floats above the bottom bar - clear of it, and clear of the home indicator
   under that - and is drawn only while the banner is off screen (the script
   toggles [hidden], which is what display:none below answers to). */
.scroll-top { display: none; }
.scroll-top[hidden] { display: none; }

.bottom-bar {
    display: none;
    align-items: stretch;
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 40;
    background: linear-gradient(180deg, var(--brand) 0%, var(--brand-deep) 100%);
    border-top: 1px solid rgba(255, 255, 255, 0.12);
    /* Clears the iPhone home indicator; 0 on anything without one. */
    padding-bottom: env(safe-area-inset-bottom, 0px);
    box-shadow: 0 -2px 14px rgba(16, 24, 40, 0.18);
}

/* The menu and the active-page marker are the same shape on either end, so the
   portrait between them reads as the centre of the bar. */
.bottom-bar__menu,
.bottom-bar__active {
    flex: 0 0 96px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 4px;
    padding: 9px 6px;
    border: none;
    background: none;
    font-family: inherit;
    color: rgba(255, 255, 255, 0.78);
    text-decoration: none;
}
.bottom-bar__menu { cursor: pointer; }
/* Lit, because it is the answer to "which page is this" - the job the
   highlighted row does in the sidebar. */
.bottom-bar__active { color: #fff; }
.bottom-bar__active .bottom-bar__glyph::after {
    content: '';
    position: absolute;
    top: -9px;
    left: 50%;
    transform: translateX(-50%);
    width: 22px;
    height: 3px;
    border-radius: 0 0 3px 3px;
    background: var(--gold);
}
.bottom-bar__glyph { position: relative; font-size: 1.1rem; line-height: 1; }
.bottom-bar__label {
    max-width: 100%;
    font-size: 0.62rem;
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* Same red pill the sidebar and page tabs use, so a count means one thing
   wherever it appears. On the menu it is the sum of what is behind it. */
.bottom-bar__badge {
    position: absolute;
    top: -6px;
    left: 12px;
    min-width: 16px;
    height: 16px;
    padding: 0 4px;
    border-radius: 999px;
    background: var(--danger);
    color: #fff;
    font-size: 0.6rem;
    font-weight: 700;
    line-height: 16px;
    text-align: center;
}

/* The identity card's head, laid on its side to fit a bar. */
.bottom-bar__identity {
    flex: 1;
    min-width: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 11px;
    padding: 6px 10px;
    border: none;
    background: none;
    font-family: inherit;
    color: #fff;
    text-align: left;
    cursor: pointer;
}
.bottom-bar__avatar {
    flex-shrink: 0;
    display: grid;
    place-items: center;
    width: 46px;
    height: 46px;
    border-radius: 50%;
    border: 2px solid var(--gold);
    background: var(--brand-deep);
    color: var(--gold);
    font-size: 1.1rem;
    font-weight: 700;
    overflow: hidden;
}
.bottom-bar__avatar img { width: 100%; height: 100%; object-fit: cover; }
.bottom-bar__who { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.bottom-bar__name {
    font-size: 0.86rem;
    font-weight: 700;
    line-height: 1.2;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.bottom-bar__rank {
    font-size: 0.62rem;
    font-weight: 700;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    color: var(--gold);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ---------- Navigation sheet ----------
   The sidebar's list, behind the menu button. Deliberately the same shape as
   .sidebar__link so moving between the two chromes does not mean learning a
   second way to read the same list. */
.nav-sheet { display: flex; flex-direction: column; gap: 4px; }
.nav-sheet__link {
    display: flex;
    align-items: center;
    gap: 13px;
    padding: 13px 14px;
    border-radius: 10px;
    color: var(--text-color);
    text-decoration: none;
    font-size: 0.95rem;
    font-weight: 500;
}
.nav-sheet__link i { width: 20px; text-align: center; color: var(--text-muted); }
.nav-sheet__link:hover { background: var(--hover-bg); }
.nav-sheet__link.is-active {
    background: var(--brand);
    color: #fff;
    font-weight: 600;
}
.nav-sheet__link.is-active i { color: #fff; }
/* Pushed to the far edge, so the row reads name-first and the mark is a
   confirmation rather than a second label. */
.nav-sheet__tick { margin-left: auto; font-size: 0.85rem; }
.nav-sheet__link .sidebar__badge { margin-left: auto; }
.nav-sheet__link.is-active .sidebar__badge + .nav-sheet__tick { margin-left: 8px; }

.sidebar__nav { padding: 18px 14px; display: flex; flex-direction: column; gap: 4px; }
.sidebar__link {
    display: flex;
    align-items: center;
    gap: 13px;
    padding: 12px 16px;
    border-radius: 10px;
    color: rgba(255, 255, 255, 0.82);
    text-decoration: none;
    font-size: 0.95rem;
    font-weight: 500;
    transition: background 0.15s, color 0.15s;
}
.sidebar__link i { width: 20px; text-align: center; font-size: 1.02rem; }
.sidebar__link:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }
.sidebar__link.is-active {
    background: var(--brand-soft);
    color: #fff;
    font-weight: 600;
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}

/* Outstanding-work counter on a nav item. */
.sidebar__badge {
    display: grid;
    place-items: center;
    min-width: 22px;
    height: 22px;
    margin-left: auto;
    padding: 0 7px;
    border-radius: 999px;
    background: #D93A3A;
    color: #fff;
    font-size: 0.73rem;
    font-weight: 700;
    line-height: 1;
}

/* ---------- Top bar ---------- */
.topbar {
    height: var(--topbar-h);
    background: var(--panel-bg);
    border-bottom: 1px solid var(--border-color);
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 26px;
    gap: 18px;
    position: sticky;
    top: 0;
    z-index: 20;
}
.topbar__brand { display: flex; align-items: center; gap: 14px; min-width: 0; }
.topbar__brand img {
    width: 46px;
    height: 46px;
    object-fit: contain;
    border-radius: 50%;
    flex-shrink: 0;
}
.topbar__title {
    font-size: 0.95rem;
    font-weight: 700;
    letter-spacing: 0.02em;
    text-transform: uppercase;
    color: var(--brand);
    line-height: 1.2;
}
.topbar__sub {
    font-size: 0.7rem;
    color: var(--text-muted);
    font-weight: 600;
    letter-spacing: 0.04em;
}
.topbar__actions { display: flex; align-items: center; gap: 8px; }

.view-toggle {
    display: flex;
    align-items: center;
    gap: 10px;
    background: #EEF2F9;
    border: 1px solid var(--border-color);
    border-radius: 999px;
    padding: 6px 14px;
    text-decoration: none;
    margin-right: 6px;
}
.view-toggle__label { font-size: 0.85rem; font-weight: 600; color: var(--text-muted); }
.view-toggle.is-dean .view-toggle__label:last-child,
.view-toggle.is-teacher .view-toggle__label:first-child { color: var(--brand); }
.view-toggle__track {
    width: 42px;
    height: 23px;
    border-radius: 999px;
    background: var(--brand);
    position: relative;
    flex-shrink: 0;
}
.view-toggle__knob {
    position: absolute;
    top: 3px;
    width: 17px;
    height: 17px;
    border-radius: 50%;
    background: #fff;
    transition: left 0.18s;
}
.view-toggle.is-dean .view-toggle__knob { left: 22px; }
.view-toggle.is-teacher .view-toggle__knob { left: 3px; }

/* ---------- Portal dropdown ----------
   Stands where the slider does, for an account holding administrator access
   alongside a department post. The closed trigger borrows the slider's pill so
   the top bar reads the same whichever control an account gets; the open panel
   borrows .row-menu__panel, because it is the same thing - a short list of
   places to go, hanging off a button. Built rather than a native <select>,
   whose open list the browser draws in its own chrome. */
.portal-switch { position: relative; margin-right: 6px; }
.portal-switch__trigger {
    display: flex;
    align-items: center;
    gap: 9px;
    height: 36px;
    padding: 0 12px;
    background: #EEF2F9;
    border: 1px solid var(--border-color);
    border-radius: 999px;
    font-family: inherit;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--brand);
    cursor: pointer;
    max-width: 230px;
}
.portal-switch__trigger:hover { border-color: var(--brand); }
.portal-switch__trigger:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.portal-switch__glyph { font-size: 0.78rem; flex-shrink: 0; }
.portal-switch__value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.portal-switch__caret { font-size: 0.68rem; opacity: 0.6; transition: transform 0.18s; }
.portal-switch.is-open .portal-switch__caret { transform: rotate(180deg); }

.portal-switch__panel {
    position: absolute;
    top: calc(100% + 6px);
    right: 0;
    z-index: 60;
    min-width: 208px;
    padding: 6px;
    background: var(--panel-bg);
    border: 1px solid var(--border-color);
    border-radius: 10px;
    box-shadow: 0 14px 32px rgba(16, 24, 40, 0.18);
}
.portal-switch__panel[hidden] { display: none; }
.portal-switch__option {
    display: flex;
    align-items: center;
    gap: 11px;
    width: 100%;
    padding: 9px 11px;
    border: none;
    border-radius: 7px;
    background: none;
    text-align: left;
    font-family: inherit;
    cursor: pointer;
    color: var(--text-color);
}
.portal-switch__option:hover { background: var(--hover-bg); }
.portal-switch__label { font-size: 0.86rem; font-weight: 600; white-space: nowrap; }
/* The portal already open: tinted and ticked rather than hidden, and not
   pressable, since submitting it would be a round trip back to here.
   It carries `disabled` for that, which browsers draw in a washed-out grey of
   their own - so the colours are restated, because this row is the one the
   reader is meant to find, not the one to skip past. */
.portal-switch__option.is-current { background: var(--hover-bg); cursor: default; }
.portal-switch__option.is-current .portal-switch__label { color: var(--brand); }
.portal-switch__option:disabled { opacity: 1; }
.portal-switch__tick { margin-left: auto; font-size: 0.72rem; color: var(--brand); flex-shrink: 0; }

/* Dark mode lives in includes/theme.php, beside the .view-toggle rule the
   trigger shares its treatment with. */

/* ================================================================
   Page furniture
   ================================================================ */
/* Held in view while a long roll or attendance sheet scrolls beneath it, so a
   page's tabs and actions stay reachable without scrolling back up. It comes
   to rest one --main-pad-y below the topbar - the same gap the main column's
   padding gives it at the top of the page - rather than jamming against it.
   The z-index stays below the topbar's 20 so the two never fight. */
.page-banner {
    background: var(--header-gradient);
    color: #fff;
    border-radius: var(--radius);
    padding: 26px 30px;
    margin-bottom: 22px;
    display: flex;
    flex-direction: column;
    position: sticky;
    top: calc(var(--topbar-h) + var(--main-pad-y));
    z-index: 15;
}
/* That gap is see-through, and page content would scroll up through it. This
   fills it with the page's own background, carried by the banner so it needs
   to know nothing about the scroll position. */
.page-banner::before {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    bottom: 100%;
    height: var(--main-pad-y);
    background: var(--light-bg);
}
/* Never on an overview. A fitted page is built not to scroll - the panel row
   below takes the leftover height and scrolls inside itself - so there is
   nothing for the banner to stick against, and a sticky item only fights a
   height-constrained flex column. These banners sit in normal flow. */
.portal__main--fit > .page-banner { position: static; }
.portal__main--fit > .page-banner::before { display: none; }
.page-banner__main { display: flex; align-items: center; gap: 20px; }
/* Takes the room left over and gives it back when there is none. Without a
   basis to shrink against, a wrapping banner breaks the row instead of
   narrowing this - min-width:0 is what lets a long subtitle be trimmed rather
   than set the width the row cannot meet. */
.page-banner__text { flex: 1 1 0; min-width: 0; }
.page-banner__icon {
    width: 62px;
    height: 62px;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.14);
    display: grid;
    place-items: center;
    font-size: 1.55rem;
    flex-shrink: 0;
}
.page-banner h1 { color: #fff; margin: 0; font-size: 1.85rem; font-weight: 700; }
.page-banner p { margin: 5px 0 0; color: rgba(255, 255, 255, 0.85); font-size: 0.85rem; }

/* Action buttons and the info glyph, grouped at the banner's right edge. */
.banner-actions {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-left: auto;
    flex-wrap: wrap;
    justify-content: flex-end;
}
/* Only present when a page passes a 'note': stacks the actions over a line
   that depends on them, both held to the banner's right edge. Un-set the
   inner .banner-actions margin, since the column now carries it instead. */
.banner-actions-col {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 6px;
    margin-left: auto;
}
.banner-actions-col .banner-actions { margin-left: 0; }
/* ".page-banner p" (this note's own element) already sets colour, size, and a
   top margin that reads fine here too, so nothing further is needed. */
.banner-action {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    width: auto;
    height: var(--banner-control-h);
    padding: 11px 20px;
    border: 1px solid rgba(255, 255, 255, 0.2);
    border-radius: 9px;
    background: rgba(255, 255, 255, 0.13);
    color: #fff;
    font-family: inherit;
    font-size: 0.92rem;
    font-weight: 600;
    cursor: pointer;
    white-space: nowrap;
    transition: background 0.18s, border-color 0.18s;
}
.banner-action:hover,
.banner-action:focus-visible {
    background: rgba(255, 255, 255, 0.24);
    border-color: rgba(255, 255, 255, 0.4);
}
/* ---------- Search field ----------
   One component in two placements - a page banner, and a panel's own section
   title. The tools below are shared; only the width and the field's own
   colouring differ per placement. */

/* Reset and submit, sitting along the field's right edge. */
.search-field__tools {
    position: absolute;
    right: 5px;
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    align-items: center;
    gap: 3px;
}
/* A link, not a script: clearing is just this page without the query, so it
   works before any JS runs and can be opened in a new tab like any other. */
.search-field__clear {
    display: grid;
    place-items: center;
    width: 26px;
    height: 26px;
    border-radius: 7px;
    color: var(--text-muted);
    font-size: 1.15rem;
    line-height: 1;
    text-decoration: none;
    transition: background 0.15s, color 0.15s;
}
.search-field__clear:hover { background: var(--hover-bg); color: var(--danger); }
/* Spelled out as a button because a phone keyboard's return key is not an
   obvious way to run a search, and on some it is not offered at all. */
/* Square, and inset far enough to read as a control sitting inside the field
   rather than a block capping the end of it. It was 32 wide by 30 tall, which
   is neither square nor a deliberate rectangle - the odd shape is what makes it
   look wrong against a round glyph. */
.search-field__go {
    display: grid;
    place-items: center;
    width: 30px;
    height: 30px;
    padding: 0;
    border: none;
    border-radius: 8px;
    background: var(--brand);
    color: #fff;
    font-family: inherit;
    font-size: 0.85rem;
    cursor: pointer;
    transition: filter 0.15s;
}
.search-field__go:hover { filter: brightness(1.15); }

/* Right padding clears the submit button; the clearable variant widens it
   again to clear the reset alongside. */
.search-field .picker__input { padding-right: 46px; }
.search-field--clearable .picker__input { padding-right: 76px; }

/* On the banner the field keeps a solid light fill rather than the translucent
   one the buttons beside it use: a text box has to read as somewhere to type,
   and white-on-gradient placeholder text does not. */
.banner-search { width: 280px; }
.banner-search .picker__input {
    /* The same height the buttons beside it take, so the row lines up. The
       vertical padding stays for the text to centre against. */
    height: var(--banner-control-h);
    padding-top: 10px;
    padding-bottom: 10px;
    padding-left: 14px;
    border-color: transparent;
    background: var(--panel-bg);
    color: var(--text-color);
    font-size: 0.9rem;
}
.banner-search .picker__input::placeholder { color: var(--text-muted); }
/* The list is wider than the box it hangs from, since each entry carries a
   subtext, and grows leftward so it never runs off the banner's edge. */
.banner-search .picker__list {
    left: auto;
    min-width: 280px;
}

/* Square icon-only variant, e.g. the filter toggle. */
.banner-action--icon {
    width: 44px;
    padding: 11px 0;
    justify-content: center;
    gap: 0;
    font-size: 1rem;
}
/* Import actions hover green rather than the default translucent white. */
.banner-action--green:hover,
.banner-action--green:focus-visible {
    background: #2E5C34;
    border-color: #4CAF50;
}

/* Marks a filter that is currently narrowing the view. */
.banner-action.is-active {
    background: #fff;
    border-color: #fff;
    color: var(--brand);
}

/* Small "i" button pushed to the banner's right edge. */
.banner-info {
    display: grid;
    place-items: center;
    width: 21px;
    height: 21px;
    padding: 0;
    flex-shrink: 0;
    align-self: flex-start;
    border: 1.25px solid rgba(255, 255, 255, 0.45);
    border-radius: 50%;
    background: transparent;
    color: #fff;
    font-size: 0.6rem;
    cursor: pointer;
    transition: background 0.18s, border-color 0.18s;
    /* Keeps the clickable area comfortable while the circle itself stays small. */
    position: relative;
}
.banner-info::after {
    content: '';
    position: absolute;
    inset: -9px;
    border-radius: 50%;
}
.banner-info:hover,
.banner-info:focus-visible { background: rgba(255, 255, 255, 0.2); border-color: #fff; }

/* Tabbed banner: the section switcher sits along the banner's bottom edge. */
.page-banner--tabbed { padding-bottom: 0; }
.page-banner__tabs {
    display: flex;
    gap: 4px;
    margin-top: 20px;
    flex-wrap: wrap;
}
/* Rendered as a <button> when it switches panes, or an <a> when the tab links
   to a page of its own - both are styled here so the row reads as one set. */
.page-tab {
    width: auto;
    background: none;
    border: none;
    border-bottom: 3px solid transparent;
    padding: 12px 20px;
    font-family: inherit;
    font-size: 0.95rem;
    font-weight: 600;
    color: rgba(255, 255, 255, 0.68);
    text-decoration: none;
    white-space: nowrap;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 9px;
    transition: color 0.18s, border-color 0.18s;
}
.page-tab:hover { color: #fff; }
.page-tab.is-active { color: #fff; border-bottom-color: #fff; }
/* Outstanding-work marker on a tab, the same red pill the sidebar nav uses so
   a count means the same thing wherever it appears. It stays full strength on
   an inactive tab: the whole point is to pull attention to a tab you are not
   currently looking at. Comes down a size on a phone, where the banner is
   tighter and the tab row it sits in has already given up its padding. */
.page-tab__badge {
    display: grid;
    place-items: center;
    min-width: 20px;
    height: 20px;
    padding: 0 6px;
    border-radius: 999px;
    background: #D93A3A;
    color: #fff;
    font-size: 0.7rem;
    font-weight: 700;
    line-height: 1;
}

/* Panes paired with banner tabs by id, e.g. #pane-account. */
.page-pane { display: none; }
.page-pane.is-active { display: block; }

/* ---------- Sectioned form panels ---------- */
.section-title {
    display: flex;
    align-items: center;
    gap: 11px;
    margin: 0 0 20px;
    padding-bottom: 14px;
    border-bottom: 1px solid var(--border-color);
    color: var(--brand);
    font-size: 1.05rem;
    font-weight: 700;
}
.section-title i { font-size: 1rem; }
.section-title--spaced { margin-top: 34px; }
/* Title on the left, a count or action pushed to the right. */
.section-title--split { justify-content: space-between; gap: 16px; }
/* A count that is a direct child of the heading rather than one of its tools -
   which is how it stays on the title's line when the tools wrap away on a phone,
   see --stack. Here it belongs at the end of the row all the same, so `order`
   puts it after the tools and the auto margin on the title holds the pair
   against the right edge. Both are no-ops for the headings that keep their count
   inside .section-tools, which is most of them. */
.section-title--split > .count-pill { order: 1; }
.section-title--split > span:first-child { margin-right: auto; }

/* ---------- Collapsible panel ----------
   A section title that opens and closes its own body, for pages that stack
   several panels and read better as a list of headings first. It is a real
   <button> so it answers the keyboard and carries aria-expanded; these rules
   only undo what a button brings with it. */
.panel-collapse__head {
    width: 100%;
    background: none;
    font-family: inherit;
    text-align: left;
    cursor: pointer;
    border: none;
    border-bottom: 1px solid var(--border-color);
}
/* Closed, the header is the whole panel, so the rule and the gap it leaves for
   a body below it would both be drawing a boundary around nothing. */
.panel-collapse__head[aria-expanded="false"] {
    margin-bottom: 0;
    padding-bottom: 0;
    border-bottom: none;
}
.panel-collapse__head:hover .panel-collapse__chevron { color: var(--brand); }

/* The count and the chevron travel together at the header's right edge. */
.panel-collapse__meta {
    display: inline-flex;
    align-items: center;
    gap: 12px;
}
.panel-collapse__chevron {
    color: var(--text-muted);
    font-size: 0.85rem;
    transition: transform 0.18s, color 0.15s;
}
.panel-collapse__head[aria-expanded="true"] .panel-collapse__chevron { transform: rotate(180deg); }
.panel-collapse__body[hidden] { display: none; }

/* Counter pill sitting beside a section title; follows the user's accent
   rather than a fixed colour, so it reads as part of the themed shell. */
.count-pill {
    padding: 6px 15px;
    border-radius: 999px;
    background: var(--brand);
    color: #fff;
    font-size: 0.8rem;
    font-weight: 600;
    white-space: nowrap;
}

/* Search box, filter toggle, and count pill riding on a section title's
   right. They wrap onto their own line on a narrow screen rather than
   squeezing the title. */
.section-tools {
    display: flex;
    align-items: center;
    gap: 10px;
    flex-wrap: wrap;
    justify-content: flex-end;
}

/* The search box is a picker, so .picker already gives it the relative
   position its dropdown hangs from. Sits on a light panel rather than a
   banner, so it keeps the ordinary input styling and only sets its size. */
.section-search { width: 260px; }
.section-search .picker__input {
    padding-top: 8px;
    padding-bottom: 8px;
    padding-left: 13px;
    font-size: 0.88rem;
    font-weight: 400;
}
/* The tools are a touch smaller here, to sit inside a shorter field without
   crowding its edges. */
.section-search .search-field__clear { width: 24px; height: 24px; font-size: 1.05rem; }
/* Square here too, just a size down - the shorter field is the only difference. */
.section-search .search-field__go { width: 26px; height: 26px; font-size: 0.8rem; }
/* The list is wider than the box it hangs from, since each entry carries a
   subtext, and grows leftward so it never runs off the panel's edge. */
.section-search .picker__list {
    left: auto;
    min-width: 260px;
}

/* A qualifier under a section title - the class a record belongs to, say -
   carried at the title's own baseline rather than on a line of its own, so the
   heading stays one row. Normal weight and muted, so it reads as the detail
   and the title keeps the emphasis. */
.section-title__sub {
    margin-left: 8px;
    color: var(--text-muted);
    font-size: 0.86rem;
    font-weight: 600;
}

/* Square action button for a panel's own toolbar - the light-background
   counterpart to .banner-action--icon. */
.panel-action {
    display: grid;
    place-items: center;
    width: 38px;
    height: 38px;
    padding: 0;
    border: 1px solid var(--border-color);
    border-radius: 9px;
    background: var(--panel-bg);
    color: var(--text-muted);
    font-size: 0.95rem;
    font-family: inherit;
    cursor: pointer;
    transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.panel-action:hover { border-color: var(--brand); color: var(--brand); }
/* Marks a filter that is currently narrowing the list. */
.panel-action.is-active {
    background: var(--brand);
    border-color: var(--brand);
    color: #fff;
}

/* A labelled control in a panel's header, beside .panel-action's icon-only
   square. Smaller and quieter than a form's submit: it acts on the list below
   it rather than committing anything, so it sits back until reached for. */
.panel-btn {
    display: inline-flex;
    align-items: center;
    gap: 7px;
    padding: 8px 13px;
    border: 1px solid var(--border-color);
    border-radius: 9px;
    background: var(--panel-bg);
    color: var(--text-muted);
    font-family: inherit;
    font-size: 0.8rem;
    font-weight: 600;
    line-height: 1;
    white-space: nowrap;
    cursor: pointer;
    transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
}
.panel-btn i { font-size: 0.85rem; }
.panel-btn:hover:not(:disabled),
.panel-btn:focus-visible:not(:disabled) {
    background: var(--hover-bg);
    border-color: var(--brand);
    color: var(--brand);
}

/* Red on approach, not at rest. A destructive control already red in a header
   shouts over the list it belongs to, and spends the colour the confirm dialog
   behind it needs. */
.panel-btn--danger:hover:not(:disabled),
.panel-btn--danger:focus-visible:not(:disabled) {
    background: #FDECEA;
    border-color: var(--danger);
    color: var(--danger);
}

/* Nothing left to do - shown as unavailable rather than removed, so the header
   keeps its shape and the control stays where it was last seen. */
.panel-btn:disabled { opacity: 0.45; cursor: default; }

/* On a narrow screen two labelled buttons push the title onto its own line.
   They shed their labels and keep their icons instead, which holds the header
   on one row and keeps both controls the same size they were. */
@media (max-width: 560px) {
    .panel-btn { padding: 8px 10px; }
    .panel-btn span { display: none; }
    .panel-btn i { font-size: 0.95rem; }
}

/* ---------- Row action menu ----------
   Rows carrying several possible moves gather them behind one control. The
   panel is fixed-positioned and placed by script: the table scrolls sideways
   inside a container that would otherwise clip a menu opening upward. */
.row-menu { display: inline-block; }
/* The glyph alone, with no button chrome around it: a table row carries one
   of these per line, and a filled control on every row would compete with
   the record itself for attention. */
.row-menu__btn {
    display: grid;
    place-items: center;
    width: 30px;
    height: 30px;
    padding: 0;
    border: none;
    background: none;
    color: var(--brand);
    font-size: 1.1rem;
    font-family: inherit;
    cursor: pointer;
    transition: opacity 0.15s, transform 0.15s;
}
.row-menu__btn:hover { opacity: 0.75; transform: scale(1.08); }

.row-menu__panel {
    position: fixed;
    z-index: 60;
    min-width: 178px;
    padding: 6px;
    background: var(--panel-bg);
    border: 1px solid var(--border-color);
    border-radius: 10px;
    box-shadow: 0 14px 32px rgba(16, 24, 40, 0.18);
}
.row-menu__panel[hidden] { display: none; }
.row-menu__item {
    display: flex;
    align-items: center;
    gap: 10px;
    width: 100%;
    padding: 9px 11px;
    border: none;
    border-radius: 7px;
    background: none;
    text-align: left;
    font-family: inherit;
    font-size: 0.86rem;
    font-weight: 600;
    cursor: pointer;
}
.row-menu__item:hover { background: var(--hover-bg); }
.row-menu__item i { width: 15px; text-align: center; font-size: 0.85rem; }

/* ---------- Student avatar ----------
   A photo where there is one, the initial where there is not. Both are the
   same circle, so a column of them lines up whether or not every student has
   had a picture uploaded yet. */
.student-avatar {
    display: grid;
    place-items: center;
    width: 38px;
    height: 38px;
    overflow: hidden;
    border-radius: 50%;
    background: var(--hover-bg);
    color: var(--brand);
    font-size: 0.95rem;
    font-weight: 700;
    line-height: 1;
}
.student-avatar img { width: 100%; height: 100%; object-fit: cover; }

/* ---------- Attendance sheet ----------
   Students down, recorded days across. Every cell is a button, so any day can
   be corrected where it is read rather than through a separate form. */
.attendance-sheet th,
.attendance-sheet td { text-align: center; }
.attendance-sheet th:nth-child(2),
.attendance-sheet td:nth-child(2) { text-align: left; }
/* The sheet is read by running a name across its dates, so the two columns
   that say whose row it is stay put while the dates slide under them - on a
   desktop as much as a phone. Without this, scrolling out to week six leaves a
   row of statuses with no name attached - the same problem --sticky-end solves
   at the other edge of a table, solved the same way. A term's worth of dates
   outruns a wide screen too; it only takes a few more weeks to do it.

   The name column has to begin exactly where the number column ends, and
   --no-w is that offset. It is *measured* by syncSheetOffset() in
   portal_footer.php rather than declared, because a width on a table cell is
   only a suggestion under auto layout - the browser sizes the column to its
   content and ignores the request. Assuming the declared figure was the
   rendered one left the name column pinned further right than the number
   column actually ended, and a strip of scrolling dates showed through the gap.

   Nothing here may feed --no-w back into the first column's own width, or
   measuring would change the thing being measured. Its floor below is a plain
   value for that reason. The figure here is only what the first paint uses,
   before the script has run. */
.attendance-sheet { --no-w: 30px; }

.attendance-sheet th:first-child,
.attendance-sheet tbody td:first-child,
.attendance-sheet th:nth-child(2),
.attendance-sheet tbody td:nth-child(2) {
    position: sticky;
    /* Clear of its neighbours: --divided puts every cell at position:relative,
       so they all compete for the same layer. */
    z-index: 3;
    background: var(--panel-bg);
}
/* The row number takes only what its digits need. The floor is what stops two
   digits sitting flush against the pinned edge. */
.attendance-sheet th:first-child,
.attendance-sheet tbody td:first-child {
    left: 0;
    min-width: 26px;
    padding-left: 6px;
    padding-right: 4px;
}
.attendance-sheet th:nth-child(2),
.attendance-sheet tbody td:nth-child(2) {
    left: var(--no-w);
    /* The shadow on the right edge is the hint that there is more behind, and
       marks where the pinned pair ends and the dates begin. */
    box-shadow: 7px 0 7px -5px rgba(16, 24, 40, 0.18);
}
/* Capped on the <strong> rather than the cell: a table cell in auto layout
   treats max-width as a suggestion, while the block inside it honours the
   ellipsis. Without a cap one long name takes the width the dates need. The full
   name stays on the cell's title. Narrower screens lower the cap. */
.attendance-sheet tbody td:nth-child(2) strong {
    display: block;
    max-width: 260px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
/* The headings ride above the body cells, for the same reason --sticky-end's
   does: both are pinned, and the row must pass under. */
.attendance-sheet thead th:first-child,
.attendance-sheet thead th:nth-child(2) { z-index: 4; }
/* The cover follows the row it belongs to, or it stays plain against a
   highlighted row and reads as a gap in it. */
.attendance-sheet tbody tr:hover td:first-child,
.attendance-sheet tbody tr:hover td:nth-child(2) { background: var(--hover-bg-solid); }

.attendance-cell {
    min-width: 74px;
    padding: 4px 9px;
    border: 1px solid transparent;
    border-radius: 999px;
    background: none;
    color: var(--text-muted);
    font-family: inherit;
    font-size: 0.76rem;
    font-weight: 600;
    cursor: pointer;
    transition: border-color 0.15s, filter 0.15s;
}
.attendance-cell:hover { border-color: var(--brand); }
/* The read-back copy on the history page, which is a span rather than a button.
   It keeps the pill and its status colour and drops every cue that it can be
   pressed - a cell that lights up under the pointer and then does nothing is
   worse than one that never offered. */
.attendance-cell--static { cursor: default; }
.attendance-cell--static:hover { border-color: transparent; }
/* The four statuses reuse the badge palette, so a status means the same colour
   here as it does anywhere else in the app. */
/* ---------- Remark dropdown ----------
   The manual-entry counterpart to the cells below. A native <select> could not
   do either of the two things this control exists for: carry the colour each
   remark takes in the class record, and let "Late" open into the two answers
   that settle a record rather than being an answer itself. So the list is drawn
   by hand and remark-select.js commits the value into a hidden input - the same
   shape the student and department pickers use. */
.remark-select { position: relative; }

.remark-select__trigger {
    display: flex;
    align-items: center;
    gap: 10px;
    width: 100%;
    padding: 10px 12px;
    border: 1px solid var(--border-color);
    border-radius: 9px;
    background: var(--input-bg);
    color: var(--text-color);
    font-family: inherit;
    font-size: 0.94rem;
    font-weight: 600;
    text-align: left;
    cursor: pointer;
    transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.remark-select__value { flex: 1; }
/* Nothing chosen yet reads as prompt text, not as an answer. */
.remark-select__value.is-placeholder { color: var(--text-muted); font-weight: 400; }
.remark-select__caret { font-size: 0.7rem; opacity: 0.6; transition: transform 0.18s; }
.remark-select.is-open .remark-select__caret { transform: rotate(180deg); }
.remark-select__trigger:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

/* The dot is the colour itself, carried at a size that reads on the closed
   trigger as well as in the open list. Empty until a status names it. */
.remark-option__dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: currentColor;
    opacity: 0.85;
    flex: 0 0 auto;
}
.remark-select__trigger .remark-option__dot { display: none; }
.remark-select[class*="remark-select--"] .remark-select__trigger .remark-option__dot { display: block; }

/* Chosen, the trigger takes its status colour - the same pairs the cells use,
   so choosing a remark and reading one back are the same recognition. */
.remark-select--present .remark-select__trigger        { background: #E7F4EC; color: #14532D; border-color: #14532D; }
.remark-select--late_excused .remark-select__trigger   { background: #FDF6D3; color: #854D0E; border-color: #854D0E; }
.remark-select--late_unexcused .remark-select__trigger { background: #FFE9D6; color: #9A3412; border-color: #9A3412; }
.remark-select--absent .remark-select__trigger         { background: #FDECEA; color: #7F1D1D; border-color: #7F1D1D; }
.remark-select--excused .remark-select__trigger        { background: #E9EFF9; color: #1E3A8A; border-color: #1E3A8A; }

/* Same mechanics as .picker__list, so a dropdown behaves the same way wherever
   one appears - including inside a modal, where .modal-content is the scroller
   and the panel has to sit above the fields under it. */
.remark-select__list {
    position: absolute;
    z-index: 30;
    left: 0;
    right: 0;
    top: calc(100% + 4px);
    max-height: 260px;
    overflow-y: auto;
    padding: 4px;
    background: var(--panel-bg);
    border: 1px solid var(--border-color);
    border-radius: 9px;
    box-shadow: 0 12px 26px rgba(16, 24, 40, 0.16);
}

.remark-option {
    display: flex;
    align-items: center;
    gap: 10px;
    width: 100%;
    padding: 9px 11px;
    border: none;
    border-radius: 7px;
    background: none;
    font-family: inherit;
    font-size: 0.88rem;
    font-weight: 600;
    text-align: left;
    cursor: pointer;
    transition: background 0.13s;
}
.remark-option__label { flex: 1; }
/* The clip marks a row that will demand a file, so the requirement is known
   before the choice rather than announced after it. */
.remark-option__proof { opacity: 0.5; font-size: 0.78rem; }
/* Each row wears its own colour at rest, which is what makes the list readable
   at a glance; the tint arrives on hover and stays on the selected one. */
.remark-option--present        { color: #14532D; }
.remark-option--late           { color: var(--text-color); }
.remark-option--late_excused   { color: #854D0E; }
.remark-option--late_unexcused { color: #9A3412; }
.remark-option--absent         { color: #7F1D1D; }
.remark-option--excused        { color: #1E3A8A; }
.remark-option--present:hover,        .remark-option--present.is-selected        { background: #E7F4EC; }
.remark-option--late_excused:hover,   .remark-option--late_excused.is-selected   { background: #FDF6D3; }
.remark-option--late_unexcused:hover, .remark-option--late_unexcused.is-selected { background: #FFE9D6; }
.remark-option--absent:hover,         .remark-option--absent.is-selected         { background: #FDECEA; }
.remark-option--excused:hover,        .remark-option--excused.is-selected        { background: #E9EFF9; }
.remark-option--late:hover { background: var(--hover-bg-solid); }

/* Late opens rather than answers, so its caret turns like the trigger's and its
   two children sit indented under it - the indent is what says they belong to
   the row above rather than standing beside it. */
.remark-branch__caret { font-size: 0.68rem; opacity: 0.6; transition: transform 0.18s; }
.remark-branch.is-open .remark-branch__caret { transform: rotate(180deg); }
.remark-option.is-nested { padding-left: 30px; }
.remark-branch__items { border-left: 2px solid var(--border-color); margin-left: 16px; }
/* One colour per status, and the pairs that belong together sit next to each
   other in hue: the two kinds of lateness are the warm middle of the scale,
   between the green of a student who was on time and the red of one who never
   arrived. Excused takes blue - it is not a worse absence, it is an accounted
   one, so it reads as information rather than as a warning.
   The retired 'unexcused' keeps its rule: no screen writes it any more, but a
   row still sitting on it must not render unstyled. */
.attendance-cell--present        { background: #E7F4EC; color: #14532D; }
.attendance-cell--late_excused   { background: #FDF6D3; color: #854D0E; }
.attendance-cell--late_unexcused { background: #FFE9D6; color: #9A3412; }
.attendance-cell--absent         { background: #FDECEA; color: #7F1D1D; }
.attendance-cell--excused        { background: #E9EFF9; color: #1E3A8A; }
.attendance-cell--unexcused      { background: #EFEFF2; color: #3F3F46; }

/* Not a status, and drawn so it cannot be mistaken for one. The day was called
   off, so nothing about the student is being recorded - it is the one cell on
   the sheet that says the same thing down every row. Outlined rather than
   filled, and in the muted ink the page uses for what is not a value, so a
   column of these reads as a gap in the term rather than as a sixth remark
   competing with the five that are.

   Dashed, which is the whole of the cue: a solid outline in this palette is a
   status pill with the fill taken off, and a reader scanning the row should be
   able to tell at a glance that this column is not one of the five remarks.

   It is pressable on both sheets, including the read-only history - it opens the
   reason rather than editing anything, and reading is what that page is for. The
   base .attendance-cell:hover supplies the accent border on approach, so nothing
   is needed here for it. */
.attendance-cell--no_class {
    background: transparent;
    border-style: dashed;
    border-color: var(--border-color);
    color: var(--text-muted);
    font-weight: 500;
}
/* Solid on approach, so the outline reads as the edge of something you are about
   to press rather than as the dashed placeholder it is at rest. */
.attendance-cell--no_class:hover { border-style: solid; color: var(--text-color); }

/* Neutral pill for identifiers in table cells. */
.pill-id {
    display: inline-block;
    padding: 4px 11px;
    border-radius: 7px;
    background: var(--hover-bg);
    border: 1px solid var(--border-color);
    font-size: 0.84rem;
    font-variant-numeric: tabular-nums;
}

/* Round icon buttons for row-level approve / reject. */
/* A filled disc reads as a button at a size a bare glyph would not, so these
   sit smaller than the icon buttons in a banner or a panel toolbar without
   losing what they are. They also set the width of the actions column, which on
   a narrow table is width taken from the row itself. */
.icon-action {
    display: inline-grid;
    place-items: center;
    width: 26px;
    height: 26px;
    margin-right: 3px;
    padding: 0;
    border: none;
    border-radius: 50%;
    color: #fff;
    font-size: 0.72rem;
    cursor: pointer;
    transition: filter 0.15s, transform 0.15s;
}
.icon-action:hover { filter: brightness(1.12); transform: scale(1.08); }
.icon-action--approve { background: #2E9E5B; }
.icon-action--reject { background: #D14343; }

/* ---------- Subject catalogue layout ---------- */
.dept-card > .section-title { font-size: 1.2rem; }

/* Year-level pill with a rule running out to the right. */
.year-band {
    display: flex;
    align-items: center;
    gap: 16px;
    margin: 24px 0 16px;
}
.year-pill {
    display: inline-flex;
    align-items: center;
    gap: 10px;
    padding: 10px 22px;
    border-radius: 999px;
    color: #fff;
    font-size: 0.95rem;
    font-weight: 600;
    white-space: nowrap;
    flex-shrink: 0;
    /* The gradient itself is set inline, since it depends on the institution. */
}
.year-band__rule { flex: 1; height: 1px; background: var(--border-color); }

/* One semester's subjects, marked with a gold spine. */
.term-block {
    margin-bottom: 16px;
    padding: 16px 20px;
    border-left: 4px solid var(--gold);
    border-radius: 11px;
    background: var(--hover-bg);
}
.term-block__title {
    margin: 0 0 10px;
    font-size: 0.82rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
}
/* The school year rides alongside the term name without competing with it. */
.term-block__title small {
    margin-left: 8px;
    font-weight: 500;
    letter-spacing: 0;
    text-transform: none;
    color: var(--text-muted);
}

/* Term name on the left, the block's own action pushed to the right. */
.term-block__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    margin-bottom: 12px;
}
.term-block__head .term-block__title { margin: 0; }

/* Section label introducing each section's own table inside a term block.

   Each carries a rule above it, so the term heading, and then every section
   under it, are separated by a line rather than by space alone. A block
   holding three sections is three tables stacked in one tinted box, and at a
   glance the gap between two of them reads much like the gap between a heading
   and its own table - the rule is what says which side of it a table belongs
   to. The first one divides the sections from the term heading above them, so
   it is drawn as well; only its margin is dropped, since the heading has
   already left its own space. */
.section-group {
    display: flex;
    align-items: center;
    gap: 9px;
    margin: 22px 0 8px;
    padding-top: 16px;
    border-top: 1px solid var(--border-color);
    color: var(--brand);
    font-size: 0.9rem;
    font-weight: 700;
}
.section-group:first-of-type { margin-top: 0; }
.term-block .admin-table th { border-bottom-color: rgba(0, 0, 0, 0.08); }
.term-block .admin-table tr:last-child td { border-bottom: none; }

/* ---------- Printable sheet ----------
   The letterhead and headings for anything meant to leave the screen on paper.
   Kept out of the modal chrome so the sheet is all that prints.

   The sheet is authored at the true size of one landscape short bond page
   (11in x 8.5in at 96dpi) rather than growing with its content, so a preview
   is a real miniature of the printout. The stage scales it down to whatever
   room the modal has; print removes the scale. */
.print-stage { overflow: hidden; }
.print-stage > .print-sheet { transform-origin: top left; }
.print-sheet {
    box-sizing: border-box;
    width: 1056px;
    height: 816px;
    padding: 10mm 12mm;
    background: var(--panel-bg);
}

.print-brand {
    display: flex;
    align-items: center;
    gap: 13px;
    margin-bottom: 16px;
}
.print-brand img { width: 40px; height: 40px; object-fit: contain; }
.print-brand__title {
    font-size: 1.02rem;
    font-weight: 800;
    letter-spacing: 0.02em;
    text-transform: uppercase;
    color: var(--brand);
}
.print-brand__sub {
    font-size: 0.82rem;
    font-weight: 600;
    color: var(--text-muted);
}

.print-head {
    margin-bottom: 16px;
    padding-bottom: 10px;
    border-bottom: 2px solid var(--brand);
    text-align: center;
}
.print-head h1 { margin: 0 0 4px; font-size: 1.2rem; color: var(--brand); }
.print-head p { margin: 0; font-size: 0.85rem; color: var(--text-muted); }
.print-head p + p { margin-top: 2px; }

.print-legend {
    display: flex;
    gap: 20px;
    flex-wrap: wrap;
    margin-top: 14px;
    font-size: 0.76rem;
    color: var(--text-muted);
}
.print-legend span { display: inline-flex; align-items: center; gap: 7px; }
.print-legend i { width: 15px; height: 15px; border-radius: 4px; }
.print-legend .swatch-ftf { background: var(--brand); }
.print-legend .swatch-online { background: var(--panel-bg); border: 2px solid var(--brand); }

/* ---------- Room availability ----------
   The free stretches read as a list per day rather than a second grid: the
   grid above already shows what is taken, and the question this answers is
   "when can I put the class", which is a set of ranges. */
/* The day checkbox rows, grouped so a "pick at least one" can be marked. */
.day-group { display: flex; gap: 12px; flex-wrap: wrap; }

/* A start and an end side by side. A class rather than an inline style because
   this pair is hidden when the per-day strip takes over, and `hidden` sets
   display:none through the UA stylesheet - which an inline display:grid would
   outrank, leaving both controls on screen at once. */
.time-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.time-pair[hidden] { display: none; }

@media (max-width: 520px) {
    .time-pair { grid-template-columns: 1fr; }
}

/* ---------- Per-day class times ----------
   A class need not keep the same hours on every day it meets: Mathematics may
   run 7:30-10:00 on a Monday and 8:00-10:00 on a Friday. One tab per day it
   meets, each holding that day's own pair of times.

   The strip only appears when "same time on every day" is cleared, so the
   ordinary class - which is most of them - is still two fields and no tabs.
   Tabs rather than a stacked list of every day: a week of paired time fields is
   eight controls in a modal that already has ten, and only one day is being
   edited at a time anyway.

   The strip scrolls sideways rather than wrapping. A wrapped second row of tabs
   reads as a second group of days, which is exactly the thing this control must
   not suggest. */
.day-times {
    border: 1px solid var(--border-color);
    border-radius: 11px;
    overflow: hidden;
}
.day-times__tabs {
    display: flex;
    gap: 2px;
    padding: 6px 6px 0;
    background: var(--hover-bg);
    border-bottom: 1px solid var(--border-color);
    overflow-x: auto;
}
.day-times__tab {
    /* width:auto against the form's own rule, which stretches a button to the
       field width it is sitting among. */
    width: auto;
    flex: 0 0 auto;
    padding: 8px 14px;
    border: none;
    border-bottom: 3px solid transparent;
    border-radius: 8px 8px 0 0;
    background: none;
    font-family: inherit;
    font-size: 0.85rem;
    font-weight: 700;
    color: var(--text-muted);
    white-space: nowrap;
    cursor: pointer;
    transition: color 0.18s, border-color 0.18s, background 0.18s;
}
.day-times__tab:hover { color: var(--text-color); background: var(--panel-bg); }
.day-times__tab.is-active {
    color: var(--brand);
    background: var(--panel-bg);
    border-bottom-color: var(--brand);
}
/* The mode glyph, so a tab says whether that day is face-to-face or online -
   the one fact that decides whether it holds a room. */
.day-times__tab i { margin-left: 6px; font-size: 0.72rem; opacity: 0.75; }
/* A tab whose times are missing or backwards, marked the way a field is. */
.day-times__tab.is-invalid { color: var(--danger); }

.day-times__pane { display: none; padding: 14px; }
.day-times__pane.is-active { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.day-times__empty { padding: 14px; font-size: 0.85rem; color: var(--text-muted); }

/* Paired times need two columns to read as a range; below this they stack
   rather than shrink to a width the clock no longer fits in. */
@media (max-width: 520px) {
    .day-times__pane.is-active { grid-template-columns: 1fr; }
}

/* ---------- Attendance scanner ----------
   The camera and the list of who it has recognised, side by side: one is
   watched while the other runs, so neither should be a scroll away from the
   other. Who it has *not* recognised is a separate job, done after a pass
   rather than during one, and gets its own full-width panel underneath. */
.scan-grid {
    display: grid;
    grid-template-columns: minmax(0, 1.5fr) minmax(260px, 1fr);
    gap: 20px;
    /* stretch, not start: the two panels share a top and a bottom edge, and the
       list matches the camera's height rather than ending wherever its content
       happens to. The camera sets that height - it is the fixed thing here,
       while the list grows with the class. */
    align-items: stretch;
}
/* Which means the list has to give somewhere, so it gives inside: the heading
   and its summary hold their place and the names scroll under them. */
.scan-grid > .glass-panel {
    display: flex;
    flex-direction: column;
    min-height: 0;
}
/* Takes whatever height is left under the heading, and no more - flex-basis 0
   so its own contents never bid for space. */
.scan-present__scroll {
    position: relative;
    flex: 1 1 0;
    min-height: 150px;
}
/* Absolute, which is the point of the pair: an out-of-flow child contributes
   nothing to its parent's intrinsic height, so a class of forty recognised
   names cannot stretch the grid row. The panel stays exactly as tall as the
   camera beside it and the names scroll inside. */
.scan-present__inner {
    position: absolute;
    inset: 0;
    overflow-y: auto;
    overflow-x: hidden;
}

/* Start, stop, and switch camera. Wrapping is allowed rather than forced: on a
   desktop they sit on one row, and a phone widens them to fill it instead. */
.scan-controls {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    margin-top: 14px;
}

/* The unaccounted-for roll, as cards across the panel rather than one name per
   row. The list is as long as the class, and in a narrow column it ran off the
   bottom of the page; across the full width it is read in a glance or two. */
.pending-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
    gap: 9px;
}
.pending-card {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    padding: 9px 12px;
    border: 1px solid var(--border-color);
    border-radius: 10px;
    background: var(--hover-bg);
}
/* A student with no embedding cannot be recognised at all, so no amount of
   scanning will clear them from this list - marked in the warning colour
   because it is the one row here that will not resolve itself. */
.pending-card--noface { border-left: 3px solid var(--warning); }
/* Takes the width the tick and the button leave, rather than only its own
   content. Under space-between a middle item that does not grow is pushed
   around by the free space beside it - so a short name sat further right than a
   long one, and a column of cards had no left edge to read down. Growing it
   spends that space inside the block instead, which puts every name at the same
   offset and leaves the button where it was. min-width:0 stays: a flex item's
   floor is its own content, and without it a long name widens the card instead
   of wrapping inside it. */
.pending-card__who { flex: 1 1 auto; min-width: 0; }
.pending-card__name {
    font-size: 0.9rem;
    font-weight: 600;
    line-height: 1.25;
}
.pending-card__id {
    margin-top: 1px;
    font-size: 0.76rem;
    color: var(--text-muted);
}
/* The tick that puts a student in a batch. A label wrapping the box rather
   than the bare input, so the whole target is pressable at thumb size - these
   cards are picked off a phone held over a class, and a 13px checkbox is not
   something to aim at. .pending-card__who already carries min-width:0, so the
   name still ellipses rather than pushing this out of the card. */
.pending-card__pick {
    display: flex;
    align-items: center;
    flex: 0 0 auto;
    margin: -9px 0 -9px -12px;
    padding: 9px 4px 9px 12px;
    cursor: pointer;
}
.pending-card__pick input {
    width: 16px;
    height: 16px;
    margin: 0;
    cursor: pointer;
    accent-color: var(--brand);
}
/* Select All, beside the button it governs in the panel head. Sized to the
   panel-btn next to it so the row reads as one set of controls. */
.pending-selectall {
    display: inline-flex;
    align-items: center;
    gap: 7px;
    margin: 0;
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--text-muted);
    cursor: pointer;
    white-space: nowrap;
}
.pending-selectall input {
    width: 16px;
    height: 16px;
    margin: 0;
    cursor: pointer;
    accent-color: var(--brand);
}

/* Collapsible availability panel, sitting inside the schedule form so a room
   can be checked without leaving a half-filled form behind in a modal. */
.room-panel {
    margin: -4px 0 16px;
    border: 1px solid var(--border-color);
    border-radius: 11px;
    overflow: hidden;
}
.room-panel__toggle {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    padding: 10px 14px;
    border: none;
    background: var(--hover-bg);
    color: var(--text-color);
    font-family: inherit;
    font-size: 0.85rem;
    font-weight: 600;
    cursor: pointer;
}
.room-panel__toggle:hover { background: var(--border-color); }
.room-panel__label i { margin-right: 8px; color: var(--brand); }
.room-panel__caret { font-size: 0.78rem; transition: transform 0.18s; }
.room-panel.is-open .room-panel__caret { transform: rotate(180deg); }
.room-panel__body { padding: 16px 14px; border-top: 1px solid var(--border-color); }

/* Inside the form the week has to fit the modal's width: scrolling sideways
   would hide the very days being checked for a free slot. */
.room-panel .timetable {
    min-width: 0;
    grid-template-columns: 58px repeat(var(--tt-days), minmax(0, 1fr));
}

/* Room over its term, with the booking count held to the far edge. */
.room-slots__head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 16px;
    margin-bottom: 12px;
}
.room-slots__room { font-size: 1.05rem; font-weight: 700; }
.room-slots__sub { margin-top: 2px; font-size: 0.84rem; color: var(--text-muted); }
.room-slots__hint { margin: 0 0 14px; font-size: 0.83rem; color: var(--text-muted); }

.room-slots__days { display: flex; flex-direction: column; gap: 8px; }
.room-slots__day {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    padding: 9px 12px;
    border-radius: 9px;
    background: var(--hover-bg);
}
.room-slots__day-name {
    width: 92px;
    flex-shrink: 0;
    font-size: 0.85rem;
    font-weight: 700;
}
.room-slots__gap,
.room-slots__all,
.room-slots__none {
    padding: 3px 11px;
    border-radius: 999px;
    font-size: 0.78rem;
    font-weight: 600;
    white-space: nowrap;
}
.room-slots__gap { background: var(--panel-bg); border: 1px solid var(--border-color); }
.room-slots__all { background: #E7F4EC; color: #14532D; }
.room-slots__none { background: #FDECEA; color: #7F1D1D; }

/* ---------- Weekly timetable ----------
   Two grid rows: day headings, then the gutter and one column per day. Blocks
   are absolutely positioned inside a day, so overlapping classes can sit side
   by side instead of fighting for one cell. --tt-slot is the height of half an
   hour and is the only number the PHP and the CSS have to agree on. */
.timetable {
    display: grid;
    grid-template-columns: 84px repeat(var(--tt-days), minmax(140px, 1fr));
    min-width: 720px;
}
.timetable__dayhead {
    padding: 10px 12px;
    border-radius: 8px 8px 0 0;
    background: var(--brand);
    color: #fff;
    font-size: 0.84rem;
    font-weight: 700;
    text-align: center;
}
.timetable__dayhead + .timetable__dayhead { box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.25); }
/* The full day name where the column can hold it; the phone rules swap these. */
.timetable__dayabbr { display: none; }

/* The first and last labels straddle the grid's outer edges, so both rows get
   headroom rather than clipping the text. */
.timetable__gutter,
.timetable__day { margin-top: 11px; }

.timetable__tick { position: relative; height: var(--tt-slot); }
.timetable__tick span {
    position: absolute;
    top: 0;
    right: 11px;
    transform: translateY(-50%);
    font-size: 0.72rem;
    font-weight: 600;
    color: var(--text-muted);
    white-space: nowrap;
}

.timetable__day {
    position: relative;
    border-left: 1px solid var(--border-color);
}
.timetable__day:last-child { border-right: 1px solid var(--border-color); }

.timetable__slot {
    height: var(--tt-slot);
    border-bottom: 1px dashed var(--border-color);
}
.timetable__slot.is-hour { border-bottom-style: solid; }
.timetable__day .timetable__slot:first-child { border-top: 1px solid var(--border-color); }

/* Face-to-face reads as a filled accent block, online as an outlined one, so
   the two are still tellable apart on a greyscale printout. */
.timetable__block {
    position: absolute;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    gap: 1px;
    padding: 4px 7px;
    border-radius: 9px;
    background: var(--brand);
    border: 1px solid var(--brand-deep);
    color: #fff;
    /* Set per grid: the renderer scales type with the row height and counts
       its line budget from the same number. */
    font-size: var(--tt-font, 11px);
    line-height: 1.3;
    /* It is a <button> so the popover answers the keyboard too; these undo what
       a button brings with it. */
    font-family: inherit;
    text-align: left;
    cursor: pointer;
}
.timetable__block:hover { filter: brightness(1.08); }
.timetable__block.is-open { outline: 2px solid var(--brand); outline-offset: 1px; }
/* Without this the title collapses instead of the block clipping: it is the
   one child with overflow:hidden, so flex-shrink squashes it to nothing while
   the plain spans hold their line box and survive. */
.timetable__block > * { flex-shrink: 0; }
.timetable__block--online {
    background: var(--panel-bg);
    border: 1px solid var(--brand);
    border-left-width: 4px;
    color: var(--text-color);
}
/* Clamped rather than wrapped freely: the block's line budget is counted in
   PHP, and a title that wrapped to a third line would push the rest out. */
.timetable__block strong {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: var(--tt-title-lines, 2);
    line-clamp: var(--tt-title-lines, 2);
    overflow: hidden;
    font-size: calc(var(--tt-font, 11px) * 1.06);
    font-weight: 700;
}
.timetable__block strong em { font-style: normal; opacity: 0.85; }
.timetable__block .timetable__who { font-weight: 600; }
/* The room sits under the time and is read with it, so it is weighted to
   match rather than fading into the lines below. */
.timetable__block .timetable__where { font-weight: 600; }
.timetable__block .timetable__where i { font-size: 0.85em; opacity: 0.8; }
.timetable__block--ftf span { opacity: 0.9; }
.timetable__block--online span { color: var(--text-muted); }

/* ---------- Timetable block popover ----------
   Everything a block is too short to print. Fixed and placed by script, since
   the grid scrolls inside its own box and sometimes inside a modal on top of
   that - either would clip a panel positioned within the block itself. */
.timetable-popover {
    position: fixed;
    z-index: 1200;
    width: 244px;
    padding: 13px 15px;
    background: var(--panel-bg);
    border: 1px solid var(--border-color);
    border-radius: 11px;
    box-shadow: 0 16px 38px rgba(16, 24, 40, 0.22);
    font-size: 0.83rem;
    color: var(--text-color);
}
.timetable-popover__code {
    font-weight: 700;
    font-size: 0.95rem;
    color: var(--brand);
}
.timetable-popover__title {
    margin-bottom: 9px;
    padding-bottom: 9px;
    border-bottom: 1px solid var(--border-color);
    line-height: 1.35;
}
/* Label left, value right, wrapping beneath its own label when the value is
   too long for the line - a room name easily is. */
.timetable-popover__row {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: 2px 14px;
    padding: 4px 0;
}
.timetable-popover__row span { color: var(--text-muted); }
.timetable-popover__row strong { font-weight: 700; }
/* Closes the panel: the facts above answer "what is this", and this is the way
   through to the class itself. */
.timetable-popover__action {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    margin-top: 13px;
    padding: 9px 14px;
    border-radius: 8px;
    background: var(--brand);
    color: #fff;
    font-size: 0.83rem;
    font-weight: 600;
    text-decoration: none;
    transition: filter 0.15s;
}
.timetable-popover__action:hover { filter: brightness(1.12); }
@media print { .timetable-popover { display: none; } }

/* ---------- Import guidance panel ---------- */
.import-guide {
    padding: 16px 20px;
    margin-bottom: 20px;
    border-left: 4px solid #4CAF50;
    border-radius: 11px;
    background: var(--hover-bg);
}
.import-guide h4 { margin: 0 0 8px; font-size: 1rem; font-weight: 700; }
.import-guide p { margin: 0 0 10px; font-size: 0.88rem; color: var(--text-muted); }
.import-guide ol { margin: 0 0 12px; padding-left: 20px; font-size: 0.88rem; }
.import-guide li { margin-bottom: 4px; }
.import-guide__note { font-style: italic; font-size: 0.83rem; }

/* ---------- The one column that needs a rule rather than an example ----------
   Day Times is not a value like the others in the list above it: it is an
   instruction about the two columns before it, and a dean who has only ever
   filled Start and End needs the relationship spelled out before the syntax
   means anything.

   Set apart on the panel's own surface rather than as one more paragraph in the
   run. The list above is read once and skimmed after; this is the part somebody
   comes back to, and it has to be findable. */
.import-guide__callout {
    padding: 14px 16px;
    margin: 0 0 12px;
    border: 1px solid var(--border-color);
    border-radius: 10px;
    background: var(--panel-bg);
}
.import-guide__callout h5 {
    margin: 0 0 8px;
    font-size: 0.88rem;
    font-weight: 700;
    color: var(--text-color);
}
.import-guide__callout h5 i { margin-right: 6px; color: #4CAF50; }
.import-guide__callout p { margin: 0 0 8px; }
.import-guide__callout ul {
    margin: 0;
    padding-left: 18px;
    font-size: 0.82rem;
    color: var(--text-muted);
}
.import-guide__callout li { margin-bottom: 3px; }
.import-guide__callout code {
    padding: 1px 5px;
    border-radius: 4px;
    background: var(--hover-bg);
    font-size: 0.8rem;
}

/* The worked row, as the columns it actually occupies. A sentence describing
   four cells is a sentence the reader has to rebuild into a row; showing the
   row skips the rebuilding. */
.import-guide__example {
    margin: 0 0 8px;
    border-collapse: collapse;
    font-size: 0.82rem;
}
.import-guide__example th,
.import-guide__example td { padding: 3px 12px 3px 0; text-align: left; }
.import-guide__example th { font-weight: 600; color: var(--text-muted); }
.import-guide__example td { font-family: ui-monospace, "Consolas", monospace; }

/* What those four cells come out as - the whole point of the block, so it is
   the one line in it set in the body ink rather than the muted. */
.import-guide__reads { color: var(--text-color) !important; }

/* Green action pair used by the import flow. */
.btn-green {
    width: 100%;
    padding: 13px 18px;
    border: none;
    border-radius: 9px;
    background: #5CB85C;
    color: #fff;
    font-family: inherit;
    font-size: 1rem;
    font-weight: 700;
    cursor: pointer;
    transition: background 0.18s;
}
.btn-green:hover, .btn-green:focus-visible { background: #479A47; }

.btn-outline-green {
    display: inline-flex;
    align-items: center;
    gap: 9px;
    padding: 10px 20px;
    border: 1.5px solid #4CAF50;
    border-radius: 9px;
    background: transparent;
    color: #3D8B40;
    font-weight: 600;
    font-size: 0.9rem;
    text-decoration: none;
    transition: background 0.18s, color 0.18s;
}
.btn-outline-green:hover, .btn-outline-green:focus-visible { background: #4CAF50; color: #fff; }

/* Import preview: many narrow columns, so the type steps down and cells stay
   on one line. The panel scrolls sideways rather than wrapping every cell. */
.preview-table { font-size: 0.8rem; }
.preview-table th,
.preview-table td { padding: 8px 11px; white-space: nowrap; }
.preview-table th { font-size: 0.66rem; letter-spacing: 0.05em; }
.preview-table .badge { font-size: 0.68rem; padding: 3px 9px; }
/* The skip reason is prose, so it may wrap in its own column. */
.preview-table .row-subtext { white-space: normal; min-width: 190px; max-width: 280px; }

/* ---------- Import preview ----------
   A dry run answers one question per row - will this land or not - so the row
   itself carries the answer rather than a word at the end of it. Reading a
   twenty-row file by scanning a status column means holding the question in
   mind for the width of the table; coloured rows answer it at a glance, and
   the Notes column is then free to say *why* rather than *whether*.

   A tinted left edge as well as a fill, because colour alone is not an answer
   for a reader who cannot separate these two hues - the bar's presence or
   absence is a second, non-colour signal, and the Notes text is a third. */
.preview-row--ok td { background: #F1F9F3; }
.preview-row--ok td:first-child { box-shadow: inset 3px 0 0 #4C9A61; }
.preview-row--skip td { background: #FDF1EF; }
.preview-row--skip td:first-child { box-shadow: inset 3px 0 0 #C0392B; }
/* The table's own hover fill would grey the verdict out from under the pointer,
   which is the moment a reader is most likely looking at it. Deepened instead,
   at a specificity high enough to outrank .admin-table tbody tr:hover td. */
.preview-table tbody tr.preview-row--ok:hover td { background: #E4F3E9; }
.preview-table tbody tr.preview-row--skip:hover td { background: #FBE4E0; }

/* The counts over the table: how many rows the file has, how many will land,
   and how many will not - each with its own colour, so the shape of the answer
   is legible before a single row is read. */
.preview-tally {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    margin-bottom: 16px;
}
.preview-tally__item {
    flex: 1;
    min-width: 108px;
    padding: 10px 14px;
    border: 1px solid var(--border-color);
    border-radius: 10px;
    background: var(--panel-bg);
}
.preview-tally__value { font-size: 1.35rem; font-weight: 800; line-height: 1.1; }
.preview-tally__label {
    margin-top: 2px;
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-muted);
}
.preview-tally__item--ok { border-color: #BFE3CC; background: #F1F9F3; }
.preview-tally__item--ok .preview-tally__value { color: #14532D; }
.preview-tally__item--skip { border-color: #F5C2BD; background: #FDF1EF; }
.preview-tally__item--skip .preview-tally__value { color: #7F1D1D; }

/* The same tiles inside the confirmation dialog, which is 440px wide against
   the preview's 1060 and has to hold four of them without wrapping. Only the
   measurements change - the colours, and so the meaning of each tile, are the
   ones the preview already taught the reader.

   84px is the floor that fits four across: 4 x 84 + 3 x 8 of gap is 348, inside
   the 396 the dialog leaves after its padding. */
.preview-tally--compact { gap: 8px; margin-bottom: 16px; }
.preview-tally--compact .preview-tally__item { min-width: 84px; padding: 8px 10px; }
.preview-tally--compact .preview-tally__value { font-size: 1.15rem; }
.preview-tally--compact .preview-tally__label { font-size: 0.62rem; letter-spacing: 0.03em; }

/* ---------- Preview scroller ----------
   The rows scroll inside their own box rather than taking the dialog with
   them. A hundred-row file would otherwise push the tally off the top and the
   "nothing has been saved yet" note off the bottom - the two things that say
   what the reader is looking at and what happens next - leaving a wall of rows
   with no frame. The box is capped to three rows by script, which measures
   them: a skipped row carries a wrapped reason and is taller than a clean one,
   so a fixed height would cut the third row in half or leave a gap under it.

   The dialog itself stops scrolling for the same reason - see #previewModal. */
.preview-scroll { overflow: auto; }
/* Held above the rows sliding under it, and opaque so they do not show
   through. The shadow stands in for the border, which a sticky cell leaves
   behind when it detaches. */
.preview-scroll thead th {
    position: sticky;
    top: 0;
    z-index: 2;
    background: var(--panel-bg);
    box-shadow: inset 0 -1px 0 var(--border-color);
}
/* The row number is how a reader points at a line to go and fix it, so it
   stays put while the columns scroll under it - a note about "row 14" is no
   use once the number has slid off the left edge.
   No background is set here: every preview row already carries its verdict
   tint, which is opaque, and setting one would paint over it. */
.preview-scroll th:first-child,
.preview-scroll td:first-child {
    position: sticky;
    left: 0;
    z-index: 1;
    border-right: 1px solid var(--border-color);
}
/* The corner belongs to both sticky axes, so it has to outrank each of them -
   otherwise it slides under whichever of the two is painted last. */
.preview-scroll thead th:first-child { z-index: 3; }

/* Dashed drop-zone styling for a file input. */
.file-drop {
    padding: 16px;
    border: 2px dashed var(--border-color);
    border-radius: 10px;
    background: var(--hover-bg);
    cursor: pointer;
}
.file-drop:hover { border-color: #4CAF50; }
/* The same drop-zone outside the import flow, where the green would be a colour
   borrowed from an action this file has nothing to do with. It takes the user's
   accent instead, like every other control that is not part of an import.
   A modifier rather than a change to the rule above, because that green is a
   deliberate choice: the import bar, its guide rule, and its buttons are one
   action, and an accent that varies per user would break the group apart. */
.file-drop--accent:hover,
.file-drop--accent:focus-within { border-color: var(--brand); }

/* Muted qualifier under a table cell's main value. */
.row-subtext {
    margin-top: 2px;
    font-size: 0.68rem;
    font-weight: 500;
    letter-spacing: 0.02em;
    color: var(--text-muted);
}

/* Bare glyph action, no filled circle behind it. Used on both a <button> and
   an <a>, so it carries what each needs: the inline-block and the underline
   reset are for the anchor, the border and background reset for the button. */
/* The bare-glyph row action - edit, delete, view. Sized to sit with
   .icon-action, so a table's controls read as one set whichever kind a given
   row happens to carry. */
.icon-view {
    display: inline-block;
    width: auto;
    height: auto;
    padding: 4px 5px;
    border: none;
    background: none;
    color: var(--brand);
    font-size: 1rem;
    line-height: 1;
    text-decoration: none;
    cursor: pointer;
    transition: color 0.15s, transform 0.15s;
}
.icon-view:hover { color: var(--brand-soft); transform: scale(1.12); }
/* Destructive variant: the same bare glyph, in the warning colour. */
.icon-view--danger { color: var(--danger); }
.icon-view--danger:hover { color: var(--danger); opacity: 0.75; }

/* The last column of the teacher dashboard's Today's Classes table: Scan,
   drawn only on a class whose window is open right now, and the icon into
   the class itself, drawn on every row. Right-aligned as a flex row rather
   than left to the cell's own text flow - without it the icon sits wherever
   the row happens to leave off, pinned beside Scan on an open class and
   flush against the cell's left edge on every other row, so the one column
   reads as two depending on the row. Right-aligned, the icon lands on the
   same edge of the cell every time, Scan or no Scan. */
.dash-today__actions {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 6px;
}
/* --sticky-end casts a drop shadow off its pinned last column, meant to read
   as "more of the row is still under here" on a table wide enough to scroll
   sideways under it. This one is six columns on a glance-sized dashboard
   panel and rarely if ever scrolls, so the shadow had nothing to announce and
   only sat there next to the Scan button and the icon it was pinning -
   pinned or not, this column drops no shadow.

   --sticky-end's own shadow rule is declared further down this file, at equal
   specificity to a selector naming only --sticky-end and --dash-today__actions -
   a tie the later rule would win regardless of which file order made more
   sense to write in. .admin-table--compact is added here, already true of
   this table, purely to outweigh that tie on its own terms rather than
   depending on this block staying below the other one. */
.admin-table--sticky-end.admin-table--compact tbody td.dash-today__actions {
    box-shadow: none;
}
/* The profile card's own rules live further down, beside the modal styles it
   was written for. Only this modifier belongs here: used where the portrait
   heads a modal that continues into a fact list or a form, rather than a card
   that carries its own facts inside it. */
.profile-view--head { margin-bottom: 20px; }

/* ---------- Fact list ----------
   Label left, value right. Used by the faculty profile card and the class
   cards, so a record reads the same whether it is a person or a class. */
.fact-list {
    margin-bottom: 20px;
    padding: 2px 18px;
    border: 1px solid var(--border-color);
    border-radius: 12px;
}
/* Inside a card that is already a box of its own, the border would be a box
   drawn around a box. */
.fact-list--plain { padding: 0; border: none; }
/* Wrapping, so a value too long for the line drops beneath its own label
   instead of squeezing it out of the way. */
.fact-list__row {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: 2px 18px;
    padding: 13px 0;
    border-bottom: 1px dashed var(--border-color);
}
.fact-list__row:last-child { border-bottom: none; }
.fact-list__label { color: var(--text-muted); font-size: 0.95rem; }
.fact-list__value { font-weight: 700; color: var(--text-color); }
/* Denser inside a card, where several sit side by side. */
.fact-list--plain .fact-list__row { padding: 8px 0; }
.fact-list--plain .fact-list__label,
.fact-list--plain .fact-list__value { font-size: 0.86rem; }
/* Quieter still, inside a dialog. There the rows are read as one set before a
   decision rather than scanned for a single value, and five facts at a card's
   full weight read as five separate warnings competing with the real one above
   them. */
.fact-list--quiet .fact-list__row { padding: 7px 0; }
.fact-list--quiet .fact-list__label,
.fact-list--quiet .fact-list__value { font-size: 0.82rem; }
.fact-list--quiet .fact-list__value { font-weight: 600; }

/* ---------- Class cards ----------
   One card per section a teacher handles: the subject leads, then the facts
   that identify the class, then the way into it. A grid rather than a table,
   so many sections are scanned rather than read row by row. */
.class-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(292px, 1fr));
    gap: 18px;
}
.class-card {
    display: flex;
    flex-direction: column;
    padding: 18px 20px;
    background: var(--panel-bg);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
}
.class-card__code { font-size: 1.05rem; font-weight: 700; color: var(--brand); }
.class-card__title {
    margin-bottom: 6px;
    padding-bottom: 11px;
    border-bottom: 1px solid var(--border-color);
    font-size: 0.92rem;
    line-height: 1.35;
}
/* Pushed to the bottom edge, so cards of different heights still line their
   buttons up along one row. */
.class-card__action {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 9px;
    margin-top: auto;
    padding: 10px 14px;
    border-radius: 9px;
    background: var(--brand);
    color: #fff;
    font-size: 0.88rem;
    font-weight: 600;
    text-decoration: none;
    transition: filter 0.15s;
}
.class-card__action:hover { filter: brightness(1.12); }

.field-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(186px, 1fr));
    gap: 20px 20px;
}
.field-grid .input-group { margin: 0; }
/* Spans every column, whatever the current column count is. */
.field-grid__full { grid-column: 1 / -1; }

/* Quieter, uppercase field labels to match the section headings. */
.field-grid .input-group label {
    text-transform: uppercase;
    letter-spacing: 0.06em;
    font-size: 0.72rem;
    font-weight: 600;
    color: var(--text-muted);
    margin-bottom: 7px;
}

.form-footer {
    display: flex;
    justify-content: flex-end;
    margin-top: 26px;
    padding-top: 20px;
    border-top: 1px solid var(--border-color);
}

/* Two equal columns that collapse to one when there is not room. The column
   count is set explicitly rather than by auto-fit so the divider below can
   only ever appear when there really are two columns. */
.split-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 34px;
}
/* Lowered from 1000px so a tablet keeps the two-column form the desktop has -
   the enrollment modal is 940px wide, so both halves still have room there. The
   divider's breathing room comes down with it, since 40px a side is a desktop
   figure. */
@media (min-width: 860px) {
    .split-grid { grid-template-columns: 1fr 1fr; gap: 0; }
    .split-grid--divided > *:first-child {
        padding-right: 26px;
        border-right: 1px solid var(--border-color);
    }
    .split-grid--divided > *:last-child { padding-left: 26px; }
}
@media (min-width: 1100px) {
    .split-grid--divided > *:first-child { padding-right: 40px; }
    .split-grid--divided > *:last-child { padding-left: 40px; }
}
/* Stacked: separate with a horizontal rule instead of a vertical one. The
   ceiling tracks the two-column rule above - at 860px the halves sit side by
   side and take the vertical divider, so a horizontal one here as well would
   draw a border on two edges of the same half. */
@media (max-width: 859px) {
    .split-grid--divided > *:first-child {
        padding-bottom: 30px;
        border-bottom: 1px solid var(--border-color);
    }
}

.preview-note {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    margin-right: auto;
    color: var(--text-muted);
    font-size: 0.86rem;
}
.preview-note i { color: var(--brand); }

/* ---------- Selectable option cards (theme mode) ---------- */
.choice-stack { display: grid; gap: 12px; }
.choice-card {
    position: relative;
    display: flex;
    align-items: center;
    gap: 16px;
    margin: 0;
    padding: 16px 18px;
    border: 1.5px solid var(--border-color);
    border-radius: 11px;
    cursor: pointer;
    font-weight: 400;
    transition: border-color 0.18s, background 0.18s;
}
/* Kept in the DOM (not display:none) so it still takes keyboard focus. */
.choice-card input,
.swatch input {
    position: absolute;
    opacity: 0;
    width: 1px;
    height: 1px;
    margin: 0;
    pointer-events: none;
}
.choice-card > i { font-size: 1.5rem; color: var(--text-muted); transition: color 0.18s; }
.choice-card__text strong { display: block; font-size: 0.98rem; color: var(--text-color); }
.choice-card__text span { font-size: 0.86rem; color: var(--text-muted); }
.choice-card:hover { border-color: var(--brand-soft); }
.choice-card:focus-within { outline: 3px solid var(--brand-soft); outline-offset: 2px; }
.choice-card.is-selected { border-color: var(--brand); background: var(--hover-bg); }
.choice-card.is-selected > i { color: var(--brand); }
/* A faint wash of the accent behind the chosen card, where supported. */
@supports (background: color-mix(in srgb, red 5%, transparent)) {
    .choice-card.is-selected { background: color-mix(in srgb, var(--brand) 7%, transparent); }
}

/* ---------- Colour swatches (accent picker) ---------- */
.swatch-row { display: flex; gap: 16px; flex-wrap: wrap; }
.swatch {
    position: relative;
    display: grid;
    place-items: center;
    width: 58px;
    height: 58px;
    margin: 0;
    border-radius: 50%;
    background: var(--swatch);
    cursor: pointer;
    transition: transform 0.15s, box-shadow 0.15s;
}
.swatch i { color: #fff; font-size: 1.25rem; opacity: 0; transition: opacity 0.15s; }
.swatch.is-selected i { opacity: 1; }
.swatch:hover { transform: scale(1.07); }
.swatch:focus-within { box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.18); }

/* Compact set for a modal header, where the swatches ride on the accent bar
   beside the title instead of sitting in a form. The rings are white because
   that bar is the one place a dark swatch has no light surface to read against. */
.swatch-row--compact { gap: 7px; }
.swatch-row--compact .swatch {
    width: 19px;
    height: 19px;
    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5);
}
.swatch-row--compact .swatch i { font-size: 0.55rem; }
.swatch-row--compact .swatch.is-selected { box-shadow: 0 0 0 2px #fff; }
.swatch-row--compact .swatch:focus-within { box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.65); }

/* Outlined accent button that fills on hover. */
.btn-outline-accent {
    display: inline-flex;
    align-items: center;
    gap: 9px;
    width: auto;
    padding: 10px 20px;
    border: 1.5px solid var(--brand);
    border-radius: 999px;
    background: transparent;
    color: var(--brand);
    font-family: inherit;
    font-size: 0.92rem;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.18s, color 0.18s;
}
.btn-outline-accent:hover,
.btn-outline-accent:focus-visible { background: var(--brand); color: #fff; }

/* Destructive twin, sized to sit inside a block header rather than a toolbar.
   Declared after the base so its hover colour wins at equal specificity. */
.btn-outline-accent--danger {
    padding: 8px 16px;
    border-color: var(--danger);
    color: var(--danger);
    font-size: 0.84rem;
}
.btn-outline-accent--danger:hover,
.btn-outline-accent--danger:focus-visible { background: var(--danger); color: #fff; }

/* Neutral twin, for the control that declines the action beside it - a Cancel
   next to a Save. Everything but the ink comes from the base, because a pair of
   buttons offering two answers to one question should differ in what they say
   and not in what shape they are: a pill beside a rounded rectangle reads as two
   unrelated controls that happen to be adjacent.

   The accent is what marks which of the two is the action, so this one gives it
   up and takes the muted ink instead. Declared after the base for the reason
   --danger is: its hover has to win at equal specificity. */
.btn-outline-accent--muted {
    border-color: var(--border-color);
    color: var(--text-muted);
    font-weight: 600;
}
.btn-outline-accent--muted:hover,
.btn-outline-accent--muted:focus-visible {
    background: var(--hover-bg);
    border-color: var(--text-muted);
    color: var(--text-color);
}

/* `hidden` is a display:none in the browser's own stylesheet, and the
   display:inline-flex above outranks it - so a button carrying the attribute
   stays on screen. The same patch .cam-actions and .scroll-top carry, for the
   same reason, and needed here now that a button in this family is drawn only
   part of the time. */
.btn-outline-accent[hidden] { display: none; }

/* ---------- Click-to-change avatar ---------- */
.avatar-edit {
    position: relative;
    display: block;
    width: 96px;
    height: 96px;
    border-radius: 50%;
    overflow: hidden;
    cursor: pointer;
    flex-shrink: 0;
    border: 3px solid var(--brand);
    background: var(--brand);
    /* Overrides the bold label styling used by form fields elsewhere. */
    margin: 0;
    font-weight: 400;
}
.avatar-edit img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-edit__initial {
    display: grid;
    place-items: center;
    width: 100%;
    height: 100%;
    color: #fff;
    font-size: 2.3rem;
    font-weight: 700;
}
.avatar-edit__overlay {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 3px;
    background: rgba(0, 0, 0, 0.55);
    color: #fff;
    font-size: 0.72rem;
    font-weight: 600;
    opacity: 0;
    transition: opacity 0.18s;
}
.avatar-edit__overlay i { font-size: 1.15rem; }
/* focus-within keeps it reachable by keyboard, not just hover. */
.avatar-edit:hover .avatar-edit__overlay,
.avatar-edit:focus-within .avatar-edit__overlay { opacity: 1; }
.avatar-edit:focus-within { outline: 3px solid var(--brand-soft); outline-offset: 2px; }

/* The file input stays in the layout for keyboard focus, but invisible. */
.avatar-edit__input {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    opacity: 0;
    cursor: pointer;
    padding: 0;
    border: none;
}

/* While the upload posts, freeze the overlay on with a spinner. */
.avatar-edit.is-uploading .avatar-edit__overlay { opacity: 1; }
.avatar-edit.is-uploading .avatar-edit__overlay i {
    animation: avatarSpin 0.8s linear infinite;
}
@keyframes avatarSpin { to { transform: rotate(360deg); } }

/* The floor is 172px rather than a roomier 228 so that four tiles still make
   one row inside the 1100px viewport the portal declares: that leaves 760px of
   content, which four 228px tracks and their gaps overrun, dropping the fourth
   onto a line of its own. auto-fit never makes more tracks than there are
   tiles, so a wide desktop is unchanged - this only decides the width at which
   the row is allowed to stay whole. */
.stat-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(172px, 1fr));
    gap: 18px;
    margin-bottom: 22px;
}
.stat-tile {
    background: var(--panel-bg);
    border: 1px solid var(--border-color);
    border-left: 4px solid var(--brand);
    border-radius: var(--radius);
    padding: 20px 22px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 14px;
    box-shadow: var(--shadow);
}
.stat-tile__value { font-size: 2.1rem; font-weight: 700; line-height: 1; }
.stat-tile__label {
    margin-top: 7px;
    font-size: 0.76rem;
    font-weight: 700;
    letter-spacing: 0.07em;
    text-transform: uppercase;
    color: var(--text-muted);
}
.stat-tile__icon {
    width: 54px;
    height: 54px;
    border-radius: 13px;
    display: grid;
    place-items: center;
    font-size: 1.3rem;
    flex-shrink: 0;
    background: #FDEAEA;
    color: #C0392B;
}
.stat-tile--green { border-left-color: #1D6F42; }
.stat-tile--green .stat-tile__icon { background: #E7F4EC; color: #1D6F42; }
.stat-tile--amber { border-left-color: #B7791F; }
.stat-tile--amber .stat-tile__icon { background: #FDF3E0; color: #B7791F; }
.stat-tile--blue { border-left-color: var(--brand); }
.stat-tile--blue .stat-tile__icon { background: #E9EFF9; color: var(--brand); }

/* legacy stat markup */
.dashboard-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(228px, 1fr)); gap: 18px; margin-bottom: 22px; }
.dashboard-stats .stat-card {
    background: var(--panel-bg);
    border: 1px solid var(--border-color);
    border-left: 4px solid var(--brand);
    border-radius: var(--radius);
    padding: 20px;
    display: flex;
    align-items: center;
    gap: 15px;
    box-shadow: var(--shadow);
}
.dashboard-stats .stat-info p {
    margin: 0;
    color: var(--text-muted);
    font-size: 0.76rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
}
.dashboard-stats .stat-info h2 { margin: 4px 0 0; font-size: 1.9rem; }

.glass-panel, .profile-container {
    background: var(--panel-bg);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 22px 24px;
    box-shadow: var(--shadow);
    margin-bottom: 22px;
}
.glass-panel > h3:first-child { margin-top: 0; font-size: 1.09rem; }

/* Columns stretch to match each other, so side-by-side panels share a
   baseline rather than each ending at its own content. */
.panel-grid {
    display: grid;
    grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
    gap: 22px;
    align-items: stretch;
}
/* The panels already carry a bottom margin from .glass-panel; inside a grid
   row that margin would break the shared edge. */
.panel-grid > .glass-panel { margin-bottom: 0; }

/* Panel heading with a link pushed to the right, e.g. "View All". */
.panel-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    margin-bottom: 12px;
}
.panel-head h3 { margin: 0; }
.panel-head a { font-size: 0.9rem; font-weight: 600; }

/* Full-width shortcut tile stacked in a Quick Actions panel. */
.action-tile {
    display: flex;
    align-items: center;
    gap: 14px;
    width: 100%;
    padding: 14px 16px;
    margin-bottom: 11px;
    border-radius: 11px;
    background: var(--hover-bg);
    border: 1px solid transparent;
    color: var(--text-color);
    text-decoration: none;
    font-weight: 600;
    transition: background 0.15s, border-color 0.15s;
}
.action-tile:last-child { margin-bottom: 0; }
.action-tile:hover { background: var(--panel-bg); border-color: var(--brand); }
.action-tile i {
    width: 22px;
    flex-shrink: 0;
    text-align: center;
    color: var(--brand);
    font-size: 1.05rem;
}

/* Two-line table cell: an identifier with its qualifier beneath. Shared so the
   dashboards' side-by-side panels keep the same row height and rhythm.

   Both lines are set tight and close. They are one thing read together - a code
   and the class it belongs to - so the leading between them is pulled well in
   from the body default, which is spaced for prose and leaves the pair looking
   like two separate rows. */
.cell-stack strong { display: block; font-weight: 700; line-height: 1.25; }
.cell-stack span {
    display: block;
    margin-top: 1px;
    font-size: 0.59rem;
    font-weight: 600;
    line-height: 1.2;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-muted);
}

/* ---------- Tables ---------- */
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th {
    text-align: left;
    padding: 11px 14px;
    font-size: 0.74rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.07em;
    color: var(--text-muted);
    border-bottom: 1px solid var(--border-color);
    /* A heading is a label, not prose: "Applying as" and "Requested by" name one
       column between them and read as one phrase, so breaking them over two
       lines gives the header row the height of two and still says the same
       thing. Uppercase and letter-spaced, they are also wider than they look,
       which is what made them wrap in columns that were otherwise wide enough.
       Every one of these tables already sits in an overflow-x:auto wrapper, so
       what a long header costs is scroll, which the sticky action column is
       there to survive - not a second line. */
    white-space: nowrap;
}
.admin-table td {
    padding: 13px 14px;
    border-bottom: 1px solid var(--border-color);
    font-size: 0.92rem;
}
.admin-table tr:last-child td { border-bottom: none; }
.admin-table tbody tr:hover td { background: var(--hover-bg); }

/* ---------- Compact table ----------
   For a table sharing its row with another panel, where the full type size
   wraps every cell into two or three lines. The type steps down and cells hold
   one line each, so the column reads as a row of values rather than a block of
   text. The panel's own overflow-x is the safety valve if it still will not
   fit. Same idea as .preview-table, which faces the same squeeze. */
.admin-table--compact th,
.admin-table--compact td {
    padding: 9px 10px;
    font-size: 0.82rem;
    white-space: nowrap;
}
.admin-table--compact th { font-size: 0.66rem; letter-spacing: 0.05em; }
/* A step under the shared .cell-stack size, which the compact table's own
   smaller type already sits beneath. */
.admin-table--compact .cell-stack span { font-size: 0.62rem; }

/* ---------- Pinned actions column ----------
   The row's own controls, held against the right edge while the rest of the
   table slides under them - otherwise reaching a button means scrolling to the
   far end and losing sight of whose row it belongs to.

   Opt-in per table and not bound to a breakpoint: a table wide enough to scroll
   is wide enough to scroll on a desktop too, and a laptop reading a directory
   of seven columns has the same problem a phone does. A table that fits scrolls
   nowhere, and the rule then costs it nothing.

   An opaque background is what makes it a cover rather than a transparent pane;
   the shadow on its left edge is the hint that there is more behind. The
   selector has to beat `.admin-table--divided tbody td`, which sets
   position:relative on every cell and would otherwise cancel the stick. */
.admin-table--sticky-end th:last-child,
.admin-table--sticky-end tbody td:last-child {
    position: sticky;
    right: 0;
    /* Comfortably clear of its neighbours. --divided puts every cell at
       position:relative, so they are all positioned elements competing for the
       same layer; a value of 1 leaves no margin for another rule to come along
       and match it. */
    z-index: 3;
    background: var(--panel-bg);
    box-shadow: -7px 0 7px -5px rgba(16, 24, 40, 0.18);
}
.admin-table--sticky-end th:last-child { z-index: 4; }
/* A cover has to be the colour of what it covers: hovering a row tints it, and
   a pinned cell left on the panel's own white reads as a gap in the highlight.
   The solid form of the tint, since --hover-bg is 5% opaque and hides nothing. */
.admin-table--sticky-end tbody tr:hover td:last-child { background: var(--hover-bg-solid); }
/* Inside a term block the table sits on that block's own tint, not the panel's.
   A cover has to be the colour of what it covers, or the pinned column reads as
   a pale stripe laid over the row. The solid form of that tint, not --hover-bg
   itself: that one is 5% opaque, which is fine for shading a row in place and no
   use at all as a cover - the table scrolled straight through underneath it. */
.term-block .admin-table--sticky-end th:last-child,
.term-block .admin-table--sticky-end tbody td:last-child { background: var(--hover-bg-solid); }

/* ---------- Row-capped scroll box ----------
   A list inside a modal that must not grow the dialog holding it.
   .modal-content is itself a scroller capped at 90vh, so an uncapped list makes
   the whole modal scroll: the form above the list walks off the top and the
   rows and the form fight over one scrollbar. Capping the list moves only the
   rows and leaves the form where it was put.

   Measured in rows because that is what the caller means. --row-h is derived
   from what a .admin-table--compact row actually draws: 9px padding top and
   bottom, .cell-stack's title line (0.82rem at 1.25) over its subtext line
   (0.62rem at 1.2, plus 1px), and the 1px rule under the row - 48.3. Rounded
   up rather than down, so the third row lands whole and the fourth shows the
   sliver that says the box scrolls; rounding down clips the third by a hair,
   which reads as a rendering fault rather than an invitation. */
.row-scroll {
    --row-h: 49px;
    --rows: 3;
    max-height: calc(var(--row-h) * var(--rows));
    overflow-y: auto;
}
/* Both lines are held to one each, so every row is the same height and the cap
   lands cleanly on the last row rather than halfway through it. A cell in an
   auto-layout table only honours text-overflow once it has a max-width to
   overflow, hence the 0 - with width:100% the column still takes the space
   left over. The untruncated text stays reachable through the title attribute
   the markup sets. */
.row-scroll td.cell-stack { max-width: 0; width: 100%; }
.row-scroll td.cell-stack strong,
.row-scroll td.cell-stack span {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* A right-aligned, muted cell carrying a time span or a timestamp. It exists as
   a class rather than the inline style it replaces because that style could not
   be answered by a media query - an inline rule outranks the stylesheet, so the
   nowrap held at every width and a column of clock times was enough on its own
   to push a four-column table wider than a phone. Held to one line where there
   is room, allowed to fold where there is not. */
.cell-time { text-align: right; white-space: nowrap; color: var(--text-muted); }

/* A cell holding a class's hours: one line per distinct span, since a class
   need not keep the same ones on every day it meets - "M 7:30am – 10:30am" over
   "TH 8:00am – 10:00am".

   The nowrap is on the lines rather than the cell, which is the point of the
   two-level rule. Run together as one string these wrapped wherever the column
   ended, splitting a time from its meridiem and finishing a cell on a bare
   "AM"; broken by day, each line is short enough to hold and the fold happens
   where it means something. Kept left-aligned, unlike .cell-time, because the
   day code leads the line and a ragged left edge would hide it. */
.cell-times { line-height: 1.35; }

/* The nowrap sits on the lines in both cells, including the right-aligned
   .cell-time the dashboard uses - the two keep their own alignment and share
   only this. */
.cell-time div,
.cell-times div { white-space: nowrap; }

/* The meeting-days cell: a face-to-face line over an online one, each a glyph
   and a run of day codes. Its size lives here rather than inline for the same
   reason .cell-time's does - an inline rule cannot be answered by a media
   query, and this is a cell that has to come down on a narrow screen. */
.cell-days { font-size: 0.85rem; white-space: nowrap; }

/* ---------- Two-length label ----------
   A pair of wordings for one thing: the full form on a screen with room for it,
   a shorter one where there is not. "Subject Code" wants about 72px of a 68px
   column, and the size that would fit it is smaller than a heading can be read
   at - so the words go rather than the type. Used on column headings and on the
   mode badge in a class list, where "Face to face" meets the same wall.

   Only the wording changes; what it names is the same either way, and the
   context around it - the column it heads, the icon beside it - still says
   which. */
.label-short { display: none; }
.admin-table--compact .badge { font-size: 0.68rem; padding: 3px 8px; }
.admin-table--compact .row-subtext { font-size: 0.66rem; }

/* ---------- Column dividers ----------
   The divider separates the values, so it is drawn only on the data rows and
   only between them - the header already reads as a row of its own, and a rule
   running the full height of every cell would fence the table into a grid
   rather than just showing where one column ends. Short and centred, so it
   reads as a separator and not as a border.

   Kept apart from --aligned below: a single table wants the dividers without
   needing a declared column grid, and only a page that stacks tables needs
   both. */
.admin-table--divided tbody td { position: relative; }
.admin-table--divided tbody td:not(:last-child)::after {
    content: '';
    position: absolute;
    right: 0;
    top: 50%;
    transform: translateY(-50%);
    width: 1px;
    height: 30%;
    background: var(--border-color);
}

/* ---------- One line per cell ----------
   For a table whose every column holds a value rather than prose: a name, a
   department, a rank, a date. Those are single things, and "BS Information
   Technology" or "Year Level Coordinator" broken across two lines is one fact
   pretending to be two - it doubles the row's height and reads as a list.

   Not .admin-table--nowrap, which is a different job under a similar name: that
   one is a phone-only opt-in for tables with more columns than a handset can
   show, and it drags width:max-content, a colgroup override, and smaller type
   along with it. This is only the wrapping, and it holds at every width. The two
   can sit on the same table without arguing.

   Left off any table with a free-text column - a reason, a remark, an audit
   line - where wrapping is what lets the text be read at all. */
.admin-table--single-line td { white-space: nowrap; }

/* ---------- A row that belongs to the row above it ----------
   The admin report's faculty breakdown: the teachers a department's figures are
   the average of, listed under it in the same table so every number stays in the
   column that names it.

   Read as subordinate rather than announced as such - a tint, lighter type, and
   the name indented behind a rule down the first cell. The alternative was a
   nested table per department, which would have given each one its own column
   widths and left the figures in a ragged line down the page. */
.admin-table tbody tr.report-subrow td {
    background: var(--hover-bg);
    font-size: 0.86rem;
    color: var(--text-muted);
    /* The tint carries the grouping; a full border under every sub-row would cut
       the group it is meant to hold together. */
    border-top-color: transparent;
}
.admin-table tbody tr.report-subrow:hover td { background: var(--hover-bg-solid); }
/* The indent and the rule belong to the cell's whole block, not to the name:
   hung on the name alone, the class count beneath it started back at the cell's
   left edge and read as though it belonged to the department row above. */
.report-subrow__label {
    padding-left: 14px;
    border-left: 2px solid var(--border-color);
}
.report-subrow__name {
    font-weight: 600;
    color: var(--text-color);
}
/* Where a column has nothing to say about a single teacher. Muted to the point
   of being a placeholder, since an em dash at full weight reads as a value. */
.report-subrow__blank { color: var(--border-color); }
/* The first cell's indent has to survive onto paper, where the tint does not
   print. Everything else about the row can go. */
@media print {
    .admin-table tbody tr.report-subrow td { background: none; }
}

/* ---------- A table asked to fit rather than scroll ----------
   The wrapper the dean's report table sits in. It keeps overflow-x:auto so a
   table that genuinely cannot fit still scrolls rather than bursting its panel,
   but the table inside is set up to fit instead - the columns holding words are
   left free to wrap, and only --nowrap below the phone breakpoint overrides
   that.

   The pin comes off with the scrolling. --sticky-end exists to hold the last
   column in view while the rest is dragged past it; where nothing is dragged it
   contributes a shadow announcing a drag that never happens, and a cell sitting
   on its own opaque background inside a row that has one already. Restored on a
   phone below, where the drag is real. */
.table-fit { overflow-x: auto; }
.table-fit .admin-table--sticky-end th:last-child,
.table-fit .admin-table--sticky-end tbody td:last-child {
    position: static;
    box-shadow: none;
}
/* The rate and the weight under it are one figure read together, and the only
   column here narrow enough that holding it to one line costs the table
   nothing. Everything to its left may wrap, which is what lets it fit. */
.table-fit .admin-table th:last-child,
.table-fit .admin-table td:last-child { white-space: nowrap; }

/* ---------- Tables stacked down a page ----------
   A page that groups its rows into several panels renders a table per panel,
   and an auto-sized table puts its columns wherever its own content happens to
   fall - so one panel's columns never line up with the next one's. A fixed
   layout over declared widths gives every panel the same grid; pair it with
   --divided above, which is what makes that shared grid visible.

   Widths come from a <colgroup> on each table, since only the page knows which
   of its columns deserves the room. */
.admin-table--aligned { table-layout: fixed; }
.admin-table--aligned th,
.admin-table--aligned td {
    /* Nothing can push a column wider now, so long values wrap inside their
       own cell rather than spilling past it. */
    overflow-wrap: anywhere;
}

.pill {
    display: inline-block;
    background: #E9EFF9;
    color: var(--brand);
    font-weight: 700;
    font-size: 0.82rem;
    padding: 4px 11px;
    border-radius: 7px;
}

.empty-state { text-align: center; padding: 44px 20px; color: var(--text-muted); }
.empty-state p { margin: 0; }
/* Optional glyph above an empty-state message. */
.empty-state__icon {
    display: block;
    margin-bottom: 14px;
    font-size: 2.5rem;
    color: var(--text-muted);
    opacity: 0.55;
}

.info-group { margin-bottom: 14px; }
.info-group label { display: block; font-size: 0.78rem; color: var(--text-muted); }
.info-group .info-val { font-weight: 600; }

/* ---------- Alerts ---------- */
.alert {
    display: flex;
    align-items: center;
    gap: 11px;
    padding: 13px 16px;
    border-radius: 10px;
    margin-bottom: 20px;
    font-size: 0.92rem;
}
.alert--success { background: #E7F4EC; color: #14532D; border: 1px solid #BFE3CC; }
.alert--error { background: #FDECEA; color: #7F1D1D; border: 1px solid #F5C2BD; }
/* Amber, for the case between the two: nothing has failed and nothing is being
   destroyed, but the action about to be taken does more than it looks like it
   does. Fixed hexes like the pair above rather than theme variables, so the
   three status colours stay a set. */
.alert--warning { background: #FEF6E7; color: #7C4A03; border: 1px solid #F5DFAE; }
/* Informational, and the one of the four that follows the viewer's own accent
   rather than a fixed status colour: it says how something works, where the
   other three say how it went.
   The soft step rather than the base, since this sits on a tinted panel in
   light mode and a dark one in dark mode, and soft is the lighter of the two -
   the same reason .section-group takes an accent on a panel background. */
.alert--info {
    background: var(--hover-bg);
    border: 1px solid var(--brand);
    color: var(--brand-soft);
}
.alert__close {
    margin-left: auto;
    background: none;
    border: none;
    font-size: 1.3rem;
    line-height: 1;
    cursor: pointer;
    color: inherit;
    opacity: 0.6;
    padding: 0;
    width: auto;
}
.alert__close:hover { opacity: 1; }

/* ---------- Toasts ----------
   Flash messages float over the page and retire themselves, so a saved
   change never shifts the layout the user is looking at. */
.toast-stack {
    position: fixed;
    top: calc(var(--topbar-h) + 16px);
    right: 22px;
    z-index: 3000;
    display: flex;
    flex-direction: column;
    gap: 10px;
    width: min(390px, calc(100vw - 44px));
    pointer-events: none;
}
.toast {
    position: relative;
    display: flex;
    align-items: flex-start;
    gap: 11px;
    padding: 13px 15px 15px;
    border-radius: 10px;
    border: 1px solid var(--border-color);
    border-left: 4px solid var(--text-muted);
    background: var(--panel-bg);
    box-shadow: 0 14px 34px rgba(16, 24, 40, 0.18);
    font-size: 0.89rem;
    line-height: 1.5;
    overflow: hidden;
    pointer-events: auto;
    animation: toastIn 0.3s cubic-bezier(0.2, 0.9, 0.3, 1.15);
}
/* The auth page has no top bar, so its toasts sit nearer the top edge. */
.auth-page .toast-stack { top: 22px; }

.toast--success { border-left-color: var(--success); }
.toast--success > i { color: var(--success); }
.toast--error { border-left-color: var(--danger); }
.toast--error > i { color: var(--danger); }
.toast > i { font-size: 1.05rem; margin-top: 1px; flex-shrink: 0; }
.toast__text { flex: 1; min-width: 0; word-break: break-word; }
.toast__close {
    width: auto;
    padding: 0 2px;
    border: none;
    background: none;
    color: var(--text-muted);
    font-size: 1.2rem;
    line-height: 1;
    cursor: pointer;
    flex-shrink: 0;
}
.toast__close:hover { color: var(--text-color); }

/* Countdown to the automatic dismissal; pauses while hovered. */
.toast__timer {
    position: absolute;
    left: 0;
    bottom: 0;
    height: 3px;
    width: 100%;
    background: currentColor;
    opacity: 0.28;
    transform-origin: left center;
    animation: toastTimer 4s linear forwards;
}
.toast:hover .toast__timer { animation-play-state: paused; }

.toast.is-leaving { animation: toastOut 0.26s ease forwards; }

@keyframes toastIn {
    from { opacity: 0; transform: translateX(110%); }
    to   { opacity: 1; transform: none; }
}
@keyframes toastOut {
    from { opacity: 1; transform: none; }
    to   { opacity: 0; transform: translateX(110%); }
}
@keyframes toastTimer {
    from { transform: scaleX(1); }
    to   { transform: scaleX(0); }
}
@media (prefers-reduced-motion: reduce) {
    .toast, .toast.is-leaving { animation: none; }
}
/* Toasts come down to thumb height on a phone - but clear of the bottom bar,
   which they would otherwise cover. The toast outranks the bar on z-index, so
   without this offset a saved message would sit on top of the navigation. */
@media (max-width: 560px) {
    .toast-stack {
        top: auto;
        bottom: calc(86px + env(safe-area-inset-bottom, 0px));
        left: 16px;
        right: 16px;
        width: auto;
    }
}

/* auth pages still use these fixed-position variants */
.error-alert, .success-alert {
    position: fixed;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 999;
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 14px 20px;
    border-radius: 10px;
    box-shadow: var(--shadow);
    font-size: 0.9rem;
    max-width: 90vw;
}
.error-alert { background: #FDECEA; color: #7F1D1D; border: 1px solid #F5C2BD; }
.success-alert { background: #E7F4EC; color: #14532D; border: 1px solid #BFE3CC; }

/* ---------- Badges ---------- */
.badge {
    display: inline-block;
    padding: 4px 11px;
    border-radius: 999px;
    font-size: 0.74rem;
    font-weight: 700;
    text-transform: capitalize;
}
.badge-pending { background: #FDF3E0; color: #92400E; }
.badge-approved { background: #E7F4EC; color: #14532D; }
.badge-deactivated, .badge-rejected { background: #FDECEA; color: #7F1D1D; }
/* Class delivery mode, e.g. on the teacher dashboard's Today's Classes list. */
.badge-online { background: #e7f1ff; color: #0b4fa8; }
.badge-ftf { background: #eef7ee; color: #1e4620; }

/* ---------- Modal ---------- */
.modal-overlay {
    position: fixed;
    inset: 0;
    background: rgba(17, 24, 39, 0.5);
    display: none;
    align-items: center;
    justify-content: center;
    z-index: 1000;
    padding: 20px;
}
/* Opening a modal swaps display, which transitions cannot animate - so the
   entrance is a keyframe animation instead, which fires on display change. */
/* No fill-mode on purpose: with `both` the element holds the from-state
   (opacity 0) whenever the animation has not run, which would leave a modal
   invisible instead of merely un-animated. */
.modal-overlay.active { display: flex; animation: modalBackdropIn 0.18s ease; }
.modal-overlay.active .modal-content {
    animation: modalContentIn 0.24s cubic-bezier(0.2, 0.9, 0.3, 1.15);
}
@keyframes modalBackdropIn {
    from { opacity: 0; }
    to   { opacity: 1; }
}
@keyframes modalContentIn {
    from { opacity: 0; transform: translateY(14px) scale(0.96); }
    to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
    .modal-overlay.active,
    .modal-overlay.active .modal-content { animation: none; }
}

.modal-content {
    background: var(--panel-bg);
    border-radius: var(--radius);
    width: 100%;
    max-width: 480px;
    max-height: 90vh;
    overflow-y: auto;
    box-shadow: 0 20px 50px rgba(16, 24, 40, 0.25);
}
/* The header carries the user's accent, so every dialog reads as part of the
   themed shell. Its contents inherit white from here rather than each modal
   setting its own colour, which is what keeps them from drifting apart. */
.modal-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    padding: 16px 22px;
    background: var(--brand);
    color: #fff;
    /* Carried on the bar itself, not left to the parent: a modal that has to
       turn off overflow clipping would otherwise show square top corners. */
    border-radius: var(--radius) var(--radius) 0 0;
    /* .modal-content is the scroller, so the bar holds its place while a long
       form moves under it and the close control stays reachable. */
    position: sticky;
    top: 0;
    z-index: 2;
}
.modal-header h3 { color: inherit; }
/* The close control is a bare span in the markup, styled here so no modal has
   to remember to. The confirm dialog's title span is nested in the h3, so a
   direct-child selector cannot catch it by mistake. */
.modal-header > span {
    line-height: 1;
    opacity: 0.85;
    transition: opacity 0.15s;
}
.modal-header > span:hover { opacity: 1; }
/* Import modals keep the flow's own green instead of the user's accent: the
   bar, the guide's rule, and the import buttons all belong to one action, and
   an accent that changes per user would break that group apart. Pinned, so it
   holds in dark mode too. */
.modal-header--green { background: #3D8B40; }
/* The bar a dialog wears when it is reporting a refusal or asking to destroy
   something. Set by showConfirm() from its `tone`, so the header, the icon in
   it, and the accept button below all state the same thing - the accent-blue
   bar over a red button was the dialog disagreeing with itself. Pinned like the
   green above, so it holds whatever accent the viewer has chosen. */
.modal-header--danger { background: #C0392B; }
.modal-body { padding: 22px; }

/* A modal's commit button, held at the bottom of the dialog while its body
   scrolls under it. .modal-content is the scroller and its header already
   sticks to the top, so this is the same trick at the other end - which matters
   most on the tall forms, where the button is otherwise several scrolls below
   the field being edited and easy to believe is missing.

   The negative margins bleed it back through .modal-body's padding so the bar
   spans the dialog's full width, the way the header does; the opaque background
   is what makes it a bar rather than a button floating over the text. */
.modal-sticky-actions {
    position: sticky;
    bottom: 0;
    z-index: 5;
    margin: 20px -22px -22px;
    padding: 14px 22px;
    background: var(--panel-bg);
    border-top: 1px solid var(--border-color);
    border-radius: 0 0 var(--radius) var(--radius);
}
/* The same bar as a real footer, for a dialog whose *body* scrolls rather than
   whose shell does - the shell being a flex column of header, body, footer.

   The difference from the sticky bar above is not cosmetic, and it is the reason
   both exist. A sticky element is held by its own parent, so one placed at the
   end of a long body only starts holding once that parent scrolls into view: on
   the import dialog the buttons appeared just as the reader reached the end of
   the guidance they were meant to act on, and were absent before that. Outside
   the scroller there is nothing to scroll past - the bar is on screen from the
   moment the dialog opens.

   Reach for this one where the body is long enough to need its own scroller, and
   the sticky one where .modal-content is still the scroller. */
.modal-footer-bar {
    flex: 0 0 auto;
    display: flex;
    gap: 10px;
    padding: 14px 22px;
    background: var(--panel-bg);
    border-top: 1px solid var(--border-color);
    border-radius: 0 0 var(--radius) var(--radius);
}

/* The two controls share the width, the commit taking the larger part - it is
   the one being aimed at, and the pair reads as a decision with a default rather
   than as two equal choices.

   Declared here rather than inline on the buttons, which is where these numbers
   started. An inline width outranks the stylesheet, so the phone rules below
   could not reach them and "Preview Rows" wrapped onto a second line inside a
   button tall enough for one - the same reason .cell-time is a class.

   min-width:0 because a flex item will not shrink below its content by default,
   which is what pushed the pair wider than the bar in the first place. width:auto
   undoes .btn-green's own width:100%, which fights flex for the same space. */
.modal-footer-bar .btn-outline-green,
.modal-footer-bar .btn-green {
    min-width: 0;
    /* One line, always. A wrapped label makes the two buttons different heights,
       and the bar then reads as broken rather than as full. */
    white-space: nowrap;
    justify-content: center;
    text-align: center;
}
.modal-footer-bar .btn-outline-green { flex: 1 1 0; }
.modal-footer-bar .btn-green { flex: 1.4 1 0; width: auto; }

/* A phone gives the bar about half the width a desktop does, and the labels do
   not get shorter - so the type and the padding come down instead. Stepped
   twice: the first for a tablet and a large handset, the second from 480px down,
   which is where "Preview Rows" stops fitting its share.

   480 rather than something nearer the point it actually breaks. The pair only
   just held at 390px - a width most phones in use are at or below - and a rule
   that passes by a pixel is one the next font change breaks silently. The step
   below has room in hand at every size from 480 to 320. */
@media (max-width: 560px) {
    .modal-footer-bar { gap: 8px; padding: 12px 16px; }
    .modal-footer-bar .btn-green { padding: 11px 12px; font-size: 0.9rem; }
    .modal-footer-bar .btn-outline-green { padding: 10px 12px; font-size: 0.82rem; gap: 6px; }
}
@media (max-width: 480px) {
    .modal-footer-bar { gap: 6px; padding: 10px 12px; }
    .modal-footer-bar .btn-green { padding: 10px 8px; font-size: 0.82rem; }
    .modal-footer-bar .btn-outline-green { padding: 9px 8px; font-size: 0.76rem; gap: 5px; }
    /* The glyph goes before the words do. It repeats what the label already
       says, and the label is the half a reader cannot reconstruct. */
    .modal-footer-bar .btn-outline-green i { display: none; }
}

/* The shared confirmation dialog: narrower than a form modal, since it only
   ever holds a sentence and two buttons. */
.modal-content--confirm { max-width: 440px; }
.modal-content--confirm #confirm-message { margin: 0; line-height: 1.6; }
/* The question an import confirmation leads with, above its counts. Carries the
   gap to the tiles itself, because #confirm-message holds no margin of its own
   and the plain-sentence dialogs must not grow one. */
.modal-content--confirm .confirm-question { margin: 0 0 14px; }
.modal-content--confirm #confirm-note {
    margin: 10px 0 0;
    font-size: 0.85rem;
    color: var(--text-muted);
}
.confirm-actions { display: flex; gap: 10px; margin-top: 20px; }
.confirm-actions .quick-action-link {
    flex: 1;
    padding: 11px 16px;
    text-align: center;
    cursor: pointer;
}
.confirm-actions .btn-primary-filled { flex: 1.3; }
.confirm-figure,
.confirm-title { display: none; }

/* Square variant: a short prompt reads better as a centred card than as a
   wide, shallow strip, so the header bar gives way to a figure and the title
   moves inline. Declared after the base so it wins at equal specificity. */
.modal-content--square {
    display: flex;
    flex-direction: column;
    max-width: 380px;
    min-height: 380px;
}
.modal-content--square .modal-header { display: none; }
.modal-content--square .modal-body {
    flex: 1;
    display: flex;
    flex-direction: column;
    justify-content: center;
    padding: 0px 28px;
    text-align: center;
}

/* The halo is the icon's own colour at low opacity, so it follows the accent
   without a second colour needing to be defined or kept in step. */
.modal-content--square .confirm-figure {
    position: relative;
    display: grid;
    place-items: center;
    width: 84px;
    height: 84px;
    margin: 0 auto 22px;
    color: var(--brand);
    font-size: 2rem;
}
.modal-content--square .confirm-figure::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 50%;
    background: currentColor;
    opacity: 0.13;
}
.modal-content--square .confirm-figure i { position: relative; }

.modal-content--square .confirm-title {
    display: block;
    margin: 0 0 12px;
    font-size: 1.3rem;
    font-weight: 700;
}
.modal-content--square #confirm-message { font-size: 1rem; color: var(--text-muted); }
.modal-content--square .confirm-actions { margin-top: 26px; }

/* ================================================================
   Responsive
   ================================================================ */
@media (max-width: 1024px) {
    /* The two-panel row is kept, only narrowed - a tablet has the width for it,
       and stacking there would throw away the shape of the page for no gain.
       The side column's floor comes down so the pair still fits at 768. */
    .panel-grid { grid-template-columns: minmax(0, 1.6fr) minmax(210px, 1fr); gap: 16px; }
    /* Panels no longer share one screen's height, so the page itself scrolls
       rather than squeezing both into an unusable strip. */
    .portal__main--fit { height: auto; overflow: visible; }
    /* The list still scrolls inside its panel, just against a generous cap
       instead of the leftover height it gets on a desktop. Letting it run to
       its full length instead - which is what `visible` did - made the Overview
       many screens long while every other page stayed one, because this is the
       only page whose panels hold an open-ended list. */
    .portal__main--fit .panel-scroll { overflow-y: auto; max-height: 58vh; }
}

/* Below this the side column would be too narrow to read, so the pair finally
   stacks - a phone is where that trade stops being worth it, not a tablet.

   minmax(0, 1fr) and not a plain 1fr. A grid track's minimum is `auto`, which
   resolves to its content's minimum width - so a panel holding a table with a
   460px floor made the track 460px, and the panel then stood wider than the
   banner above it and ran off the screen. Pinning the minimum to zero lets the
   track take the screen's width and leaves the table to scroll inside its own
   box, which is what it already has an overflow for. */
@media (max-width: 700px) {
    .panel-grid { grid-template-columns: minmax(0, 1fr); }
}

/* ---------- Tall screens: drop the fit layout ----------
   Keyed on shape rather than width, because width can no longer tell us
   anything. The portal declares a fixed 1100px viewport, so a phone reports
   1100px however narrow it really is - and every width query above is blind to
   it. What does change is the proportion: a 390x844 phone laying out at 1100px
   wide has a viewport 2380px tall, so `height: 100vh` stretches a panel row
   holding one class to the better part of eight hundred pixels of nothing.

   A desktop is landscape and a phone in the hand is not, so anything squarer
   than 1:1 gives up the fixed height and lets the panels be as tall as what is
   in them. The px ceiling replaces the vh one for the same reason - a viewport
   height that is itself a scaling artefact is no basis for a measurement. */
@media (max-aspect-ratio: 1/1) {
    .portal__main--fit { height: auto; overflow: visible; }
    /* flex:1 was there to hand the row the leftover height; with no fixed
       height to leave over, it only stretches the row past its content. */
    .portal__main--fit .panel-grid { flex: none; }
    .portal__main--fit .panel-grid > .glass-panel { min-height: 0; }
    .portal__main--fit .panel-scroll { overflow-y: auto; max-height: 620px; }
}
/* The banner's controls stop fitting beside the title long before the sidebar
   collapses, so the search takes the full row and the actions sit under it
   rather than each squeezing to a few characters wide.

   They keep to the right when they get there. A control row that drops below
   the heading and then sits under its first letter reads as something left
   behind; held to the same edge it came from, it reads as the banner's own
   toolbar - and it is where a thumb already is. */
@media (max-width: 860px) {
    .page-banner__main { flex-wrap: wrap; }
    .banner-actions-col { width: 100%; margin-left: 0; align-items: flex-end; }
    .banner-actions {
        width: 100%;
        margin-left: 0;
        justify-content: flex-end;
    }
    .banner-actions__note { text-align: right; }
    /* Shares the row with the buttons rather than taking one of its own. At
       width:100% the field filled the line and pushed the filter onto a second,
       which read as two separate toolbars; growing to fill whatever the buttons
       leave keeps one row and puts the search where the thumb already is.

       min-width:0 is what actually lets it shrink - a flex item's floor is its
       own content unless told otherwise, and a text input's intrinsic width is
       wide enough to force the wrap back. */
    .banner-search { flex: 1 1 150px; width: auto; min-width: 0; }
    /* The suggestion list is sized for the desktop field it normally hangs
       from; here it has to answer to the phone instead, so it takes the width
       of the box above it rather than a fixed 280px that would overhang. */
    .banner-search .picker__list { left: 0; right: 0; min-width: 0; }
    /* Once the actions have wrapped onto rows of their own the banner is too
       tall to hold against a phone screen, so it scrolls away with the page. */
    .page-banner { position: static; }
    .page-banner::before { display: none; }
}

/* ---------- Where the bottom bar takes over ----------
   Two triggers, one outcome. An upright screen is the real case: the portal
   declares a fixed 1100px viewport, so a phone never reports a narrow width and
   a max-width query alone would never fire on the device this is for. The width
   trigger is kept for a desktop window dragged narrower than the sidebar.

   Only `display` and the room the bar needs are set here - the bar's own
   styling lives with the component, so neither trigger can dress it
   differently. The sidebar goes rather than being pushed off-screen: it used to
   slide away with nothing to bring it back, which left a phone with no
   navigation at all. */
@media (max-aspect-ratio: 1/1), (max-width: 860px) {
    .sidebar { display: none; }
    .bottom-bar { display: flex; }
    /* Room for the bar, so the last row of a table or a Save button is never
       stranded underneath it. */
    .portal__main { padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px)); }

    /* Only here: on desktop the banner is sticky, so there is nothing to
       return to. Sits above the bottom bar and the home indicator beneath it,
       and to the right, where a thumb already is and where it covers a table's
       last column rather than the names down its left. */
    .scroll-top {
        display: grid;
        place-items: center;
        position: fixed;
        right: 16px;
        bottom: calc(96px + env(safe-area-inset-bottom, 0px));
        z-index: 39;   /* under the bottom bar's 40, over the page */
        width: 44px;
        height: 44px;
        padding: 0;
        border: none;
        border-radius: 50%;
        background: var(--brand);
        color: #fff;
        font-size: 1rem;
        cursor: pointer;
        box-shadow: 0 4px 14px rgba(16, 24, 40, 0.28);
        /* Rises into place rather than appearing mid-screen fully formed. */
        animation: scrollTopIn 0.18s ease-out;
    }
    .scroll-top:active { transform: scale(0.94); }
    /* [hidden] has to win over the display:grid above, which is more specific
       than the attribute selector on its own. */
    .scroll-top[hidden] { display: none; }
}

@keyframes scrollTopIn {
    from { opacity: 0; transform: translateY(8px); }
    to   { opacity: 1; transform: none; }
}

/* Width-only trims: an upright tablet has room for these, a narrow window does
   not, so they stay keyed to width rather than shape. */
@media (max-width: 860px) {
    .portal__main { padding-left: 16px; padding-right: 16px; }

    /* The bar itself comes down with what it holds - 78px was sized around a
       46px mark and a 40px control. */
    :root { --topbar-h: 60px; }

    /* The name is meant to read as one line, the way it does on a desktop. It
       was taking three, because everything beside it was still at desktop size
       and left it about 126px for text that wanted 190. So every neighbour
       gives a little - mark, toggle, buttons, and the bar's own padding - and
       the name takes the ~200px that frees up.
       nowrap is what holds it to the single line; the ellipsis is the safety
       net below the width where even this stops fitting. */
    .topbar { padding: 0 11px; gap: 10px; }
    .topbar__brand { gap: 9px; }
    .topbar__brand img { width: 34px; height: 34px; }
    .topbar__title {
        font-size: 0.72rem;
        letter-spacing: 0.01em;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }
    .topbar__sub { font-size: 0.6rem; }

    .topbar__actions { gap: 4px; }
    .view-toggle__label { display: none; }
    .view-toggle { padding: 5px 9px; margin-right: 2px; }
    .view-toggle__track { width: 34px; height: 19px; }
    /* The knob's travel is written as absolute offsets against the desktop's
       42px track, so narrowing the track alone would have slid it off the end -
       both ends move with it. 34 - 15 - 2 puts the far edge back on the rail. */
    .view-toggle__knob { width: 15px; height: 15px; top: 2px; }
    .view-toggle.is-dean .view-toggle__knob { left: 17px; }
    .view-toggle.is-teacher .view-toggle__knob { left: 2px; }

    /* The portal dropdown gives up its label here, for the same reason the
       toggle above gives up its two. The label is a phrase rather than a word -
       "Program Coordinator" is wider than the whole toggle it replaces - so at
       this width it was taking the room the system's name needs and pushing the
       title into the wrap that block exists to prevent.
       What is left says enough: the arrows mark it as the way between portals,
       and the caret says a list opens. Which portal you are in is on the
       identity card and in the page you are looking at, so the closed control is
       not the only thing that answers it - and the open panel still names all
       three, with the current one ticked. */
    .portal-switch { margin-right: 2px; }
    .portal-switch__value { display: none; }
    .portal-switch__trigger { gap: 6px; padding: 0 10px; height: 32px; }
    /* Anchored to the right edge on the desktop, which on a narrow bar would run
       the panel off the left. Held inside the viewport instead. */
    .portal-switch__panel { min-width: 190px; max-width: calc(100vw - 22px); }

    .icon-btn { width: 32px; height: 32px; font-size: 1rem; }
}

/* ---------- Tablets ----------
   Where a table stops fitting, which happens well above the phone breakpoint.

   These rules were written for the phone block below and were reached only under
   600px, so an iPad got the desktop's percentage colgroup and nothing else. A
   percentage is a share of whatever the container is: at 1024px in portrait the
   sidebar is still drawn, which leaves the main column about 730px, and the
   schedules table's 12% room column is then 88px - so "Laboratory A" arrives as
   "Laborat / ory A" and an 18% time column prints "M 8:00am - 10:00am" straight
   over the days beside it. The columns were not too narrow to read; they were
   too narrow for what was in them, and nothing was allowed to scroll instead.

   So the structural half of the phone treatment starts here - what a table is
   allowed to do with its width, and which columns stay pinned while the rest is
   dragged. Nothing about type or spacing comes up with it: a tablet has the room
   for full-size text, and shrinking it here would answer a problem it does not
   have. The block below keeps that half, and refines these where a handset needs
   a tighter answer than a tablet does.

   1024px and not 860, where the sidebar goes. A 12.9" iPad in portrait reports
   exactly 1024 and still shows the sidebar, so its content column is *narrower*
   than a 900px window's - the width that matters is the one left over, and this
   is the bound that covers both. */
@media (max-width: 1024px) {
    /* A little of the desktop's 13px/14px back, since past this width every
       pixel of padding is one the columns do not have. The phone takes it
       further still. */
    .admin-table th, .admin-table td { padding: 10px 10px; }

    /* A time span is one fact and reads best on one line, but a column too
       narrow to hold it has to be allowed to fold rather than print over its
       neighbour. */
    .cell-time { white-space: normal; }

    /* ---------- Tables with more columns than the width can show ----------
       The table has to be allowed to outgrow its box before nowrap means
       anything. .admin-table is width:100%, which pins it to the container - so
       the cells could not wrap and could not widen either, and simply spilled
       over each other. width:auto lets it take the width its content needs;
       min-width:100% keeps it filling the panel when that content is narrow.

       max-content, not auto. `auto` lets the browser settle on a narrower table
       and squeeze the columns to reach it - which with nowrap cells means the
       content hangs out of its column and over the next one, and the column that
       gets covered is whichever sits beside the pinned one. max-content states
       the width the cells actually need, so no column is compressed.

       The colgroup is overridden here, and !important is the only thing that
       reaches it: those widths are inline `style` attributes on <col>, set to
       keep several tables on one desktop page aligned to the same grid. */
    .admin-table--nowrap { width: max-content; min-width: 100%; }
    .admin-table--nowrap col { width: auto !important; }
    .admin-table--nowrap th,
    .admin-table--nowrap td { white-space: nowrap; }
    .admin-table--nowrap .cell-stack span,
    .admin-table--nowrap .row-subtext { white-space: nowrap; }

    /* Here the drag is real, so the pin .table-fit released comes back: a table
       of ten columns cannot fit whatever way it wraps, and the last column is the
       one that has to stay in view while the rest is pulled past it. */
    .table-fit .admin-table--sticky-end th:last-child,
    .table-fit .admin-table--sticky-end tbody td:last-child {
        position: sticky;
        box-shadow: -7px 0 7px -5px rgba(16, 24, 40, 0.18);
    }

    /* ---------- A table asked to fit rather than scroll ----------
       The opposite bargain, for a table with few enough columns to be made to
       fit. Nothing here is nowrap: the table is pinned to the container, so a
       line that cannot wrap cannot widen its column either and simply prints
       over the next one. Everything wraps; the job is to give each column enough
       width that it breaks at a space rather than mid-word.

       No hiding of columns, deliberately. display:none on a cell does not remove
       the <col> that declares it, so the row's remaining cells shift left onto
       the earlier columns and the last is left as dead space at the right edge.
       A column not wanted at this width has to come out of the markup. */
    .admin-table--fit { width: 100%; table-layout: fixed; }
    .admin-table--fit col { width: auto !important; }
    /* Both end columns get only what their contents need - a subject code runs
       to seven characters, the actions to two buttons and the word above them -
       so every pixel beyond that goes to the title, which is the column with
       something to say. 68 and not less: at 56 the heading broke into "ACTIO /
       N", which is a column narrower than the word naming it. */
    .admin-table--fit th:first-child,
    .admin-table--fit td:first-child { width: 68px; }
    .admin-table--fit th:last-child,
    .admin-table--fit td:last-child { width: 68px; }
    .admin-table--fit th, .admin-table--fit td { padding-left: 6px; padding-right: 6px; }
    /* The tracking is a desktop flourish; at this width it is the difference
       between a heading fitting its column and being hyphenated across two. */
    .admin-table--fit th { letter-spacing: 0.02em; white-space: nowrap; }
    /* The swap: long wording out, short wording in. nowrap on the headings above
       is safe only because of this - the short forms are one word each. */
    .label-full { display: none; }
    .label-short { display: inline; }
    /* fixed layout holds a column to its width whatever is in it, so a single
       word longer than its column would hang out over the next. This is the
       backstop that breaks it instead - it only ever fires on a word with no
       space to break at. */
    .admin-table--fit td { overflow-wrap: break-word; }
    /* The title on one line, cut rather than wrapped where it will not fit. The
       full text stays on the cell's own title attribute for anyone who needs it;
       the phone block buys the extra characters by stepping the size down. */
    .admin-table--fit .cell-title {
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
    }

    /* ---------- Class record ----------
       The No. and Name columns are pinned at every width - see the Attendance
       sheet rules. A tablet affords less of a long name than a desktop; a phone
       lowers this again below. */
    .attendance-sheet tbody td:nth-child(2) strong { max-width: 190px; }

    /* ---------- Stat cards: one row, whatever the count ----------
       Both stat components size their tracks from a minimum - 172px for
       .stat-tile, 228px for .stat-card - and auto-fit makes as many tracks as
       fit that minimum. Four of either overrun a tablet's content column, so the
       fourth dropped to a line of its own and sat under three, a quarter as wide
       as the row above it. That reads as a card that failed to load rather than
       as the fourth of a set.

       auto-flow:column lays every card along a single track instead of wrapping,
       and the template has to be cleared first or the items would fill its
       columns before the auto ones are reached. minmax(0, 1fr) rather than a
       plain 1fr: a grid track's floor is its content, so without the zero a long
       label pushes the row wider than the panel instead of wrapping inside its
       card. It also takes a fifth card without a rule change, which
       repeat(4, 1fr) would not.

       What buys the width is the glyph. It is the only place these cards differ
       in colour, so dropping it makes them read as one set - which is what they
       are - and hands the figure the room it needs. Type comes down a step and
       no further: a tablet is not a handset, and the block below takes both
       further where it is. */
    .stat-grid,
    .glass-panel > .dashboard-stats {
        grid-template-columns: none;
        grid-auto-flow: column;
        grid-auto-columns: minmax(0, 1fr);
        gap: 10px;
    }

    .stat-tile {
        padding: 14px 10px;
        border-left-width: 3px;
        justify-content: center;
        text-align: center;
        gap: 0;
    }
    .stat-tile__icon { display: none; }
    .stat-tile__value { font-size: 1.55rem; }
    .stat-tile__label {
        margin-top: 4px;
        font-size: 0.64rem;
        letter-spacing: 0.03em;
        line-height: 1.25;
        overflow-wrap: break-word;
    }

    /* The reports pages' older card, under the same treatment.

       `.glass-panel >` is what keeps this to the page. Each reports page also
       renders these cards inside its printed sheet, which is a fixed 816px page
       scaled by transform - so an unscoped viewport rule would reach inside and
       reshape a printout because the screen showing the preview is narrow. The
       sheet's cards live under [data-part="cards"], not directly in a panel. */
    .glass-panel > .dashboard-stats .stat-card {
        flex-direction: column;
        align-items: center;
        text-align: center;
        gap: 0;
        padding: 14px 8px;
        border-left-width: 3px;
    }
    .glass-panel > .dashboard-stats .stat-card i { display: none; }
    /* column-reverse, because the markup puts the label before the figure and
       every other page that draws this card wants it that way. */
    .glass-panel > .dashboard-stats .stat-info {
        display: flex;
        flex-direction: column-reverse;
        gap: 3px;
        min-width: 0;
    }
    .glass-panel > .dashboard-stats .stat-info h2 {
        margin: 0;
        font-size: 1.55rem;
        line-height: 1.15;
        /* The four sit in a row now, so their digits line up with each other and
           should be the same width apart. */
        font-variant-numeric: tabular-nums;
    }
    /* Every one of these labels breaks into two at this width - MEETINGS /
       RECORDED, OVERALL / RATE - which is fine. The tracking comes off because
       it is what would push them to three. */
    .glass-panel > .dashboard-stats .stat-info p {
        font-size: 0.64rem;
        letter-spacing: 0.03em;
        line-height: 1.25;
    }
}

/* ---------- Phones ----------
   From the 320px of an iPhone SE up.

   The middle setting between two that were tried and rejected: a scaled-down
   desktop that put body text near 5px, and full desktop sizing that read as
   oversized on a handset. So type comes down about a step from the desktop
   figure and no further, spacing comes down rather more, and the app's smallest
   labels are held to roughly 12px - low enough not to dominate, high enough to
   stay readable, which was the point of the exercise.

   Controls keep a 40px touch target. Below that a thumb starts missing; the
   44px of platform guidance is the comfortable figure, and 40 is what fits
   here without the page feeling built for it. */
@media (max-width: 600px) {
    /* One dial for the whole page's type. Every font size in this stylesheet is
       in rem and no width, gap, or offset is - checked - so moving the root
       scales the text and leaves the geometry exactly where it was. Nudging
       this figure is how to take another step in either direction without
       re-tuning three dozen rules and hoping they stay in proportion. */
    html { font-size: 15px; }

    .portal__main { padding-left: 11px; padding-right: 11px; }

    /* The icon now shares a row with the heading instead of sitting alone above
       it, so it is sized against the two lines of text beside it rather than
       against the banner. Aligned to the top of that text, since a tall
       subtitle would otherwise leave it floating mid-block. */
    .page-banner { padding: 14px 13px; border-radius: 10px; }
    .page-banner__main { gap: 11px; align-items: flex-start; }
    .page-banner__icon { width: 38px; height: 38px; font-size: 0.98rem; margin-top: 1px; }
    .page-banner h1 { font-size: 1.22rem; line-height: 1.25; }
    /* Well under the heading it sits beneath. These lines are the page's
       standing facts - the term, the room, the days a class meets - read once
       and then skipped past, so they give up the room the title needs. */
    .page-banner p { font-size: 0.72rem; line-height: 1.35; margin-top: 3px; }

    /* Sits tighter under the heading and keeps to the banner's own left edge,
       clear of the icon's column, so the row of tabs reads as one block.

       Held to one line. Wrapped, the last tab drops beneath the others and
       reads as a separate control rather than the third of a set - and it
       pushes the banner taller on the screen with least room to spare. Three
       labels as long as "Overview / Attendance / Class Record" do not fit a
       360px handset at this size, so the strip scrolls when it has to; the
       glyphs come off further down, which is usually enough that it never
       does. */
    .page-banner__tabs {
        margin-top: 10px;
        gap: 0;
        flex-wrap: nowrap;
        overflow-x: auto;
        /* No scrollbar under the strip: it would sit directly on the active
           tab's underline, a second and louder rule saying less. The tabs
           themselves are the affordance - a half-cut one at the edge is what
           says the row moves. */
        scrollbar-width: none;
    }
    .page-banner__tabs::-webkit-scrollbar { display: none; }
    /* Neither shrink nor grow: a flex item squeezed below its content would
       break the label across two lines instead of letting the row scroll,
       which is the wrap this is meant to prevent. */
    .page-tab { padding: 9px 11px; font-size: 0.84rem; gap: 7px; flex: 0 0 auto; }

    /* The count comes down with the label it marks. At the desktop's 20px it was
       taller than the cap height of 0.84rem type and became the loudest thing in
       a banner that has already given up most of its room - a mark on the word,
       drawn larger than the word. Kept a shade above the bottom bar's 16px,
       which sits on a glyph rather than beside text. */
    .page-tab__badge { min-width: 17px; height: 17px; padding: 0 5px; font-size: 0.62rem; }

    /* Tighter than the desktop's 12px: the buttons themselves are smaller, so
       the old gap would have read as a bigger space than the things it
       separates. */
    .banner-actions { gap: 5px; margin-top: 4px; }

    /* The one-row layout and the rebuilt tile are in the tablet block above -
       four cards overrun a tablet's content column just as surely, only later.
       What is left here is how much further a handset has to take it: at four
       tiles on a 320px screen the row leaves about 50px of usable width each, so
       the figures come down again and the label is freed to break mid-word,
       which only "Departments" is long enough to need. */
    .stat-grid { gap: 7px; }
    .stat-tile { padding: 10px 7px; }
    .stat-tile__value { font-size: 1.3rem; }
    .stat-tile__label { font-size: 0.58rem; letter-spacing: 0.02em; }

    .glass-panel, .profile-container { padding: 14px 13px; margin-bottom: 12px; }
    .section-title { font-size: 0.92rem; margin-bottom: 13px; padding-bottom: 10px; }
    .section-title i { font-size: 0.9rem; }

    /* The count pill comes down with the heading it sits on.
       This was scoped to .section-title--tight, which only teacher/class.php and
       teacher/history.php ask for - so those two pages had a pill sized for a
       phone and every other page kept its desktop one, beside a title that had
       already shrunk. There is nothing about a tight heading that makes a count
       smaller; it is the screen that does, so the rule belongs to the screen.
       Less specific than the selector it replaces and later in the file than the
       base rule, which is what still lets it win. */
    .count-pill { padding: 5px 10px; font-size: 0.72rem; }

    /* ---------- Stacked heading ----------
       The title takes the first line and the tools drop beneath it.

       Opt-in, and asked for by exactly one heading: the account directory's,
       which is the only section title in the app carrying a search box. That box
       is 260px - wider than some phones' whole panel - and .section-title is a
       flex row with no flex-wrap, so the title got whatever the box left it.
       "User Accounts" came out as "User" over "Accounts", squeezed into a column
       at the far left.

       The mirror of --tight below, and opt-in for the same reason read the other
       way round: a heading whose right side is a count and a button or two holds
       one line comfortably and should, which is every other heading here. Only a
       search box is too wide to share, and only this one has it. Applied to all
       of them instead, this put the pill and the chevron on a second line on
       pages that had no need of it.

       The search box gives up its fixed width here too, growing from a basis
       small enough to sit beside the buttons and the count on the row below. */
    .section-title--stack { flex-wrap: wrap; }
    /* Shares the first line with the count rather than taking the whole of it -
       the count is one pill wide and the title has room for it, so sending it
       down with the tools left the heading looking half-finished. */
    .section-title--stack > span:first-child { flex: 1 1 auto; }
    /* Back to source order here, which puts it beside the title instead of after
       the tools; and held at its own width so a long title cannot squeeze it. */
    .section-title--stack > .count-pill { order: 0; flex: 0 0 auto; }
    /* A full line of its own, which is what forces the wrap beneath the pair
       above. Ordered last so it stays there whatever comes before it. */
    .section-title--stack .section-tools { width: 100%; order: 1; }
    .section-title--stack .section-search { width: auto; flex: 1 1 150px; }

    /* ---------- Tight heading ----------
       A heading whose right-hand side is only buttons and a count, held to one
       line here. Left to wrap, the three parts each took a row of their own on
       a narrow phone - the name, then the buttons, then the count beneath them
       - which reads as three headings rather than one.

       Opt-in rather than applied to every .section-title--split, because some
       of them carry a search box on the right, and a search box squeezed onto
       a shared line is worse than one that wraps. The tools are fixed width,
       so the title takes what is left and ellipsises its tail - the class group
       in brackets, which the banner directly above already states. */
    .section-title--tight { flex-wrap: nowrap; gap: 9px; }
    .section-title--tight > span:first-child {
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }
    .section-title--tight .section-tools { flex-wrap: nowrap; flex: 0 0 auto; gap: 6px; }
    /* The pill's own rule moved above, out of this cluster: it applies to every
       phone heading now, not only a tight one. */
    .section-title--tight .section-title__sub { margin-left: 6px; font-size: 0.76rem; }
    /* The department heading is set a size above the other section titles, by a
       rule specific enough to outrank the one above - so it kept its desktop
       1.2rem while everything around it came down, and ended up the largest
       thing on the page. Still a step above its siblings here, just a smaller
       step. Shared with the schedules catalogue, which heads its cards the
       same way. */
    .dept-card > .section-title { font-size: 1.02rem; }

    /* The catalogue's own headings. The pill is a marker for the block beneath
       it, not a heading in its own right, so it comes down further than the
       panel title above it - at desktop size it was the loudest thing on a page
       whose subject rows are the point. */
    .year-band { gap: 11px; margin: 16px 0 11px; }
    .year-pill { padding: 7px 15px; font-size: 0.8rem; gap: 8px; }
    .year-pill i { font-size: 0.85rem; }
    .term-block { padding: 12px 13px; margin-bottom: 12px; border-left-width: 3px; }
    .term-block__title { font-size: 0.74rem; letter-spacing: 0.06em; }
    /* The rule above each section comes down with the space around it, or the
       gap it sits in would be most of a short block's height. */
    .section-group { font-size: 0.84rem; margin: 16px 0 7px; padding-top: 12px; }
    .panel-grid { gap: 12px; }
    .panel-head { margin-bottom: 10px; gap: 10px; }

    /* A panel heading carries no font-size of its own, so it falls to the
       browser's 1.17em for an h3 - which lands well above the section titles
       beside it and made the dashboard panels read as the largest thing on the
       page. Pinned here to sit just above body text, the way .section-title
       already does. */
    .glass-panel h3, .panel-head h3 { font-size: 1.02rem; }
    .panel-head a { font-size: 0.82rem; }

    /* The shortcut tiles are a list of links, not headings - they were taking
       body size plus the weight, which read as louder than the panel above. */
    .action-tile { font-size: 0.88rem; gap: 11px; }
    .action-tile i { width: 19px; font-size: 0.95rem; }

    /* Spacing, not type - this is what kept a page of two panels from running
       several screens, and none of it costs a word its size. */
    .empty-state { padding: 22px 14px; }
    .empty-state__icon { margin-bottom: 8px; font-size: 2.1rem; }
    .action-tile { padding: 11px 12px; margin-bottom: 7px; }
    /* Qualified to match the 1024px rule it is lowering - a bare .panel-scroll
       here would lose to that selector's specificity and do nothing. */
    .portal__main--fit .panel-scroll { max-height: 55vh; }

    /* Full-width sheet rather than a floating card, which at this size only
       wastes the edges it floats in. The padding goes with it, so the sheet
       meets the screen edges the way it is expected to on a phone. */
    .modal-overlay { align-items: flex-end; padding: 0; }
    .modal-content {
        max-width: none;
        max-height: 92vh;
        border-radius: 14px 14px 0 0;
    }
    .modal-body { padding: 15px 13px; }

    /* ---------- Dialog bar on a phone ----------
       One line, whatever it carries. The print dialogs put a row of colour
       swatches beside their title, and at these widths the swatches spilled
       onto a second row and took the title with them onto a third - turning a
       header into a third of the dialog before a word of it had been read.

       The controls hold their size and place; the title takes what is left and
       ellipsises, which is the right way round - a swatch that has moved is a
       control you have to hunt for, while a truncated title is still the title.
       Its font size is set inline on every h3 in the app, so this cannot shrink
       the text; it only stops it wrapping. */
    .modal-header { gap: 10px; padding: 13px 15px; }
    .modal-header h3 {
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }
    .swatch-row--compact { flex-wrap: nowrap; flex: 0 0 auto; gap: 5px; }
    .swatch-row--compact .swatch { width: 16px; height: 16px; }
    .swatch-row--compact .swatch i { font-size: 0.48rem; }

    /* 30px. WCAG's minimum target is 24, so there is still room over the floor,
       but the comfortable figure is 44 and this is well under it - if a control
       here ever proves fiddly to hit, this is the number to raise.

       Moved on the variable rather than on each control, so the search field
       comes down with the buttons instead of staying at its full height beside
       them - which is the whole reason the height is shared in the first place. */
    :root { --banner-control-h: 30px; }
    /* The field comes down to 30px here, so its inner button has to come down
       with it or it would fill the box edge to edge. The tools sit tighter to
       the right for the same reason. */
    .banner-search .search-field__go { width: 22px; height: 22px; border-radius: 6px; }
    .banner-search .search-field__clear { width: 22px; height: 22px; font-size: 1rem; }
    .banner-search .search-field__tools { right: 4px; gap: 2px; }
    .banner-action {
        padding: 6px 10px;
        min-height: 30px;
        font-size: 0.75rem;
        gap: 6px;
        border-radius: 7px;
    }
    .banner-action i { font-size: 0.8rem; }
    .banner-action--icon { width: 30px; min-height: 30px; padding: 0; font-size: 0.8rem; }
    .panel-action { width: 30px; height: 30px; font-size: 0.78rem; }

    /* Down to a glyph. This one can shrink further than the buttons beside it
       because it already carries an invisible ::after that stretches its hit
       area past its edges - so the circle shrinks and what a thumb has to find
       does not. The inset is widened to hold that area near 32px even as the
       ring comes down to 15. */
    .banner-info { width: 15px; height: 15px; font-size: 0.44rem; border-width: 1px; }
    .banner-info::after { inset: -9px; }

    /* ---------- Room-availability timetable ----------
       The whole point of this grid inside the Add Schedule form is to see which
       days are free, so all of them have to be on screen at once - a week you
       have to scroll sideways to read cannot answer the question it is there
       for. Seven columns into roughly 330px leaves about 40px each, so
       everything in it is sized to that: the hour gutter narrows, the headings
       drop to "Mon", and the blocks keep only what fits.

       The blocks stay legible because they are not asked to carry the whole
       class - tapping one opens the popover, which has the room for it. */
    .room-panel__body { padding: 12px 10px; }
    /* 48px, sized to the longest label the gutter has to hold. The tick text is
       absolutely positioned and right-aligned with nowrap, so it grows leftward
       out of the column - too narrow a gutter does not shrink it, it cuts the
       front off it, and "12:00 PM" arrives as "2:00 PM". Six or seven columns
       still clear 42px each after this, which is what the day names need. */
    .room-panel .timetable { grid-template-columns: 48px repeat(var(--tt-days), minmax(0, 1fr)); }
    .room-panel .timetable__dayhead {
        padding: 6px 2px;
        font-size: 0.6rem;
        letter-spacing: 0;
        border-radius: 5px 5px 0 0;
    }
    /* Short form in, long form out. */
    .room-panel .timetable__dayname { display: none; }
    .room-panel .timetable__dayabbr { display: inline; }
    .room-panel .timetable__tick span { right: 5px; font-size: 0.6rem; }
    /* The renderer sets --tt-font inline per block, so the override has to be
       on the property it reads rather than on font-size, which it would win. */
    .room-panel .timetable__block {
        --tt-font: 8px !important;
        padding: 2px 3px;
        border-radius: 5px;
        line-height: 1.2;
    }
    /* At this width a subject title is a line and a half of nothing legible.
       The code and the time are what tell one block from another; the rest is
       a tap away in the popover. */
    .room-panel .timetable__block .timetable__who,
    .room-panel .timetable__block .timetable__where { display: none; }
    .room-panel .timetable__block strong { -webkit-line-clamp: 1; line-clamp: 1; }

    /* Pill buttons that sit inside a heading rather than a toolbar. The
       destructive one heads each term block on the schedules page, where at
       desktop size it was wider than the block's own title and read as the
       point of the block rather than a way to empty it. */
    .btn-outline-accent { padding: 7px 13px; font-size: 0.76rem; gap: 6px; }
    .btn-outline-accent--danger { padding: 4px 9px; font-size: 0.62rem; gap: 5px; border-width: 1px; }
    .btn-outline-accent--danger i { font-size: 0.64rem; }
    /* It shares a row with the block's title, and a pill that has folded its
       label onto two lines is taller than the heading beside it. Held to one
       line and kept from being squeezed, since at this size it has nothing
       spare to give. */
    .term-block__head { gap: 10px; }
    .term-block__head .btn-outline-accent { white-space: nowrap; flex-shrink: 0; }

    /* A table this wide cannot be made to fit, so it scrolls sideways inside
       its own container rather than forcing the whole page to - which is what
       lets its text stay a readable size instead of being squeezed to fit. The
       preview tables are left out: their cells are already nowrap, so they set
       their own width and a floor would only fight it. */
    /* No floor at all. It was there to stop columns crushing, but a table's own
       content already sets a minimum the browser will not go under - so the
       wide ones (the attendance sheet, the compact lists whose cells are all
       nowrap) still overflow their box and scroll, exactly as before. The only
       thing the floor changed was to make narrow tables scroll that had no need
       to, which is what put a four-column dashboard list half off the screen.
       Padding comes down with it, since every pixel here is one the columns do
       not have. */
    /* Header size is set once, further down with the rest of the app's smallest
       type - a second rule here would only be overridden by it. */
    .admin-table th, .admin-table td { padding: 7px 6px; font-size: 0.68rem; }
    .attendance-cell { min-width: 60px; padding: 6px 10px; font-size: 0.8rem; }

    /* max-content, the colgroup override, and the pinned last column all live in
       the tablet block above: they are what a table is allowed to do with its
       width, and a table stops fitting long before a handset. What stays here is
       the type those rules then carry, which only a phone needs taken down. */

    /* The identifying column carries two lines - a code over its full title -
       and is the widest thing in the table without being the most useful. Both
       lines come down a step, the subtext further, since it is a qualifier for
       the line above rather than something read on its own. */
    .admin-table--nowrap td:first-child { font-size: 0.74rem; }
    .admin-table--nowrap td:first-child strong { font-size: 0.8rem; }
    .admin-table--nowrap .row-subtext { font-size: 0.66rem; }

    /* Day codes are two or three letters read at a glance, so they take the
       smallest size in the table. The glyph goes under the text rather than
       with it - it marks which of the two lines is which, and at this size a
       matching glyph would be the wider half of the pair. The lines are pulled
       tight as well: two stacked at default leading is taller than the row
       around them needs to be. */
    .cell-days { font-size: 0.58rem; line-height: 1.3; }
    .cell-days i { font-size: 0.52rem; margin-right: 1px; }

    /* A step under the size they take on a desktop. These set the width of the
       pinned actions column, and every pixel there is one taken from the row
       sliding underneath it. */
    .icon-action { width: 23px; height: 23px; margin-right: 2px; font-size: 0.64rem; }
    /* A glyph rather than a button here, so it can come down further than a
       control with a box around it: what a thumb has to find is the padded area,
       which stays near 24px while the mark inside it shrinks. */
    .icon-view { padding: 4px 5px; font-size: 0.78rem; }

    /* ---------- Class record on a phone ----------
       The pinning itself is in the tablet block above - a term of dates outruns
       any screen and the two identifying columns have to hold whatever the
       width. Two figures are a phone's alone.

       The number column gives up more of its padding, since every pixel there is
       one the dates do not have; and the name is capped harder, because at 190px
       a name would take room the statuses beside it need. The full name stays on
       the cell's title either way. */
    .attendance-sheet th:first-child,
    .attendance-sheet tbody td:first-child { padding-left: 4px; padding-right: 2px; }
    .attendance-sheet tbody td:nth-child(2) strong { max-width: 116px; }

    /* ---------- Scanner on a phone ----------
       The camera goes above the list it feeds, which is the order they are used
       in. The video gives up some height too: at 60vh it filled the screen and
       pushed the Start button under the fold, so the one control the page
       exists for could not be reached without scrolling past the picture. */
    .scan-grid { grid-template-columns: minmax(0, 1fr); gap: 12px; }
    #video { max-height: 40vh; }
    /* Stacked, there is no camera beside it to take a height from, so the list
       is given one outright - otherwise a full class pushes everything below it
       off the page. A definite height is also what the absolutely positioned
       scroller inside needs to have something to fill. */
    .scan-present__scroll { flex: none; height: 38vh; min-height: 0; }
    /* Full-width and stacked, rather than three buttons wrapping unevenly. */
    .scan-controls { gap: 8px; }
    .scan-controls > * { flex: 1 1 auto; justify-content: center; text-align: center; }
    /* One card per row: two across a phone leaves neither name readable. */
    .pending-grid { grid-template-columns: minmax(0, 1fr); gap: 7px; }
    .pending-card { padding: 8px 10px; }
    /* --fit's own layout is in the tablet block above. What is left to a phone is
       one size: the title is set a step under the rest of the table to buy the
       characters to hold its line - at the body size the longest of these
       overruns the column by a third whatever else is trimmed. */
    .admin-table--fit .cell-title { font-size: 0.74rem; }

    /* The app's smallest type sits between 0.68 and 0.78rem - 11 to 12.5px -
       which is fine to skim past on a monitor and is the first thing to go on a
       handset. Brought to roughly 12px: a step up from where they were, a step
       down from full size. Phones only, so the desktop hierarchy is untouched. */
    /* .cell-stack span is left out: its size is set with the component now,
       below what this line would put it back up to. */
    .row-subtext { font-size: 0.72rem; }
    .field-hint { font-size: 0.74rem; }
    .badge { font-size: 0.72rem; }
    /* Column headings are labels, not content - a step under the rows they
       name, which also buys the width they need to sit on one line. */
    .admin-table th { font-size: 0.68rem; }

    /* Position is left to the 560px rule further up, which already places the
       toast for a phone. Setting `top` here without clearing its `bottom` would
       pin both edges and stretch one message down the whole screen. */
    .toast { width: auto; }
}

/* ---------- Narrowest phones and the folded Galaxy Z Fold ----------
   344px and below. Where something has to give, what goes is a word that is
   repeated elsewhere or an ornament - never the size of type that has to be
   read. The bar's end zones drop their captions because the glyph above each
   says the same thing; the account's rank drops because the sheet behind the
   portrait carries it in full. */
@media (max-width: 380px) {
    .bottom-bar__label { display: none; }
    .bottom-bar__menu,
    .bottom-bar__active { flex-basis: 56px; padding: 13px 4px; }
    .bottom-bar__glyph { font-size: 1.2rem; }
    .bottom-bar__rank { display: none; }
    /* A tab's glyph is an ornament: the label beside it already names the
       section, and it is the only tab in the set that is underlined when
       active. Dropping the three of them returns about sixty pixels, which on
       this class page is the whole difference between "Overview / Attendance /
       Class Record" sitting on one line and the last of them scrolling out of
       reach. The padding comes in with them; the label's own size does not
       move, which is the rule for this breakpoint. */
    .page-tab i { display: none; }
    .page-tab { padding: 9px 9px; }

    /* The institution already shows on the account sheet, and dropping it here
       gives the name below the whole height of the bar to sit on one line. */
    .topbar__sub { display: none; }
    .topbar__brand img { width: 30px; height: 30px; }
    .topbar__title { font-size: 0.64rem; }
    /* Down a step, not two - it is still the largest thing on the page, which
       is what a page heading is for. */
    .page-banner h1 { font-size: 1.18rem; }
    /* Two name fields share a row happily at 390px and not at 320 - this is
       where a paired row finally becomes two. */
    .auth-row { grid-template-columns: 1fr; }
    /* Four tiles on one row at this width leave about 50px of usable space
       each, so the last of the padding goes and the label takes the smallest
       size it can still be read at. The glyph is already gone above. */
    .stat-grid { gap: 6px; }
    .stat-tile { padding: 9px 5px; border-left-width: 3px; }
    .stat-tile__value { font-size: 1.15rem; }
    .stat-tile__label { font-size: 0.54rem; letter-spacing: 0; }

    /* A week with a Saturday in it is seven columns across 320px. The gutter
       comes in only as far as its longest label allows - measured against
       "12:00 PM", not guessed, or the clock loses its leading digit again - and
       the day names take the smallest size they still read at. */
    .room-panel .timetable { grid-template-columns: 44px repeat(var(--tt-days), minmax(0, 1fr)); }
    .room-panel .timetable__tick span { right: 4px; font-size: 0.55rem; }
    .room-panel .timetable__dayhead { padding: 5px 1px; font-size: 0.55rem; }
}

/* ================================================================
   Auth page (index.php)
   The palette inverts between tabs: a navy page carries a maroon card for
   log in, and the two swap for register.
   ================================================================ */
:root {
    /* Institutional brand tones, carried over from the original stylesheet. */
    --auth-maroon: #800000;
    --auth-maroon-deep: #4A0000;
    --auth-maroon-soft: #A00000;
    --auth-navy: #004085;
    --auth-navy-deep: #001F3F;
    --auth-navy-mid: #003366;

    /* One width for both tabs; the register pane absorbs its extra fields
       by running smaller type and growing taller instead of wider. */
    --auth-card-w: 460px;
    /* Shared timing so every colour swaps together as one movement. */
    --auth-fade: 0.5s ease;
}

.auth-page {
    min-height: 100vh;
    display: grid;
    place-items: center;
    padding: 26px 20px;
    position: relative;
    /* Vertical scroll stays available as a fallback on very short screens;
       the decorative layers are fixed so they never extend the page. */
    overflow-x: hidden;
    background: var(--auth-navy-deep);
}

/* CSS cannot interpolate between two gradients, so the blue and red washes
   are stacked as separate layers and cross-faded on opacity instead. */
.auth-bg {
    position: fixed;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    transition: opacity var(--auth-fade);
}
.auth-bg--login {
    background: linear-gradient(135deg,
        var(--auth-navy-deep) 0%, var(--auth-navy-mid) 60%, var(--auth-navy) 100%);
    opacity: 1;
}
.auth-bg--register {
    background: linear-gradient(135deg,
        var(--auth-maroon-deep) 0%, var(--auth-maroon) 60%, var(--auth-maroon-soft) 100%);
    opacity: 0;
}
.register-mode .auth-bg--login { opacity: 0; }
.register-mode .auth-bg--register { opacity: 1; }

/* Soft oversized circles bleeding off the corners, sitting above both washes.
   Fixed rather than absolute so they never add to the scrollable height. */
.auth-page::before,
.auth-page::after {
    content: '';
    position: fixed;
    z-index: 1;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.045);
    pointer-events: none;
}
.auth-page::before { width: 640px; height: 640px; top: -180px; right: -150px; }
.auth-page::after  { width: 540px; height: 540px; bottom: -200px; left: -160px; }

.auth-card {
    position: relative;
    z-index: 2;
    width: 100%;
    max-width: var(--auth-card-w);
    background: #fff;
    border-radius: 16px;
    box-shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
    overflow: hidden;
}

/* Same cross-fade as the page: two gradient layers, opacity swapped. */
.auth-card__head {
    position: relative;
    padding: 20px 26px 0;
    background: var(--auth-maroon-deep);
}
.auth-card__head::before,
.auth-card__head::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 0;
    transition: opacity var(--auth-fade);
}
.auth-card__head::before {
    background: linear-gradient(135deg, var(--auth-maroon) 0%, var(--auth-maroon-soft) 100%);
    opacity: 1;
}
.auth-card__head::after {
    background: linear-gradient(135deg, var(--auth-navy-mid) 0%, var(--auth-navy) 100%);
    opacity: 0;
}
.register-mode .auth-card__head::before { opacity: 0; }
.register-mode .auth-card__head::after  { opacity: 1; }

/* Keep the header content above its gradient layers. */
.auth-brand, .auth-tabs { position: relative; z-index: 1; }

.auth-brand { display: flex; align-items: center; gap: 12px; }
.auth-brand img {
    width: 46px;
    height: 46px;
    border-radius: 50%;
    object-fit: cover;
    background: #fff;
    flex-shrink: 0;
}
.auth-brand__title {
    color: #fff;
    font-weight: 700;
    font-size: 0.9rem;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    line-height: 1.3;
}
.auth-brand__sub { color: rgba(255, 255, 255, 0.82); font-size: 0.8rem; font-weight: 600; }

.auth-tabs { display: flex; margin-top: 20px; }
.auth-tab {
    flex: 1;
    width: auto;
    background: none;
    border: none;
    border-bottom: 3px solid transparent;
    padding: 14px 0;
    font-family: inherit;
    font-size: 1rem;
    font-weight: 600;
    color: rgba(255, 255, 255, 0.62);
    cursor: pointer;
    transition: color 0.2s, border-color 0.2s;
}
.auth-tab:hover { color: rgba(255, 255, 255, 0.9); }
.auth-tab.active { color: #fff; border-bottom-color: #fff; }

.auth-body { padding: 26px 30px 30px; }

.auth-pane { display: none; }
/* Fade the incoming pane in so the swap reads as one motion with the
   colour change rather than a hard cut. */
.auth-pane.active { display: block; animation: authPaneIn var(--auth-fade); }
@keyframes authPaneIn {
    from { opacity: 0; transform: translateY(6px); }
    to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
    .auth-bg, .auth-card__head::before, .auth-card__head::after,
    .auth-title, .auth-submit, .auth-link, .auth-subpanel { transition: none; }
    .auth-pane.active { animation: none; }
}

/* The register pane holds far more fields than the log-in pane at the same
   card width, so it steps the type and spacing down and grows taller. Sized
   to clear a 1366x768 laptop viewport with the default (teacher) field set. */
/* Trim the page gutter too, so the tall register card keeps some breathing
   room on a short laptop viewport. */
.auth-page.register-mode { padding: 14px 20px; }
.register-mode .auth-card__head { padding: 12px 26px 0; }
.register-mode .auth-brand img { width: 36px; height: 36px; }
.register-mode .auth-brand__title { font-size: 0.82rem; }
.register-mode .auth-brand__sub { font-size: 0.73rem; }
.register-mode .auth-tab { padding: 9px 0; font-size: 0.92rem; }
.register-mode .auth-body { padding: 13px 26px 15px; }
#pane-register .auth-title { font-size: 1.2rem; margin-bottom: 9px; }
#pane-register .input-group { margin-bottom: 4px; }
#pane-register .input-group label { font-size: 0.72rem; margin-bottom: 2px; }
#pane-register input,
#pane-register select { padding: 5px 10px; font-size: 0.81rem; }
#pane-register .auth-row { gap: 10px; }
#pane-register .auth-options { margin: 2px 0 9px; }
#pane-register .auth-check { font-size: 0.75rem; gap: 7px; }
#pane-register .auth-submit { padding: 9px; font-size: 0.92rem; }

/* The field area is capped to the viewport, so expanding the dean-only
   blocks scrolls the form inside the card instead of growing the page.
   The offset covers the card chrome the fields sit inside: page gutter,
   card header, body padding, title, consent row, and submit button. */
.auth-scroll {
    max-height: calc(100vh - 270px);
    overflow-y: auto;
    padding-right: 8px;
    /* Reserve the gutter so content does not shift when the bar appears. */
    scrollbar-gutter: stable;
}
.auth-scroll::-webkit-scrollbar { width: 6px; }
.auth-scroll::-webkit-scrollbar-track { background: transparent; }
.auth-scroll::-webkit-scrollbar-thumb { background: #CBD5E1; border-radius: 3px; }
.auth-scroll::-webkit-scrollbar-thumb:hover { background: #94A3B8; }

.auth-title {
    text-align: center;
    font-size: 1.75rem;
    font-weight: 700;
    margin: 0 0 22px;
    color: var(--auth-maroon);
    transition: color var(--auth-fade);
}
.register-mode .auth-title { color: var(--auth-navy); }

.auth-body .input-group { margin-bottom: 15px; }
.auth-body .input-group label {
    color: #374151;
    font-size: 0.88rem;
    font-weight: 600;
    margin-bottom: 7px;
}
.auth-body input, .auth-body select {
    background: #FAFBFC;
    padding: 11px 13px;
    font-size: 0.93rem;
    transition: border-color 0.2s, box-shadow 0.2s;
}
.auth-body input:focus, .auth-body select:focus {
    border-color: var(--auth-maroon);
    box-shadow: 0 0 0 3px rgba(128, 0, 0, 0.13);
}
.register-mode .auth-body input:focus,
.register-mode .auth-body select:focus {
    border-color: var(--auth-navy);
    box-shadow: 0 0 0 3px rgba(0, 64, 133, 0.13);
}
.auth-body input[type="checkbox"] { width: auto; padding: 0; }

.auth-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }

/* ---------- One semester's class dates ----------
   Set off from the fields around it, because it belongs to the semester chosen
   above rather than to the form as a whole, and it saves on its own button. */
.sem-dates {
    margin-bottom: 16px;
    padding: 14px 16px 4px;
    border: 1px solid var(--border-color);
    border-left: 3px solid var(--brand);
    border-radius: 10px;
    background: var(--hover-bg);
}
/* Guarded: the rule above sets no display, but the foot below does, and the
   panel itself is toggled from script - so say it plainly either way. */
.sem-dates[hidden] { display: none; }
.sem-dates__foot {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 14px;
    flex-wrap: wrap;
    padding-bottom: 12px;
}

/* Alerts sit inside the card, above the form, so they never overlap a card
   that is nearly as tall as the viewport. */
.auth-body .alert { margin-bottom: 14px; padding: 10px 13px; font-size: 0.85rem; }
.register-mode .auth-body .alert { margin-bottom: 10px; padding: 8px 12px; font-size: 0.8rem; }

.auth-options {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    margin: 4px 0 20px;
    font-size: 0.92rem;
}
.auth-check {
    display: flex;
    align-items: center;
    gap: 9px;
    cursor: pointer;
    color: #374151;
    font-weight: 400;
}
/* The mark beside a label the form will not go without. Red, like the one the
   student record's own required fields carry, so "compulsory" looks the same
   wherever this system says it. aria-hidden is not needed: the input itself is
   `required`, which is what a screen reader announces - the asterisk is the
   sighted reader's copy of that same fact. */
.auth-req {
    color: var(--danger);
    font-weight: 700;
}

.auth-link {
    color: var(--auth-maroon);
    font-weight: 600;
    text-decoration: none;
    transition: color var(--auth-fade);
}
.auth-link:hover { text-decoration: underline; }
.register-mode .auth-link { color: var(--auth-navy); }

/* The info panel that walks somebody through email verification - "Email
   verification" on Reset Password, its own copy on Register - follows this
   card's own maroon/navy the way its title, its link, and its submit button
   already do, rather than .alert--info's ordinary var(--brand): that variable
   is the signed-in portal's personalization accent, and this page is never
   signed into anything, so it was only ever falling back to the house blue -
   the one colour on this card that answers to neither pane. Crossfaded on the
   same var(--auth-fade) as everything else here, so the switch between panes
   moves together rather than one piece catching up after the rest. */
.auth-card .alert--info {
    border-color: var(--auth-maroon);
    color: var(--auth-maroon-soft);
    transition: border-color var(--auth-fade), color var(--auth-fade);
}
.register-mode .auth-card .alert--info {
    border-color: var(--auth-navy);
    color: var(--auth-navy-mid);
}

.auth-submit {
    width: 100%;
    padding: 13px;
    border: none;
    border-radius: 9px;
    background: var(--auth-maroon);
    color: #fff;
    font-family: inherit;
    font-size: 1rem;
    font-weight: 700;
    cursor: pointer;
    transition: background var(--auth-fade), filter 0.15s;
}
.auth-submit:hover { filter: brightness(1.12); }
/* Extra air when the button sits straight under a field.
   .input-group's own 16px is the gap between one field and the next, and it is
   not enough here: a full-width solid button is a much heavier thing than a
   label, and at that distance it reads as attached to the input rather than as
   the next step. Only where the two are actually adjacent - the verification
   steps and the reset form - so the log-in and sign-up buttons, which follow a
   checkbox row that brings its own spacing, are untouched. */
.input-group + .auth-submit { margin-top: 10px; }
/* A gated submit says so by looking unavailable rather than by failing when
   pressed. The hint under it carries the reason - see #reg_gate. */
.auth-submit:disabled {
    background: #B9BEC6;
    cursor: not-allowed;
    filter: none;
}
.register-mode .auth-submit:disabled { background: #B9BEC6; }

/* ---------- Email verification steps ----------
   The verification panes are plain forms, one step per page load, so there is
   nothing here for a control that changes state under a script. What is left is
   the sentence introducing a step, and the banner reporting the step already
   done.

   An earlier version drove all of this from fetch() and needed a row of paired
   field-and-button controls, a countdown on a resend, and three tones of status
   line. It is gone: when the script did not run, the page could only say the
   server was unreachable, about a server that was answering. */

/* The address already proved, standing above the form it unlocked. Green
   because it reports something that went right, and it is the only part of
   these panes that is not a question. */
.verified-banner {
    display: flex;
    align-items: center;
    gap: 11px;
    margin-bottom: 20px;
    padding: 12px 14px;
    border: 1px solid #BFE3CC;
    border-radius: 10px;
    background: #F1F9F3;
    color: #14532D;
    font-size: 0.88rem;
}
.verified-banner > i { font-size: 1.05rem; flex: 0 0 auto; }
/* The address takes the room; the control to change it does not. */
.verified-banner > div { flex: 1; min-width: 0; }
.verified-banner strong {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.verified-banner__sub {
    margin-top: 1px;
    font-size: 0.74rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    opacity: 0.75;
}
/* A submit button wearing a link's clothes, because changing the address clears
   session state and so has to be posted rather than followed. */
.verified-banner__change {
    flex: 0 0 auto;
    padding: 0;
    border: none;
    background: none;
    color: #14532D;
    font-family: inherit;
    font-size: 0.78rem;
    font-weight: 700;
    text-decoration: underline;
    cursor: pointer;
}
.verified-banner__change:hover { opacity: 0.7; }
.register-mode .auth-submit { background: var(--auth-navy); }

/* Conditional block revealed by a choice above it, e.g. the extra fields a
   Program Coordinator or Year Level Coordinator promotion needs. */
.detail-panel {
    background: var(--hover-bg);
    border-left: 4px solid var(--gold);
    border-radius: 9px;
    padding: 14px 16px;
    margin-bottom: 16px;
}
.detail-panel > h4 {
    margin: 0 0 10px;
    font-size: 0.95rem;
    font-weight: 700;
}
.detail-panel .input-group:last-child { margin-bottom: 0; }
.detail-panel__hint {
    margin: 8px 0 0;
    font-size: 0.82rem;
    color: var(--text-muted);
}
.checklist {
    max-height: 150px;
    overflow-y: auto;
    padding: 10px 12px;
    border: 1px solid var(--border-color);
    border-radius: 8px;
    background: var(--panel-bg);
}
.checklist label {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 8px;
    font-weight: 400;
    cursor: pointer;
}
.checklist label:last-child { margin-bottom: 0; }

/* ---------- Rejected form fields ----------
   A save that fails names the field that caused it, so the correction happens
   where the value was typed instead of in a toast after the form has closed. */
.is-invalid,
input.is-invalid,
select.is-invalid {
    border-color: var(--danger) !important;
    background: #FDF6F5;
}
.day-group.is-invalid {
    border-radius: 8px;
    outline: 1px solid var(--danger);
    outline-offset: 5px;
}
.field-error {
    display: block;
    margin-top: 5px;
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--danger);
}
/* The whole-form reason, for a clash that belongs to no single field. */
.form-error {
    display: flex;
    gap: 10px;
    margin-bottom: 18px;
    padding: 12px 14px;
    border: 1px solid #F5C2BD;
    border-radius: 10px;
    background: #FDECEA;
    color: #7F1D1D;
    font-size: 0.88rem;
    line-height: 1.5;
}

/* An author display always beats the hidden attribute's, so without this the
   empty banner sits in the form before anything has gone wrong. */
.form-error[hidden] {
    display: none;
}

/* Nudged onto the first line of text and held at its size, so a long message
   wraps beside the icon rather than squashing it. */
.form-error > i {
    flex-shrink: 0;
    margin-top: 2px;
    color: var(--danger);
}

/* Inline feedback under a field. */
/* Standing guidance folded into a mark beside a field's label, for the kind of
   note that is read once and skipped past forever afterwards but still has to be
   somewhere. Reachable by keyboard as well as pointer, so it is not a hover-only
   secret; `cursor: help` is what says the mark is worth stopping on. */
.label-info {
    margin-left: 5px;
    font-size: 0.72rem;
    color: var(--text-muted);
    cursor: help;
    transition: color 0.15s;
}
.label-info:hover,
.label-info:focus-visible { color: var(--brand); }
/* The mark is a button so it answers a tap as well as a hover - a note that can
   only be reached by hovering does not exist on a phone, which is where most of
   this portal is read. */
.label-info { position: relative; }
.label-info__btn {
    width: auto;
    padding: 0;
    border: none;
    background: none;
    color: inherit;
    font-size: inherit;
    line-height: 1;
    cursor: help;
}
.label-info__bubble {
    position: absolute;
    z-index: 40;
    top: calc(100% + 7px);
    right: 0;
    width: max-content;
    max-width: 260px;
    padding: 10px 12px;
    border-radius: 9px;
    background: var(--text-color);
    color: var(--panel-bg);
    font-size: 0.76rem;
    font-weight: 400;
    line-height: 1.5;
    /* The label above is <sup>; the bubble must not inherit the raise or it
       floats away from the mark it belongs to. */
    vertical-align: baseline;
    text-transform: none;
    letter-spacing: normal;
    box-shadow: 0 10px 24px rgba(16, 24, 40, 0.22);
}

/* ---------- Record fields ----------
   One row per fact on a student's record: label left, value right, the same
   shape .profile-view__facts draws, so a student's record reads the way every
   other record in the app does. Each row is locked behind its own pencil - a
   record is corrected a field at a time, and a form that is entirely open
   invites a stray keystroke into a field nobody meant to touch. */
.record-list {
    padding: 2px 16px;
    border: 1px solid var(--border-color);
    border-radius: 11px;
}
.record-list:empty { display: none; }

.record-field {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 14px;
    min-height: 44px;
    padding: 8px 0;
}
.record-field + .record-field { border-top: 1px dashed var(--border-color); }

.record-field__label {
    flex: 0 0 auto;
    font-size: 0.86rem;
    font-weight: 400;
    color: var(--text-muted);
    white-space: nowrap;
}
.record-field__req { color: var(--danger); }

/* The value carries the weight, since it is the thing being read. Allowed to
   wrap because a department name is longer than the space a label leaves it. */
.record-field__value {
    flex: 1 1 auto;
    min-width: 0;
    font-weight: 700;
    text-align: right;
    overflow-wrap: anywhere;
}
/* Nothing on record reads as a dash rather than as a blank, so an empty row is
   plainly empty rather than looking like one that failed to load. */
.record-field__value.is-empty { color: var(--text-muted); font-weight: 400; }

/* Editing swaps the value for the control in the same place, so the row does
   not change shape as it opens. */
.record-field__input {
    flex: 1 1 auto;
    min-width: 0;
    max-width: 62%;
    padding: 7px 10px;
    font-size: 0.88rem;
}

/* The control that opens a row, sitting at the end of it.
   It was a superscript on the label: about ten pixels of glyph, at the opposite
   end of the row from the value it changes, raised so it read as a footnote
   mark rather than something to press. Three problems, and on a phone the size
   was the worst of them.
   Now a 30px target beside the value, which is both the thing being edited and
   where the eye already is. */
.field-edit {
    flex: 0 0 auto;
    display: inline-grid;
    place-items: center;
    width: 30px;
    height: 30px;
    padding: 0;
    /* Pulled tight on both sides: in against the value it belongs to, and out
       so the glyph - not the button's padding - lines up with the panel edge the
       values are already flush against. The 30px target is unaffected. */
    margin-left: -4px;
    margin-right: -6px;
    border: 1px solid transparent;
    border-radius: 8px;
    background: none;
    color: var(--text-muted);
    font-size: 0.76rem;
    cursor: pointer;
    /* Present but quiet. A row of eight pencils at full strength competes with
       the record itself, which is what somebody opened the dialog to read. */
    opacity: 0.5;
    transition: opacity 0.15s, color 0.15s, background 0.15s, border-color 0.15s;
}
/* Comes forward for the row being pointed at or tabbed into, so the one that
   can be acted on is the one that looks it. Never below 0.5 though - hover does
   not exist on a touch screen, and a control that only appears on hover is a
   control a phone cannot find. */
.record-field:hover .field-edit,
.record-field:focus-within .field-edit { opacity: 1; }

.field-edit:hover,
.field-edit:focus-visible {
    opacity: 1;
    color: var(--brand);
    background: var(--hover-bg);
    border-color: var(--border-color);
}
/* The tinted chip above is the focus indicator, so the browser's own ring would
   be a second one drawn around it. */
.field-edit:focus-visible { outline: none; }

/* Once a row is open its pencil has done its job, and leaving it lit invites a
   second press that would do nothing. The row is also given `disabled` by
   setRowLocked(), which is what actually takes it out of the tab order - this
   only says so on screen. */
.record-field.is-editing .field-edit,
.field-edit:disabled {
    color: var(--brand);
    opacity: 0.3;
    cursor: default;
    background: none;
    border-color: transparent;
}

/* ---------- Notification feed ----------
   A list of things that have already happened, so it is quiet by default: no
   card per row, no borders boxing each one, just a rule between them. The only
   thing that earns emphasis is whether a line has been seen. */
.notif-list { list-style: none; margin: 0; padding: 0; }

.notif {
    /* The anchor for the press target laid over an unread row - see
       .notif__read below. */
    position: relative;
    display: flex;
    align-items: flex-start;
    gap: 12px;
    padding: 14px 4px;
}
.notif + .notif { border-top: 1px solid var(--border-color); }

/* ---------- Pressing one line to mark it read ----------
   The whole row is the target. There is nothing smaller on it worth aiming at
   and nowhere for it to lead, so the gesture is simply "I have read this one" -
   which is what the sweep at the top of the panel could never say about a
   single line.

   Absolutely positioned over the row rather than wrapped around its markup: a
   <button> may hold only phrasing content, and the row is a message above a
   timestamp. The form is out of flow, so the flex layout beside it is
   untouched.

   Rendered only on unread rows, so a read one has no invisible control sitting
   on it doing nothing. */
.notif__read {
    position: absolute;
    inset: 0;
    margin: 0;
}
.notif__read-hit {
    width: 100%;
    height: 100%;
    padding: 0;
    border: none;
    background: none;
    cursor: pointer;
}
/* The row shading under the pointer is the only sign it can be pressed: the
   target itself is invisible by design, and a cursor change is a hint you have
   to already be hovering to receive. Unread rows only, matching where the
   target actually is. */
.notif--new:hover { background: var(--hover-bg); }
/* The target has no shape of its own, so the focus ring is drawn inside the
   row's edge - keyboard users get the same row the pointer gets. */
.notif__read-hit:focus-visible {
    outline: 2px solid var(--brand);
    outline-offset: -3px;
    border-radius: 8px;
}
/* Above the target covering the row. Everything else stays beneath it on
   purpose: the message and the timestamp are what is being pressed. */
.notif__delete { position: relative; z-index: 1; margin: 0; }

/* The dot is the whole unread indicator on the row itself - present for every
   line so the text starts at the same place, and coloured only when it means
   something. */
.notif__dot {
    flex: 0 0 auto;
    width: 8px;
    height: 8px;
    margin-top: 7px;
    border-radius: 50%;
    background: var(--border-color);
}
.notif--new .notif__dot { background: var(--brand); }

.notif__body { flex: 1 1 auto; min-width: 0; }
.notif__message { line-height: 1.55; overflow-wrap: anywhere; }
/* Unread reads heavier, which is the one difference worth drawing. */
.notif--new .notif__message { font-weight: 600; }

.notif__when {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-top: 3px;
    font-size: 0.76rem;
    color: var(--text-muted);
}
.notif__badge {
    padding: 1px 7px;
    border-radius: 999px;
    background: var(--hover-bg);
    color: var(--brand);
    font-size: 0.66rem;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

/* Quiet until the row is under the pointer: a column of crosses down the right
   would read as the point of the list. */
.notif__dismiss {
    flex: 0 0 auto;
    display: inline-grid;
    place-items: center;
    width: 28px;
    height: 28px;
    padding: 0;
    border: none;
    border-radius: 7px;
    background: none;
    color: var(--text-muted);
    font-size: 0.85rem;
    cursor: pointer;
    opacity: 0;
    transition: opacity 0.15s, background 0.15s, color 0.15s;
}
.notif:hover .notif__dismiss,
.notif:focus-within .notif__dismiss { opacity: 0.7; }
.notif__dismiss:hover,
.notif__dismiss:focus-visible {
    opacity: 1;
    background: var(--hover-bg);
    color: var(--danger);
}
/* Hover does not exist on a touch screen, so below the breakpoint the control
   is simply always there - a dismiss nobody can reach is not a dismiss. */
@media (max-width: 768px) {
    .notif__dismiss { opacity: 0.6; }
}

/* The rule between what is known and what is missing. Its caption is the whole
   point - a bare line would read as decoration. */
.record-split {
    display: flex;
    align-items: center;
    gap: 12px;
    margin: 18px 0 12px;
    color: var(--text-muted);
    font-size: 0.72rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.08em;
}
/* The `hidden` attribute is only `display: none` in the browser's own
   stylesheet, which any class here outranks - so every component that sets a
   display and is also hidden from script has to say so itself. Same reason
   .panel-collapse__body and .row-menu__panel carry one. */
.record-split[hidden] { display: none; }
.record-split::before,
.record-split::after {
    content: '';
    flex: 1;
    height: 1px;
    background: var(--border-color);
}

/* ---------- Camera frame ----------
   The controls that act on the picture sit on the picture. A shutter below the
   video means looking away from the student to press it. */
.cam-frame { position: relative; line-height: 0; }
/* The still takes the video's exact box, so the frame does not resize at the
   moment of capture - a picture that jumps as it is taken reads as a fault. */
.cam-frame video,
.cam-frame img {
    display: block;
    width: 100%;
    max-height: 38vh;
    border-radius: 11px;
    background: #111;
    object-fit: contain;
}
/* Without this the display above outranks the browser's own `[hidden]` rule:
   the video kept playing under the still, and the empty <img> drew as a broken
   image with its alt text spilled across the frame. */
.cam-frame video[hidden],
.cam-frame img[hidden] { display: none; }
/* Cancel sits away from the shutter centred above it: it is the one control
   here that leaves rather than acts on the picture, and a thumb reaching for
   the shutter must not find it. */
.cam-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
/* Hidden in review, where there is nothing left to cancel - and guarded because
   the display above would otherwise outrank the browser's own [hidden] rule. */
.cam-actions[hidden] { display: none; }
.cam-frame__controls {
    position: absolute;
    left: 50%;
    bottom: 12px;
    transform: translateX(-50%);
    display: flex;
    align-items: center;
    gap: 14px;
}
/* Live and review are alternatives, so exactly one is drawn - without this the
   display above beat `[hidden]` and both sets stacked on the same spot, putting
   Discard on top of the shutter. */
.cam-frame__controls[hidden] { display: none; }
.cam-btn {
    display: grid;
    place-items: center;
    width: 40px;
    height: 40px;
    padding: 0;
    border: 1.5px solid rgba(255, 255, 255, 0.55);
    border-radius: 50%;
    /* Dark and translucent rather than the panel colour: it sits on a live
       picture whose brightness is not ours to predict. */
    background: rgba(17, 24, 39, 0.55);
    color: #fff;
    font-size: 0.95rem;
    cursor: pointer;
    transition: background 0.15s, border-color 0.15s;
}
.cam-btn:hover { background: rgba(17, 24, 39, 0.8); border-color: #fff; }
/* The shutter is the one thing on this screen worth pressing, so it is larger
   than the control beside it and reads as the primary of the pair. */
.cam-btn--shoot { width: 54px; height: 54px; font-size: 1.25rem; }
/* A labelled variant for the review frame: "Discard" has to say what it does,
   where a shutter and a camera-flip are understood from their glyphs alone. */
.cam-btn--label {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    width: auto;
    height: auto;
    padding: 9px 18px;
    border-radius: 999px;
    font-size: 0.86rem;
    font-weight: 600;
}
.cam-btn--danger { border-color: rgba(255, 255, 255, 0.6); }
.cam-btn--danger:hover { background: var(--danger); border-color: var(--danger); }

.field-hint { display: block; margin-top: 5px; font-size: 0.78rem; color: var(--text-muted); }
.field-hint.is-ok { color: var(--success); }
.field-hint.is-warn { color: var(--warning); }

/* The resend wait, which sits above the button it explains rather than under a
   field. A hint's top margin belongs under the field it follows; here the space
   is wanted beneath it instead, so the line and the button read as one pair.

   Left, like every other hint on the card: centred it read as a caption for the
   button rather than as the note under the field above it. */
.otp-countdown--resend {
    margin-top: 0;
    margin-bottom: 8px;
}

/* A hint that has been hidden actually goes.

   .field-hint declares display:block, and a declaration in a stylesheet beats
   the browser's own display:none for [hidden] - so the script's el.hidden left
   the line still occupying its row, empty but for the clock face beside it. */
.field-hint[hidden] { display: none; }

/* ---------- Searchable person picker ----------
   A plain <datalist> renders one line per option, so this is a small custom
   list in order to show a name with its own subtext beneath. */
.picker { position: relative; }
.picker__list {
    position: absolute;
    z-index: 30;
    left: 0;
    right: 0;
    top: calc(100% + 4px);
    /* A ceiling, not the height: place() in picker.js brings this down to the
       room the list actually has, so a picker low in a dialog cannot hang past
       the card and turn it into a scroller. */
    max-height: 220px;
    overflow-y: auto;
    background: var(--panel-bg);
    border: 1px solid var(--border-color);
    border-radius: 9px;
    box-shadow: 0 12px 28px rgba(16, 24, 40, 0.16);
}
/* Opened upward, when place() finds more room above the box than below it -
   which is the case for a field near the foot of a dialog. The shadow turns over
   with it, so the list still reads as lifted off the page rather than lit from
   underneath. */
.picker__list.is-above {
    top: auto;
    bottom: calc(100% + 4px);
    box-shadow: 0 -12px 28px rgba(16, 24, 40, 0.16);
}
.picker__option {
    display: block;
    width: 100%;
    padding: 9px 13px;
    border: none;
    background: none;
    text-align: left;
    cursor: pointer;
    font-family: inherit;
}
/* Caret and clear, added by setupPicker() to every picker it drives. Scoped to
   .has-tools so the search boxes - which use .picker but bring their own
   controls - are left alone. */
.picker.has-tools .picker__input { padding-right: 58px; }
.picker__tools {
    position: absolute;
    right: 6px;
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    align-items: center;
    gap: 2px;
}
.picker__clear,
.picker__toggle {
    display: grid;
    place-items: center;
    width: 24px;
    height: 24px;
    padding: 0;
    border: none;
    border-radius: 6px;
    background: none;
    color: var(--text-muted);
    font-family: inherit;
    cursor: pointer;
    transition: background 0.15s, color 0.15s;
}
.picker__clear { font-size: 1.15rem; line-height: 1; }
.picker__clear:hover { background: var(--hover-bg); color: var(--danger); }
.picker__toggle { font-size: 0.78rem; }
.picker__toggle:hover { background: var(--hover-bg); color: var(--brand); }
.picker__clear[hidden] { display: none; }
/* The clear button standing on its own, without the caret beside it, needs
   less room than the pair does. */
.picker.has-clear .picker__input { padding-right: 34px; }
/* The tools are appended after the list, so an open list can turn the caret. */
.picker__list:not([hidden]) ~ .picker__tools .picker__toggle i { transform: rotate(180deg); }
.picker__toggle i { transition: transform 0.18s; }

.picker__option:hover,
.picker__option.is-highlighted { background: var(--hover-bg); }
.picker__option strong { display: block; font-size: 0.92rem; color: var(--text-color); }
.picker__option span { display: block; font-size: 0.78rem; color: var(--text-muted); }
.picker__empty { padding: 12px 13px; font-size: 0.85rem; color: var(--text-muted); }

/* ---------- Tabs inside a modal ---------- */
.modal-tabs {
    display: flex;
    gap: 4px;
    margin: -4px 0 18px;
    border-bottom: 1px solid var(--border-color);
}
.modal-tab {
    width: auto;
    padding: 10px 18px;
    border: none;
    border-bottom: 3px solid transparent;
    background: none;
    font-family: inherit;
    font-size: 0.92rem;
    font-weight: 600;
    color: var(--text-muted);
    cursor: pointer;
    transition: color 0.18s, border-color 0.18s;
}
.modal-tab:hover { color: var(--text-color); }
.modal-tab.is-active { color: var(--brand); border-bottom-color: var(--brand); }
.modal-tabpane { display: none; }
.modal-tabpane.is-active { display: block; }

/* ---------- The OTP stepper ----------
   Three circles and the labels under them, joined by a line that fills in as
   each is reached - Send Code, Verify Code, Confirm. Drawn by
   renderOtpStepper() in includes/EmailOtp.php above whichever of the three
   forms a code-gated change is currently showing, so the strip and the form
   agree about where the person is without either having to be told by the
   other: both read the same three states off the same session proof.

   Left-aligned on a desktop width, against the panel's own edge, the way its
   heading and every field below it already sit - centred either way, desktop
   or phone, matching the step body below it. A left-aligned strip was tried
   here and did not read as anchored to the panel so much as adrift from the
   heading it sat directly under - a three-item strip pinned to one edge reads
   as a list starting somewhere arbitrary, and centred it reads instead as a
   path with a beginning and an end. */
.otp-stepper {
    display: flex;
    align-items: flex-start;
    justify-content: center;
    gap: 0;
    margin: 0 0 22px;
}
.otp-stepper__step {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 7px;
    width: 108px;
}
.otp-stepper__circle {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    height: 30px;
    border-radius: 50%;
    border: 2px solid var(--border-color);
    background: var(--panel-bg);
    color: var(--text-muted);
    font-size: 0.82rem;
    font-weight: 700;
    transition: background 0.2s, border-color 0.2s, color 0.2s;
}
/* The step ahead, not yet reached: label held to the same muted ink as its
   circle, so the strip reads unmistakably as one step lit among three rather
   than three labels of equal weight with a decoration above them. */
.otp-stepper__label {
    font-size: 0.74rem;
    font-weight: 600;
    color: var(--text-muted);
    text-align: center;
    line-height: 1.3;
}
.otp-stepper__step.is-active .otp-stepper__circle {
    border-color: var(--brand);
    background: var(--brand);
    color: #fff;
}
.otp-stepper__step.is-active .otp-stepper__label { color: var(--brand); }
/* Done takes the success colour rather than the brand: the step behind is a
   fact that has already happened, and it is the same distinction the attendance
   sheet draws between an accent action and a completed one. */
.otp-stepper__step.is-done .otp-stepper__circle {
    border-color: var(--success);
    background: var(--success);
    color: #fff;
}
.otp-stepper__step.is-done .otp-stepper__label { color: var(--text-color); }

/* The connector sits at circle height, not centred on the whole step - a label
   of two lines would otherwise pull it visibly off the circles it is meant to
   join. Two widths short of the step's own so it spans the gap between two
   circles and stops there, rather than running the full width and hiding under
   each one. */
.otp-stepper__line {
    width: 54px;
    height: 2px;
    margin-top: 14px;
    background: var(--border-color);
    transition: background 0.2s;
}
.otp-stepper__line.is-done { background: var(--success); }

/* ---------- The strip inside the log-in card ----------
   There the open step takes the card's own colour instead of --brand.

   --brand is the accent a signed-in reader chose for their portal, and nobody
   has signed in at this door - so it falls back to the institution blue and the
   strip sat navy on a maroon card, directly above a maroon button. The card
   sets the colour of everything else on it and the strip is not the exception.

   Completed steps keep the success green. That is the one distinction on the
   strip carrying meaning rather than identity - what is behind you against
   where you are - and in the card's own maroon the two would differ by nothing
   but the tick inside them. */
.auth-card .otp-stepper__step.is-active .otp-stepper__circle {
    border-color: var(--auth-maroon);
    background: var(--auth-maroon);
}
.auth-card .otp-stepper__step.is-active .otp-stepper__label { color: var(--auth-maroon); }

/* And the navy once the card is registering, which is the swap the whole page
   makes: the head, the background and the submit button all cross to it
   together, and a strip left maroon would be the one thing that did not.

   Equal specificity to the rule above and placed after it, which is what decides
   the tie - .register-mode sits on <body>, so both selectors are four classes
   and neither outranks the other on its own. */
.register-mode .otp-stepper__step.is-active .otp-stepper__circle {
    border-color: var(--auth-navy);
    background: var(--auth-navy);
}
.register-mode .otp-stepper__step.is-active .otp-stepper__label { color: var(--auth-navy); }

/* ---------- Terms and Conditions ----------
   The dialog the register pane gates its agreement box behind.

   Shell held still, body scrolling - the arrangement the import dialog uses,
   and here it is load-bearing rather than tidy: the accept button is shut until
   the body reaches its end, so it has to be on screen from the start for that
   to read as a condition rather than as a control that appeared from nowhere. */
.terms-modal {
    display: flex;
    flex-direction: column;
    max-width: 640px;
    max-height: 86vh;
    overflow: hidden;
}
.terms-modal .modal-header { flex: 0 0 auto; }
.terms-body {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    /* Prose, so it is set looser than the forms around it and to a measure the
       eye can return along. */
    font-size: 0.89rem;
    line-height: 1.7;
    color: var(--text-color);
}
.terms-body h4 {
    margin: 20px 0 6px;
    font-size: 0.92rem;
    font-weight: 700;
}
.terms-body h4:first-of-type { margin-top: 14px; }
.terms-body p { margin: 0 0 10px; }
.terms__intro { font-weight: 600; }
.terms__revised {
    margin: 0 0 14px !important;
    font-size: 0.78rem;
    color: var(--text-muted);
}

/* Full width, because it is the dialog's one action and its label changes to
   carry the reason it is shut. */
.terms-foot .terms-accept { margin: 0; }
.terms-accept:disabled {
    background: var(--border-color);
    color: var(--text-muted);
    cursor: not-allowed;
}

/* The agreement row. The label runs to two lines in a narrow card, so the box
   sits against the first of them rather than centred on the pair. */
.auth-terms { align-items: flex-start; }
.auth-terms .auth-check { align-items: flex-start; }
.auth-terms .auth-check input { margin-top: 3px; }
/* Not-allowed on the box itself would be a lie - it can be pressed, and
   pressing it is how the terms open. */
.auth-terms .auth-link { font-weight: 600; }

@media (max-width: 480px) {
    /* The labels are what a narrow screen has no room for; the circles and the
       line they are strung on still say which step is open without them. */
    .otp-stepper__step { width: 74px; }
    .otp-stepper__label { display: none; }
    .otp-stepper__line { width: 40px; }
}

/* Centred, matching the stepper and the step body below it - for anything
   drawn before that wrapper opens (the panel's own intro line, on step one). */
.otp-align { text-align: center; }

/* ---------- The identity panel ----------
   The avatar, the name and rank, and the Account Action button that some pages
   hang off it - drawn by renderProfileIdentity() in includes/profile_page.php.

   The desktop row: avatar, then the name block, then whatever room is left,
   with the button pinned to the far right the way this app pins every header
   action. Unchanged from the plain flex rule this replaces - only moved into
   the stylesheet so the phone override below has something to win against. */
.profile-identity {
    display: flex;
    gap: 22px;
    align-items: center;
    flex-wrap: wrap;
}
.profile-identity__action { margin-left: auto; }

@media (max-width: 560px) {
    /* The same row, held together by shrinking what is in it rather than by
       giving up the row - a first pass here stacked the three pieces instead,
       and it read as a different component on a phone than the one everybody
       already knows from the desktop. Smaller keeps it recognisably the same
       card at every width, which is worth more than the extra headroom
       stacking would have bought back. */
    .profile-identity { gap: 12px; }

    .profile-identity .avatar-edit {
        width: 52px;
        height: 52px;
        border-width: 2px;
    }
    /* The letter scales with the circle it sits in, or a 52px ring holds a
       2.3rem initial sized for one nearly twice its size. */
    .profile-identity .avatar-edit__initial { font-size: 1.3rem; }

    .profile-identity__info h2 { font-size: 1.05rem; }
    .profile-identity__info p { font-size: 0.78rem; }

    /* The label goes, the same way .panel-btn already sheds its own label at
       this width and for the identical reason - a control fighting a heading
       for room loses its words before it loses its place. The icon alone
       still says what pressing it does, and the button keeps a title
       attribute so the words are not gone, only unwritten. */
    .profile-identity__action .btn-outline-accent { padding: 8px 11px; }
    .profile-identity__action .btn-outline-accent span { display: none; }
    .profile-identity__action .btn-outline-accent i { font-size: 0.95rem; }
}

/* The card each step's own fields sit in, under the stepper - a div wrapping
   just the fields, not the <form> that holds them. Capped rather than run
   the full width of the glass-panel: the panel itself is sized for the
   identity header above it and for a two-column field grid on the confirm
   step, and a single code box stretched to that width reads as a form still
   waiting to be filled in rather than as the one thing this step is asking
   for.

   420px is not a round number chosen for its own sake - it is the narrowest a
   two-column .field-grid holds its columns side by side at (2 x 186px + the
   20px gap = 392px), so the confirm step's New Password / Confirm Password
   pair does not fall back to one column just because this wrapper narrowed it.

   Centred, matching the stepper above it - held to the same width and margin
   at every viewport, so nothing about this card changes shape between a
   desktop and a phone beyond what the stepper's own label-hiding already
   does. Every element that used to carry its own text-align:center inline is
   answered from here instead, which is what a plain class buys over an
   attribute even where the two end up agreeing at every width.

   The <form> itself is left uncapped and full width, and .form-footer is a
   sibling of this div rather than a child of it - which is the whole reason
   the split exists. Nested inside the 420px card, the footer's own
   border-top ran only as wide as the card, well short of the section-title's
   divider above it doing the same job at the panel's full width; and Save's
   flex-end put it at the card's right edge, itself sitting in the middle of
   a wider panel, which reads as centred-ish rather than as pinned to a
   corner. Outside the card, the divider matches the one above it and the
   button reaches the panel's actual right edge - the same corner Personal
   Details' own Save already sits in, above this same page. */
.otp-step-body { max-width: 420px; margin: 0 auto; text-align: center; }

/* The code itself: spaced out like a code rather than typed prose, and large
   enough that six digits read as the one thing on the step.

   A comfortable fixed width with real padding, rather than sizing the box to
   the six characters in ch units: that measure is the width of one glyph and
   takes no account of the letter-spacing stretching the run out well past six
   of them, which is what was clipping the last digit against the box's own
   edge - visible in testing once the box was checked at its actual rendered
   width rather than assumed from the character count. The padding here is
   slack rather than a correction pinned to one spacing value, so it keeps
   working if either number is tuned later. */
.otp-code-input {
    width: auto;
    min-width: 220px;
    padding: 12px 22px;
    text-align: center;
    font-size: 1.5rem;
    font-weight: 700;
    letter-spacing: 0.35em;
}

/* ---------- A dialog's action row ----------
   The way out and the way on, on one line. The action takes the width because it
   is what the dialog is for; the way back is an arrow, which says "back" without
   spending a bar of its own on it - three stacked full-width buttons at the foot
   of a card read as three things of equal weight, and one of them was only a
   cancel. */
.modal-actions { display: flex; align-items: stretch; gap: 10px; }
/* The action fills whatever the arrow leaves, so its label stays centred on the
   space it actually has rather than on the row. */
.modal-actions .btn-primary-filled { flex: 1; margin: 0; }
.modal-actions__back {
    flex: 0 0 auto;
    width: 46px;
    display: grid;
    place-items: center;
    border: 1px solid var(--border-color);
    border-radius: 10px;
    background: none;
    color: var(--text-muted);
    font-size: 0.95rem;
    cursor: pointer;
}
.modal-actions__back:hover { border-color: var(--brand); color: var(--brand); }
.modal-actions__back:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

/* ---------- Read-only profile card ---------- */
/* Sized to sit inside the modal without scrolling: the card is read at a
   glance, and a scrollbar through six facts and a button reads as a defect. */
.profile-view { position: relative; text-align: center; }

/* ---------- Profile action ----------
   An action pinned to the avatar's own line rather than stacked under the card.
   The profile is centred, so a button beside the avatar in normal flow would
   shove it off centre; this is lifted out and pinned to the right edge at the
   avatar's own height instead.

   Icon only, and it carries a title and an aria-label to say what it is: a
   destructive action reached from a profile should be deliberate to find, not
   the widest thing on the card. */
.profile-view__action-slot { position: absolute; top: 0; right: 0; }
.profile-view__action {
    display: grid;
    place-items: center;
    width: 38px;
    height: 38px;
    border: 1.5px solid var(--danger);
    border-radius: 50%;
    background: transparent;
    color: var(--danger);
    font-size: 0.95rem;
    cursor: pointer;
    transition: background 0.18s, color 0.18s;
}
.profile-view__action:hover,
.profile-view__action:focus-visible { background: var(--danger); color: #fff; }

.profile-view__avatar {
    display: grid;
    place-items: center;
    overflow: hidden;
    width: 88px;
    height: 88px;
    margin: 2px auto 12px;
    border-radius: 50%;
    border: 3px solid var(--brand-deep);
    background: var(--brand);
    color: #fff;
    font-size: 1.95rem;
    font-weight: 700;
    letter-spacing: 0.02em;
}
/* The photo fills the circle; the initials keep it when there is no upload. */
.profile-view__avatar img { width: 100%; height: 100%; object-fit: cover; }
/* Same treatment as the sidebar's portrait, for the account sheet's copy of it.
   Only the anchor form is touched: the read-only cards that use this class
   elsewhere are divs and keep their own look. */
a.profile-view__avatar {
    color: #fff;
    text-decoration: none;
    cursor: pointer;
    transition: border-color 0.15s, box-shadow 0.15s;
}
a.profile-view__avatar:hover,
a.profile-view__avatar:focus-visible {
    border-color: var(--brand-soft);
    box-shadow: 0 0 0 3px var(--hover-bg);
    outline: none;
}

.profile-view__name { margin: 0; font-size: 1.22rem; font-weight: 700; }
.profile-view__role {
    display: inline-block;
    margin: 7px 0 0;
    padding: 4px 14px;
    border-radius: 999px;
    background: var(--hover-bg);
    font-size: 0.83rem;
    color: var(--text-muted);
}
.profile-view__facts {
    margin-top: 14px;
    padding: 4px 16px;
    border: 1px solid var(--border-color);
    border-radius: 11px;
    text-align: left;
}
.profile-view__row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 18px;
    padding: 9px 0;
}
.profile-view__row + .profile-view__row { border-top: 1px dashed var(--border-color); }
.profile-view__row dt { color: var(--text-muted); font-size: 0.9rem; }
.profile-view__row dd { margin: 0; font-weight: 600; text-align: right; }
/* Long values read better stacked than squeezed against the label. */
.profile-view__row--stacked { display: block; }
.profile-view__row--stacked dd { text-align: left; margin-top: 3px; }

/* Conditional blocks inside the register form. Both only appear for deans,
   and each keeps its own inner scroll so opening them cannot push the rest
   of the form off-screen. */
.auth-subpanel {
    background: #F7F9FC;
    border-left: 4px solid var(--auth-navy);
    border-radius: 9px;
    padding: 12px 14px 1px;
    margin-bottom: 11px;
    transition: border-color var(--auth-fade);
}
.auth-subpanel .input-group { margin-bottom: 10px; }
.auth-checklist {
    max-height: 128px;
    overflow-y: auto;
    border: 1px solid var(--border-color);
    border-radius: 9px;
    padding: 9px 11px;
    background: #fff;
}

@media (max-width: 560px) {
    .auth-body { padding: 24px 22px 28px; }
    .auth-card__head { padding: 18px 22px 0; }
    .auth-row { grid-template-columns: 1fr; gap: 0; }
    .auth-title { font-size: 1.6rem; }
    .auth-brand__title { font-size: 0.9rem; }
}

/* ================================================================
   Print
   ================================================================ */
@media print {
    .sidebar, .topbar, .no-print { display: none !important; }
    .portal__main { padding: 0; }
    .glass-panel { box-shadow: none; border: 1px solid #ccc; break-inside: avoid; }
}
