/* ============================================================================
   app.css — the one stylesheet.

   Loaded by BOTH shells (core/base.html and core/base_standalone.html) after
   Bootstrap, so everything here wins over Bootstrap's defaults.

   It exists because the two shells each carried their own near-identical copy
   of the token/card/button/form/table/alert/modal rules, and 131 page templates
   carried a third, fourth and fifth copy in inline <style> blocks — 13,189
   lines of it. They had already drifted: four different "success" greens, four
   reds, and #00897B hardcoded in 48 files that could no longer be re-themed by
   touching a token.

   Sections
     1  Tokens
     2  Base & reset
     3  Focus, motion, touch
     4  Shell A — sidebar layout        (core/base.html)
     5  Shell B — standalone layout     (core/base_standalone.html)
     6  Bootstrap component overrides
     7  UI component layer (.ui-*)
     8  Animations & utilities
   ========================================================================= */


/* ── 1. Tokens ───────────────────────────────────────────────────────────────
   Every colour a page needs has a name here. A raw hex in a page template is a
   bug: it cannot be re-themed, and it is how the palette drifted in the first
   place. The guard test in courses/tests.py enforces this.
   ------------------------------------------------------------------------- */
:root {
    /* ---- Density ----------------------------------------------------------
       How big the system is, in two numbers. Nothing outside this file sets a
       size of its own, so these move everything together.

         --ui-scale   chrome: padding, gaps, component heights, radii, widths
         --ui-text    the type ramp, through the root font-size every rem follows

       Chrome is at half. Type is not, and cannot be: half of a 16px base is 8px
       body text with 7px captions under it, which is past the point this system
       can be read at arm's length. 0.72 is the floor that keeps the smallest
       label near 10px. Set --ui-text to 0.5 to see the literal half — it is one
       line, and it reverts as easily.

       A few dimensions carry `max(...)` rather than the raw half. Those are the
       ones where 50% stops the thing working rather than just making it smaller;
       each says what its floor is for. */
    --ui-scale: 0.5;
    --ui-text:  0.72;

    /* The two steps below the base size, each with a pixel floor so the
       smallest text cannot follow --ui-text all the way down. Between them they
       replaced a ramp of nine different sub-1rem sizes. */
    --fs-xs: max(9.5px,  0.72rem);
    --fs-sm: max(10.5px, 0.85rem);

    /* Brand ramp — teal. --brand-600 is the interactive default. */
    --brand-50:  rgba(0, 137, 123, 0.06);
    --brand-100: rgba(0, 137, 123, 0.10);
    --brand-200: rgba(0, 137, 123, 0.20);
    --brand-600: #00897B;
    --brand-700: #00695C;
    --brand-800: #00574B;
    --brand-900: #003D33;

    /* Ink ramp — text, darkest to lightest. */
    --ink-900: #212529;
    --ink-800: #263238;
    --ink-700: #495057;
    --ink-500: #6C757D;
    --ink-300: #ADB5BD;
    --ink-200: #CED4DA;

    /* Surfaces. --surface-2 is the page background behind the cards. */
    --surface-0: #FFFFFF;
    --surface-1: #F8F9FA;
    --surface-2: #F5F7FE;
    --surface-3: #E9ECEF;

    /* Semantic status. One value each — these replace the four greens
       (#26A69A #198754 #2E7D32 #1B5E20) and four reds (#EF5350 #DC3545
       #C62828 #B71C1C) that were in circulation. Each has a tint for pill
       backgrounds and an -ink step dark enough to read on that tint. */
    --ok:         #26A69A;
    --ok-tint:    rgba(38, 166, 154, 0.15);
    --ok-ink:     #00695C;

    --warn:       #FFA726;
    --warn-tint:  rgba(230, 129, 0, 0.12);
    --warn-ink:   #E65100;

    --err:        #EF5350;
    --err-tint:   rgba(239, 83, 80, 0.15);
    --err-ink:    #C62828;

    --info:       #29B6F6;
    --info-tint:  rgba(13, 71, 161, 0.10);
    --info-ink:   #0D47A1;

    --neutral:      var(--ink-500);
    --neutral-tint: var(--surface-3);
    --neutral-ink:  var(--ink-700);

    /* Lines, radii, elevation. */
    --hairline:  rgba(0, 0, 0, 0.05);
    --radius-sm: calc(8px  * var(--ui-scale));
    --radius-md: calc(12px * var(--ui-scale));
    --radius-lg: calc(14px * var(--ui-scale));
    --radius-pill: 999px;

    --shadow-1: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
    --shadow-2: 0 2px 10px rgba(0, 0, 0, 0.07);
    --shadow-3: 0 0.5rem 1rem rgba(0, 0, 0, 0.10);
    --shadow-modal: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.18);

    /* Layout. Floored: a 125px sidebar clips its own group labels, and a 28px
       navbar cannot hold the brand and the avatar at the same time. */
    --sidebar-width:           max(190px, calc(250px * var(--ui-scale)));
    --sidebar-collapsed-width: max(52px,  calc(70px  * var(--ui-scale)));
    --header-height:           max(40px,  calc(56px  * var(--ui-scale)));
    --topbar-height:           max(40px,  calc(54px  * var(--ui-scale)));

    /* Minimum comfortable touch target. Anything a finger hits gets this.
       Floored at 30px: the standard asks for 44 and half of that is 22, which
       is smaller than a fingertip. §7 of docs/ui-standard.md records the
       trade this makes. */
    --tap-min: max(30px, calc(44px * var(--ui-scale)));

    /* One height for anything that can stand beside anything else: inputs,
       selects and buttons alike. A filter bar lines up because of this, not
       because each page guessed the same padding — which is what it was doing,
       and why the search box and the button next to it were different heights.
       Tied to the tap minimum so every control also clears it. */
    --control-h: var(--tap-min);

    --transition-speed: 0.3s;

    /* Safe area for notched phones. */
    --safe-left:   env(safe-area-inset-left, 0px);
    --safe-right:  env(safe-area-inset-right, 0px);
    --safe-bottom: env(safe-area-inset-bottom, 0px);

    /* ---- Legacy aliases -------------------------------------------------
       The old names, kept pointing at the new ramp. 131 templates still
       reference them; they can be retired once Phase 3 finishes, and the
       guard test will say when nothing uses them any more. */
    --primary-color:   var(--brand-600);
    --secondary-color: var(--brand-700);
    --accent-color:    #FFD54F;
    --success-color:   var(--ok);
    --danger-color:    var(--err);
    --warning-color:   var(--warn);
    --info-color:      var(--info);
    --light-color:     var(--surface-0);
    --dark-color:      var(--ink-800);
}


/* ── 2. Base & reset ─────────────────────────────────────────────────────── */
* { box-sizing: border-box; }

/* The type knob. Every rem in this file, in Bootstrap, and in the spacing
   utilities every template uses (p-3, gap-2, mb-4, the grid gutters) is
   measured against this one length. */
html { font-size: calc(16px * var(--ui-text)); }

body {
    font-family: 'Poppins', sans-serif;
    background-color: var(--surface-2);
    overflow-x: hidden;
    margin: 0;
    /* Keep content clear of the notch. */
    padding-left:  var(--safe-left);
    padding-right: var(--safe-right);
}

