/* ============================================================
   NetStudy Portal — shared brand foundation
   ------------------------------------------------------------
   Components shared by the admin (client portal) and instructor
   portals, and intended for the manager module when it migrates.

   Load order (see each module's *Frame layout):
     bootstrap-5.3/css/bootstrap.min.css
     [adminlte.min.css]              admin only
     css/ds/styles.css               design tokens      (vendored)
     css/ds/ns-bootstrap.css         Bootstrap bridge   (vendored)
     fontawesome/css/all.min.css
     css/ns/ns-portal.css            <- this file
     css/admin/main.css | css/instructor/main.css
     $this->headers                  per-controller sheets

   RULES — these are the design system's, not negotiable here:
     * No literal hex, font name, radius, shadow or duration.
       Everything resolves to a token in css/ds/tokens/.
     * Author against semantic aliases (--text-body, --surface-card,
       --border-subtle, --surface-brand), not the raw ramps, and
       never against the base palette (--ns-blue, --ns-navy).
     * Neutrals are warm. Cool greys are out of spec.
     * A card always has BOTH a border and a shadow.
     * Focus rings are never removed.
     * Transition only what needs it — never `transition: all`.

   css/ds/* is vendored. Fix page-level problems here or in the
   module sheet, never by editing the design system.
   ============================================================ */


/* ============================================================
   BOOTSTRAP BRIDGE GAP — `--bs-*-rgb`
   ------------------------------------------------------------
   ns-bootstrap.css re-points --bs-success/warning/danger/info
   but NOT their `-rgb` counterparts, and Bootstrap's
   `.text-bg-*` / `.bg-*-subtle` / `.border-*-subtle` utilities
   read ONLY the -rgb form. Left unpatched, a `.text-bg-success`
   chip renders stock Bootstrap emerald next to our green — the
   exact cool/off-palette mismatch the system forbids.

   Patched here rather than in css/ds/, which is vendored. This
   belongs upstream in the design system; remove this block once
   ns-bootstrap.css ships the -rgb values itself.
   ============================================================ */

/* ── Contrast corrections to two DS text tokens ──────────────
   Measured against the surfaces they actually sit on (WCAG AA
   needs 4.5:1 for text below ~18px):

     --text-muted  #7C776E  on #FFF card  4.45:1   under AA
                            on #F7F4EF    4.05:1   under AA
     --text-subtle #A29C91  on #FFF card  2.73:1   well under

   Since the navy header fills were removed, every uppercase
   micro-label (card titles, table headers, legends) is muted
   text on a light surface, so this stopped being theoretical.
   One step down the same warm ramp fixes it without changing
   the palette:

     --neutral-600 #5B5750  on #FFF card  7.18:1   passes
                            on #F7F4EF    6.55:1   passes

   --text-muted stays clearly lighter than --text-body
   (neutral-800 #2A2825), so the hierarchy is intact. Belongs
   upstream in the design system; patched here because css/ds
   is vendored.

   The --surface-hover override that used to sit here is gone.
   It existed because that token was identical to --surface-page
   (both --neutral-50), so a row hover read as the row LOSING its
   background rather than gaining a highlight. The design system
   has since fixed that at the source -- hover is now blue-50, a
   cool tint that can never be mistaken for the page -- so a local
   override would only hold the old workaround in place.
   ------------------------------------------------------------ */
:root {
    --text-muted:    var(--neutral-600);
    --text-subtle:   var(--neutral-500);

    --bs-success-rgb:   89, 125, 79;    /* --status-success  #597D4F */
    --bs-warning-rgb:   192, 136, 34;   /* --status-warning  #C08822 */
    --bs-danger-rgb:    163, 43, 34;    /* --status-danger   #A32B22 */
    --bs-info-rgb:      39, 90, 156;    /* --status-info     #275A9C */
    --bs-secondary-rgb: 1, 42, 94;      /* --ns-navy         #012A5E */
    --bs-dark-rgb:      1, 42, 94;      /* --ns-navy         #012A5E */
    --bs-light-rgb:     247, 244, 239;  /* --neutral-50      #F7F4EF */
}


/* ============================================================
   FOCUS KEEPS THE FIELD WHITE
   Bootstrap declares the fill twice — `.form-control` (0,1,0) and
   again on `.form-control:focus` (0,2,0) — both as
   var(--bs-body-bg), which the design system points at the paper
   page. The DS's own `.form-control { background: var(--ns-white) }`
   is only (0,1,0), so it wins at rest and loses the moment the
   field takes focus: every input, textarea and select in both
   portals turned beige on click.

   A field is white whether or not it is focused. What says
   "focused" is the ring and the border colour, which the DS
   already sets — the fill changing as well is a third signal for
   one state, and it read as the field going disabled.

   Patched here because css/ds is vendored; upstream the fix is to
   drop background-color from ns-bootstrap's :focus rule.
   ============================================================ */

.form-control:focus,
.form-select:focus,
textarea.form-control:focus {
    background-color: var(--surface-card);
}

/* Read-only fields keep their sunken fill on focus — there the tint IS the
   signal that the value cannot be edited. */
.form-control[readonly]:focus {
    background-color: var(--surface-sunken);
}


/* ============================================================
   SELECT CARET
   ns-bootstrap.css styles .form-control and .form-select in one
   rule using the `background` SHORTHAND. The shorthand resets
   background-image to none, which wipes out Bootstrap's chevron
   SVG, and its `padding: 7px 11px` overrides the 2.25rem right
   padding that reserved room for it. The result is a select with
   appearance:none, no native arrow AND no drawn one — it reads as
   a text input, which is what it looked like on the Add Existing
   Custom Page screen and on all 270 selects across both portals.

   Restored through Bootstrap's own --bs-form-select-bg-img so the
   markup contract stays intact, with the chevron re-pointed from
   Bootstrap's #343a40 to --text-muted. The hex is inline because a
   data: URI cannot read a custom property; it is #5B5750.
   Belongs upstream in ns-bootstrap.css, which is vendored — the
   real fix there is `background-color` instead of `background`.
   ============================================================ */

.form-select {
    --bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%235B5750' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
    padding-right: 2.25rem;
    background-image: var(--bs-form-select-bg-img), var(--bs-form-select-bg-icon, none);
    background-repeat: no-repeat;
    background-position: right 0.75rem center;
    background-size: 16px 12px;
}

/* The disabled rule uses the same shorthand, so the caret has to be
   restated at matching specificity or it vanishes again. */
.form-select:disabled {
    background-image: var(--bs-form-select-bg-img), var(--bs-form-select-bg-icon, none);
    background-repeat: no-repeat;
    background-position: right 0.75rem center;
    background-size: 16px 12px;
}

/* A small select needs a tighter gutter, or the caret floats away
   from a control that is 4px shorter. */
.form-select-sm {
    padding-right: 1.75rem;
    background-position: right 0.5rem center;
}


/* ============================================================
   ACTION HIERARCHY
   Rank comes from WEIGHT, not hue — exactly ONE filled button
   per action group, and everything standing beside it steps
   down a weight.

     Primary    filled brand    the action the page exists to
                                perform. One per group.
     Secondary  white + border  utilities sitting beside a
                                primary: Refresh, Recalculate,
                                Export, Preview, Cancel, Back
     Ghost      transparent     navigation, low-stakes actions,
                                and anything repeated per table row
     Danger     ghost red       destructive, set apart from the
                                group. Filled red ONLY when it is
                                the sole action in a confirm dialog.

   Hue may still say WHAT an action is — destructive is red. It is
   never allowed to say how IMPORTANT an action is; that is the
   weight's job. Two filled brand buttons side by side (which is
   what "Save Changes" + a navy "Refresh" used to be) force the
   reader to read both labels before they can tell which one the
   page is actually for.
   ============================================================ */

/* ── Bootstrap bridge gap — disabled button colours ─────────
   ns-bootstrap.css re-points --bs-btn-bg / --bs-btn-hover-bg
   for each variant but not --bs-btn-disabled-*. Bootstrap's own
   `.btn:disabled` rule (specificity 0,2,0) outranks a plain
   `.btn-primary` background declaration, so a disabled primary
   fell back to Bootstrap's #0d6efd. Disabled buttons should be
   the same colour as enabled ones, just at the design system's
   .48 opacity — "45–50% opacity, not-allowed cursor, nothing
   else". Belongs upstream with the -rgb patch above.
   ------------------------------------------------------------ */
.btn-primary {
    --bs-btn-disabled-bg: var(--blue-500);
    --bs-btn-disabled-border-color: var(--blue-500);
    --bs-btn-disabled-color: var(--text-inverse);
}

.btn-success {
    --bs-btn-disabled-bg: var(--green-500);
    --bs-btn-disabled-border-color: var(--green-500);
    --bs-btn-disabled-color: var(--text-inverse);
}

.btn-danger {
    --bs-btn-disabled-bg: var(--status-danger);
    --bs-btn-disabled-border-color: var(--status-danger);
    --bs-btn-disabled-color: var(--text-inverse);
}


/* ── Secondary ────────────────────────────────────
   .btn-secondary and .btn-outline-secondary are ONE rank and
   render identically. The two modules use them interchangeably
   for the same job (Cancel, Go Back, Clear, Export), and a
   reader has no way to know that the markup distinguishes them.

   No lift and no shadow here on purpose: .btn-primary rises 1px
   with a --shadow-lg on hover, so keeping the secondary flat is
   part of what separates the ranks. The hover has to do the work
   instead, so it is COOL, not warm: --neutral-50 is the same
   colour as --surface-page, so a warm hover made the button
   dissolve into the paper at the exact moment the reader aimed
   at it. --blue-50 moves toward the button's own label colour.
   ------------------------------------------------------------ */