/* Thin scrollbars on desktop; the OS handles it on touch. */
@media (pointer: fine) {
    ::-webkit-scrollbar       { width: 6px; height: 6px; }
    ::-webkit-scrollbar-track { background: #F1F1F1; border-radius: 10px; }
    ::-webkit-scrollbar-thumb { background: #B8B9BE; border-radius: 10px; }
    ::-webkit-scrollbar-thumb:hover { background: #9394A0; }
}


/* ── 3. Focus, motion, touch ─────────────────────────────────────────────────
   None of this existed before: :focus-visible appeared once across 220
   templates and prefers-reduced-motion not at all, while every page ran an
   entrance animation.
   ------------------------------------------------------------------------- */

/* A visible ring for keyboard users, and only for them — :focus-visible does
   not fire on mouse clicks, so this costs pointer users nothing. */
:focus-visible {
    outline: 2px solid var(--brand-600);
    outline-offset: 2px;
    border-radius: 4px;
}
/* Bootstrap components carry their own focus shadow; keep the ring but drop
   the doubled-up box-shadow so it does not read as two rings. */
.btn:focus-visible,
.form-control:focus-visible,
.form-select:focus-visible,
.nav-link:focus-visible {
    box-shadow: none;
}

/* Honour the OS "reduce motion" setting. Animation is decoration here — every
   page still reaches the same end state instantly. */
@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.001ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.001ms !important;
        scroll-behavior: auto !important;
    }
}


/* ── 4. Shell A — sidebar layout (core/base.html) ────────────────────────── */
.wrapper { display: flex; width: 100%; align-items: stretch; }

.content-wrapper {
    width: 100%;
    min-height: 100vh;
    background-color: var(--surface-2);
}

/* Top navbar */
.top-navbar {
    height: var(--header-height);
    background-color: var(--surface-0);
    box-shadow: var(--shadow-1);
    position: sticky;
    top: 0;
    z-index: 1030;
}
.top-navbar .container-fluid { padding-inline: 0.75rem; }

.navbar-brand {
    font-weight: 700;
    color: var(--brand-600);
    font-size: 1.1rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    /* Keep clear of the hamburger and the avatar. */
    max-width: calc(100vw - calc(140px * var(--ui-scale)));
}
@media (min-width: 992px) {
    .navbar-brand { font-size: 1.5rem; max-width: none; }
}

.navbar-profile {
    display: flex;
    align-items: center;
    padding: 0.4rem 0.5rem;
    color: var(--ink-800);
    text-decoration: none;
}
.navbar-profile i { font-size: 1.4rem; }

/* Sidebar */
.sidebar {
    min-width: var(--sidebar-width);
    max-width: var(--sidebar-width);
    background: var(--surface-0);
    box-shadow: var(--shadow-1);
    transition: all var(--transition-speed);
    height: 100vh;
    position: sticky;
    top: 0;
    z-index: 1030;
    overflow-y: auto;
    overflow-x: hidden;
}

/* Desktop: collapse to an icon rail. */
@media (min-width: 992px) {
    .sidebar.collapsed {
        min-width: var(--sidebar-collapsed-width);
        max-width: var(--sidebar-collapsed-width);
    }
}

/* Mobile: slide in over the content. */
@media (max-width: 991.98px) {
    .sidebar {
        position: fixed;
        top: 0;
        left: 0;
        height: 100%;
        transform: translateX(-100%);
        z-index: 1040;
        padding-left: var(--safe-left);
    }
    .sidebar.active { transform: translateX(0); }
}

.sidebar-overlay {
    display: none;
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.45);
    z-index: 1039;
    backdrop-filter: blur(2px);
}
.sidebar-overlay.active { display: block; }

.sidebar .sidebar-header {
    padding: calc(16px * var(--ui-scale)) calc(20px * var(--ui-scale));
    background: var(--surface-0);
    border-bottom: 1px solid var(--hairline);
    display: flex;
    justify-content: space-between;
    align-items: center;
}
.sidebar.collapsed .sidebar-title,
.sidebar.collapsed .sidebar-header h3 { display: none; }

.sidebar-title {
    margin: 0;
    color: var(--brand-600);
    font-weight: 600;
    font-size: 1.05rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.sidebar-toggle {
    background: transparent;
    border: none;
    color: var(--ink-500);
    cursor: pointer;
    padding: calc(6px * var(--ui-scale));
    font-size: 1.2rem;
    transition: color 0.2s;
    min-width: calc(40px * var(--ui-scale));
    min-height: calc(40px * var(--ui-scale));
    display: flex;
    align-items: center;
    justify-content: center;
}
.sidebar-toggle:hover { color: var(--brand-600); }

.sidebar .nav-item { margin: 4px 8px; border-radius: var(--radius-sm); overflow: hidden; }

.sidebar .nav-link {
    padding: calc(10px * var(--ui-scale)) calc(14px * var(--ui-scale));
    color: var(--ink-500);
    font-weight: 500;
    font-size: var(--fs-xs);
    border-radius: var(--radius-sm);
    transition: all 0.25s;
    display: flex;
    align-items: flex-start;
    min-height: var(--tap-min);
}
.sidebar .nav-link i {
    margin-right: calc(10px * var(--ui-scale));
    min-width: calc(20px * var(--ui-scale));
    text-align: center;
    flex-shrink: 0;
    margin-top: 2px;
}
.sidebar .nav-link span { word-break: break-word; line-height: 1.35; }
.sidebar .nav-link:hover { background: var(--surface-1); color: var(--brand-600); }
.sidebar .nav-link.active {
    background: var(--brand-600);
    color: var(--surface-0);
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}

/* A heading over a run of related nav items.

   Static labels rather than collapsible sections, deliberately: a collapsible
   group needs its open/closed state persisted per user or it re-closes on every
   page load, and it has nowhere to go on the 70px collapsed rail. A label costs
   no JS and no state, and still answers "what kind of thing is this?" — which
   is the only question a 21-item flat list left unanswered. */
.sidebar .nav-group-label {
    font-size: var(--fs-xs);
    font-weight: 700;
    letter-spacing: 0.7px;
    text-transform: uppercase;
    color: var(--ink-300);
    padding: calc(16px * var(--ui-scale)) calc(16px * var(--ui-scale)) calc(6px * var(--ui-scale));
    margin: 0;
    white-space: nowrap;
}
.sidebar .nav-group-label:first-child { padding-top: 8px; }

/* Collapsed, the words go but the grouping must not: the label becomes the
   rule that separates one run of icons from the next. */
.sidebar.collapsed .nav-group-label {
    padding: 0;
    height: 1px;
    overflow: hidden;
    color: transparent;
    background: var(--hairline);
    margin: calc(10px * var(--ui-scale)) calc(14px * var(--ui-scale));
}

.sidebar.collapsed .nav-item span,
.sidebar.collapsed .sidebar-footer span { display: none; }
.sidebar.collapsed .nav-link { padding: 14px 0; justify-content: center; }
.sidebar.collapsed .nav-link i { margin-right: 0; font-size: 1.2rem; }

.sidebar-divider { height: 1px; margin: 12px 10px; background: var(--hairline); }

.sidebar-footer {
    border-top: 1px solid var(--hairline);
    padding: calc(12px * var(--ui-scale)) calc(10px * var(--ui-scale));
    margin-top: auto;
    padding-bottom: calc(calc(12px * var(--ui-scale)) + var(--safe-bottom));
}

/* Main content */
.main-content {
    padding: calc(12px * var(--ui-scale));
    padding-bottom: calc(calc(16px * var(--ui-scale)) + var(--safe-bottom));
}
@media (min-width: 576px) { .main-content { padding: 16px; } }
@media (min-width: 768px) { .main-content { padding: 20px; } }

/* Page title area */
.page-title-area {
    display: flex;
    flex-direction: column;
    gap: calc(10px * var(--ui-scale));
    margin-bottom: calc(16px * var(--ui-scale));
}
@media (min-width: 576px) {
    .page-title-area {
        flex-direction: row;
        align-items: center;
        justify-content: space-between;
        flex-wrap: wrap;
    }
}

.page-title {
    font-size: 1.2rem;
    font-weight: 600;
    color: var(--ink-900);
    margin: 0;
    line-height: 1.3;
}
@media (min-width: 768px) { .page-title { font-size: 1.5rem; } }

.page-actions-wrap {
    display: flex;
    flex-wrap: wrap;
    gap: calc(8px * var(--ui-scale));
    align-items: center;
}

.footer {
    padding: calc(16px * var(--ui-scale)) calc(20px * var(--ui-scale));
    background: var(--surface-0);
    text-align: center;
    border-top: 1px solid var(--hairline);
    font-size: var(--fs-xs);
    color: var(--ink-500);
}


/* ── 5. Shell B — standalone layout (core/base_standalone.html) ───────────── */
.sa-topbar {
    position: sticky;
    top: 0;
    z-index: 1030;
    height: var(--topbar-height);
    background: var(--surface-0);
    box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08);
    display: flex;
    align-items: center;
    padding: 0 calc(16px * var(--ui-scale));
    gap: calc(12px * var(--ui-scale));
}

.sa-home-btn {
    display: inline-flex;
    align-items: center;
    gap: calc(6px * var(--ui-scale));
    text-decoration: none;
    color: var(--brand-600);
    font-weight: 600;
    font-size: var(--fs-sm);
    padding: calc(6px * var(--ui-scale)) calc(12px * var(--ui-scale));
    border-radius: var(--radius-sm);
    background: var(--brand-50);
    transition: background 0.2s;
    white-space: nowrap;
    flex-shrink: 0;
}
.sa-home-btn:hover { background: var(--brand-200); color: var(--brand-700); }
.sa-home-btn i { font-size: var(--fs-sm); }