.btn-secondary,
.btn-outline-secondary {
    --bs-btn-bg: var(--surface-card);
    --bs-btn-border-color: var(--border-default);
    --bs-btn-color: var(--blue-600);
    --bs-btn-hover-bg: var(--blue-50);
    --bs-btn-hover-border-color: var(--blue-300);
    --bs-btn-hover-color: var(--blue-700);
    --bs-btn-active-bg: var(--blue-100);
    --bs-btn-active-border-color: var(--blue-300);
    --bs-btn-active-color: var(--blue-700);
    --bs-btn-disabled-bg: var(--surface-card);
    --bs-btn-disabled-border-color: var(--border-default);
    --bs-btn-disabled-color: var(--blue-600);
    box-shadow: none;
}


/* ── Ghost ────────────────────────────────────────
   Navigation and low-stakes actions, and the default for any
   control repeated down a table — a fill that appears on every
   row stops meaning "important" and just adds noise. The
   transparent border keeps it the same size as a secondary, so
   a ghost and a secondary can sit in one row without jumping.
   ------------------------------------------------------------ */
.btn-ghost {
    --bs-btn-bg: transparent;
    --bs-btn-border-color: transparent;
    --bs-btn-color: var(--blue-600);
    --bs-btn-hover-bg: var(--blue-50);
    --bs-btn-hover-border-color: transparent;
    --bs-btn-hover-color: var(--blue-700);
    --bs-btn-active-bg: var(--blue-100);
    --bs-btn-active-border-color: transparent;
    --bs-btn-active-color: var(--blue-700);
    --bs-btn-disabled-bg: transparent;
    --bs-btn-disabled-border-color: transparent;
    --bs-btn-disabled-color: var(--blue-600);
    box-shadow: none;
}


/* ── Danger ───────────────────────────────────────
   A ghost that carries the danger colour. Filled red is reserved
   for the sole action of a confirm dialog (.btn-danger), where
   the whole page exists to ask the question — there, red IS the
   primary. Everywhere else a delete stands next to a save, and a
   filled red next to a filled blue is a coin toss.

   Bootstrap's own .btn-outline-danger draws a red border at rest
   and floods solid red on hover; both are heavier than the save
   button they sit beside. Rest is borderless, and the tint only
   arrives once the reader has aimed at it.
   ------------------------------------------------------------ */
.btn-outline-danger {
    --bs-btn-bg: transparent;
    --bs-btn-border-color: transparent;
    --bs-btn-color: var(--status-danger);
    --bs-btn-hover-bg: var(--status-danger-bg);
    --bs-btn-hover-border-color: var(--status-danger-border);
    --bs-btn-hover-color: var(--status-danger);
    --bs-btn-active-bg: var(--status-danger-bg);
    --bs-btn-active-border-color: var(--status-danger-border);
    --bs-btn-active-color: var(--status-danger);
    --bs-btn-disabled-bg: transparent;
    --bs-btn-disabled-border-color: transparent;
    --bs-btn-disabled-color: var(--status-danger);
    box-shadow: none;
}


/* ============================================================
   PAGE HEADER
   The brand signal: Jost title directly over a Merriweather
   one-liner. .ns-page-title / .ns-page-subtitle come from
   ns-bootstrap.css; this adds the row that pairs them with an
   action button, which every list page needs.
   ============================================================ */

.ns-page-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--space-4);
    margin-bottom: var(--space-5);
}

.ns-page-head-actions {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex-shrink: 0;
}

@media (max-width: 767.98px) {
    .ns-page-head {
        flex-direction: column;
        align-items: stretch;
    }
}


/* ============================================================
   DASHBOARD GREETING
   The time-of-day line that stands in for an h1 on a portal home
   page. Both dashboards write it from script after load, so the
   wrapper reserves its height to stop the page jumping.
   Lived in css/admin/admindash.css until the instructor dashboard
   adopted the same greeting.
   ============================================================ */

.dash-greeting {
    min-height: 2.5rem;
}

.dash-heading {
    font-family: var(--font-display);
    font-size: var(--text-2xl);
    font-weight: var(--weight-bold);
    color: var(--text-heading);
    letter-spacing: var(--tracking-tight);
    margin: 0;
}


/* ============================================================
   DATA TABLES
   Two selectors on purpose: .itemtable is the hand-built and
   admin-CGridView table, .grid-view table is Yii's
   default class, still used across the instructor module.
   ============================================================ */

table.itemtable,
.grid-view table {
    width: 100%;
    margin-bottom: 0;
    background: var(--surface-card);
    border-collapse: separate;
    border-spacing: 0;
}

/* The table head carries NO tint. The card header above it is the coloured
   layer; this one speaks through type and rule weight instead — small uppercase
   micro-labels over a 2px rule. Colouring both layers, or neither, is what stops
   them reading as a hierarchy.

   The three selectors are the same header in the three table flavours the app
   builds: hand-written .itemtable, Yii's .grid-view table, and a plain
   Bootstrap .table. */
table.itemtable > thead > tr > th,
.grid-view table > thead > tr > th,
.table > thead > tr > th {
    font-family: var(--font-body);
    font-size: var(--text-xs);
    font-weight: var(--weight-bold);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    white-space: nowrap;
    vertical-align: middle;
    line-height: 1.2;
    padding: var(--space-2) var(--table-cell-x);
    /* No tint. The card header owns the colour; this layer owns the rule —
       the 2px is what says "column names" rather than "another heading".
       --neutral-600 explicitly, not --text-muted: the DS value of that alias
       (--neutral-500 #7C776E) is 4.44:1 on white, under AA at this size. This
       sheet happens to remap the alias to the same neutral-600, but a column
       label should not depend on that patch still being here. */
    color: var(--neutral-600);
    background: var(--surface-table-header);
    border: 0;
    border-bottom: 2px solid var(--border-default);
}

/* Sort links ARE the header label, so they match it rather than taking the page
   link colour; hover is what marks them clickable. */
table.itemtable > thead > tr > th a,
.grid-view table > thead > tr > th a,
.table > thead > tr > th a {
    color: inherit;
    text-decoration: none;
}

table.itemtable > thead > tr > th a:hover,
.grid-view table > thead > tr > th a:hover,
.table > thead > tr > th a:hover {
    color: var(--text-link);
    text-decoration: underline;
}

/* Separators go BETWEEN rows only: a rule above the first row or below the last
   would double up on the header band and the card border that already close the
   block. Hence border-top on every row after the first, rather than a
   border-bottom on all of them that then has to be cancelled on the last. */
table.itemtable > tbody > tr > td,
.grid-view table > tbody > tr > td {
    padding: var(--space-2) var(--table-cell-x);
    border: 0;
    vertical-align: middle;
    color: var(--text-body);
}

table.itemtable > tbody > tr + tr > td,
.grid-view table > tbody > tr + tr > td {
    border-top: 1px solid var(--border-subtle);
}

table.itemtable:not(.ns-table-static) > tbody > tr:hover > td,
.grid-view table > tbody > tr:hover > td {
    background: var(--surface-hover);
}

/* …and .ns-table-static opts a table out of that. A fill that follows the pointer
   reads as the promise of a click, so a table that only reports figures — the items
   on a transaction, a receipt — must not light up under a pointer with nowhere to
   go. Written as a :not() on the hover rule rather than an override, so a row that
   carries a fill of its own keeps it. The module sheets that state this hover again
   (css/admin/main.css) carry the same exclusion. */

/* Static also outranks Bootstrap's own .table-hover, which does not paint a
   background at all — it lays down an opaque `box-shadow: inset` fed by
   --bs-table-bg-state, which no background-color can sit on top of. Clearing that
   property is the only way to drop it, and doing it here means the modifier is the
   whole answer: a table marked static is static whatever else it carries. */
table.ns-table-static > tbody > tr:hover > * {
    --bs-table-bg-state: transparent;
}

/* The :last-child border-bottom reset that used to sit here is gone — with
   separators drawn as border-top on tr + tr there is nothing left to cancel. */

/* ── Row status edge ────────────────────────────────────────
   FILLS MEAN INTERACTION, EDGES MEAN MEANING.

     rest       --surface-card
     hover      --surface-hover      (above)
     selected   --surface-selected
     attention  inset 3px --status-warning   on the first cell
     overdue    inset 3px --status-danger
     resolved   inset 3px --status-success

   These tables used to tint a whole row warm to mean "needs
   attention", which collided with hover: two full-row fills, so a
   flagged row read as a stale hover and a hovered row read as
   flagged. Moving the status onto a 3px edge lets the two stack —
   a flagged row still lights up under the cursor, and still shows
   it is flagged while it does.

   The edge is an inset box-shadow on the row's FIRST cell:
   box-shadow is not affected by border-collapse the way a border
   is, and it paints inside the cell so it cannot shift the layout.
   It must never set a background — that is what it replaced.

   An edge is for a row somebody has to DO something about. A state
   that is merely not the default — Inactive, Retired, Submitted,
   Approved — gets a Badge and no edge, or the edges lose all force.
   Every edged row carries a Badge naming the reason as well:
   colour alone says nothing to a reader who cannot separate the
   hues, and nothing at all about what is wrong. See statusBadge()
   in protected/globals.php.
   ------------------------------------------------------------ */