.sa-page-title {
    flex: 1;
    font-size: 1rem;
    font-weight: 600;
    color: var(--ink-900);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
@media (min-width: 768px) { .sa-page-title { font-size: 1.1rem; } }

.sa-profile-area { flex-shrink: 0; }

.sa-profile-toggle {
    display: flex;
    align-items: center;
    gap: calc(8px * var(--ui-scale));
    text-decoration: none;
    color: var(--ink-800);
    padding: calc(6px * var(--ui-scale)) calc(8px * var(--ui-scale));
    border-radius: var(--radius-sm);
    transition: background 0.2s;
}
.sa-profile-toggle:hover { background: #F0F0F0; color: var(--ink-800); }
.sa-profile-toggle .name { font-size: var(--fs-xs); font-weight: 500; }
.sa-profile-toggle i { font-size: 1.4rem; color: var(--ink-500); }

.sa-actions-bar {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: calc(8px * var(--ui-scale));
    padding: calc(10px * var(--ui-scale)) calc(16px * var(--ui-scale));
    background: var(--surface-0);
    border-bottom: 1px solid var(--hairline);
}
/* Stack on a phone so a Back link and a label do not squeeze each other. */
@media (max-width: 575.98px) {
    .sa-actions-bar { flex-wrap: wrap; justify-content: flex-start; }
}
/* The bar base_standalone.html wraps a page's page_actions block in. A page
   with no actions leaves it empty, and an empty bar is a stray hairline. */
.sa-actions-auto:not(:has(*)) { display: none; }

.sa-content {
    padding: calc(16px * var(--ui-scale));
    padding-bottom: calc(calc(24px * var(--ui-scale)) + var(--safe-bottom));
    min-height: calc(100vh - var(--topbar-height));
}
@media (min-width: 576px) { .sa-content { padding: 20px; } }
@media (min-width: 768px) { .sa-content { padding: 24px; } }

.sa-footer {
    background-color: var(--ink-800);
    text-align: center;
    padding: calc(20px * var(--ui-scale)) calc(16px * var(--ui-scale)) calc(calc(20px * var(--ui-scale)) + var(--safe-bottom));
    font-size: var(--fs-xs);
    color: rgba(255, 255, 255, 0.7);
}


/* ── 6. Bootstrap component overrides ────────────────────────────────────── */

/* Cards */
.card {
    border: none;
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-1);
    margin-bottom: calc(16px * var(--ui-scale));
}
/* Lift on hover only where there is a real pointer — on touch it just makes
   the card twitch under the finger. */
@media (hover: hover) and (pointer: fine) {
    .card { transition: box-shadow 0.3s, transform 0.3s; }
    .card:hover { box-shadow: var(--shadow-3); transform: translateY(-2px); }
}

/* Already a flex row with space-between — pages do NOT need to re-add
   `d-flex justify-content-between` (about 110 of them do). */
.card-header {
    background: var(--surface-0);
    border-bottom: 1px solid var(--hairline);
    padding: calc(12px * var(--ui-scale)) calc(16px * var(--ui-scale));
    border-top-left-radius:  var(--radius-md) !important;
    border-top-right-radius: var(--radius-md) !important;
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: calc(8px * var(--ui-scale));
}
.card-header h5 { margin: 0; font-weight: 600; font-size: var(--fs-sm); }
@media (min-width: 576px) { .card-header h5 { font-size: 1rem; } }

.card-body { padding: 16px; }
@media (min-width: 768px) { .card-body { padding: 20px; } }

/* Buttons */
.btn {
    border-radius: var(--radius-sm);
    font-weight: 500;
    padding: calc(7px * var(--ui-scale)) calc(14px * var(--ui-scale));
    /* Not half of the old height: a 20px control is fine for a mouse and not
       for a thumb. The same height every other control takes, so a button
       beside an input is the same size as the input. */
    min-height: var(--control-h);
    transition: background-color 0.2s, border-color 0.2s;
}
@media (hover: hover) and (pointer: fine) {
    .btn { transition: all 0.3s; }
    .btn-primary:hover,
    .btn-outline-primary:hover {
        transform: translateY(-2px);
        box-shadow: 0 0.4rem 0.8rem rgba(0, 0, 0, 0.1);
    }
}
.btn-primary { background-color: var(--brand-600); border-color: var(--brand-600); }
.btn-primary:hover { background-color: var(--brand-700); border-color: var(--brand-700); }
.btn-outline-primary { color: var(--brand-600); border-color: var(--brand-600); }
.btn-outline-primary:hover { background-color: var(--brand-600); color: var(--surface-0); }
/* A pressed toggle (.active, or a checked .btn-check) is the brand fill too,
   not Bootstrap's blue. */
.btn-outline-primary.active, .btn-outline-primary:active, .btn-check:checked + .btn-outline-primary {
    background-color: var(--brand-600); border-color: var(--brand-600); color: var(--surface-0);
}

/* An icon-only button is square and still meets the touch minimum. */
.btn-icon { min-width: var(--tap-min); padding-inline: calc(10px * var(--ui-scale)); }

/* Forms */
.form-control, .form-select {
    min-height: var(--control-h);
    border-radius: var(--radius-sm);
    padding: calc(10px * var(--ui-scale)) calc(14px * var(--ui-scale));
    border: 1px solid var(--ink-200);
    transition: border-color 0.2s, box-shadow 0.2s;
    font-size: var(--fs-sm);
}
.form-control:focus, .form-select:focus {
    border-color: var(--brand-600);
    box-shadow: 0 0 0 0.2rem var(--brand-200);
}
.form-label { font-weight: 500; color: var(--ink-700); font-size: var(--fs-sm); }

/* The required marker. One class instead of a bare red asterisk, so it also
   announces itself to a screen reader via the ::after content. */
.form-label .req,
.req { color: var(--err-ink); font-weight: 600; }

/* Tables */
.table { border-collapse: separate; border-spacing: 0; }
.table thead th {
    background: var(--surface-1);
    border-bottom: 2px solid var(--hairline);
    color: var(--ink-700);
    font-weight: 600;
    text-transform: uppercase;
    font-size: var(--fs-xs);
    padding: calc(10px * var(--ui-scale)) calc(12px * var(--ui-scale));
    letter-spacing: 0.2px;
    white-space: nowrap;
}
.table tbody tr:hover { background: var(--brand-50); }
.table td {
    padding: calc(10px * var(--ui-scale)) calc(12px * var(--ui-scale));
    vertical-align: middle;
    border-bottom: 1px solid var(--hairline);
    font-size: var(--fs-sm);
}
.table-responsive { -webkit-overflow-scrolling: touch; }

/* Alerts & badges */
.alert {
    border: none;
    border-radius: var(--radius-sm);
    padding: calc(14px * var(--ui-scale)) calc(16px * var(--ui-scale));
    box-shadow: var(--shadow-1);
    font-size: var(--fs-sm);
}
.badge { font-weight: 500; padding: 0.35em 0.65em; border-radius: 6px; }

/* Modals */
.modal-content {
    border: none;
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-modal);
    overflow: hidden;
}
.modal-header {
    background: var(--surface-0);
    border-bottom: 1px solid var(--hairline);
    padding: calc(14px * var(--ui-scale)) calc(18px * var(--ui-scale));
}
.modal-footer {
    background: var(--surface-0);
    border-top: 1px solid var(--hairline);
    padding: calc(12px * var(--ui-scale)) calc(18px * var(--ui-scale));
}
.modal-title { font-weight: 600; font-size: 1rem; }

/* On a small phone a non-centred modal becomes a bottom sheet. */
@media (max-width: 575.98px) {
    .modal-dialog:not(.modal-dialog-centered):not(.modal-fullscreen) {
        margin: 0;
        max-width: 100%;
        position: absolute;
        bottom: 0;
        width: 100%;
    }
    .modal-dialog:not(.modal-dialog-centered):not(.modal-fullscreen) .modal-content {
        border-radius: var(--radius-lg) var(--radius-lg) 0 0;
        min-height: auto;
    }
    .modal-body { max-height: 65vh; overflow-y: auto; }
}

/* Stacking. Modals must clear the sticky sidebar and topbar. */
.modal-backdrop, .modal-backdrop.show { z-index: 9000 !important; }
.modal, .modal.show, .modal.fade.show  { z-index: 9999 !important; }
.modal-dialog { z-index: 10000 !important; }
.dropdown-menu { z-index: 100; }

body.modal-open { overflow: hidden; }
.modal-open .top-navbar,
.modal-open .sidebar,
.modal-open .sticky-top { z-index: auto !important; }


/* ── 7. UI component layer ───────────────────────────────────────────────────
   The families that were being re-implemented per page. Counts are how many
   templates carried their own copy before this file existed.
   ------------------------------------------------------------------------- */

/* 7.1 Hero strip — a gradient header with a row of facts under it.
       Replaces .hero-strip (7), .status-hero (3), .class-hero (4),
       .term-banner (5), .profile-header (4), .clearance-header (3). */
.ui-hero {
    border-radius: var(--radius-lg);
    overflow: hidden;
    box-shadow: var(--shadow-2);
    margin-bottom: calc(20px * var(--ui-scale));
}
.ui-hero-head {
    background: linear-gradient(135deg, var(--brand-600) 0%, var(--brand-700) 100%);
    padding: calc(20px * var(--ui-scale)) calc(24px * var(--ui-scale)) calc(16px * var(--ui-scale));
    color: var(--surface-0);
}
.ui-hero-head h2,
.ui-hero-head h5 { margin: 0; font-weight: 700; font-size: 1.1rem; }
.ui-hero-head .sub {
    font-size: var(--fs-xs);
    color: rgba(255, 255, 255, 0.78);
    margin-top: 3px;
}
.ui-hero-meta {
    background: var(--surface-0);
    display: flex;
    flex-wrap: wrap;
    gap: calc(20px * var(--ui-scale));
    padding: calc(14px * var(--ui-scale)) calc(24px * var(--ui-scale));
}

/* 7.2 Label/value pair. Replaces .meta-item (7), .info-label (16),
       .info-value (12), .meta-label / .meta-value (14). */
.ui-meta {
    display: flex;
    align-items: center;
    gap: calc(8px * var(--ui-scale));
    font-size: var(--fs-xs);
    color: var(--ink-700);
}
.ui-meta-icon {
    width: calc(28px * var(--ui-scale));
    height: calc(28px * var(--ui-scale));
    border-radius: var(--radius-sm);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: var(--fs-xs);
    flex-shrink: 0;
    background: var(--brand-100);
    color: var(--brand-600);
}
.ui-meta-label {
    font-size: var(--fs-xs);
    color: var(--ink-300);
    display: block;
    text-transform: uppercase;
    letter-spacing: 0.3px;
}
.ui-meta-value { font-weight: 600; }

/* 7.3 Filter / toolbar bar. Replaces .filter-bar (23). */
.ui-filters {
    background: var(--surface-0);
    border-radius: var(--radius-md);
    padding: calc(14px * var(--ui-scale)) calc(20px * var(--ui-scale));
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
    margin-bottom: calc(16px * var(--ui-scale));
    display: flex;
    flex-direction: column;
    gap: calc(8px * var(--ui-scale));
}
@media (min-width: 576px) {
    /* flex-end, not center: a labelled field is taller than a bare button, and
       centring the two puts the buttons halfway up the label. Sharing a bottom
       edge is what makes a row of mixed controls read as one row. */
    .ui-filters { flex-direction: row; align-items: flex-end; flex-wrap: wrap; }
}
.ui-filters .form-control,
.ui-filters .form-select { min-width: 0; }
/* A field and its label, stacked. Every filter bar labels its fields — two
   unlabelled selects side by side are a guessing game — and putting the rule
   here means no page has to size its own label.

   .input-group is excluded because it is the one direct child that is already a
   flex row and has to stay one: it stacks an addon *beside* its input, not
   above it. Without the exclusion this rule outranks Bootstrap's own
   flex-direction (0,2,1 against 0,1,0) and turns every search box in the system
   into a full-width icon sitting on top of a full-width field. */
.ui-filters > div:not(.ui-filters-end):not(.input-group) {
    display: flex;
    flex-direction: column;
}
/* Bootstrap gives .input-group width:100%, which as a flex-basis claims the
   whole line and pushes the filters that belong beside it onto the next one.
   Only the basis is set here — whether the box grows into the leftover space is
   the page's call, via .flex-grow-1. The floor is one of the max(...) cases:
   half of 240 is a search box too narrow to read a student number back in. */
.ui-filters > .input-group {
    width: auto;
    flex-basis: max(180px, calc(240px * var(--ui-scale)));
}
/* The addon is a control like any other: same border, same radius, same type
   size, so a search box reads as one field beside the selects rather than as
   Bootstrap's defaults bolted onto ours. */
.ui-filters .input-group-text {
    border: 1px solid var(--ink-200);
    border-radius: var(--radius-sm);
    font-size: var(--fs-sm);
    color: var(--ink-500);
}
.ui-filters .form-label {
    font-size: var(--fs-xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.3px;
    color: var(--ink-500);
    margin-bottom: calc(4px * var(--ui-scale));
    white-space: nowrap;
}
/* Pushes everything after it to the right edge. */
.ui-filters .ui-filters-end {
    margin-left: auto;
    display: flex;
    align-items: flex-end;
    gap: calc(8px * var(--ui-scale));
}
/* A textarea is the one control that is meant to be taller than one line. */
.ui-filters textarea.form-control { min-height: 0; }
/* A checkbox is shorter than an input; give its row the same height so it
   lines up with the fields either side of it rather than floating. */
.ui-filters .form-check {
    min-height: var(--control-h);
    display: flex;
    align-items: center;
    gap: calc(12px * var(--ui-scale));
    margin: 0;
    padding: 0;
    white-space: nowrap;
}
.ui-filters .form-check .form-check-input { margin: 0; float: none; }
@media (max-width: 575.98px) {
    .ui-filters .ui-filters-end { margin-left: 0; }
}

/* 7.4 Stat tile. Replaces .stat-chip (8) and .stats-card (6). */
.ui-stats {
    display: flex;
    gap: calc(12px * var(--ui-scale));
    flex-wrap: wrap;
    margin-bottom: calc(16px * var(--ui-scale));
}
.ui-stat {
    background: var(--surface-0);
    border-radius: calc(10px * var(--ui-scale));
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
    padding: calc(14px * var(--ui-scale)) calc(20px * var(--ui-scale));
    display: flex;
    align-items: center;
    gap: calc(12px * var(--ui-scale));
    flex: 1;
    min-width: calc(130px * var(--ui-scale));
}
@media (hover: hover) and (pointer: fine) {
    .ui-stat { transition: transform 0.25s, box-shadow 0.25s; }
    .ui-stat:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); }
}
.ui-stat-icon {
    width: calc(38px * var(--ui-scale));
    height: calc(38px * var(--ui-scale));
    border-radius: calc(10px * var(--ui-scale));
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 1rem;
    flex-shrink: 0;
    background: var(--brand-100);
    color: var(--brand-600);
}
.ui-stat-value { font-size: 1.15rem; font-weight: 700; color: var(--ink-900); line-height: 1.1; }
.ui-stat-label {
    font-size: var(--fs-xs);
    color: var(--ink-500);
    text-transform: uppercase;
    letter-spacing: 0.3px;
}

/* 7.5 Status pill. Replaces .status-badge (24), .status-pill (8) and the
       .pill-<state> families. Tone is carried by .ui-tone-*, so a new state
       needs no new CSS — only a mapping in the view or template. */
.ui-status {
    font-size: var(--fs-xs);
    font-weight: 600;
    padding: 3px calc(10px * var(--ui-scale));
    border-radius: var(--radius-pill);
    display: inline-block;
    white-space: nowrap;
    background: var(--neutral-tint);
    color: var(--neutral-ink);
}
/* Tone variants, shared by the stat icon and the status pill above. Declared
   after .ui-status on purpose: the pill's neutral default has the same
   specificity, so with the tones ahead of it every "ui-status ui-tone-ok"
   pill in the system rendered grey. Order is the whole fix. */
.ui-tone-ok      { background: var(--ok-tint);      color: var(--ok-ink); }
.ui-tone-warn    { background: var(--warn-tint);    color: var(--warn-ink); }
.ui-tone-err     { background: var(--err-tint);     color: var(--err-ink); }
.ui-tone-info    { background: var(--info-tint);    color: var(--info-ink); }
.ui-tone-neutral { background: var(--neutral-tint); color: var(--neutral-ink); }
.ui-tone-brand   { background: var(--brand-100);    color: var(--brand-700); }
/* On the hero's brand gradient every tint is a faint smudge — the ok pill
   was green on green. There the pill is a white chip, whatever its tone; the
   word carries the state, as it does in the table under it. */
.ui-hero-head .ui-status {
    background: rgba(255, 255, 255, 0.92);
    color: var(--brand-700);
}

/* 7.6 Empty state — "there is nothing here".
       Two shapes, because the markup differs and one class cannot serve both:

         .ui-empty      a block, inside a card body or panel
         .ui-empty-row  a <tr>, styling its own <td> directly

       Replaces .empty-state (20 files) and .empty-row (26). The padding had
       drifted to 48px in some pages and 60px in others, over four greys. */
.ui-empty,
.ui-empty-row > td,
.ui-loading,
.ui-loading-row > td {
    text-align: center;
    padding: calc(48px * var(--ui-scale)) calc(20px * var(--ui-scale));
    color: var(--ink-500);
    font-size: var(--fs-sm);
}

.ui-empty i,
.ui-empty-row > td > i {
    font-size: 2.6rem;
    margin-bottom: calc(14px * var(--ui-scale));
    display: block;
    color: var(--ink-200);
}
.ui-empty p, .ui-empty-row > td p { margin: 0; }
.ui-empty-hint { font-size: var(--fs-xs); color: var(--ink-300); margin-top: 6px; }

/* An empty or loading row is not a record — it must not take the row hover
   tint, and it carries no bottom rule because nothing follows it. */
.table tbody tr.ui-empty-row:hover,
.table tbody tr.ui-loading-row:hover { background: transparent; }
.ui-empty-row > td,
.ui-loading-row > td { border-bottom: none; }

/* 7.7 Loading — the state between "asked" and "answered". Without it, a table
       that fetches its rows reads as empty while the request is in flight,
       which is what an error looks like too.
       Replaces .loading-row (17 files) and .loading-state (8). */
.ui-loading, .ui-loading-row > td { font-size: var(--fs-sm); }