tr.ns-row-attention > td:first-child {
    box-shadow: inset 3px 0 0 var(--status-warning);
}

tr.ns-row-overdue > td:first-child {
    box-shadow: inset 3px 0 0 var(--status-danger);
}

tr.ns-row-resolved > td:first-child {
    box-shadow: inset 3px 0 0 var(--status-success);
}

/* Selection is an interaction, so it keeps a fill. */
tr.ns-row-selected > td {
    background: var(--surface-selected);
}


/* ── The "happening today" marker ─────────────────────────────
   A calendar glyph in front of the row's name, marking a class
   that is running today inside the ordinary upcoming list. It
   replaced a separate Today's Classes card above the list, which
   duplicated every multi-day class straddling today and hid those
   rows from the list's own search filters.

   Not an edge and not a fill: an edge means somebody has to act,
   and both would collide with the attention edge these same rows
   can already carry. A marker sits in the content, so a row can
   be "needs attention" AND "today" without the two fighting.

   The glyph keeps the calendar-day icon the retired card used, so
   the signal is the one this page already taught, at the same 15px
   optical size as the alert glyph. The word travels with it in a
   .visually-hidden span; see todayFlag() in protected/globals.php.

   GOLD, not the `info` blue the retired card used. Blue is the row
   LINK colour, and the marker sits directly against the linked
   course name — rendered side by side the two merge and the mark
   stops marking anything. Gold is the one hue in the palette that
   means act on this, which is what a class running today is: the
   roster, the attendance forms and the instructor are all due.

   That holds only while the attention EDGE cannot appear in the
   same table, or gold would mean two things at once. It cannot
   here: the upcoming query excludes MOREINFO and NEEDSATTENTION,
   the only two statuses that draw one. Re-admit either status to a
   list that uses this marker and the marker needs rethinking.
   ------------------------------------------------------------ */
.ns-today-flag {
    color: var(--status-warning);
    font-size: 15px;
    margin-right: var(--space-2);
    /* Marked rows hang right of unmarked ones by design — the indent is part of
       what makes them findable, and a permanent empty gutter on all 40 rows to
       buy alignment for the two that are marked is the worse trade. The fixed
       width is so that MARKED rows agree with each other whatever glyph they
       carry, since a Font Awesome icon is narrower than its em box. */
    width: 15px;
    display: inline-block;
    text-align: center;
}

/* Bootstrap's .table-* row tints. No STATUS row uses these any more — every one
   of those moved to the edge above. What is left is the legacy instructor
   stage-3 state picker (_form3.php / _form3_multiloc.php), where the same class
   doubles as the selected-row indicator and is toggled by hand in JS, so it is
   not a status fill to convert. The re-pointing stays until that picker is
   rebuilt: Bootstrap hardcodes .table-warning to #fff3cd and .table-success to
   #d1e7dd rather than deriving them, so without it those rows render off-palette
   warm fills. BS5 paints the tint through --bs-table-bg-state, so setting the
   *-bg vars is what actually moves the colour. */
.table-warning {
    --bs-table-bg: var(--status-warning-bg);
    --bs-table-border-color: var(--status-warning-border);
    --bs-table-color: var(--text-body);
    --bs-table-hover-bg: var(--gold-100);
    --bs-table-hover-color: var(--text-body);
}

.table-success {
    --bs-table-bg: var(--status-success-bg);
    --bs-table-border-color: var(--status-success-border);
    --bs-table-color: var(--text-body);
    --bs-table-hover-bg: var(--green-100);
    --bs-table-hover-color: var(--text-body);
}

.table-danger {
    --bs-table-bg: var(--status-danger-bg);
    --bs-table-border-color: var(--status-danger-border);
    --bs-table-color: var(--text-body);
    --bs-table-hover-color: var(--text-body);
}

.table-info {
    --bs-table-bg: var(--status-info-bg);
    --bs-table-border-color: var(--status-info-border);
    --bs-table-color: var(--text-body);
    --bs-table-hover-color: var(--text-body);
}

/* A second, quieter line inside a data cell — the day count under a date range,
   and anything else that qualifies the value above it without being the value.
   Block, so it never runs on to the end of the line it belongs under. */
.ns-cell-note {
    display: block;
    font-size: var(--text-xs);
    color: var(--text-muted);
    line-height: 1.3;
}

/* Numerals that get compared down a column line up. */
.tabular,
table.itemtable td.tabular,
.grid-view table td.tabular {
    font-variant-numeric: tabular-nums;
}

/* The identifying cell of a row, carrying the link to that record. It replaces
   a per-row "View details" button: a button reads as an action, and navigation
   is not one — so the thing that names the row is the thing you click. */
.ns-row-link {
    color: var(--text-link);
    font-weight: var(--weight-semibold);
    text-decoration: none;
}

.ns-row-link:hover,
.ns-row-link:focus-visible {
    color: var(--text-link-hover);
    text-decoration: underline;
}

/* ── Row action: a quiet icon button in a trailing column ────
   For the one destructive action a row may carry. A filled or outlined red
   button repeated down every row turns the most dangerous control on the page
   into its most eye-catching one, and puts it under the cursor on the way to
   everything else. This is a ghost by default and only takes the danger colour
   on hover/focus — the point at which the reader has actually aimed at it.

   It must still open a confirm; the icon is not the safeguard. */
.ns-row-action {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    padding: 0;
    color: var(--text-subtle);
    background: transparent;
    border: 0;
    border-radius: var(--radius-sm);
    text-decoration: none;
    transition: color var(--duration-fast) var(--ease-standard),
                background-color var(--duration-fast) var(--ease-standard);
}

.ns-row-action:hover,
.ns-row-action:focus-visible {
    color: var(--text-link);
    background: var(--surface-hover);
    text-decoration: none;
}

.ns-row-action--danger:hover,
.ns-row-action--danger:focus-visible {
    color: var(--status-danger);
    background: var(--status-danger-bg);
}

.ns-row-action:focus-visible {
    outline: 0;
    box-shadow: var(--focus-ring);
}

td.ns-row-action-col,
th.ns-row-action-col {
    width: 1%;
    text-align: center;
    white-space: nowrap;
}

/* The column holding a row's "View details" control. */
td.details-btn-col,
th.details-btn-col {
    width: 10em;
    text-align: center;
    white-space: nowrap;
}

/* The "Clone Class" column is off until somebody asks for it. Cloning is a
   deliberate act taken on one class the person has already picked out, so the
   column would otherwise sit on every row of every table repeating an offer
   nobody made — the "Clone an Existing Class" control above the tables reveals
   it. Both portals draw this column, so the rule cannot live in either module
   sheet; it was in css/admin/main.css, which is why the instructor module's
   copy of the column was permanently visible. */
th.clone,
td.clone {
    display: none;
}

/* Yii writes its own wrapper and summary line around every grid, which makes
   .grid-view the natural Card for the table. The DataTable spec calls this "the
   primary data surface" and says to put it in a Card so rows meet the card edge
   — and without it the search panel above was the only elevated object on a list
   page, so it read as the focal point instead of the data. A card is always
   border AND shadow, never one or the other. */
.grid-view {
    padding: 0;
    background: var(--surface-card);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-card);
    box-shadow: var(--shadow-sm);
    overflow: hidden;
    /* Wrapping flex so the result count can be re-ordered out of its own band at
       the top of the card and onto the footer line beside the pager — see the
       .summary rule below. The table is forced to a full row, so the two footer
       pieces meet on the line under it. */
    display: flex;
    flex-wrap: wrap;
    align-items: center;
}

.grid-view > table,
.grid-view > .keys,
.grid-view > .table-responsive {
    order: 1;
    flex: 0 0 100%;
    max-width: 100%;
}

.grid-view .pager {
    order: 2;
}

/* "Displaying 1-50 of 36340 results" is meta about the table, not a section of
   it — alone in a band above the data it reads as an empty header. It belongs on
   the footer line with the pagination it describes, so it is ordered after the
   pager and pushed to the far end. */
.grid-view .summary {
    order: 3;
    /* Grows to close the footer line: without it the band stops at the text and
       leaves bare card between the count and the right edge. */
    flex: 1 1 auto;
    margin: 0;
    padding: var(--space-3) var(--space-4);
    border-top: 1px solid var(--border-subtle);
    background: var(--surface-subtle);
    font-size: var(--text-sm);
    color: var(--text-muted);
    text-align: right;
}

/* On its own — a single page of results, so no pager — the count still has to
   close the card, so it stretches to the full width rather than sitting as a
   short tab in the corner. */
.grid-view:not(:has(.pager li:not(.hidden))) .summary {
    flex: 1 0 100%;
}

.grid-view .summary:empty {
    display: none;
}

/* CGridView's "no results" cell. This is prose about the absence of data, not
   data, so it follows body type rather than table density — and it is the empty
   state, so it takes the sunken, centred treatment inside the card body rather
   than reading as a stray half-row.

   No `display` change: the cell carries a colspan, and taking it out of
   table-cell layout shrinks it to the first column's width and leaves the rest
   of the row blank beside it. */
/* .grid-view td.empty and tr.ns-upload-empty are folded into the EMPTY STATE
   section below — they are the same component in a table row. */