/* 7.8 Toast region. There was no shared one: 29 templates each built their
       own. Fixed to the bottom on a phone, top-right on a desktop. */
.ui-toasts {
    position: fixed;
    z-index: 10050;      /* above modals, which sit at 9999–10000 */
    display: flex;
    flex-direction: column;
    gap: calc(8px * var(--ui-scale));
    pointer-events: none;
    left: calc(12px * var(--ui-scale));
    right: calc(12px * var(--ui-scale));
    bottom: calc(calc(12px * var(--ui-scale)) + var(--safe-bottom));
}
@media (min-width: 576px) {
    .ui-toasts {
        left: auto;
        bottom: auto;
        top: calc(var(--header-height) + calc(12px * var(--ui-scale)));
        right: calc(16px * var(--ui-scale));
        max-width: calc(360px * var(--ui-scale));
    }
}
.ui-toast {
    pointer-events: auto;
    background: var(--surface-0);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow-3);
    padding: calc(12px * var(--ui-scale)) calc(14px * var(--ui-scale));
    display: flex;
    align-items: flex-start;
    gap: calc(10px * var(--ui-scale));
    font-size: var(--fs-sm);
    color: var(--ink-900);
    border-left: 4px solid var(--neutral);
    animation: uiToastIn 0.25s ease-out;
}
.ui-toast.is-ok   { border-left-color: var(--ok); }
.ui-toast.is-warn { border-left-color: var(--warn); }
.ui-toast.is-err  { border-left-color: var(--err); }
.ui-toast.is-info { border-left-color: var(--info); }
.ui-toast i { margin-top: 2px; flex-shrink: 0; }
.ui-toast.is-ok   i { color: var(--ok-ink); }
.ui-toast.is-warn i { color: var(--warn-ink); }
.ui-toast.is-err  i { color: var(--err-ink); }
.ui-toast.is-info i { color: var(--info-ink); }
.ui-toast-close {
    margin-left: auto;
    background: none;
    border: none;
    color: var(--ink-300);
    cursor: pointer;
    padding: 0 2px;
    line-height: 1;
}
.ui-toast-close:hover { color: var(--ink-700); }

@keyframes uiToastIn {
    from { opacity: 0; transform: translateY(8px); }
    to   { opacity: 1; transform: none; }
}

/* 7.9 Section heading inside a card body, for pages that group fields. */
.ui-section-title {
    font-size: var(--fs-xs);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.2px;
    color: var(--ink-500);
    margin: 0 0 calc(10px * var(--ui-scale));
}

/* 7.10 Autocomplete — a text input with a menu of matches under it.
        Replaces .autocomplete-wrapper/.autocomplete-dropdown (list_section),
        .autocomplete-results/.ac-sub (list_course) and .ac-drop/.ac-sub
        (evaluation_list): three copies of the same widget that had drifted to
        two z-indexes, two font sizes and a hardcoded #f0f4f8 hover.

        The menu is absolutely positioned, so the wrapper must establish the
        containing block — that is the whole reason .ui-autocomplete exists and
        is not optional.

            <div class="ui-autocomplete">
                <input class="form-control" ...>
                <div class="ui-autocomplete-menu list-group d-none">
                    <button class="list-group-item">
                        Cataingan
                        <span class="ui-autocomplete-sub">Masbate</span>
                    </button>
                </div>
            </div>

        z-index 1060 clears Bootstrap's modal backdrop (1050): these menus open
        inside modals on three of the pages that use them. */
.ui-autocomplete { position: relative; }
.ui-autocomplete-menu {
    position: absolute;
    top: 100%;
    left: 0;
    width: 100%;
    z-index: 1060;
    max-height: calc(280px * var(--ui-scale));
    overflow-y: auto;
    box-shadow: var(--shadow-2);
}
.ui-autocomplete-menu .list-group-item {
    cursor: pointer;
    font-size: var(--fs-sm);
    padding: 0.45rem 0.75rem;
    text-align: left;
}
.ui-autocomplete-menu .list-group-item:hover,
.ui-autocomplete-menu .list-group-item.is-active { background: var(--surface-2); }
.ui-autocomplete-sub {
    display: block;
    font-size: var(--fs-xs);
    color: var(--ink-500);
}

/* 7.10b Schedule block — core/ui/_schedule.html and UI.schedule(). One line
        per meeting: the days in brand weight, the time, the room after a
        door. Replaces the one-run 'MWF 08:00-09:00 (R204); F …' string every
        list printed, which nobody could scan for a room or a day. */
.ui-sched { display: flex; flex-direction: column; gap: .15rem; line-height: 1.35; }
.ui-sched-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 .5rem; }
.ui-sched-days { font-weight: 700; color: var(--brand-700); min-width: 2.75em; letter-spacing: .02em; white-space: nowrap; }
.ui-sched-time { color: var(--ink-800); font-variant-numeric: tabular-nums; white-space: nowrap; }
.ui-sched-room { color: var(--ink-500); white-space: nowrap; }
.ui-sched-room i { font-size: .8em; margin-right: .3em; opacity: .8; }
.ui-sched-room.is-tba { font-style: italic; }
.ui-sched-label { color: var(--ink-500); font-style: italic; }
td > .ui-sched { font-size: var(--fs-sm); }   /* a cell; elsewhere the block takes its parent's size */

/* Schedule Editor (static/js/schedule-editor.js) — meeting times and rooms
   for one class, mounted inside the registrar's, program head's, workload and
   NSTP dialogs alike. A meeting is one row on two lines: the days, the times
   and the remove button, then the building and room beneath them. The rows
   use the theme's own shapes — a .btn-group for the days, .form-select for
   the times, .ui-autocomplete for building and room, .alert for clashes,
   .form-text for the summary — so the only rules here are the row's layout. */
.se-list { display: flex; flex-direction: column; gap: .4rem; }
.se-empty { padding: .4rem .25rem; }
.se-row {
    display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; gap: .35rem .5rem; align-items: center;
    border: 1px solid var(--hairline); border-left: 3px solid var(--brand-600);
    border-radius: var(--radius-sm); padding: .45rem .5rem .45rem .6rem; background: var(--surface-0);
}
.se-row.is-conflict { border-left-color: var(--err); background: var(--err-tint); }
.se-row-n { font-size: var(--fs-xs); font-weight: 700; color: var(--ink-500); min-width: 1rem; }
.se-row-days { justify-self: start; }
.se-row-days .btn { padding-left: .5rem; padding-right: .5rem; min-width: 2.2rem; }
.se-row-days .btn:hover { transform: none; box-shadow: none; }   /* a toggle in a group does not lift */
.se-row-time { display: flex; align-items: center; gap: .35rem; }
.se-row-time .form-select { width: auto; min-width: 7.2rem; }
.se-dash { color: var(--ink-500); }
.se-row-room { grid-column: 2 / 4; }
.se-room-pair { display: flex; flex-wrap: wrap; gap: .35rem; }
.se-room-pair .ui-autocomplete { flex: 1 1 10rem; }
.se-room-pair .ui-autocomplete-menu { width: max(100%, 300px); }
.se-ac-muted { color: var(--ink-500); }
.se-ac-muted .ui-autocomplete-sub { color: var(--warn-ink); }
@media (max-width: 575.98px) {
    .se-row { grid-template-columns: auto minmax(0, 1fr) auto; }
    .se-row-time, .se-row-room { grid-column: 2 / 3; }
}
.se-add { margin-top: .5rem; }

/* clashes + summary */
.se-issues { margin-top: .75rem; }
.se-summary { margin-top: .5rem; }
.se-summary:empty { display: none; }
.se-summary-text { font-weight: 600; color: var(--ink-800); }

/* 7.11 Record card grid — the shape a list takes when a table row cannot hold
        it, or when the page is a board rather than a register.

        Replaces .eval-grid/.eval-card (evaluation_list), .ticket-grid/
        .ticket-card (tickets_management) and .ac-grid/.ac-card
        (academic_calendar). All three had independently chosen the same two
        breakpoints and then diverged on radius (12/14px), shadow, and border.

        `.ui-record-card` is safe on a <button> as well as a <div> — the
        calendar makes the whole card the target so it is keyboard reachable —
        which is why width and text-align are reset here.

        A state accent goes on the left edge via .ui-accent-*, not by
        redefining the card. */
.ui-card-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: calc(14px * var(--ui-scale));
    padding: calc(16px * var(--ui-scale));
}
@media (min-width: 576px) { .ui-card-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 992px) { .ui-card-grid { grid-template-columns: repeat(3, 1fr); } }

.ui-record-card {
    display: flex;
    flex-direction: column;
    width: 100%;
    text-align: left;
    background: var(--surface-0);
    border: 1px solid var(--surface-3);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-2);
    overflow: hidden;
}
/* Hover lift only where there is a real pointer: on a touch screen it fires on
   tap and leaves the card stuck in the hovered state.

   The colour and shadow change unconditionally; only the movement is behind
   `no-preference`. The global reduced-motion rule in §3 zeroes transition
   *duration*, which would leave the 2px jump intact and merely instant — so a
   transform has to be suppressed at the source, not damped. */