/* Different thing entirely, despite the shared class name: span.empty is Yii's
   "this cell has no value" marker sitting INSIDE a populated row. It stays an
   inline muted note. */
.grid-view span.empty {
    font-style: normal;
    color: var(--text-muted);
}

/* ── Inline filter row (CGridView 'filter' option) ───────────
   Yii drops raw <input>/<select> into a header row, which render as browser
   defaults — a different height, radius and border from every other control in
   the app. Same treatment as the DS input, at a compact size so the row stays a
   filter strip and not a form. Used for EVERY filterable table; a filter bar in
   the card header is the alternative, and this is the one we picked, because
   the control belongs over the column it filters.

   The filter strip is part of the head, so it takes the head's ground and rule. */
.grid-view table > thead > tr.filters > td {
    background: var(--surface-table-header);
    padding: var(--space-2) var(--table-cell-x);
    border: 0;
    border-bottom: 2px solid var(--border-default);
}

.grid-view table > thead > tr.filters > td input,
.grid-view table > thead > tr.filters > td select,
.grid-view table > thead > tr.filters > td .form-control,
.grid-view table > thead > tr.filters > td .form-select {
    width: 100%;
    height: 32px;
    padding: 0 var(--space-2);
    font-family: var(--font-body);
    font-size: var(--text-sm);
    font-weight: var(--weight-regular);
    text-transform: none;
    letter-spacing: normal;
    color: var(--text-body);
    background-color: var(--surface-card);
    border: 1px solid var(--border-default);
    border-radius: var(--radius-sm);
}

.grid-view table > thead > tr.filters > td input:focus,
.grid-view table > thead > tr.filters > td select:focus {
    border-color: var(--border-focus);
    box-shadow: var(--focus-ring);
    outline: 0;
}

/* The header row above carries a 2px rule; the filter row underneath it would
   draw a second one directly below. Only the last row of the head gets it. */
.grid-view table > thead > tr:not(:last-child) > th {
    border-bottom: 0;
}

/* .ns-empty-inline is folded into the EMPTY STATE section below — it was the
   same component with a tint that the card border already made redundant. */

/* Yii's pager. Note the nesting: CLinkPager emits
   <div class="pager">Go to page: <ul class="yiiPager">…</ul></div>
   so the bar treatment belongs on the OUTER .pager only — putting it on both
   drew the strip twice, one inside the other. ul.yiiPager is just the list. */
ul.yiiPager {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-1);
    list-style: none;
    padding: 0;
    margin: 0;
}

.grid-view .pager {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
    margin: 0;
    padding: var(--space-3) var(--space-4);
    border-top: 1px solid var(--border-subtle);
    /* --surface-subtle, not --surface-sunken: a footer is the faintest tint in
       the system. Sunken is reserved for inset wells and empty states. */
    background: var(--surface-subtle);
    font-size: var(--text-sm);
    color: var(--text-muted);
}

/* Yii still renders the wrapper when everything paginates onto one page, which
   would leave an empty strip glued to the bottom of the card. */
.grid-view .pager:empty {
    display: none;
}

/* Every page link hidden === a single-page result. :has() is supported by every
   browser this portal targets; the strip simply stays visible if it is not. */
.grid-view .pager:not(:has(li:not(.hidden))) {
    display: none;
}

ul.yiiPager li a,
ul.yiiPager li span {
    display: inline-block;
    padding: var(--space-1) var(--space-3);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-control);
    background: var(--surface-card);
    color: var(--text-body);
    font-size: var(--text-sm);
    text-decoration: none;
    transition: var(--transition-control);
}

ul.yiiPager li a:hover {
    background: var(--surface-hover);
    border-color: var(--border-default);
    text-decoration: none;
}

ul.yiiPager li.selected a,
ul.yiiPager li.selected span {
    background: var(--surface-brand);
    border-color: var(--surface-brand);
    color: var(--text-on-brand);
}

/* Yii marks unavailable page links .hidden but still renders them, and the
   framework's own pager.css colours them #888 (3.54:1). WCAG exempts inactive
   controls from the contrast minimum, but #888 is both off-palette and only
   reads as disabled by accident. --text-subtle is 4.45:1 and warm; the opacity
   is dropped because dimming an already-light colour twice is what made these
   nearly invisible. */
ul.yiiPager li.hidden a,
ul.yiiPager li.hidden span {
    color: var(--text-subtle);
    opacity: 1;
    cursor: not-allowed;
}


/* ============================================================
   SUBNAV — underline section navigation within a record
   Gold/blue underline, never a filled tab.
   ============================================================ */

.ns-subnav {
    display: flex;
    flex-wrap: nowrap;
    overflow-x: auto;
    overflow-y: hidden;
    border-bottom: 2px solid var(--border-subtle);
    list-style: none;
    padding: 0;
    margin: 0 0 var(--space-5);
}

.ns-subnav .nav-link {
    color: var(--text-muted);
    font-family: var(--font-body);
    font-weight: var(--weight-medium);
    font-size: var(--text-base);
    padding: var(--space-2) var(--space-4);
    border: 0;
    border-bottom: 3px solid transparent;
    border-radius: 0;
    margin-bottom: -2px;
    white-space: nowrap;
    background: transparent;
    transition: color var(--duration-fast) var(--ease-standard),
                border-color var(--duration-fast) var(--ease-standard);
}

.ns-subnav .nav-link:hover {
    color: var(--text-heading);
    background: transparent;
    border-bottom-color: var(--border-default);
}

.ns-subnav .nav-link.active {
    color: var(--text-link);
    background: transparent;
    border-bottom-color: var(--surface-brand);
    font-weight: var(--weight-semibold);
}

.ns-subnav .nav-link-danger {
    color: var(--status-danger);
}

.ns-subnav .nav-link-danger:hover {
    color: var(--status-danger);
    border-bottom-color: var(--status-danger);
}


/* ============================================================
   STAT CHIPS — summary counts above a result table
   ============================================================ */

.ns-stat-chip {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-3) var(--space-4);
    background: var(--surface-card);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-card);
    box-shadow: var(--shadow-sm);
}

.ns-stat-chip-icon {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 38px;
    height: 38px;
    flex-shrink: 0;
    border-radius: var(--radius-md);
    background: var(--surface-brand-tint);
    color: var(--surface-brand);
    font-size: var(--text-md);
}

.ns-stat-chip-number {
    font-family: var(--font-display);
    font-weight: var(--weight-bold);
    font-size: var(--text-xl);
    line-height: var(--leading-tight);
    letter-spacing: var(--tracking-tight);
    color: var(--text-heading);
    font-variant-numeric: tabular-nums;
}

.ns-stat-chip-label {
    font-size: var(--text-2xs);
    font-weight: var(--weight-semibold);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--text-muted);
}


/* ============================================================
   CARD HEADER
   ------------------------------------------------------------
   ONE definition, for all three things that render a card header
   in this app: the TableCard widget's .ns-section-head, Bootstrap
   /AdminLTE's .card-header, and a form section's <legend>. They
   used to be three rules holding the same values, which drifted
   apart every time one of them changed. Change a value here and
   it changes everywhere; there is no second copy to keep in sync.

   The band is a LABEL, not a page title. At 14px padding with an
   18px title it stood ~52px tall, and on a form page with four or
   five sections it repeated down the screen and outweighed the
   fields it was naming. 9px padding and a 15px title bring it to
   ~34px, which sits clearly under the page <h1> instead of
   competing with it.

   `border: 0` also resets Bootstrap's own `legend { border-bottom:
   1px solid }` and its calc(1.275rem + 0.3vw) font-size — the two
   declarations that make an unstyled legend render as large text
   over a full-width rule. That reset happens once, right here.
   ============================================================ */

/* The band. */
.ns-section-head,
.card-header,
fieldset > legend {
    padding: 9px var(--card-padding);
    background: var(--surface-card-header);
    color: var(--text-on-card-header);
    border: 0;
    border-bottom: 1px solid var(--border-card-header);
}

/* The text in it. A <legend> is in both rules because a legend IS both — the
   band and the title are the same element. */
.ns-section-title,
.card-header .card-title,
fieldset > legend {
    margin: 0;
    font-family: var(--font-display);
    font-size: var(--text-base);
    font-weight: var(--weight-bold);
    line-height: var(--leading-tight);
    letter-spacing: var(--tracking-tight);
    color: var(--text-on-card-header);
    text-transform: none;
}

/* A subtitle or record count under the title. */
.ns-section-subtitle {
    margin: 2px 0 0;
    font-family: var(--font-body);
    font-size: var(--text-sm);
    font-weight: var(--weight-regular);
    line-height: 1.4;
    letter-spacing: normal;
    text-transform: none;
    color: var(--text-on-card-header-muted);
}

/* Anything else riding in the band — an action, a count pill, an icon — takes
   the band's own colour rather than the page link blue. */
.ns-section-head a,
.card-header a,
.card-header i {
    color: inherit;
}

/* ── Layout only, below this line ────────────────────────────
   The three boxes differ in HOW they sit at the top of the card,
   never in what they look like. No value from the two rules above
   is repeated here. */

/* The band is a fixed 36px whether it carries an action, a count pill or just a
   title. That is the whole point of shrinking it: a 30px button inside 9px
   padding put the band straight back to 49px, and a header that reopens the
   moment you give it a control is not a compact header. Both of these are flex
   already, so the content centres itself in the fixed height. */
.ns-section-head,
.card-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    padding-top: 0;
    padding-bottom: 0;
    min-height: 36px;
}

/* AdminLTE rounds the card, not the header, and does it with !important. */
.card-header {
    border-radius: var(--radius-card) var(--radius-card) 0 0 !important;
}

.ns-section-head-actions {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex-shrink: 0;
}




/* ============================================================
   SECTION CARDS  (fieldset = card, legend = its header)
   ------------------------------------------------------------
   THE page-composition rule: every content region is a white
   card on paper. Nothing sits directly on the page.

   These pages used to lay content straight onto the ground with
   a tiny uppercase label over a full-width hairline for each
   section — a ladder of grey lines with no grouping, and no
   figure/ground at all once the page went paper. The markup was
   already right: every section is a <fieldset> with a <legend>,
   so the grouping exists in the document and only needed to be
   drawn. Restyling the pair here turns ~160 form views into
   card-composed pages without touching one of them.

   The legend is pulled out to the card edges with a negative
   margin rather than the content being wrapped in a body div,
   which would mean editing every one of those views.
   ============================================================ */

/* The card measure, in one place because two different card components read it:
   `fieldset` below caps itself here by default, and `.ns-section` opts in with
   .ns-section-narrow. They have to be the same number — the whole point of the
   cap is that cards line up with each other — so it cannot be typed twice.
   --portal-*, not --ns-*: the design system owns that namespace and has no token
   for a measure. */
:root {
    --portal-measure: 1120px;
}

/* Something that is NOT a card but belongs to the column of cards -- a warning
   above the confirm buttons, an error summary over a form -- lines up with them.
   Left uncapped it runs a few hundred pixels past the card it is talking about
   and reads as a page-level banner instead of a note about that card.
   Opt-in: a listing page's alert legitimately spans the full content width. */
.ns-measure {
    max-width: var(--portal-measure);
}

fieldset {
    /* flow-root, not overflow:hidden — the legend below is floated to escape the
       browser's native "notch in the top border" rendering, and the fieldset has
       to contain that float without clipping anything that legitimately escapes
       the card (autocomplete lists, tooltips). */
    display: flow-root;
    background: var(--surface-card);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-sm);
    /* --card-padding, matching the header below it: the legend pads to the same
       value, so title and body share one left edge. */
    padding: var(--card-padding);
    margin: 0 0 var(--space-6);
    /* Form fields stop at a readable measure; a table inside a card is exempt
       below, since a data grid legitimately wants the full width. */
    max-width: var(--portal-measure);
}

/* A card carrying a real DATA GRID runs to the full content width. The test is
   .grid-view — a CGridView, which brings its own column set, filter row, sorter
   and pager and is the page's subject.

   It used to be `:has(table)` as well, and that was too broad: any table at all
   released the card from the measure, so a two-column, 284px-wide list of
   certification terms stretched its card to 1486px while the form card directly
   above it stopped at 1120px — the same page rendering two different card
   widths depending on which section you looked at. A hand-built <table> inside
   a form card is a supporting list (certification terms, saved cards,
   requirement history), not the page's subject, and belongs inside the same
   measure as the fields above it. Across both modules, all 32 such tables are
   six columns or fewer, so none of them needed the exemption.

   If one ever genuinely does, .ns-fieldset-wide is the opt-out — deliberate and
   visible in the markup, rather than granted automatically by the presence of a
   <table>. */
fieldset:has(.grid-view),
fieldset.ns-fieldset-wide {
    max-width: none;
}

/* An action that follows a table inside a card — "Add a New Term", "Add a New
   Entry" — has to clear the last row. Tables in these cards carry
   margin-bottom:0 so the button was landing flush against the final border.
   A margin utility in the markup still wins, since Bootstrap's spacing helpers
   are !important. */
fieldset > .table-responsive + .btn,
fieldset > table + .btn,
fieldset > .grid-view + .btn,
.card-body > .table-responsive + .btn,
.card-body > table + .btn,
.card-body > .grid-view + .btn {
    margin-top: var(--space-4);
}

/* Nested fieldsets are a grouping inside a section, not a second card. */
fieldset fieldset {
    background: transparent;
    border: 0;
    border-radius: 0;
    box-shadow: none;
    padding: 0;
    margin: 0 0 var(--space-4);
    max-width: none;
}

/* A legend is the card header of a form card — see CARD HEADER above for what it
   looks like. All that is left here is how the box gets to the card edges: the
   float removes the UA's special legend placement (inset, straddling the top
   border), and the negative margins take it back out past the fieldset's own
   padding so the rule under the title meets both sides.

   100% is the fieldset's CONTENT box, so the card's padding has to be added on
   and then cancelled. Keep the two in step: they are the same --card-padding the
   fieldset is padded with. */
fieldset > legend {
    float: left;
    display: block;
    box-sizing: border-box;
    width: calc(100% + var(--card-padding) * 2);
    margin: calc(var(--card-padding) * -1) calc(var(--card-padding) * -1) var(--card-padding);
    border-radius: 0;
}

/* A legend is a card header, so it can carry the card's action. Scoped with
   :has() rather than made flex for every legend — plenty of legends hold an
   inline "(optional)" qualifier, and space-between would fling that to the far
   edge of the bar. A plain legend is a block, and its 9px padding gives it the
   same 36px the flex bands are pinned to. */
fieldset > legend:has(.ns-section-head-actions) {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    padding-top: 0;
    padding-bottom: 0;
    min-height: 36px;
}

/* A subgroup inside a section — "Mailing address" within Personal Information —
   does NOT get a second band. Two sand bands in one card read as two broken
   cards. It differs in KIND instead: an uppercase micro-label on the card's own
   white, no fill and no full-width rule.

   One level of nesting only. A third level means the card should have been two
   cards, and there is deliberately no rule for it. */
fieldset fieldset > legend {
    float: none;
    width: 100%;
    margin: var(--space-6) 0 var(--space-3);
    padding: 0;
    background: none;
    border: 0;
    font-family: var(--font-body);
    font-size: var(--text-xs);
    font-weight: var(--weight-bold);
    line-height: 1.2;
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--neutral-600);
}

/* Content resumes below the floated header rather than beside it. */
fieldset > legend + * {
    clear: both;
}

/* ── Labels sit above their control, never beside it ─────────
   Many forms were built as a two-column split: a narrow column holding only the
   <label>, then a wider one holding the input. Standardising on labels-above
   by hand would mean rewriting every one of those rows; giving the label column
   the full row width instead pushes the input to the next line, which IS labels
   above, and leaves the markup alone.

   Both halves of the pair have to be checked. A column holding a lone <label>
   is also how read-only "Course:" / "When:" detail rows are built, and those
   are a description list, not a form — so the rule only fires when the very
   next column actually contains a control. */
fieldset .row > [class*="col-"]:has(> label:only-child):has(+ [class*="col-"] .form-control),
fieldset .row > [class*="col-"]:has(> label:only-child):has(+ [class*="col-"] .form-select),
fieldset .row > [class*="col-"]:has(> label:only-child):has(+ [class*="col-"] textarea),
fieldset .row > [class*="col-"]:has(> label:only-child):has(+ [class*="col-"] .password-wrapper) {
    flex: 0 0 auto;
    width: 100%;
    margin-bottom: var(--space-1);
}

/* A grid inside a card is already a card in its own right (see .grid-view
   below) — one border and shadow, not two nested ones. */
fieldset > .grid-view,
.card > .card-body > .grid-view,
.card > .card-body.p-0 > .grid-view {
    border: 0;
    border-radius: 0;
    box-shadow: none;
}

/* Qualifier inside a legend, e.g. "(optional)". Bootstrap's .text-muted
   is meant for white backgrounds and disappears on the navy bar. */
.ns-legend-note {
    font-weight: var(--weight-regular);
    text-transform: none;
    letter-spacing: var(--tracking-normal);
    opacity: .7;
}

/* The .inner1 / .inner2 nested-legend variants were removed here. They painted a
   brand fill behind the legend, which contradicts the divider treatment above,
   and neither module uses them (manager has its own copy in css/manager/form.css
   and is not on this sheet yet). Re-add a nesting cue when something needs one —
   quieter, not louder. */


/* ============================================================
   NUMERIC COLUMNS
   Money and counts read down the column, not across the row, so
   they are right-aligned and set in tabular figures — the decimal
   points and thousands separators then stack. The header takes the
   same alignment, or it floats away from the numbers under it.
   Money values themselves go through money() in globals.php.
   ============================================================ */

th.ns-num,
td.ns-num,
.ns-num {
    text-align: right;
    font-variant-numeric: tabular-nums;
    font-feature-settings: "tnum";
}


/* ── Long unbroken value in a cell ───────────────────────────
   An email address or URL is a single token with no break opportunity, so
   table-layout:auto reserves the whole string's width and that one column
   swallows the table. `anywhere` (not `break-word`) is the one that also
   lowers the cell's min-content width, which is what the layout reads. */
td.ns-cell-long {
    min-width: 19em;   /* an ordinary address on one line; the rare 40-char one still wraps */
    max-width: 22em;
    overflow-wrap: anywhere;
}


/* ============================================================
   SECTION CARD
   A titled card wrapping a data table. The fieldset/legend pair
   above covers form sections; this covers the read-only listing
   sections that are built from a bare <h4> plus a table — the
   student tabs, where three tables sat directly on the paper page
   with no container, no radius and header rules running edge to
   edge.

   The table goes FLUSH inside the card: no padding on the card
   around it, because the cells carry their own. overflow:hidden
   is what clips the table's corners to the card radius, and it is
   also what closes the block at the bottom, so the rows only need
   separators BETWEEN them.
   ============================================================ */