@media (hover: hover) and (pointer: fine) {
    a.ui-record-card,
    button.ui-record-card { transition: box-shadow 0.2s, border-color 0.2s, transform 0.2s; }
    a.ui-record-card:hover,
    button.ui-record-card:hover {
        border-color: var(--brand-600);
        box-shadow: var(--shadow-3);
    }
}
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
    a.ui-record-card:hover,
    button.ui-record-card:hover { transform: translateY(-2px); }
}
.ui-record-card-top {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    gap: calc(8px * var(--ui-scale));
    padding: calc(14px * var(--ui-scale)) calc(16px * var(--ui-scale)) calc(12px * var(--ui-scale));
    border-bottom: 1px solid var(--hairline);
}
.ui-record-card-body {
    display: flex;
    flex-direction: column;
    gap: calc(4px * var(--ui-scale));
    padding: calc(10px * var(--ui-scale)) calc(16px * var(--ui-scale)) calc(14px * var(--ui-scale));
    flex: 1;
}
.ui-record-card-foot {
    padding: calc(10px * var(--ui-scale)) calc(16px * var(--ui-scale));
    border-top: 1px solid var(--hairline);
}
.ui-accent-ok      { border-left: 4px solid var(--ok); }
.ui-accent-warn    { border-left: 4px solid var(--warn); }
.ui-accent-err     { border-left: 4px solid var(--err); }
.ui-accent-info    { border-left: 4px solid var(--info); }
.ui-accent-brand   { border-left: 4px solid var(--brand-600); }
.ui-accent-neutral { border-left: 4px solid var(--ink-200); }

/* 7.12 Meter — a proportion, as a bar. Replaces .ea-meter (email_accounts) and
        the raw Bootstrap .progress markup the three analytics pages each built
        by hand.

        Distinct from .ui-stat, which is a number you read. A meter is a number
        you compare against its own maximum: a quota, a coverage percentage, a
        completion rate.

        Colour is never the only carrier — the label beside it states the
        figure, per §7 of the UI standard. */
.ui-meter-row + .ui-meter-row { margin-top: 14px; }
.ui-meter-head {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: calc(12px * var(--ui-scale));
    margin-bottom: calc(5px * var(--ui-scale));
}
.ui-meter-label {
    font-size: var(--fs-xs);
    color: var(--ink-700);
    /* The label is the identity of the row; a long programme name truncates
       rather than pushing its own figure off the card. */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.ui-meter-figure {
    font-size: var(--fs-xs);
    font-weight: 600;
    color: var(--ink-500);
    white-space: nowrap;
    flex-shrink: 0;
}
.ui-meter {
    height: calc(6px * var(--ui-scale));
    min-width: calc(90px * var(--ui-scale));
    border-radius: var(--radius-pill);
    background: var(--surface-3);
    overflow: hidden;
}
.ui-meter > span {
    display: block;
    height: 100%;
    border-radius: var(--radius-pill);
    background: var(--brand-600);
    transition: width 0.4s ease;
}
.ui-meter-lg { height: 10px; }
/* A meter in a table cell: the column header is the label, so the bar and its
   figure share one line. The figure keeps a fixed minimum width so a column of
   percentages lines up down the page. */
.ui-meter-inline { display: flex; align-items: center; gap: 8px; }
.ui-meter-inline .ui-meter { flex: 1 1 auto; margin: 0; }
.ui-meter-inline .ui-meter-figure {
    white-space: nowrap;
    min-width: calc(42px * var(--ui-scale));
    text-align: right;
    font-variant-numeric: tabular-nums;
}

/* 7.13 Switch — an on/off control that writes immediately.

        Bootstrap's `.form-check.form-switch` is the base and 12 templates
        already use it; this only adds the two things it has no opinion about:
        a size big enough to be a tap target, and a busy state for the gap
        between the click and the server agreeing.

        `.is-busy` is the point. These toggles POST, so there is a moment where
        the switch shows one thing and the database holds another. Without a
        state for it, an admin double-toggles a module during a slow request.

            <div class="form-check form-switch ui-switch">
                <input class="form-check-input" type="checkbox" role="switch"
                       id="x" aria-label="Enrollment module">
            </div>

        Replaces .module-toggle/.toggle-track (core/features.html), which was a
        hand-built switch with its own 52x28 geometry and a #cbd5e1 track. */
.ui-switch { min-height: var(--tap-min); display: flex; align-items: center; margin: 0; }
.ui-switch .form-check-input {
    width: 2.75rem;
    height: 1.5rem;
    margin: 0;
    cursor: pointer;
}
.ui-switch .form-check-input:checked {
    background-color: var(--brand-600);
    border-color: var(--brand-600);
}
.ui-switch.is-busy { opacity: 0.55; }
.ui-switch.is-busy .form-check-input { cursor: progress; pointer-events: none; }

/* 7.14 Timeline — the stages of an approval, in order, with where it got to.

        Three pages had built one: workload_detail and the two
        shifting_request_detail screens. They had already drifted — one draws
        the rail on the list with ::before, the other per item; one used a
        token for it and the other #e0e0e0 — so the same workflow looked like
        two different things depending on which desk you sat at.

            <ol class="ui-timeline">
                <li class="ui-tl-item">
                    <span class="ui-tl-dot ui-tone-ok"><i class="fas fa-check"></i></span>
                    <div class="ui-tl-body">
                        <div class="ui-tl-title">Approved by Dean</div>
                        <div class="ui-tl-meta">12 Mar 2026 · A. Santos</div>
                    </div>
                </li>
            </ol>

        A stage is `done`, `pending` or `skipped` — the dot's own vocabulary,
        because that is what a stage actually is, and naming it by tone would
        make every caller translate "approved" into "green" for itself. The
        .ui-tone-* classes still work on the dot for anything these three do
        not cover.

        The rail is drawn per item and stops at the last one, so a list that
        grows does not trail a line into empty space. */
.ui-timeline { list-style: none; padding: 0; margin: 0; }
.ui-tl-item {
    display: flex;
    gap: calc(14px * var(--ui-scale));
    padding-bottom: calc(18px * var(--ui-scale));
    position: relative;
}
.ui-tl-item:not(:last-child)::before {
    content: '';
    position: absolute;
    left: calc(15px * var(--ui-scale));
    top: calc(34px * var(--ui-scale));
    bottom: 0;
    width: 2px;
    background: var(--surface-3);
}
.ui-tl-dot {
    width: calc(32px * var(--ui-scale));
    height: calc(32px * var(--ui-scale));
    border-radius: 50%;
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: var(--fs-xs);
    font-weight: 700;
    position: relative;
    z-index: 1;
    /* A ring in the page colour so the rail appears to pass behind the dot
       rather than through it. */
    border: 2px solid var(--surface-0);
    background: var(--neutral-tint);
    color: var(--neutral-ink);
}
.ui-tl-dot.done    { background: var(--ok-tint);      color: var(--ok-ink); }
.ui-tl-dot.pending { background: var(--warn-tint);    color: var(--warn-ink); }
.ui-tl-dot.skipped { background: var(--surface-1);    color: var(--ink-200); }
.ui-tl-body { padding-top: 4px; min-width: 0; }
.ui-tl-title { font-weight: 600; font-size: var(--fs-sm); color: var(--ink-900); }
.ui-tl-meta { font-size: var(--fs-xs); color: var(--ink-300); }
.ui-tl-note { font-size: var(--fs-xs); color: var(--ink-500); }

/* 7.15 Disclosure card — a card whose header opens and closes its body.

        The faculty accordion on the admin workload sheet: a row per member
        that expands to their assignments. Bootstrap's own accordion assumes a
        single-open group and brings its own JS; this is the looser shape three
        of these screens actually want — any number open at once, toggled by a
        class the page controls.

        Kept here rather than in the page because §9.2 admits no page style
        blocks: a component with one caller still belongs to the kit, and the
        alternative is the 135-template inline-<style> problem app.css exists
        to end. */
.ui-disclosure {
    border: 1px solid var(--surface-3);
    border-radius: var(--radius-md);
    margin-bottom: calc(10px * var(--ui-scale));
    overflow: hidden;
    background: var(--surface-0);
}
@media (hover: hover) and (pointer: fine) {
    .ui-disclosure { transition: box-shadow 0.2s; }
    .ui-disclosure:hover { box-shadow: var(--shadow-2); }
}
.ui-disclosure-head {
    display: flex;
    align-items: center;
    gap: calc(12px * var(--ui-scale));
    padding: calc(13px * var(--ui-scale)) calc(16px * var(--ui-scale));
    cursor: pointer;
    user-select: none;
    min-height: var(--tap-min);
}
.ui-disclosure-head:hover { background: var(--surface-1); }
.ui-disclosure-chevron {
    font-size: var(--fs-xs);
    color: var(--ink-300);
    flex-shrink: 0;
    margin-left: auto;
}
@media (prefers-reduced-motion: no-preference) {
    .ui-disclosure-chevron { transition: transform 0.2s; }
}
.ui-disclosure.open .ui-disclosure-chevron { transform: rotate(180deg); }
.ui-disclosure-body { border-top: 1px solid var(--hairline); display: none; }
.ui-disclosure.open .ui-disclosure-body { display: block; }

/* An identity badge: initials in a circle, where a photo would go. */
.ui-avatar {
    width: calc(36px * var(--ui-scale));
    height: calc(36px * var(--ui-scale));
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 700;
    font-size: var(--fs-xs);
    flex-shrink: 0;
    background: var(--brand-100);
    color: var(--brand-600);
}
.ui-meter.ui-meter-ok   > span { background: var(--ok); }
.ui-meter.ui-meter-warn > span { background: var(--warn); }
.ui-meter.ui-meter-err  > span { background: var(--err); }
.ui-meter.ui-meter-info > span { background: var(--info); }

/* 7.16 Row grammar — the inside of a table row.
   §5.9 fixed what a <table> looks like; two pages could still satisfy it and
   render the same record four different ways. The students and faculty
   registers did: the id was a black badge on one and a <code> on the other,
   the name was bare text on one and a two-line block on the other, and
   "no college" was `-` on one and `—` on the other.

   A row is built from slots, in this order:
       [select] [identity] [attribute…] [state] [actions]
   and each slot has one markup. See docs/ui-standard.md §10. */

/* Identity — who or what this row is. An optional avatar, a primary line, and
   at most one muted line under it. A third line belongs on the detail page. */
.ui-identity { display: flex; align-items: center; gap: 10px; min-width: 0; }
.ui-identity-text { min-width: 0; }
.ui-identity-primary {
    font-weight: 600;
    color: var(--ink-900);
    line-height: 1.25;
}
.ui-identity-sub {
    font-size: var(--fs-xs);
    color: var(--ink-500);
    line-height: 1.3;
}
.ui-identity .ui-avatar { width: 32px; height: 32px; font-size: var(--fs-xs); }
/* A machine-text name still reads as the row's name, so it keeps its weight
   and does not shrink to the size .ui-mono uses inside a paragraph. */
.ui-identity-primary.ui-mono { font-size: var(--fs-xs); }
a.ui-identity-primary { text-decoration: none; color: var(--brand-700); }
a.ui-identity-primary:hover { text-decoration: underline; }

/* Numeric — right aligned, and lined up digit over digit so a column of units
   can be read down. Goes on the <th> as well, or the header floats free of the
   numbers under it. */
.ui-num { text-align: right; font-variant-numeric: tabular-nums; }

/* Actions — sized to its buttons, never wrapped. `width:1%` makes the table
   give this column exactly what it needs and squeeze the data columns instead,
   which is the opposite of what happened on the users page: a stale
   `max-width` there clipped the buttons off the end of every row. */
.ui-actions { white-space: nowrap; width: 1%; }
.ui-actions .btn-group { flex-wrap: nowrap; }
/* An action that has to POST needs its own <form>, which would otherwise break
   the group into separate pills. `display:contents` lets the button sit in the
   group as though the form were not there, so a POST action and a link action
   are indistinguishable — which is the whole point of the slot. */
.ui-actions form { display: contents; }


/* 7.17 Actions menu — one control per row, and one per page.
   Replaces the row of buttons that used to sit in both places. A row now
   carries a single burger labelled "Actions"; what it opens is what the row
   used to show all at once. The gain is horizontal: the actions column is one
   button wide at every width, so the data columns get the rest of the table
   and no row has to choose between showing its buttons and showing its
   content. */
.ui-menu .dropdown-toggle { white-space: nowrap; }
.ui-menu .dropdown-menu {
    min-width: calc(180px * var(--ui-scale));
    padding: calc(4px * var(--ui-scale)) 0;
    border: 1px solid var(--hairline);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow-2);
}
.ui-menu .dropdown-item {
    display: flex;
    align-items: center;
    padding: calc(8px * var(--ui-scale)) calc(14px * var(--ui-scale));
    font-size: var(--fs-sm);
    min-height: var(--tap-min);
}
.ui-menu .dropdown-item i { width: 1.1em; text-align: center; }
.ui-menu .dropdown-item:active { background: var(--brand-600); }
/* A form wrapping a menu item — an action that has to POST — should not break
   the list it sits in. */