.ns-section {
    background: var(--surface-card);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-sm);
    overflow: hidden;
    margin: 0 0 var(--space-6);
    /* No max-width. The measure is a cap for FORMS — a column of fields that
       stops at a readable line length — and it lives on `fieldset`, the form
       container. .ns-section is the table card, and a table wants the full
       content width; capping it left the right third of a listing page empty.
       Where a table card has to line up with form cards instead, that is
       .ns-section-narrow below. */
}

/* A table card that has to match the form cards beside it. The opt-in mirror of
   .ns-fieldset-wide: same measure, declared in the markup rather than inferred.

   The default above assumed cards only ever have to line up within ONE page, so
   a page built entirely of fieldsets stayed self-consistent. A TAB BAR breaks
   that assumption — the six class pages (admin and instructor
   classroom/details, marketing, files, attendance, rosters, notes) are one
   record read through six views, and the card edge is the thing that does not
   move as you go along the bar. Attendance Docs is a table card while its
   neighbours are form cards, so it alone ran ~300px wider and the page appeared
   to resize on every tab change.

   Reach for it when a table card sits in a set with form cards, NOT to quiet a
   listing page down — there the full width is the point. */
.ns-section.ns-section-narrow {
    max-width: var(--portal-measure);
}

/* …and a real DATA GRID releases it again, exactly as it does on a fieldset. Once
   a card carries a CGridView it is the page's subject, brings its own column set,
   filter row and pager, and wants the room — the measure only governs the card
   while it holds prose or a short supporting table.

   This is what keeps the Rosters tab honest in both portals: empty, the grid is
   not rendered at all and the card sits on the measure with its siblings;
   populated, both portals' cards run full width. Neither one has a rule the other
   lacks. */
.ns-section.ns-section-narrow:has(.grid-view) {
    max-width: none;
}

/* …unless the grid is SUPPORTING MATERIAL rather than the subject. The release
   above reads a CGridView as "this card is what the page is for", which is true
   of a listing page and of the Rosters tab, and false of an audit trail sitting
   at the foot of a detail page: the registration page is a column of form cards
   describing one record, and its event log is a footnote on that record that
   happens to need a pager. Left released it ran ~290px past every card above it
   and read as a second, wider page stapled to the bottom of the first.

   Declared in the markup, like .ns-fieldset-wide, because only the view knows
   which of the two a given grid is — the selector cannot tell them apart. Comes
   after the release rule and matches it on specificity, so it wins on order. */
.ns-section.ns-section-supporting,
.ns-section.ns-section-supporting:has(.grid-view) {
    max-width: var(--portal-measure);
}

/* .ns-section-head, -title, -subtitle and -actions are all defined once in
   CARD HEADER above, alongside .card-header and fieldset > legend. */

/* Tables sit flush; anything else in the card gets normal padding. */
.ns-section > .table-responsive,
.ns-section > table,
.ns-section > .grid-view {
    margin: 0;
}

.ns-section-body {
    padding: var(--space-4);
}

/* .ns-section-empty is folded into the EMPTY STATE section below. */

/* The section's own action, attached to the bottom of the card rather than
   floating loose on the page underneath it. */
.ns-section-foot {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-3) var(--space-4);
    border-top: 1px solid var(--border-subtle);
    background: var(--surface-subtle);
}

.ns-section-foot.is-end {
    justify-content: flex-end;
}


/* ============================================================
   ALERTS AND CALLOUTS
   Bootstrap .alert-* plus Yii's setFlash() output (div.flash-*),
   which are the same component with different markup.

   A status colour sets the ICON, the BORDER and a badge label.
   It never sets body copy. ns-bootstrap.css ships
   `.alert-warning { color: var(--status-warning) }`, which put a
   whole paragraph in #C08822 on the #FDF7EB tint — 2.29:1, and
   it read as decoration rather than as text you were meant to
   read. Body copy is --text-body on every variant; the tint,
   the border and the icon carry the status.

   The icon is injected rather than added to 427 call sites, and
   it is absolutely positioned rather than laid out with flex:
   these alerts contain arbitrary legacy markup — bare text
   nodes, <h4> + <p> pairs, <u>, dismiss buttons — and flex would
   turn each of those into a separate flex item strung out along
   one line. Absolute positioning gives the same icon-and-gutter
   result and cannot be broken by the content. Alerts whose
   markup already supplies its own <i> keep it, and it takes the
   status colour from the same custom property.
   ============================================================ */

.alert,
div.flash-error,
div.flash-notice,
div.flash-success,
div.errorSummary {
    position: relative;
    margin-bottom: var(--space-4);
    /* The left padding gains the 3px the edge takes, so text does not shift. */
    padding: 12px var(--space-4) 12px calc(var(--space-4) + 3px);
    border: 1px solid transparent;
    border-radius: var(--radius-md);
    /* The 3px tone edge. Same mechanism as the row attention status: an inset
       shadow rather than a border-left, so it cannot alter the box model or
       fight the 1px tone border on the other three sides. The tints are only
       ~1.1:1 against the paper page in luminance, so the container separates by
       hue plus this edge — not by lightness. */
    box-shadow: inset 3px 0 0 var(--ns-alert-accent);
    color: var(--text-body);
    font-size: var(--text-sm);
    line-height: 1.6;
}

/* Palette. Each variant sets only the accent + glyph; the rules
   below read them, so a new variant needs no new structure. */
.alert                 { --ns-alert-accent: var(--text-muted);      --ns-alert-glyph: "\f05a"; }
.alert-warning,
div.flash-notice       { --ns-alert-accent: var(--status-warning);  --ns-alert-glyph: "\f071";
                         background: var(--status-warning-bg); border-color: var(--status-warning-border); }
.alert-info            { --ns-alert-accent: var(--status-info);     --ns-alert-glyph: "\f05a";
                         background: var(--status-info-bg);    border-color: var(--status-info-border); }
.alert-success,
div.flash-success      { --ns-alert-accent: var(--status-success);  --ns-alert-glyph: "\f058";
                         background: var(--status-success-bg); border-color: var(--status-success-border); }
.alert-danger,
div.flash-error,
div.errorSummary       { --ns-alert-accent: var(--status-danger);   --ns-alert-glyph: "\f06a";
                         background: var(--status-danger-bg);  border-color: var(--status-danger-border); }

/* Body copy is never the status colour — on any variant. This has
   to restate the colour because ns-bootstrap.css sets it per
   variant at the same specificity and is vendored. */
.alert-warning,
.alert-info,
.alert-success,
.alert-danger,
div.flash-error,
div.flash-notice,
div.flash-success,
div.errorSummary {
    color: var(--text-body);
}

/* The icon gutter, only where the markup has not supplied one. */
.alert:not(:has(> i)):not(:has(> svg)),
div.flash-error:not(:has(> i)),
div.flash-notice:not(:has(> i)),
div.flash-success:not(:has(> i)),
div.errorSummary:not(:has(> i)) {
    padding-left: calc(var(--space-4) + 3px + 15px + var(--space-3));
}

.alert:not(:has(> i)):not(:has(> svg))::before,
div.flash-error:not(:has(> i))::before,
div.flash-notice:not(:has(> i))::before,
div.flash-success:not(:has(> i))::before,
div.errorSummary:not(:has(> i))::before {
    content: var(--ns-alert-glyph);
    position: absolute;
    left: calc(var(--space-4) + 3px);
    top: 12px;
    width: 15px;
    font-family: "Font Awesome 7 Free";
    font-weight: 900;
    font-size: 15px;
    line-height: 1.5;
    text-align: center;
    color: var(--ns-alert-accent);
}

/* An icon the markup supplies takes the same accent. */
.alert > i,
div.flash-error > i,
div.flash-notice > i,
div.flash-success > i {
    color: var(--ns-alert-accent);
}

/* Optional title. A heading inside a callout is a title, not a
   status — it reads as heading text with the icon beside it. */
.alert > h1, .alert > h2, .alert > h3,
.alert > h4, .alert > h5, .alert > h6,
.alert .alert-heading {
    color: var(--text-heading);
    font-size: var(--text-base);
    font-weight: var(--weight-semibold);
    letter-spacing: normal;
    margin: 0 0 var(--space-2);
}

.alert > p:last-child,
div.flash-error > p:last-child,
div.flash-notice > p:last-child,
div.flash-success > p:last-child {
    margin-bottom: 0;
}

/* Links inside a callout are links, not status text. :not(.btn), because a
   callout that carries an action carries it as a button -- the Dismiss control on
   the class dashboard alerts is an <a class="btn"> -- and `.alert a` outranks
   Bootstrap's own `.btn { text-decoration: none }`, so the button came out
   underlined. */
.alert a:not(.btn),
.alert .alert-link,
div.flash-error a:not(.btn),
div.flash-notice a:not(.btn),
div.flash-success a:not(.btn) {
    color: var(--text-link);
    text-decoration: underline;
}

/* Bootstrap parks the dismiss button in the padding box; the icon
   gutter is on the other side, so only the right needs clearing. */
.alert-dismissible {
    padding-right: calc(var(--space-4) + 24px);
}


/* ── Tone-bordered action panels ──────────────────
   The paired choice cards ("Remove from this website" / "Delete
   permanently") keep a white card background with a 1px tone border,
   which is right — they are choices, not a status message. They still
   need the 3px edge to separate from the page like every other status
   container. The card's own --shadow-sm is restated because a second
   box-shadow declaration replaces the first rather than adding to it. */
.card.border-primary {
    box-shadow: inset 3px 0 0 var(--surface-brand), var(--shadow-sm);
}

.card.border-danger {
    box-shadow: inset 3px 0 0 var(--status-danger), var(--shadow-sm);
}

.card.border-warning {
    box-shadow: inset 3px 0 0 var(--status-warning), var(--shadow-sm);
}

.card.border-success {
    box-shadow: inset 3px 0 0 var(--status-success), var(--shadow-sm);
}

.card.border-primary > .card-body,
.card.border-danger > .card-body,
.card.border-warning > .card-body,
.card.border-success > .card-body {
    padding-left: calc(var(--card-padding) + 3px);
}


/* ── Status badges, chips and pills ───────────────
   Outline form is the tone's tint, the tone's border, and the tone's
   TEXT token — never the tone hue itself. On the deepened tints the raw
   hues drop under AA for small text (success 4.04:1, warning 2.71:1);
   the -text tokens are those same hues darkened until they clear it
   (6.62:1 and 6.95:1). The hue keeps the icon, the border, the 3px edge
   and any solid fill carrying white text.

   ns-bootstrap.css sets these per variant at the same specificity and is
   vendored, so the colour has to be restated here rather than fixed at
   source. */
.badge-compliant,
.badge-passed {
    color: var(--status-success-text);
}

.badge-due-soon,
.badge-awaiting {
    color: var(--status-warning-text);
}

.badge-overdue,
.badge-failed {
    color: var(--status-danger-text);
}

.badge-in-progress {
    color: var(--status-info-text);
}

.badge-not-started {
    color: var(--status-neutral-text);
}


/* ── A pill that is itself the row's link ────────────────────
   Where the status column carries the link into the record, the pill
   replaces the plain blue anchor that used to say "clickable". The tone
   still has to read as the status, so the affordance is motion and the
   inherited focus ring rather than link colour or an underline. */
.ns-pill-link {
    display: inline-block;
    text-decoration: none;
    border-radius: var(--radius-badge);
}

.ns-pill-link .badge {
    transition: transform var(--duration-fast) var(--ease-standard);
}

.ns-pill-link:hover .badge,
.ns-pill-link:focus-visible .badge {
    transform: translateY(-1px);
}

.ns-pill-link:active .badge {
    transform: translateY(0);
}


/* ============================================================
   FORM VALIDATION — Yii CActiveForm output
   ============================================================ */

.errorMessage,
div.errorMessage {
    color: var(--status-danger);
    font-size: var(--text-xs);
    margin-top: var(--space-1);
}

/* Yii's error summary is a callout — a tinted, bordered box listing
   messages — so it takes the callout structure rather than a red
   paragraph of its own. Its palette and icon come from the ALERTS AND
   CALLOUTS section above; only the list needs resetting here. */
.errorSummary > p {
    font-weight: var(--weight-semibold);
    margin: 0 0 var(--space-1);
}

.errorSummary ul {
    margin: 0;
    padding-left: var(--space-4);
}

.errorSummary p {
    font-weight: var(--weight-semibold);
    margin-bottom: var(--space-1);
}

.errorSummary ul {
    margin: 0;
    padding-left: var(--space-5);
}

.error input,
.error textarea,
.error select,
input.error,
textarea.error,
select.error {
    border-color: var(--status-danger);
}

.error input:focus,
.error textarea:focus,
.error select:focus,
input.error:focus,
textarea.error:focus,
select.error:focus {
    border-color: var(--status-danger);
    box-shadow: var(--focus-ring-danger);
}

/* Required-field marker. */
.required label:after,
label.required:after {
    content: " *";
    color: var(--status-danger);
}


/* ============================================================
   LOGIN CARD — shared by both portals' guest layouts
   ============================================================ */

.ns-login-page {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: var(--space-6);
    background: var(--surface-page-alt);
}

.ns-login-card {
    width: 100%;
    max-width: 420px;
    background: var(--surface-card);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-card);
    box-shadow: var(--shadow-lg);
    overflow: hidden;
}

.ns-login-head {
    padding: var(--space-6) var(--space-6) var(--space-5);
    background: var(--surface-brand-deep);
    text-align: center;
}

.ns-login-head img {
    height: 34px;
    max-width: 100%;
    object-fit: contain;
}

.ns-login-title {
    margin: var(--space-3) 0 0;
    font-family: var(--font-body);
    font-size: var(--text-2xs);
    font-weight: var(--weight-semibold);
    letter-spacing: var(--tracking-caps);
    text-transform: uppercase;
    color: var(--blue-200);
}

.ns-login-body {
    padding: var(--space-6);
}

.ns-login-foot {
    margin-top: var(--space-5);
    font-size: var(--text-xs);
    color: var(--text-muted);
    text-align: center;
}


/* ============================================================
   EMPTY STATE
   ONE quiet line in the card body. No icon, no circled glyph, no
   illustration, no button.

   It used to be a centred block — a 52px glyph in a neutral
   circle, a sentence, an action — inside ~200px of padding. On a
   page carrying two or three empty sections that block drew more
   attention than the sections that actually had data: absence
   became the loudest thing on the screen. An empty section now
   costs a row of height, not a screenful.

   Left-aligned on the table's own cell inset, so the line reads
   as the table's first row rather than as a notice floating in
   the card. Italic because it is the one place inside a data
   region where the text is prose ABOUT the data rather than data.

   The action does NOT live here. It belongs in the card header
   (.ns-section-head-actions), where it sits in the same place
   whether the section is empty or full — an affordance that
   appears and disappears with the data is one the reader cannot
   learn. The single exception is an inline link inside the
   sentence, for something that only makes sense while empty.

   Five selectors, one component: .ns-empty is the current name,
   .ns-section-empty and .ns-empty-inline are older ones kept so
   existing markup keeps working, and the last two are the same
   state expressed as a table row — CGridView's own empty cell and
   the attendance uploader's.
   ============================================================ */

.ns-empty,
.ns-section-empty,
.ns-empty-inline,
/* The same state as a table row. These three are spelled out to the full cell
   depth on purpose: a plain `.ns-empty` (0,1,0) loses to the cell rule above
   (0,1,4), so an empty <td> kept --text-body and the row's own padding. */
table > tbody > tr > td.ns-empty,
.grid-view table > tbody > tr > td.empty,
table > tbody > tr.ns-upload-empty > td {
    /* margin:0 matters — the block is usually a <p>, and Bootstrap's 1rem
       paragraph margin was adding a dead strip under every empty card. */
    margin: 0;
    padding: var(--space-4) var(--table-cell-x);
    background: var(--surface-card);
    text-align: left;
    font-size: var(--text-base);
    font-style: italic;
    color: var(--text-muted);
}

/* The sentence may be the block itself or a <p> inside it. Either way it is one
   line: no max-width to centre against, no margin to space siblings it does not
   have. */
.ns-empty > p,
.ns-empty-body,
.ns-empty-inline p {
    margin: 0;
    max-width: none;
    font-size: inherit;
    font-style: inherit;
    color: inherit;
}

/* The one action permitted inside an empty state: a link within the sentence,
   upright so it reads as a control rather than as more prose. */
.ns-empty a,
.grid-view td.empty a {
    font-style: normal;
}


/* ============================================================
   UTILITIES
   ============================================================ */

.ns-label {
    font-family: var(--font-body);
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--text-muted);
}

/* Buttons never underline, in any state. */
.btn,
.btn:hover,
.btn:focus,
.btn:active {
    text-decoration: none;
}

/* Bootstrap's .table-responsive clips a dropdown opened in the last
   row; grids that need one opt out with this. */
.table-responsive.ns-allow-overflow {
    overflow: visible;
}


/* ============================================================
   ATTENDANCE FORM COUNT
   A live CE class is filed with its attendance documents, and the
   one thing the filing needs that the file itself cannot be read
   for is how many student attendance forms are inside it. The
   question is put in the staged upload row rather than in a dialog,
   so the answer sits beside the filename it belongs to and stays
   correctable right up until the upload is sent.
   ============================================================ */

/* The question itself, above the staged rows. Hidden until files are
   picked — it is an instruction for an upload in progress, not a
   caption for the list of files already filed. */
.ns-form-count-prompt {
    margin: var(--space-3) 0;
    padding: 0 var(--table-cell-x);
    font-size: var(--text-sm);
    line-height: var(--leading-normal);
    color: var(--text-muted);
}

/* Same sentence, after it has been ignored once. */
.ns-form-count-prompt.ns-form-count-missing {
    color: var(--status-danger);
    font-weight: var(--weight-semibold);
}

/* Narrow enough to read as a count rather than a text field, wide
   enough for three digits and the number spinner beside them. */
.ns-form-count-input {
    width: 6.5em;
    margin: 0 auto;
    text-align: center;
}

/* Bootstrap's invalid state paints a warning glyph into the field and reserves
   padding for it. In a field this narrow the glyph lands on top of the number
   spinner; the red border says the same thing without the collision. */
.ns-form-count-input.is-invalid {
    background-image: none;
    padding-right: var(--space-3);
}