.ui-menu form { display: contents; }

/* The menu has to escape the table's horizontal scroller, which would
   otherwise clip it to the cell. Only while it is open, so the table still
   scrolls the rest of the time. */
.table-responsive:has(.dropdown-menu.show) { overflow: visible; }
.ui-actions .dropdown-menu { z-index: 1040; }


/* 7.18 Document list — revisions of one document, newest first.
   Each row is a file you can open: a document mark carrying its revision
   number, then what changed, then who changed it and when. Lived in the
   academic calendar's own <style> block until the page became a table; it is
   a component, so it lives here and the page carries no stylesheet. */
.ui-doc {
    display: flex;
    align-items: center;
    gap: calc(14px * var(--ui-scale));
    width: 100%;
    text-align: left;
    text-decoration: none;
    background: var(--surface-0);
    border: 1px solid var(--surface-3);
    border-left: 3px solid var(--ink-200);
    border-radius: var(--radius-md);
    padding: calc(13px * var(--ui-scale)) calc(15px * var(--ui-scale));
    margin-bottom: calc(10px * var(--ui-scale));
    transition: background .15s, border-color .15s, transform .15s;
}
.ui-doc:hover {
    background: var(--surface-1);
    border-color: var(--brand-600);
    border-left-color: var(--brand-600);
}
/* The revision in force is marked in the rail, the mark and the tag — never by
   position alone. */
.ui-doc.is-current { border-left-color: var(--ok); }

.ui-doc-mark {
    position: relative;
    flex-shrink: 0;
    width: calc(42px * var(--ui-scale));
    height: calc(42px * var(--ui-scale));
    border-radius: var(--radius-md);
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--surface-2);
    color: var(--ink-500);
}
.is-current .ui-doc-mark { background: var(--ok-tint); color: var(--ok-ink); }
/* The number rides the mark rather than sitting in its own pill, so the row
   starts with one object instead of two. */
.ui-doc-mark .ui-doc-num {
    position: absolute;
    right: -3px;
    bottom: -3px;
    min-width: calc(21px * var(--ui-scale));
    padding: 0 calc(5px * var(--ui-scale));
    border-radius: var(--radius-pill);
    border: 2px solid var(--surface-0);
    background: var(--ink-700);
    color: var(--surface-0);
    font-size: var(--fs-xs);
    font-weight: 700;
    text-align: center;
}
.is-current .ui-doc-mark .ui-doc-num { background: var(--ok-ink); }

.ui-doc-main { flex: 1; min-width: 0; }
.ui-doc-title {
    display: flex;
    align-items: center;
    gap: calc(7px * var(--ui-scale));
    font-size: var(--fs-sm);
    font-weight: 600;
    color: var(--ink-900);
}
.ui-doc-title span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ui-doc-title i { flex-shrink: 0; color: var(--ink-300); }
.ui-doc-title.is-empty { font-weight: 500; color: var(--ink-300); }

.ui-doc-facts {
    display: flex;
    flex-wrap: wrap;
    gap: calc(4px * var(--ui-scale)) calc(12px * var(--ui-scale));
    margin-top: calc(4px * var(--ui-scale));
    font-size: var(--fs-xs);
    color: var(--ink-500);
}
.ui-doc-facts span { display: inline-flex; align-items: center; gap: calc(5px * var(--ui-scale)); }
.ui-doc-facts i { color: var(--ink-300); }

.ui-doc-go { flex-shrink: 0; display: flex; align-items: center; gap: calc(8px * var(--ui-scale)); color: var(--ink-300); }
.ui-doc:hover .ui-doc-go { color: var(--brand-600); }

.ui-doc-tag {
    display: inline-flex;
    align-items: center;
    gap: calc(5px * var(--ui-scale));
    border-radius: var(--radius-pill);
    padding: calc(4px * var(--ui-scale)) calc(10px * var(--ui-scale));
    font-size: var(--fs-xs);
    font-weight: 700;
    white-space: nowrap;
    /* Neutral, not a warning: a past revision is history, not a problem. */
    background: var(--surface-2);
    color: var(--ink-500);
}
.ui-doc-tag.is-current { background: var(--ok-tint); color: var(--ok-ink); }
@media (max-width: 400px) {
    /* The past tag is the first thing to drop on a narrow phone — it is the
       default state, and the rail colour still says which is which. */
    .ui-doc-tag:not(.is-current) { display: none; }
}
@media (max-width: 575.98px) {
    .ui-doc-go .fa-chevron-right { display: none; }
}