/* ============================================================
   FILE LISTS
   The attached-file / course-material lists on the class pages
   in both portals. Shared because both render the same markup
   from the same data (Classroom::getClassroomDocuments), and a
   copy per module meant a file card that looked like a card in
   one portal and a bullet point in the other.
   ============================================================ */

ul.fileList {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.45rem;
}

ul.fileList li {
    margin: 0;
    padding: 0;
}

ul.fileList li a.fileItem {
    display: flex;
    align-items: center;
    gap: 0.85rem;
    padding: 0.7rem 1rem;
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-control);
    background: var(--surface-card);
    text-decoration: none;
    transition: box-shadow var(--duration-normal) var(--ease-standard), transform var(--duration-normal) var(--ease-standard), border-color var(--duration-normal) var(--ease-standard);
    box-shadow: var(--shadow-xs);
}

ul.fileList li a.fileItem:hover {
    border-color: var(--surface-brand);
    box-shadow: var(--shadow-lg);
    transform: translateY(-1px);
    text-decoration: none;
}

ul.fileList li a.fileItem .fileIconWrap {
    width: 2.4rem;
    height: 2.4rem;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--radius-control);
    background: var(--surface-brand-tint);
    color: var(--surface-brand);
    font-size: var(--text-lg);
    flex-shrink: 0;
}

ul.fileList li a.fileItem .fileName {
    flex: 1;
    min-width: 0;
    font-size: var(--text-sm);
    font-weight: var(--weight-medium);
    color: var(--text-link);
    line-height: 1.3;
}

/* The license location a handout is tagged for. Rendered only on a file that is
   restricted to one, so its presence is the whole message -- an untagged handout
   goes to every attendee and says nothing here. Quiet on purpose: it qualifies the
   file name rather than competing with it. */
ul.fileList li a.fileItem .fileTag {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    max-width: 15rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    padding: 0.1rem 0.6rem;
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-pill);
    background: var(--surface-sunken);
    color: var(--text-muted);
    font-size: var(--text-xs);
    font-weight: var(--weight-medium);
    line-height: 1.6;
}

/* A file the reader can also act on. The buttons sit alongside the card rather than
   inside it, because the card is itself a link and a button nested in an anchor is
   not one. */
ul.fileList li.fileRow {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

ul.fileList li.fileRow a.fileItem {
    flex: 1;
    min-width: 0;
}

ul.fileList .fileActions {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    flex-shrink: 0;
}

ul.fileList .fileActions form {
    margin: 0;
}

/* Class responsibilities (the Responsibilities tab in both portals). Long instructions
   read as a wall without air between the bullets. One copy here rather than one per
   module: admin had 0.75em in its own sheet and instructor a 1em <style> block in the
   view, so the same list was spaced two different ways depending on who opened it. */
#instructorBoxIntro li { margin: 0.75em 0; }

/* Buttons sitting inside an input-group next to a link field must not wrap their label,
   or "Copy" breaks onto two lines and the group grows taller than the input. Replaces the
   inline white-space rule these buttons used to carry. */
.ns-nowrap { white-space: nowrap; }

/* ============================================================
   UPLOADED PICTURE
   A sponsor's logo, a speaker's photo. Small, optional images
   that sit on a record rather than being downloaded, so the
   control is the picture itself plus the two things you might
   do to it -- not a file picker sitting where the image should
   be. Shared because three forms in two sections now draw it.
   ============================================================ */

.ns-image-current {
    display: flex;
    align-items: center;
    gap: 1rem;
    flex-wrap: wrap;
    margin-bottom: .65rem;
}

/* Capped rather than fixed, so a wide logo and a tall headshot both sit inside the
   same box without either being stretched. The tint behind it is what makes a
   transparent PNG readable -- on white, a white logo is an empty rectangle. */
.ns-image-preview {
    max-width: 160px;
    max-height: 120px;
    object-fit: contain;
    padding: .4rem;
    background-color: var(--surface-sunken);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-card);
}

.ns-image-actions {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: .35rem;
}

.ns-image-actions a {
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    color: var(--surface-brand);
    text-decoration: none;
    transition: color var(--duration-fast) var(--ease-standard);
}

.ns-image-actions a:hover,
.ns-image-actions a:focus-visible { color: var(--blue-600); text-decoration: underline; }

/* Removing is destructive and sits directly under the action that is not, so it says
   so in the one place colour is allowed to carry meaning. */
.ns-image-actions a.ns-image-remove { color: var(--status-danger); }
.ns-image-actions a.ns-image-remove:hover,
.ns-image-actions a.ns-image-remove:focus-visible { color: var(--status-danger); }

.ns-image-upload { max-width: 26rem; }

/* A row for a record that is still real but is no longer offered -- a speaker taken off
   the client's list who is still on the classes they already spoke at. Two classes on
   the selector: a Bootstrap 5 table paints colour on the cells, so a rule on the <tr>
   alone is silently outranked. */
tr.ns-row-muted > td,
.grid-view table.items tbody tr.ns-row-muted > td { color: var(--text-muted); }

/* ============================================================
   PEOPLE ON A CLASS
   The instructor / panelist / speaker lists, the sponsor list
   beside them, and the join links that go with them. Shared
   because the same rows are drawn in the scheduling wizard and
   again, read-only, on the class details tab -- and a link that
   is copyable in one place and cramped in the other is the same
   bug twice.
   ============================================================ */

/* These rows carry a field and a link as well as a name, so they are taller and looser
   than a plain listing and their cells align to the top rather than the middle -- an
   input next to a two-line link block otherwise floats in the row. */
table.ns-people-table > tbody > tr > td { vertical-align: top; padding-top: .7rem; padding-bottom: .7rem; }
table.ns-people-table td input[type="text"] { max-width: 22rem; }
table.ns-people-table td.ns-people-table__actions { width: 9em; white-space: nowrap; text-align: right; }

/* A link here is for copying, not reading: the box is wide, the button is beside it, and
   several sessions on one recurring webinar stack rather than run on. */
.ns-copy-row { display: flex; align-items: center; gap: .35rem; max-width: 30rem; }
.ns-copy-row + .ns-copy-row { margin-top: .35rem; }
.ns-copy-row input { font-size: var(--text-xs); }

/* Which session a link is for. Only drawn on a class with more than one, where the answer
   is not obvious from the row it sits in. */
.ns-copy-label { font-size: var(--text-xs); color: var(--text-muted); }
.ns-copy-row + .ns-copy-label { margin-top: .5rem; }

/* Nothing is wrong -- they simply have not given us an address yet -- so this is the
   colour for "needs attention", not the one for a failure. */
.ns-link-warn { font-size: var(--text-sm); color: var(--status-warning-text); }

/* The same sentence, where there is a page that fixes it. A warning colour is a weak
   signal for something you are meant to ACT on -- it says "look" and stops -- so once
   this is clickable it stops being coloured like a notice and is drawn like the link it
   is: brand colour and an underline standing still, not an affordance the reader has to
   hover to discover. */
a.ns-link-warn {
    color: var(--surface-brand);
    text-decoration: underline;
}

a.ns-link-warn:hover,
a.ns-link-warn:focus-visible {
    color: var(--blue-600);
    text-decoration: underline;
}

/* ---- Who you just picked ----
   A person chosen from a dropdown is a name and nothing else, so this restates what the
   class page and their invitation will actually use: their photo, title, address and bio,
   with the way through to change any of it. Sits inside a form, so it reads as a quiet
   inset rather than a second card competing with the fieldset around it. */
.ns-person-card {
    display: flex;
    align-items: flex-start;
    gap: .9rem;
    max-width: 32rem;
    padding: .85rem 1rem;
    background-color: var(--surface-sunken);
    border: 1px solid var(--border-subtle);
    border-radius: var(--radius-card);
}

.ns-person-card__photo {
    flex: 0 0 auto;
    width: 56px;
    height: 56px;
    object-fit: cover;
    border-radius: 50%;
    background-color: var(--surface-card);
    border: 1px solid var(--border-subtle);
}

.ns-person-card__body { min-width: 0; }

.ns-person-card__name {
    font-weight: var(--weight-semibold);
    color: var(--text-heading);
}

.ns-person-card__title {
    font-size: var(--text-sm);
    color: var(--text-muted);
}

.ns-person-card__meta {
    margin-top: .3rem;
    font-size: var(--text-sm);
    color: var(--text-body);
    overflow-wrap: anywhere;
}

.ns-person-card__edit {
    display: inline-block;
    margin-top: .55rem;
    font-size: var(--text-sm);
    font-weight: var(--weight-semibold);
    color: var(--surface-brand);
    text-decoration: none;
}

.ns-person-card__edit:hover,
.ns-person-card__edit:focus-visible { color: var(--blue-600); text-decoration: underline; }

/* ---- Pick one, or make one ----
   Two equal ways to answer the same question. The alternative used to be a link inside a
   sentence under the control, which reads as a footnote -- so somebody whose speaker is
   not on the list concludes they cannot proceed rather than seeing the way forward. Given
   the same visual weight as the dropdown, it needs no reading at all. */
.ns-pick-or-add {
    display: flex;
    align-items: center;
    gap: .75rem;
    flex-wrap: wrap;
}

.ns-pick-or-add > .form-select { flex: 1 1 16rem; width: auto; min-width: 0; }

.ns-pick-or-add__or {
    flex: 0 0 auto;
    font-size: var(--text-sm);
    color: var(--text-muted);
}