/* Track — undergraduate vs postgraduate.

   Course Offerings and Student Enrollment each show one track at a time, and
   with two tabs of the same colour a registrar editing the wrong term found
   out only from the rows. The track is now said three times on the page: the
   selected tab is coloured, a banner above the table names the track and the
   term, and the table's header carries the same tint.

   Undergraduate is the brand teal, because it is the everyday case;
   postgraduate is the info blue — the one semantic colour not already spoken
   for by a status. Each block reads the track off a `data-track` attribute so
   the page's JavaScript switches all three by setting one value. `semester`
   and `trimester` are the values the pages already use for the two tracks. */
.ui-track-tabs .nav-link { color: var(--ink-500); font-weight: 600; border-top: 3px solid transparent; }
.ui-track-tabs .nav-link.active[data-track="semester"]  { color: var(--brand-700); border-top-color: var(--brand-600); }
.ui-track-tabs .nav-link.active[data-track="trimester"] { color: var(--info-ink);  border-top-color: var(--info); }

/* Section tabs — the rooms of one section, e.g. the six admin clearance
   pages. The one-colour sibling of the track tabs above: there is no second
   track to tell apart, so the current tab is the brand and the rest are ink.
   Bootstrap's own nav-tabs paint every link its link blue, which is the one
   colour this system never uses. */
.ui-tabs .nav-link { color: var(--ink-500); font-weight: 600; border-top: 3px solid transparent; }
.ui-tabs .nav-link:hover,
.ui-tabs .nav-link:focus-visible { color: var(--brand-700); }
.ui-tabs .nav-link.active { color: var(--brand-700); border-top-color: var(--brand-600); }

.ui-track-banner {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: calc(6px * var(--ui-scale)) calc(14px * var(--ui-scale));
    padding: calc(12px * var(--ui-scale)) calc(18px * var(--ui-scale));
    border-left: 5px solid;
    border-radius: var(--radius-sm);
    background: var(--surface-1);
    border-color: var(--ink-300);
    color: var(--ink-700);
}
.ui-track-banner[data-track="semester"]  { background: var(--brand-100); border-color: var(--brand-600); color: var(--brand-800); }
.ui-track-banner[data-track="trimester"] { background: var(--info-tint);  border-color: var(--info);      color: var(--info-ink); }
.ui-track-banner .ui-track-name {
    font-size: var(--fs-sm);
    font-weight: 800;
    letter-spacing: .1em;
    text-transform: uppercase;
}
.ui-track-banner .ui-track-term { font-size: 1rem; font-weight: 700; }
.ui-track-banner .ui-track-term:empty::before { content: 'No term selected'; font-weight: 400; opacity: .7; }
.ui-track-banner .ui-track-note { font-size: var(--fs-sm); opacity: .8; margin-left: auto; }

/* The track colour on the table itself, so it still says which track it is
   once the banner has scrolled away. The header takes the full tint, every
   body row a wash of the same colour, and hover a step darker — the rows are
   read against the tint rather than against white, so the two tracks look
   different at a glance and not only in the header. The text colours are the
   system's own; only the ground changes.

   Set on the cells, not the table: Bootstrap paints each cell from
   --bs-table-bg and overlays hover with a box-shadow read from
   --bs-table-hover-bg, so both have to be redirected where they are read. */
.ui-track-table[data-track="semester"]  > thead > tr > th { background: rgba(0, 137, 123, 0.34); color: var(--brand-900); }
.ui-track-table[data-track="trimester"] > thead > tr > th { background: rgba(13, 71, 161, 0.28); color: var(--info-ink); }

.ui-track-table[data-track="semester"]  > tbody > tr > td,
.ui-track-table[data-track="semester"]  > tfoot > tr > * { --bs-table-bg: rgba(0, 137, 123, 0.16); background-color: rgba(0, 137, 123, 0.16); }
.ui-track-table[data-track="trimester"] > tbody > tr > td,
.ui-track-table[data-track="trimester"] > tfoot > tr > * { --bs-table-bg: rgba(13, 71, 161, 0.13); background-color: rgba(13, 71, 161, 0.13); }

/* The track question at the top of a dialog. Two buttons, nothing picked
   until the registrar picks, and the picked one takes its track's colour —
   the same teal or blue as the page tabs, so the dialog and the list behind
   it are visibly talking about the same thing. */
.ui-track-pick { display: flex; gap: calc(8px * var(--ui-scale)); }
.ui-track-pick-btn {
    flex: 1 1 0;
    border: 2px solid var(--ink-200);
    background: var(--surface-0);
    color: var(--ink-700);
    font-weight: 600;
    min-height: var(--control-h);
}
.ui-track-pick-btn:hover { border-color: var(--ink-300); background: var(--surface-1); color: var(--ink-900); }
.ui-track-pick-btn.is-picked[data-track="semester"]  { border-color: var(--brand-600); background: var(--brand-100); color: var(--brand-800); }
.ui-track-pick-btn.is-picked[data-track="trimester"] { border-color: var(--info);      background: var(--info-tint); color: var(--info-ink); }

.ui-track-table[data-track="semester"]  > tbody > tr:not(.ui-empty-row):not(.ui-loading-row):hover > td { --bs-table-hover-bg: rgba(0, 137, 123, 0.30); }
.ui-track-table[data-track="trimester"] > tbody > tr:not(.ui-empty-row):not(.ui-loading-row):hover > td { --bs-table-hover-bg: rgba(13, 71, 161, 0.24); }


/* ── 8. Animations & utilities ───────────────────────────────────────────── */
@keyframes fadeIn  { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideIn { from { transform: translateY(16px); opacity: 0; } to { transform: none; opacity: 1; } }
.fade-in  { animation: fadeIn  0.4s; }
.slide-in { animation: slideIn 0.4s; }

/* A halo expanding out of a dot: "this is live right now". Used behind the
   online-users indicator. The dot underneath carries the meaning; this only
   draws attention to it, so the reduced-motion rule in §3 stopping the
   animation costs nothing. */
@keyframes uiPulse {
    0%, 100% { transform: scale(1);   opacity: 0.6; }
    50%      { transform: scale(2.2); opacity: 0; }
}
.ui-pulse { animation: uiPulse 2s ease-in-out infinite; }

/* Clamp to N lines, with an ellipsis. For a card title or subject line that
   must not set the height of its whole row — every card grid needs it, and
   each was writing its own -webkit-line-clamp stack. */
.ui-clamp-1,
.ui-clamp-2,
.ui-clamp-3 {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    overflow: hidden;
}
.ui-clamp-1 { -webkit-line-clamp: 1; }
.ui-clamp-2 { -webkit-line-clamp: 2; }
.ui-clamp-3 { -webkit-line-clamp: 3; }

/* Text somebody typed, shown as they typed it. Ticket descriptions, remarks,
   rejection reasons: the line breaks are theirs and dropping them turns a
   list of points into one run-on paragraph. */
.ui-prewrap { white-space: pre-wrap; word-break: break-word; line-height: 1.6; }

/* A row for a record that is switched off — a disabled email account, an
   archived course. Dimmed rather than hidden: it is still there, and an admin
   looking for why mail is not sending needs to see it. Not colour alone; the
   row still carries its own "Disabled" label. */
.ui-dim { opacity: 0.55; }

/* Machine text: an SMTP error, a stack line, a token. Wraps anywhere because
   these have no spaces to break at and would otherwise stretch a table cell
   off the page. */
.ui-mono {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: var(--fs-xs);
    overflow-wrap: anywhere;
}

/* Anything a finger taps. Applied to icon-only controls that would otherwise
   be 24px square. */
.tap-target {
    min-width: var(--tap-min);
    min-height: var(--tap-min);
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

/* 7.19 Clearance — the student's own view of who still has to sign.
   One row per office. Lived in the student clearance page's own <style>
   block, which is why the enrollment page and the graduation page drifted
   into two different looks for the same list. The page header is `ui-hero`,
   not a banner of its own — a `.ui-term-banner` briefly lived here and was a
   fifth copy of the thing `_hero.html` exists to replace. */
.ui-sig-list { margin: 0; }
.ui-sig-row {
    display: flex;
    align-items: center;
    gap: calc(12px * var(--ui-scale));
    padding-left: 0;
    padding-right: 0;
}
.ui-sig-main { flex: 1; min-width: 0; }
.ui-sig-name { font-weight: 600; }
/* The remark is why a row is held, so it is the one place on this page a
   student finds out what to actually do. Muted but not hidden. */
.ui-sig-remark {
    font-size: var(--fs-sm);
    color: var(--ink-500);
    margin-top: calc(2px * var(--ui-scale));
}
.ui-sig-state { flex-shrink: 0; }

.ui-course-list { list-style: none; padding: 0; margin: 0; }
.ui-course-list li {
    padding: calc(5px * var(--ui-scale)) 0;
    border-bottom: 1px solid var(--surface-3);
    font-size: var(--fs-sm);
}
.ui-course-list li:last-child { border-bottom: 0; }
