/*
 * The application shell — sidebar, top bar, content — shared by the Portal and the back office.
 *
 * Lives here, next to AppShell.razor, rather than in either app's tailwind.css: the shell is one
 * component now, and a stylesheet copied into two apps is what let the two of them drift in the
 * first place. Everything below is written against the design tokens each app defines on :root, so
 * it inherits both palettes and the dark theme without knowing either exists.
 *
 * The breakpoint is 48rem, the same 768px Tailwind's `md:` uses everywhere else in this codebase.
 * Below it the sidebar is an off-canvas drawer; at and above it, a static column. wwwroot/js/
 * nav-drawer.js owns the open/closed state and writes it as `data-nav-open` on <html>.
 */

.app-shell {
  /* 1280 is a reading width. It is right for a form, a bulletin or a settings page, and wrong for
     a mark sheet: on a 2100px screen a third of the glass was margin while the gradebook's last
     four columns were off the right. Every school-information system this was checked against —
     PowerSchool, Infinite Campus, Skyward, Arbor, Bromcom — runs grid screens near full width and
     keeps the narrow measure for prose. So the shell gets the glass and prose keeps its own
     max-w-* , which every such page already carries. Beyond this it is the table that scrolls
     (.tbl-scroll) and never the page. */
  max-width: 1600px;
  margin-inline: auto;
  min-height: 100vh;
  min-height: 100dvh;
}

.app-shell-main {
  /* Grid children default to min-width:auto, which lets a wide table push the whole column past
     the viewport instead of scrolling inside itself. */
  min-width: 0;
}

.app-sidebar {
  background: var(--surface);
  border-inline-end: 1px solid var(--line);
  padding: 18px 12px;
  overflow-y: auto;
  overscroll-behavior: contain;
}

.app-content {
  padding: 24px 26px 60px;
}

/* Anything the host wants between the top bar and the content — the back office's review bell. */
.app-banner {
  padding: 12px 26px 0;
}

/* Everything that only exists to work a drawer is off by default, and switched on below by the
   phone media query *and* the `data-nav-drawer` flag nav-drawer.js writes on <html> when it loads.
   That is the whole no-JS story: without the script there is no burger, no close button and no
   scrim, and the sidebar stays the plain block it always was — poor on a phone but navigable,
   which a collapsed menu with no way to open it is not.

   The close control is in the markup at every width rather than rendered conditionally, because a
   button that leaves the DOM cannot hold focus across a resize. */
.app-nav-close-row,
.nav-toggle,
.app-scrim {
  display: none;
}

.app-nav-close-row {
  justify-content: flex-end;
  margin-bottom: 2px;
}

.app-nav-close {
  width: 36px;
  height: 36px;
  flex: none;
  display: grid;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 9px;
  background: transparent;
  color: var(--ink-soft);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
}

.app-nav-close:hover {
  background: var(--surface-2);
  color: var(--ink);
}

/* The hamburger. Rendered by TopBar so it sits at the head of the app bar, where a phone user's
   thumb and every other application on their device agree it should be. */
.nav-toggle {
  width: 36px;
  height: 36px;
  flex: none;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 9px;
  background: transparent;
  color: var(--ink);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
}

.nav-toggle:hover {
  background: var(--surface-2);
}

.nav-toggle:focus-visible,
.app-nav-close:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ----------------------------------------------------------------- wide tables -------------- */
/* A .tbl is width:100% with nowrap headers, so on a phone it is wider than the screen and the only
   thing that can absorb the overflow is the document — a swipe on the fees ledger slid the whole
   page sideways, header and drawer with it. .app-shell-main already sets min-width:0 so the column
   does not grow; this gives the table somewhere of its own to scroll. Wrap any .tbl that can carry
   more than two or three columns. On a wide viewport nothing overflows and the rule is inert. */
/* ── The plan pickers (feature 0149) ───────────────────────────────────────────────────────────

   Two scrolling checkbox lists side by side: classes left, subjects right. A fixed height rather
   than a growing one, because a school with forty classes would otherwise push Apply off the screen
   — which is the defect this feature exists to fix, reintroduced by the fix. */

.plan-picker {
  border: 1px solid var(--field-line);
  border-radius: var(--r-sm);
  padding: 8px;
  margin: 0;
  max-height: 14rem;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 2px;
  background: var(--surface-2);
}

.plan-pick {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 3px 4px;
  border-radius: var(--r-sm);
  font-size: 13px;
  cursor: pointer;
}

.plan-pick:hover { background: var(--surface); }

/* A subject that cannot carry a plan for any chosen class. Dimmed and not-allowed, with the reason
   in the checkbox's accessible name — the same rule .btn:disabled learned in 157c: a control that
   refuses must look like it does, and must be able to say why. */
.plan-pick.is-blocked {
  opacity: .55;
  cursor: not-allowed;
}

.plan-pick.is-blocked input { cursor: not-allowed; }

/* ── end plan pickers ──────────────────────────────────────────────────────────────────────── */

/* ── The table (feature 0158, 158b) ────────────────────────────────────────────────────────────

   One definition, here, because both planes render the same tables through the same components.
   It lived in Portal/Styles/tailwind.css and again in Manage/Styles/tailwind.css, and the copies
   had already drifted — the back office had `white-space: nowrap` on its headers and no
   `overflow-wrap: break-word` on its cells, so it carried neither of the two fixes the comments
   below exist to explain. The Portal's is kept, being the one that had learned something. */

.tbl { border-collapse: collapse; width: 100%; font-size: 13px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); box-shadow: var(--shadow); }
/* Wrapping, not `nowrap`. The gradebook puts a whole competency statement in a header cell
   ("Ability to use English to buy and sell basic necessities"), and `nowrap` made each of those
   columns as wide as its sentence — which pushed TOTAL, GRADE, RANK and COMMENT off the right of
   a 2100px screen. Headers elsewhere are one or two words and wrap identically either way.

   `middle`, not `bottom` (0136 D2a). A header row is as tall as its tallest label, and on the
   gradebook that is a competency statement three or four lines deep; `bottom` then dropped
   STUDENT, USI, TOTAL /20, GRADE, RANK and COMMENT to the floor of a tall cell, floating away
   from the column each one names. Not a gradebook rule — every table in the application has a
   header row, and this is the only one of them that was ever set deliberately. */
.tbl thead th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-faint); font-weight: 750; padding: 10px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; }
/* An email address is one unbreakable token. Without this the staff directory's minimum width is
   the longest address in the school, and no amount of wrapping elsewhere helps. `break-word`
   rather than `anywhere`: it breaks a word only when the word alone would overflow, so ordinary
   names still break at spaces. */
.tbl tbody td { padding: 11px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; overflow-wrap: break-word; }
.tbl tbody tr:last-child td { border-bottom: 0; }
.tbl-hover tbody tr:hover { background: var(--surface-2); }

/* A report has many numeric columns but is read as one document. Use the available
   document width and compact cell padding before asking the reader to scroll.
   Keep the type size: tiny marks are not a solution to an overflowing report. */
.bulletin-card { max-width: 72rem; }
.bulletin-subjects { min-width: 40rem; }
.bulletin-subjects thead th { padding: 8px 6px; letter-spacing: normal; overflow-wrap: anywhere; }
.bulletin-subjects tbody td { padding: 8px 6px; }

/* ── end table ─────────────────────────────────────────────────────────────────────────────── */

/* ── DataGrid (feature 0158, 158a) ─────────────────────────────────────────────────────────────

   Here rather than in a host stylesheet because the component is in this library and six hosts
   render it — the same reason .panel-head moved down here, and the defect that taught it: a class
   defined in the Portal's sheet and nowhere else lays out in the portal and silently does not in the
   back office.

   Every directional property is logical (inline-start, not left). `ar` is rtl:true in
   languages.yaml, so a grid built on physical sides would put its column menu and its sort marks on
   the wrong side of an Arabic page — and that is the sort of thing nobody notices until a school
   does. */

.grid-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 12px;
}

/* 158f: the add is its own band above the strip, not a third item in it. Separated from the strip
   below it, so an open panel reads as a thing in its own right rather than as furniture attached to
   the search box it used to sit beside. */
.grid-toolbar {
  margin-bottom: 12px;
}

/* The search takes the room and the column menu keeps its size, so the strip does not rewrap at a
   width nobody chose. The toolbar used to be a third item here, pushed right with an auto margin;
   since 158f it is its own band above and that rule would have been a contradiction left behind. */
.grid-search { flex: 1 1 12rem; min-width: 0; }

.grid-columns { position: relative; flex: none; }
.grid-columns > summary { list-style: none; cursor: pointer; }
.grid-columns > summary::-webkit-details-marker { display: none; }

.grid-columns-menu {
  position: absolute;
  inset-inline-end: 0;
  inset-block-start: calc(100% + 4px);
  z-index: 20;
  min-width: 12rem;
  padding: 8px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  box-shadow: 0 6px 24px rgb(0 0 0 / 12%);
}

.grid-columns-item {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  white-space: nowrap;
}

/* A header that sorts is a button, not a th with a click handler: it has to be reachable from a
   keyboard and announce itself, and the element that does both already exists. */
.grid-sort {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border: 0;
  background: none;
  padding: 0;
  margin: 0;
  font: inherit;
  font-weight: inherit;
  color: inherit;
  cursor: pointer;
  text-align: start;
}

.grid-sort:hover { text-decoration: underline; }
.grid-sort-mark { font-size: 11px; opacity: .75; }

/* A column's own filter, under its own heading (163c). Full-width so it lines up with the cells it
   narrows, and lighter than the heading above it: the heading names the column and this is a
   control on it, so a picker as loud as the word it sits beneath reads as two headings. */
.grid-filter {
  display: block;
  width: 100%;
  margin-top: 6px;
  padding: 3px 6px;
  font-size: 12px;
  font-weight: 400;
}

/* The panel a row opens, in the row's own place (158b). Tinted rather than bordered: it is the same
   record as the row above it, and a border between the two would read as a separate entry. The tint
   is .tbl-hover's own, so the detail does not change under the pointer — there is nothing to click
   a form's backing row for, and a highlight that follows the mouse across a form says otherwise. */
.grid-row-detail > td { background: var(--surface-2); }

/* A cell's intent, not each page's own guess at alignment (158i). Reported from `/assessments`:
   a dot-separated sentence of whatever length a plan's fields happened to add up to, followed by
   an Edit button — fifteen rows, fifteen button positions, a visible staircase down the card. A
   row's height must not depend on its content, so a text cell clamps to a fixed number of lines
   instead of wrapping without limit; the full value still reaches the reader through `title=`.
   Clamping and not truncating at a character count, because a count is wrong at every column
   width and in every one of nine languages. */
.grid-cell-clamp {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.grid-cell-identifier { white-space: nowrap; }

.grid-cell-number { text-align: end; font-variant-numeric: tabular-nums; }

.grid-cell-date { text-align: end; white-space: nowrap; }

.grid-cell-status { text-align: center; }

/* The action column at the same end of every row, sized to its own content rather than sharing
   the stretch every other column gets — `width: 1%` is the standard trick for "as narrow as its
   content allows" in a table whose other columns are unconstrained. `170b` is the shape of the
   buttons inside it; this is only where the column sits. */
.grid-cell-actions { text-align: end; white-space: nowrap; width: 1%; }

/* ── end DataGrid ──────────────────────────────────────────────────────────────────────────── */

/* The head of a panel: a title on one side, a cancel on the other (158a).

   It lived in the Portal's own stylesheet and in no other, so every .panel-head in Manage.Client
   rendered as a stacked block with the cancel underneath the title — the same shape of defect as a
   class nothing defines, except that it looked deliberate. It is here now because it belongs to a
   component in this library, and a component's furniture has to travel with it. */
.panel-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 12px;
}

.panel-head > :first-child { margin-top: 0; }
.panel-cancel { flex: none; }

.table-scroll {
  overflow-x: auto;
  /* The scroll ends at the table's edge instead of handing the rest of the gesture to the page,
     which on a phone is what turns "I read the last column" into "the screen moved". */
  overscroll-behavior-x: contain;
  /* The card's own rounding would otherwise be clipped square by the scrolling box. */
  border-radius: var(--r);
}

/* ----------------------------------------------------------------- language control -------- */
/* One class, two switchers: the web's form-backed <select> and Connect's in-app one look and sit
   identically, which is the whole reason the shell is shared. It was written inline on the web's
   markup, where the app could not restyle it — Connect's top bar is a brand gradient and a select
   painted in --surface on it is a white slab. */
.lang-select {
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 4px 8px;
  font-size: 13px;
  cursor: pointer;
}

/* ----------------------------------------------------------------- theme control ----------- */
/* The button carries both glyphs and CSS hides one, keyed off the same [data-theme] pin the theme
   script writes before first paint. Doing it here rather than in the click handler is what makes it
   correct on a statically rendered page: there is no component to re-render and no interactivity to
   wait for, and the three hosts that each own a copy of toggleTheme() do not each need a fourth copy
   of "and now update the icon".

   The glyph shown is the theme the tap will switch *to*: a moon while the page is light, a sun while
   it is dark. */
.theme-toggle {
  display: grid;
  place-items: center;
}

.theme-glyph {
  width: 17px;
  height: 17px;
}

/* Light is the default, so the moon is what the unpinned, light-scheme page shows. */
.theme-glyph-sun {
  display: none;
}

/* No pin: follow the operating system. Guarded on the absence of the attribute rather than written
   as a plain media query, so an explicit choice always beats the OS in both directions. */
@media (prefers-color-scheme: dark) {
  html:not([data-theme]) .theme-glyph-moon {
    display: none;
  }

  html:not([data-theme]) .theme-glyph-sun {
    display: block;
  }
}

html[data-theme="dark"] .theme-glyph-moon {
  display: none;
}

html[data-theme="dark"] .theme-glyph-sun {
  display: block;
}

/* The Portal's school switcher carries both an active-school name and an action label, and CSS
   picks which one is on screen; the phone block below does the swapping. */
.school-switch-label {
  display: none;
}

/* The hat: which relationship you are acting in, beside the school you are acting at (188c). It is
   a submit button in a form rather than a link, because switching is a write — the API records the
   choice — so it must not be something a prefetch or a crawler can trigger. Styled to sit level with
   the school chip, and stripped of the button chrome a form control brings with it. */
.hat-switch-form {
  display: contents;
}

.hat-switch {
  background: none;
  border: 1px solid transparent;
  font: inherit;
  cursor: pointer;
}

.hat-switch-label {
  display: none;
}

.hat-switch-name {
  max-width: 10rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ---------------------------------------------------------------- phone / small tablet ------ */
@media (max-width: 47.999rem) {
  html[data-nav-drawer] .nav-toggle {
    display: grid;
  }

  html[data-nav-drawer] .app-nav-close-row {
    display: flex;
  }

  html[data-nav-drawer] .app-sidebar {
    position: fixed;
    inset-block: 0;
    inset-inline-start: 0;
    z-index: 60;
    width: min(84vw, 300px);
    box-shadow: var(--shadow);
    border-inline-end: 1px solid var(--line);
    /* visibility, not display: it keeps the panel out of the tab order while closed — so the
       nav's forty links are not forty invisible tab stops — and still animates.

       The zero-duration, end-delayed visibility step is what makes that workable in both
       directions. Opening flips to visible on the same tick the attribute lands, so the script can
       move focus into a panel that is already focusable — a visibility:hidden element ignores
       .focus() outright. Closing holds it visible for the length of the slide, so the panel does
       not vanish mid-animation. */
    visibility: hidden;
    transform: translateX(-100%);
    transition: transform .22s ease, visibility 0s linear .22s;
  }

  html[data-nav-drawer][dir="rtl"] .app-sidebar {
    transform: translateX(100%);
  }

  /* After the RTL rule, which it ties on specificity with. */
  html[data-nav-drawer][data-nav-open] .app-sidebar {
    transform: translateX(0);
    visibility: visible;
    transition: transform .22s ease, visibility 0s;
  }

  html[data-nav-drawer] .app-scrim {
    display: block;
    position: fixed;
    inset: 0;
    z-index: 55;
    background: rgba(9, 14, 26, .5);
    opacity: 0;
    visibility: hidden;
    transition: opacity .22s ease, visibility 0s linear .22s;
  }

  html[data-nav-drawer][data-nav-open] .app-scrim {
    opacity: 1;
    visibility: visible;
    transition: opacity .22s ease, visibility 0s;
  }

  /* An open drawer over a page that still scrolls underneath is the classic mobile-drawer bug. */
  html[data-nav-open],
  html[data-nav-open] body {
    overflow: hidden;
  }

  .app-content {
    padding: 18px 16px 48px;
  }

  .app-banner {
    padding: 10px 16px 0;
  }

  /* The app bar has to fit a 360px-wide phone: the burger, the context chip and the controls.
     The breadcrumb is what goes — every page states where it is in its own <h1>. */
  .topbar {
    padding-inline: 14px;
    gap: 8px;
    flex-wrap: wrap;
  }

  /* School and persona are useful context, not expendable controls. Give them
     their own phone row so search, language, theme and account all remain reachable. */
  .topbar .topbar-context {
    order: 1;
    flex-basis: 100%;
    min-width: 0;
    margin-inline: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
  }

  .topbar .topbar-context > * { max-width: 100%; }

  /* The breadcrumb stays. Hiding it (0123 B3) took away the only way up on the one device whose
     sidebar is a closed drawer — the page's own <h1> says where you are, but nothing said what is
     above it. It folds instead: the parent and the current page, behind a leading ellipsis. */
  .topbar .crumb {
    display: none;
  }

  .topbar .crumb-list-folded > .crumb-item {
    display: none;
  }

  .topbar .crumb-list-folded > .crumb-parent,
  .topbar .crumb-list-folded > .crumb-current {
    display: flex;
  }

  /* The ellipsis only where something is actually folded away — the class is set by the component
     when the trail is longer than the two crumbs that survive, so it cannot claim a trail that is
     not there. */
  .topbar .crumb-list-folded::before {
    content: "…";
    opacity: 0.5;
    margin-inline-end: 6px;
  }

  /* The parent is the affordance; the current page can give up its width for it. */
  .topbar .crumb-current > span {
    max-width: 40vw;
  }

  /* Flag plus native language name is wide; the flag alone still identifies the choice. */
  .topbar select {
    max-width: 92px;
  }

  /* Keep the avatar, drop the name — the drawer shows it in full.

     Scoped to the user chip by its own class, rather than to "any .uchip span that is not the
     avatar". The Portal's school switcher is the same pill and has no avatar, so the unscoped rule
     hid every span it owns. What survived was an empty 19px circle: no text, no icon, and in dark
     mode almost no contrast against the bar behind it — still there and still tappable, which is
     the worst version of the bug. Any chip added to this bar later would have met the same fate. */
  .topbar .user-chip {
    padding: 4px 5px;
  }

  .topbar .user-chip > span:not(.avatar) {
    display: none;
  }

  /* The switcher stays a labelled control on a phone, just a smaller one — it shares a 360px bar
     with the burger, the language picker, the theme button and the avatar. */
  .topbar .school-switch {
    padding: 3px 10px 3px 8px;
    font-size: 11.5px;
    gap: 5px;
  }

  /* The house glyph is decoration the label does not need, and the bar has no room for both. */
  .topbar .school-switch .school-switch-home {
    display: none;
  }

  .topbar .school-switch .school-switch-name {
    max-width: 8.5rem;
  }

  .topbar .school-switch--multi .school-switch-name,
  .topbar .hat-switch .hat-switch-name {
    display: inline;
  }

  .topbar .hat-switch .hat-switch-label,
  .topbar .school-switch--multi .school-switch-label {
    display: none;
  }
}

/* ---------------------------------------------------------------- tablet and up ------------- */
@media (min-width: 48rem) {
  .app-shell {
    display: grid;
    grid-template-columns: 236px 1fr;
    align-items: start;
  }

  .app-sidebar {
    position: sticky;
    top: 0;
    height: 100vh;
    height: 100dvh;
  }
}

/* Crossing the breakpoint restyles the panel from "column" to "closed drawer", and the transition
   above happily animates that: rotate a tablet into portrait and the sidebar slides away on its
   own. nav-drawer.js hangs this attribute on <html> for the frame in which that happens. After the
   two state rules, so it wins on document order at equal specificity. */
html[data-nav-resizing] .app-sidebar,
html[data-nav-resizing] .app-scrim {
  transition: none;
}

@media (prefers-reduced-motion: reduce) {
  .app-sidebar,
  .app-scrim {
    transition: none;
  }
}

/* ---------------------------------------------------------------------------------------------
   Pending state (feature 0082).

   One indicator for both halves of the problem: `.spinner` is an element the interactive pages
   render themselves, and `.is-busy::before` is the same ring drawn into a button that form-busy.js
   marked on a static-SSR page, where there is no component to render anything.

   Sized in `em` and coloured from `currentColor`, so one rule serves .btn-primary, .btn-secondary,
   .btn-danger and .btn-link in both themes without a variant each — the button already sets the
   colour and the font size the ring should match.

   The 300ms delay is the point, not a detail. Most mutations here answer well inside it, and an
   indicator that appears and vanishes inside 300ms reads as a glitch rather than as progress
   (Miller/Card thresholds; see the feature doc). Under the delay the control is still locked — the
   *lock* is what stops the double-submit, the spinner only explains it. It is spent as animation
   delay rather than a timer so it costs no JS and, on the interactive side, no extra render.
   --------------------------------------------------------------------------------------------- */

.spinner,
.is-busy::before {
  content: "";
  display: inline-block;
  inline-size: 1em;
  block-size: 1em;
  flex: none;
  box-sizing: border-box;
  border: 2px solid currentColor;
  /* The missing quadrant is what makes the rotation readable; a closed ring looks static. */
  border-inline-end-color: transparent;
  border-radius: 50%;
  /* Held invisible through the delay. `forwards` and not `both` deliberately: backwards fill would
     paint the animation's first frame during the delay, which is the flash being avoided. */
  opacity: 0;
  animation:
    cvx-spin 0.6s linear infinite,
    cvx-spinner-appear 120ms linear 300ms forwards;
}

/* .btn is already inline-flex with an 8px gap, so the pseudo-element lands as a flex item and is
   spaced from the label for free. Stated here anyway for any other control form-busy.js marks. */
.is-busy {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

/* Feedback at 0ms, for the 0.1–1s band where a spinner is too much but nothing is too little. */
form[aria-busy="true"] {
  cursor: progress;
}

@keyframes cvx-spin {
  to { rotate: 360deg; }
}

@keyframes cvx-spinner-appear {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* WCAG 2.3.3. A pending indicator still has to indicate, so this is not "hold still" — the ring
   closes (nothing left to read rotation from) and breathes its colour instead. The fade animates
   border-color rather than opacity so it does not fight the appear animation for the property. */
@media (prefers-reduced-motion: reduce) {
  .spinner,
  .is-busy::before {
    border-inline-end-color: currentColor;
    animation:
      cvx-spinner-appear 120ms linear 300ms forwards,
      cvx-spinner-breathe 1.4s ease-in-out 300ms infinite;
  }
}

@keyframes cvx-spinner-breathe {
  0%, 100% { border-color: color-mix(in srgb, currentColor 30%, transparent); }
  50% { border-color: currentColor; }
}

/* The scrollbars are ours, in both axes and both themes (0136 D3).
 *
 * Half of the reported symptom is fixed by `color-scheme` in each app's tokens — without it the
 * browser paints its own furniture from the light default whatever the page looks like. This is
 * the other half: a bar drawn from the same tokens as everything around it, so it inherits both
 * palettes and the dark theme without knowing either exists, exactly as the header of this file
 * says every rule in it must.
 *
 * Scoped to the shell's own scrollers and the document, not `*`. A rule on every element in the
 * page is a rule on the third-party grid, the map and every editor pane this application ever
 * embeds — none of which asked us to restyle them, and all of which would inherit it silently.
 *
 * And a scrollbar stays a scrollbar. 12px is wide enough to grab with a mouse; the track is
 * visible so a reader can see how much is off-screen; and the WebKit pseudo-element is never
 * `display: none`. A table that overflows and whose scrollbar has been styled out of existence is
 * content that is there and cannot be reached — the same defect, silently, as the print bug that
 * clipped whatever sat to the right of the viewport.
 */
html,
.app-sidebar,
.app-content,
.tbl-scroll,
.table-scroll,
.jump-list {
  /* The standard properties: Firefox has honoured them for years, Chrome since 121. */
  scrollbar-width: thin;
  scrollbar-color: var(--ink-faint) var(--surface-2);
}

/* And the WebKit pseudo-elements for the versions that predate the standard ones. Both are
   declared because neither covers the whole field on its own, and a browser that understands both
   takes the standard pair — which is why `thin` above and 12px here are not a contradiction. */
html::-webkit-scrollbar,
.app-sidebar::-webkit-scrollbar,
.app-content::-webkit-scrollbar,
.tbl-scroll::-webkit-scrollbar,
.table-scroll::-webkit-scrollbar,
.jump-list::-webkit-scrollbar {
  width: 12px;
  height: 12px;
}

html::-webkit-scrollbar-track,
.app-sidebar::-webkit-scrollbar-track,
.app-content::-webkit-scrollbar-track,
.tbl-scroll::-webkit-scrollbar-track,
.table-scroll::-webkit-scrollbar-track,
.jump-list::-webkit-scrollbar-track {
  background: var(--surface-2);
}

html::-webkit-scrollbar-thumb,
.app-sidebar::-webkit-scrollbar-thumb,
.app-content::-webkit-scrollbar-thumb,
.tbl-scroll::-webkit-scrollbar-thumb,
.table-scroll::-webkit-scrollbar-thumb,
.jump-list::-webkit-scrollbar-thumb {
  background: var(--ink-faint);
  border-radius: 999px;
  /* The border is the track showing through, which is what keeps a 12px bar from reading as a
     slab. It is the track's own colour rather than a made-up one, so it follows the theme. */
  border: 3px solid var(--surface-2);
  background-clip: padding-box;
}

html::-webkit-scrollbar-thumb:hover,
.app-sidebar::-webkit-scrollbar-thumb:hover,
.app-content::-webkit-scrollbar-thumb:hover,
.tbl-scroll::-webkit-scrollbar-thumb:hover,
.table-scroll::-webkit-scrollbar-thumb:hover,
.jump-list::-webkit-scrollbar-thumb:hover {
  background: var(--ink-soft);
  background-clip: padding-box;
}

/* A sheet held over the page that asked for it (0136 D1).
 *
 * The rules BulletinTemplatePreview used to carry as Tailwind utilities, moved here with it: one
 * overlay for the template catalogue and for the gradebook's report card, so the two cannot drift
 * about what a modal looks like.
 */
.sheet-overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  overflow: auto;
  padding: 24px;
  /* Not a token: this is a scrim over whatever the page happens to be, and it has to darken both
     palettes. A surface colour would vanish into the dark theme it is supposed to sit above. */
  background: rgb(0 0 0 / .55);
}

.sheet-overlay-panel {
  width: 100%;
  max-width: 64rem;
  border-radius: var(--r);
  background: var(--surface);
  padding: 16px;
  box-shadow: var(--shadow);
}

.sheet-overlay-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 12px;
}

.sheet-overlay-body { max-width: 100%; }

/* A competency statement where it is a column and not a sentence (0136 D2b).
 *
 * Lives here rather than in either app's sheet because CompetencyStatementText is drawn by the
 * gradebook and by the report card, and a rule copied into two stylesheets is what let the two of
 * them drift the last time.
 *
 * `.stmt` covers the whole-statement case as well as the cut one, so both spellings of the same
 * idea inherit one measure and a header cannot change width depending on which branch it took.
 */
.stmt {
  display: inline-block;
  max-width: 15rem;
  text-transform: none;
  letter-spacing: 0;
}

.stmt-long { position: relative; }

/* The second way to the full text, because the first one — `title` — does not exist on a touch
   device. A button and not a decoration: focusable, labelled, and reached by Tab in the header's
   own order. Sized in `em` against the text it sits after, so it stays proportionate in an 11px
   header and in a 13px body cell. */
.stmt-view {
  display: inline-flex;
  flex: none;
  width: 1em;
  height: 1em;
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink-faint);
  cursor: pointer;
  vertical-align: baseline;
}

.stmt-view svg {
  width: 100%;
  height: 100%;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.stmt-view:hover { color: var(--accent); }

.stmt-view:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

/* A popover, so it is in the top layer rather than in the flow.

   Two things follow from that, and both are the reported bug. It closes when the icon is pressed
   again — the browser toggles it, where the old :focus-within reveal could only ever open, because
   a second tap cannot unfocus the button it just tapped. And it is not clipped: the panel used to
   be `position: absolute` inside `.tbl-scroll`, whose `overflow-x: auto` is a clipping context, so
   on a narrow screen the statement was sliced off at the table's edge.

   Centred rather than anchored to the icon. Anchor positioning would put it under the eye, and is
   not something every browser a school owns can be relied on for yet; a small card in the middle of
   the screen is legible at any width and cannot be cut off by the column it came from. */
.stmt-full {
  margin: auto;
  min-width: 14rem;
  max-width: min(22rem, calc(100vw - 2rem));
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface);
  box-shadow: var(--shadow);
  color: var(--ink);
  font-weight: 400;
  font-size: 13px;
  line-height: 1.45;
  text-align: start;
  text-transform: none;
  letter-spacing: 0;
  white-space: normal;
}

/* Something behind it, so a card floating over a dense grid reads as being in front of the page
   rather than as part of it — and so the tap that dismisses it has somewhere to land. */
.stmt-full::backdrop {
  background: rgb(0 0 0 / .35);
}

/* Report cards and invoices print without the chrome; the drawer's fixed positioning would
   otherwise paint a 300px panel over the first page. */
@media print {
  /* Paper can cross the desktop breakpoint. Once the sidebar is hidden, the main
     document must not occupy the grid's former 236px navigation column. */
  .app-shell { display: block; max-width: none; min-height: 0; }
  .bulletin-card { max-width: none; width: 100%; }
  .bulletin-subjects { min-width: 0; box-shadow: none; }

  .app-sidebar,
  .app-scrim,
  .nav-toggle {
    display: none !important;
  }

  .app-content {
    padding: 0 !important;
  }

  /* An overlay is `position: fixed` over a page that is still in the document, so the naive result
     is a report card printed on top of, or after, the mark sheet it was opened from (0136 D1).
     
     Read the selector as "hide everything in the shell that is neither the overlay, nor inside it,
     nor on the way down to it". The overlay is rendered where the page that owns it sits — Blazor
     has no portal, and moving a rendered node out from under the renderer breaks its diffing — so
     the branch it lives on has to be kept and every other branch dropped. `:has()` is what makes
     that expressible without the component knowing where in the tree it was placed.
     
     `display`, not `visibility`: a hidden-but-laid-out gradebook is thirty blank pages after the
     report card, which is the same defect in a different costume.
     
     The class is put on <html> by the overlay itself, so a page cannot forget it. */
  html.sheet-overlay-open .app-shell *:not(:has(.sheet-overlay)):not(.sheet-overlay, .sheet-overlay *) {
    display: none !important;
  }

  .sheet-overlay {
    position: static;
    display: block;
    overflow: visible;
    padding: 0;
    background: none;
  }

  .sheet-overlay-panel {
    max-width: none;
    padding: 0;
    border-radius: 0;
    box-shadow: none;
    background: none;
  }

  /* The cut is a screen rule (0136 D2b). Paper has no hover and no icon to press, and a truncated
     competency on a report card a family keeps is a defect — so the stub and its control go, and
     the whole statement is printed in their place. This is why the full text is in the document
     rather than only in the `title` attribute: an attribute cannot be handed back. */
  .stmt-cut,
  .stmt-view {
    display: none !important;
  }

  .stmt-full {
    display: inline !important;
    position: static;
    max-width: none;
    padding: 0;
    border: 0;
    box-shadow: none;
    background: none;
    font-size: inherit;
  }

  /* A scrollbar is a screen affordance and has nothing to say on paper. Handed back rather than
     hidden: `display: none` on the pseudo-element is the one thing the rule above refuses, and a
     print stylesheet is not a reason to make an exception nobody would see. */
  html,
  .app-sidebar,
  .app-content,
  .tbl-scroll,
  .table-scroll,
  .jump-list {
    scrollbar-width: auto;
    scrollbar-color: auto;
  }
}

/* An attachment whose bytes are still arriving (ApiImage). It exists to hold the grid cell open:
   in Cogitova Connect an image is fetched over the API rather than by the element itself, and
   without a placeholder a feed of photos reflows under the reader's thumb as each one lands. */
.media-placeholder {
  min-height: 8rem;
  background: var(--surface-2);
}

/* The offline shell (0089 scope item 1) — the chrome a cold start with no network boots into.
   Kept in this sheet rather than in a new one because the worker already caches this file, and a
   second stylesheet is a second thing that has to be on the device before it is needed. */
.offline-shell {
    display: flex;
    flex-direction: column;
    min-height: 100dvh;
    background: var(--ground);
    color: var(--ink);
}

.offline-shell-bar {
    display: flex;
    align-items: center;
    gap: .5rem;
    padding: .75rem 1rem;
    border-bottom: 1px solid var(--line);
    background: var(--surface);
}

.offline-shell-brand {
    font-weight: 700;
    letter-spacing: .01em;
}

.offline-shell-body {
    flex: 1;
    width: 100%;
    max-width: 42rem;
    margin-inline: auto;
    padding: 1.25rem 1rem 2rem;
}

/* The choosing shell and its cards (roadmap 228 D1) — the screen a person meets when their address
   holds more than one identity, and the only thing on it.

   Beside .offline-shell rather than in either app's tailwind.css for the same two reasons that one
   gives, and one more of its own: this screen renders before a tenant is resolved, so it must not
   depend on anything a school configures. */
.choice-shell {
    display: flex;
    flex-direction: column;
    min-height: 100dvh;
    background: var(--ground);
    color: var(--ink);
}

.choice-shell-bar {
    display: flex;
    align-items: center;
    gap: .5rem;
    padding: .75rem 1rem;
    border-bottom: 1px solid var(--line);
    background: var(--surface);
}

.choice-shell-brand {
    font-weight: 700;
    letter-spacing: .01em;
}

.choice-shell-body {
    flex: 1;
    width: 100%;
    margin-inline: auto;
}

/* The whole card is the control, not a button in the corner of one. A row that looks like a choice
   and only responds on a small target is the shape people miss, and this is the one screen where
   missing it means standing still. */
.identity-card {
    display: block;
}

.identity-card-button {
    display: flex;
    inline-size: 100%;
    align-items: center;
    justify-content: space-between;
    gap: .75rem;
    padding: 1rem;
    border: 1px solid var(--line);
    border-radius: .75rem;
    background: var(--surface);
    text-align: start;
    cursor: pointer;
    transition: border-color .12s ease, background .12s ease;
}

.identity-card-button:hover,
.identity-card-button:focus-visible {
    border-color: var(--brand);
    background: var(--ground);
}

.identity-card-body {
    display: flex;
    flex-direction: column;
    gap: .15rem;
    min-inline-size: 0;
}

.identity-card-school {
    font-weight: 600;
    color: var(--ink);
}

.identity-card-where {
    font-size: .875rem;
    color: var(--ink-soft);
}

.identity-card-org {
    margin-inline-start: .25rem;
}

/* The hat, and it is the half that tells two cards naming one school apart — so it is not muted. */
.identity-card-hat {
    flex: none;
    padding: .2rem .6rem;
    border: 1px solid var(--line);
    border-radius: 999px;
    font-size: .8125rem;
    font-weight: 600;
    color: var(--ink);
    white-space: nowrap;
}

/* ── Portraits (0112 R1, ADR-0043 d7) ────────────────────────────────────────────────────────────
 *
 * Here rather than in either app's tailwind.css for the reason at the top of this file, and one
 * more: the empty frame is a *decision* (a neutral square with the person's initials — never a
 * silhouette, never a stock avatar of a face that is not theirs), and a decision copied into two
 * stylesheets is one that will be made differently in each.
 *
 * The caller sets one number, --portrait-size, and the frame keeps 7:9 from it. Every surface then
 * shows the same proportion as the printed card, so nobody is surprised by what the crop did.
 */
.portrait-frame {
  --portrait-size: 4rem;
  inline-size: var(--portrait-size);
  block-size: calc(var(--portrait-size) * 9 / 7);
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  border-radius: 6px;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink-soft);
  /* The letters scale with the frame, so one component serves a 2rem class list and a 9rem profile
     without a size prop per surface. */
  font-size: calc(var(--portrait-size) / 2.6);
  font-weight: 600;
  letter-spacing: .02em;
  user-select: none;
}

.portrait-frame-image {
  inline-size: 100%;
  block-size: 100%;
  /* The stored asset is already 7:9, so this crops nothing in practice. It is here so that a
     legacy row one pixel out is letterboxed rather than stretched — a stretched face is worse than
     a hairline of background. */
  object-fit: cover;
  display: block;
}

.portrait-frame-initials {
  line-height: 1;
}

.portrait-editor-stage {
  inline-size: 100%;
  max-inline-size: 21rem;
  aspect-ratio: 7 / 9;
  border-radius: 6px;
  border: 1px solid var(--line);
  background: #000;
  display: block;
  /* Without this a drag on a phone scrolls the page instead of moving the photograph. */
  touch-action: none;
}

/* The posting editor, in the narrow cell it edits (185).

   Found in a browser and by nothing else. The job-title column is sized to its content, and its
   content across a whole seeded school is an em dash — so opening the editor put two `.field-input`
   boxes, at `inline-size: 100%`, into a column about 20 pixels wide. Both were unusable and the
   employee number's placeholder was invisible. The component tests read the boxes' `value` and
   `maxlength` and pass either way.

   A floor on the stack rather than a width on the column, because the column is one of several the
   table sizes for itself and pinning it would fight that everywhere else. */
.staff-posting-edit {
  min-inline-size: 11rem;
}

/* The same stage, showing something that is not 7:9 (170c).

   Found in a browser and by nothing else: the card designer reused `.portrait-editor-stage` for its
   background crop, and that class carries the *portrait's* proportions. A 420 x 280 canvas was
   displayed in a 7:9 box, so a school choosing a landscape card background dragged a picture
   squashed 93% horizontally, inside a frame that was not the shape of the card it was cropping for.
   Every suite was green: the canvas existed, the shape handed to the module was right, and no C#
   test can see a stylesheet.

   `aspect-ratio: auto` makes the element follow its own `width`/`height` attributes, which the
   component already sets from the card's orientation — so the box is the card's shape by
   construction rather than by a second number kept in step here. */
.crop-stage-free {
  aspect-ratio: auto;
  max-inline-size: 26rem;
}

/* The picker is driven by its <label>, which is the button a person sees. Hidden rather than
   removed: an input with display:none is still focusable in some browsers and still submits, and
   this way the label's own focus ring is the one that shows. */
.portrait-editor-file {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* 0143: the portrait is the control.

   The frame is the button and the badge is the affordance. An avatar with a hidden click target is
   a control nobody finds, so the corner carries a visible mark — shown only to a reader who holds
   people.portrait.manage, because a reader without it is looking at a photograph, not a control. */
.portrait-control {
  display: flex;
  flex-direction: column;
  gap: .5rem;
  flex: none;
  /* The column is the frame's width and no wider, so the identity beside it keeps everything else.
     That is the whole point of the feature: the rare act stops holding two fifths of the header. */
  inline-size: 9rem;
}

.portrait-control-anchor {
  position: relative;
  display: block;
}

.portrait-control-trigger {
  position: relative;
  display: block;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  border-radius: 6px;
  color: inherit;
}

/* The frame already draws its own border; this is the focus ring for the button wrapped around it,
   offset so it reads as a ring rather than a second border. */
.portrait-control-trigger:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.portrait-control-badge {
  position: absolute;
  inset-block-end: -.35rem;
  inset-inline-end: -.35rem;
  inline-size: 1.75rem;
  block-size: 1.75rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  border: 2px solid var(--surface);
  /* An accent/ink pair rather than --ink with a hard-coded white glyph: --ink is near-white in the
     dark theme, so that combination drew a white camera on a white disc — the affordance this
     feature depends on, invisible in exactly one of the two themes. Caught in a browser; every
     assertion in every suite was satisfied that the badge was "there". */
  background: var(--accent);
  color: var(--accent-ink);
  pointer-events: none;
}

.portrait-control-trigger:hover .portrait-control-badge,
.portrait-control-trigger:focus-visible .portrait-control-badge {
  filter: brightness(1.15);
}

/* Sits under the pointer, over the page, and closes the menu on a press anywhere else. The
   keyboard does not need it — Escape closes — so it is aria-hidden in the markup. */
.portrait-menu-backdrop {
  position: fixed;
  inset: 0;
  z-index: 40;
}

.portrait-menu {
  position: absolute;
  z-index: 41;
  inset-block-start: calc(100% + .4rem);
  inset-inline-start: 0;
  min-inline-size: 12.5rem;
  padding: .25rem;
  display: flex;
  flex-direction: column;
  border-radius: 8px;
  border: 1px solid var(--line);
  background: var(--surface);
  box-shadow: 0 8px 24px rgb(0 0 0 / .18);
}

.portrait-menu-item {
  display: block;
  inline-size: 100%;
  padding: .5rem .65rem;
  border: 0;
  border-radius: 5px;
  background: none;
  color: inherit;
  font: inherit;
  text-align: start;
  cursor: pointer;
  white-space: nowrap;
}

.portrait-menu-item:hover,
.portrait-menu-item:focus-visible {
  background: var(--surface-2);
}

.portrait-menu-item:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.portrait-menu-item[disabled] {
  opacity: .55;
  cursor: default;
}

.portrait-menu-item-danger {
  color: var(--bad);
}

/* The school-authored footer (0145). Centred and ruled off, because the PDF renderer centres it in
   the page frame — a footer that is centred on paper and ragged-left on screen is the same document
   disagreeing with itself, which is the defect the sheets and the renderer share a component to
   avoid. */
.doc-footer {
  margin-block-start: 1rem;
  padding-block-start: .5rem;
  border-block-start: 1px solid var(--line);
  text-align: center;
}

/* The letterhead preview on the school's profile (0145). A sheet of paper, deliberately: the
   question it answers is "what will this print", and a preview that looks like the rest of the form
   answers a different one. */
.doc-preview {
  padding: 1rem;
  border-radius: 6px;
  border: 1px solid var(--line);
  background: var(--surface);
}

/* The R2 disclosure and the "no photograph" line. Small, under the frame, and never inside the
   menu — it is addressed to the person the photograph is of. */
.portrait-note {
  margin: 0;
  line-height: 1.35;
  text-wrap: pretty;
}


/* A school's crest or mascot (0112 R4). No aspect ratio — a wide ministry banner and a square
   roundel are both correct — so the frame is a fixed box and the mark fits inside it. */
.brand-mark-frame {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 9rem;
    height: 9rem;
    padding: 0.5rem;
    border: 1px dashed var(--line, rgba(0, 0, 0, 0.15));
    border-radius: 0.5rem;
    background: var(--surface-2, rgba(0, 0, 0, 0.02));
}

.brand-mark-image {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
}

/* Visually hidden, still focusable: the <label> around it is the control people see. */
.brand-mark-file {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* --- identity card proofs (0112 R5/R6) -------------------------------------------------------
   The two faces, side by side and wrapping on a phone. The aspect ratios are ID-1 *with* its
   bleed — 91.6 × 59.98 — because the picture being shown is the artboard, crop marks and all,
   and a frame at 85.6 × 53.98 would letterbox the very margin the proof exists to show. */
.card-proofs {
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
}

.card-proof {
    margin: 0;
    flex: 1 1 18rem;
    min-width: 0;
}

.card-proof-image {
    display: block;
    width: 100%;
    max-width: 22rem;
    height: auto;
    border: 1px solid var(--line, rgba(0, 0, 0, 0.15));
    border-radius: 0.25rem;
    background: #fff;
}

/* The white card a school signs on (160c). The box is the card's own signature box — 26 x 8 mm —
   so what is drawn is the shape that prints, and `touch-action: none` is what stops a phone
   scrolling the page instead of taking the stroke. Never a filled background in the canvas itself:
   the ink has to arrive as a PNG with everything else transparent, or it prints as a white box
   across the card. The white is the frame's, under the canvas. */
.signature-pad {
    display: block;
    width: 100%;
    max-width: 22rem;
    aspect-ratio: 26 / 8;
    touch-action: none;
    cursor: crosshair;
    background: #fff;
    border: 1px dashed var(--line, rgba(0, 0, 0, 0.25));
    border-radius: 0.25rem;
}

/* The stored signature, shown at the card's proportions rather than in the crest's square frame. */
.signature-frame {
    width: 100%;
    max-width: 22rem;
    height: auto;
    aspect-ratio: 26 / 8;
    background: #fff;
}

.card-proof-horizontal { aspect-ratio: 91.6 / 59.98; }
.card-proof-vertical { aspect-ratio: 59.98 / 91.6; max-width: 15rem; }

.card-proof-missing {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1rem;
    text-align: center;
    color: var(--ink-soft);
    background: var(--surface-2, rgba(0, 0, 0, 0.02));
}

/* --- the card on a learner's own screen (0112 R10) -------------------------------------------
   Big, square and pixellated on purpose: image-rendering: pixelated keeps the modules hard-edged
   when the browser scales the raster up, and a QR blurred by bilinear smoothing is a QR another
   phone's camera struggles to read across a desk. */
.my-card-code {
    display: block;
    width: 100%;
    max-width: 16rem;
    margin: 0.5rem auto 0;
    aspect-ratio: 1 / 1;
    image-rendering: pixelated;
    background: #fff;
    border-radius: 0.25rem;
}

/* --- the scanning surface (0112 R10) --------------------------------------------------------- */
.scan-viewfinder {
    display: block;
    width: 100%;
    max-width: 28rem;
    aspect-ratio: 4 / 3;
    background: #000;
    border-radius: 0.5rem;
    object-fit: cover;
}

/* The viewfinder before a camera has been opened (0134 D2).
   Deliberately not black: a black rectangle on this page is the defect this feature was reported
   about, and an empty frame the same colour as the working one is the same rectangle with a button
   in front of it. This one reads as a panel waiting to be used. */
.scan-viewfinder-idle {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.75rem;
    padding: 1rem;
    text-align: center;
    background: var(--surface-2, #f4f4f5);
    border: 1px dashed var(--line, #d4d4d8);
    object-fit: initial;
}

/* The <video> stays in the tree across every state so @ref keeps pointing at the same node — see
   the page. Hidden rather than removed, which is what makes that safe. */
.scan-viewfinder.hidden {
    display: none;
}

.scan-result {
    display: flex;
    gap: 1rem;
    align-items: center;
    padding: 1rem;
    border-radius: 0.5rem;
    margin-top: 1rem;
}

/* Readers, the entry log and self-capture (0112 R10). */

.reader-secret {
    display: block;
    margin-top: .5rem;
    padding: .5rem .75rem;
    border-radius: .375rem;
    background: var(--surface-2, rgba(0, 0, 0, .06));
    /* Wraps rather than scrolls: this is shown once and has to be copyable by hand from a phone. */
    word-break: break-all;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}

.reader-row,
.gate-entry {
    display: flex;
    gap: 1rem;
    align-items: center;
    justify-content: space-between;
}

.reader-row.is-revoked { opacity: .6; }

.gate-entry.is-flagged { border-left: 3px solid var(--warn); }

.session-code { text-align: center; }

.session-code-image {
    /* The code is read from across a classroom, off a projector or a laptop lid. Big, and never
       smoothed — an interpolated QR is one a phone at the back of the room cannot resolve. */
    width: min(20rem, 70vw);
    height: auto;
    image-rendering: pixelated;
}

.session-code-text {
    margin-top: .5rem;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 1.25rem;
    letter-spacing: .08em;
}

.attendance-mix-parts { white-space: nowrap; }

/* The percentage a band threshold is stored as, beside the mark a school types it in (16c).
   Quiet on purpose: the mark is the number being edited, and this is the one being kept. */
.band-percent {
    display: inline-block;
    margin-left: .4rem;
    font-size: 12px;
    color: var(--ink-soft);
    white-space: nowrap;
}

/* The breadcrumb (0123 D4): a real trail, every crumb but the last a link. Replaces the
   product/section sentence, which read the first path segment and so said "Students" three levels
   into a student's results. */
.crumbs {
  min-width: 0;
  overflow: hidden;
}

.crumb-list {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 13px;
  color: var(--ink-soft);
}

.crumb-item {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}

.crumb-item + .crumb-item::before {
  content: "/";
  opacity: 0.45;
}

.crumb-item a {
  color: inherit;
  text-decoration: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.crumb-item a:hover {
  text-decoration: underline;
}

.crumb-current {
  color: var(--ink);
  font-weight: 600;
}

.crumb-current > span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ── The icon set, the jump-to palette and the user menu (0123 phase 18c) ───────────────────── */

/* One weight, no fills, currentColor — so an icon inherits its link's colour and the active state,
   the hover state and dark mode all need no second set. flex:none because a long label in a narrow
   drawer would otherwise squeeze the glyph into an ellipse. */
.nav-icon {
  width: 18px;
  height: 18px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: .85;
}

/* The sprite itself renders nothing. Taken out of flow rather than display:none, because a
   display:none <svg> makes its <symbol>s unreferenceable in some engines — the icons would all
   vanish, everywhere, with nothing in the console to say why. */
.nav-sprite {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

/* The jump-to trigger sits in the row of top-bar buttons and matches them. */
.topbar .jump-open .nav-icon { opacity: 1; }

.jump {
  width: min(38rem, calc(100vw - 2rem));
  max-height: min(32rem, calc(100vh - 6rem));
  padding: 0;
  border: 1px solid var(--line);
  border-radius: var(--r);
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 24px 60px rgb(0 0 0 / .28);
  overflow: hidden;
  /* A dialog is centred by the UA; pulling it up puts it where a palette is expected and, more
     usefully, keeps it clear of a phone's on-screen keyboard. */
  margin-top: 8vh;
}

.jump::backdrop { background: rgb(0 0 0 / .45); }

.jump-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px;
  border-bottom: 1px solid var(--line-soft);
}

.jump-head-icon { opacity: .55; }

.jump-input {
  flex: 1;
  min-width: 0;
  border: 0;
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 15px;
  outline: none;
}

.jump-esc {
  border: 1px solid var(--line);
  border-radius: 7px;
  background: var(--surface-2);
  color: var(--ink-soft);
  font-size: 11.5px;
  font-weight: 650;
  padding: 4px 9px;
  cursor: pointer;
}

.jump-list {
  overflow-y: auto;
  max-height: min(24rem, calc(100vh - 14rem));
  padding: 6px;
}

.jump-group-title {
  font-size: 10.5px;
  text-transform: uppercase;
  letter-spacing: .07em;
  color: var(--ink-faint);
  font-weight: 750;
  padding: 10px 10px 4px;
}

.jump-row {
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 8px 10px;
  border-radius: 8px;
  color: var(--ink-soft);
  text-decoration: none;
  font-size: 13.5px;
  font-weight: 560;
}

.jump-row:hover { text-decoration: none; }

/* The highlight is a class the script moves, not :hover — the keyboard has to be able to drive it,
   and a palette whose arrow keys move nothing visible is a palette nobody uses twice. */
.jump-row-on {
  background: var(--accent);
  color: var(--accent-ink);
}

.jump-row-on .nav-icon { opacity: 1; }

.jump-row-label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Unfiltered, the palette is the sidebar's own headings and each row's section would be said
   twice. Filtered, the headings go and the row has to say it itself. */
.jump-row-where {
  font-size: 11.5px;
  color: var(--ink-faint);
  flex: none;
}

.jump-row-on .jump-row-where { color: var(--accent-ink); opacity: .8; }

.jump-list:not([data-jump-filtering]) .jump-row-where { display: none; }
.jump-list[data-jump-filtering] .jump-group-title { display: none; }

.jump-empty {
  padding: 22px 12px;
  text-align: center;
  color: var(--ink-faint);
  font-size: 13px;
  margin: 0;
}

.jump-foot {
  padding: 8px 14px;
  border-top: 1px solid var(--line-soft);
  background: var(--surface-2);
  color: var(--ink-faint);
  font-size: 11.5px;
}

/* What is waiting on the person reading the page (0177). Sits on the same pill as the other top
   bar chips and reads as a count rather than as a status light: the glyph is quiet, the number is
   the thing, and the accent ring is what makes it findable on a bar of grey pills. */
.work-waiting {
  gap: 6px;
  padding-inline: 8px 10px;
  border-color: var(--accent);
  color: var(--ink);
  cursor: pointer;
}

.work-waiting-glyph { width: 15px; height: 15px; opacity: .72; }

.work-waiting-count {
  min-width: 1.15rem;
  padding: 0 5px;
  border-radius: 999px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 11.5px;
  font-weight: 700;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* One row per queue in the panel: its name on the left, its own count on the right, so a reader
   choosing between two queues is choosing on the number rather than on the order. */
.work-waiting-link { display: flex; align-items: center; justify-content: space-between; gap: 14px; }

.work-waiting-pill {
  padding: 0 6px;
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--ink-soft);
  font-size: 11px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/* The panel is wider than the account menu's because each row carries a queue's name *and* its
   count, and a name that wraps under its own pill stops reading as one row. */
.work-waiting-menu > .user-menu-panel { min-width: 15rem; }

/* The menu behind the signed-in person's chip (0123 N6). <details> again: the top bar is
   statically rendered, so this has to work with no script at all. */
.user-menu { position: relative; }
.user-menu > summary { list-style: none; cursor: pointer; }
.user-menu > summary::-webkit-details-marker { display: none; }
.user-menu > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.user-menu-panel {
  position: absolute;
  top: calc(100% + 6px);
  inset-inline-end: 0;
  z-index: 40;
  min-width: 12rem;
  display: flex;
  flex-direction: column;
  padding: 6px;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface);
  box-shadow: 0 14px 34px rgb(0 0 0 / .22);
}

.user-menu-link {
  padding: 8px 10px;
  border-radius: 7px;
  color: var(--ink-soft);
  text-decoration: none;
  font-size: 13px;
  font-weight: 560;
}

.user-menu-link:hover {
  background: var(--surface-2);
  color: var(--ink);
  text-decoration: none;
}

@media (max-width: 48rem) {
  /* The palette is the phone's only way past a closed drawer to a page it does not list, so the
     trigger stays at every width — unlike the crumb trail, which folds. */
  .jump { margin-top: 4vh; }
}

/* ── The entity picker (0139) ───────────────────────────────────────────────────────────────────
   The portal's first type-to-narrow chooser. Drawn like the jump palette's rows on purpose: the
   two are the same gesture — type, arrow, enter — and a reader who has learned one should not have
   to learn the other. What differs is where it sits: the palette is a dialog over the page, this
   is an overlay anchored to the field it belongs to. */
.combo {
  position: relative;
}

.combo-list {
  position: absolute;
  z-index: 40;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  margin: 0;
  padding: 6px;
  list-style: none;
  max-height: min(20rem, 50vh);
  overflow-y: auto;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 10px;
  box-shadow: 0 12px 32px rgb(0 0 0 / 18%);
}

.combo-row {
  padding: 8px 10px;
  border-radius: 8px;
  color: var(--ink-soft);
  font-size: 13.5px;
  font-weight: 560;
  cursor: pointer;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A class the component moves, not :hover — the keyboard has to be able to drive it, and a picker
   whose arrow keys move nothing visible is one nobody uses twice. Same reasoning as .jump-row-on,
   one block up. */
.combo-row-on {
  background: var(--accent);
  color: var(--accent-ink);
}

/* Neither of these is choosable, and neither should look it. */
.combo-empty,
.combo-more {
  padding: 8px 10px;
  color: var(--ink-faint);
  font-size: 12.5px;
  cursor: default;
}

/* What a multi-select has that a single one does not: the choices, above the box, each with its own
   way out (168c). A chip is the only record of what has been picked — MultiPicker empties its input
   on choosing, unlike PickerCombo, which keeps the label as its record — so these have to read as
   objects rather than as decoration. */
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 6px 3px 10px;
  border-radius: 999px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  color: var(--ink);
  font-size: 12.5px;
  font-weight: 560;
}

/* Sized to the 24px the rest of this sheet uses for a tap target rather than to the glyph: a
   remove control small enough to miss is one a reader hits the wrong chip with. */
.chip-x {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-soft);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
}

.chip-x:hover,
.chip-x:focus-visible {
  background: var(--line);
  color: var(--ink);
}

.combo-more {
  border-top: 1px solid var(--line-soft);
  margin-top: 4px;
  padding-top: 8px;
}

/* 0158e: the three pickers that re-ask the server, grouped so they read as filters rather than as
   more search boxes. A fieldset because that is what a group of related controls is, and its legend
   is the group's name to a screen reader as well as on the screen. Bordered lightly: the point is
   to separate it from the search beside it, not to draw a card around it. */
.roll-filters {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 12px;
  margin: 0;
  padding: 8px 12px 10px;
  border: 1px solid var(--field-line);
  border-radius: 8px;
}

.roll-filters > legend {
  padding: 0 4px;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* The session has ended, and the page behind this cannot be used (0181 181b).
 *
 * A higher z-index than .sheet-overlay on purpose: a report card sheet may well be open when the
 * cookie dies, and the dialog that explains why nothing works has to sit above the thing that
 * stopped working, not underneath it.
 *
 * Centred rather than top-aligned like .sheet-overlay, because this one is three lines and has no
 * scrolling body — and it does not reuse those classes for the same reason the sheet did not reuse
 * the gradebook's: they answer different questions, and one that must never be dismissible should
 * not inherit the styling of one whose bar carries a close button.
 */
.session-ended {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgb(0 0 0 / .55);
}

.session-ended-panel {
  width: 100%;
  max-width: 26rem;
  display: flex;
  flex-direction: column;
  gap: 12px;
  align-items: flex-start;
  border-radius: var(--r);
  background: var(--surface);
  padding: 20px;
  box-shadow: var(--shadow);
}

/* Homework is authored text; a long word must stay inside its card. */
.homework-text { overflow-wrap: anywhere; }
.homework-instructions { white-space: pre-wrap; overflow-wrap: anywhere; }

/* 171b: plain correspondence stays inside its card, including long unbroken text. */
.staff-thread { display: flex; flex-direction: column; gap: 0.75rem; }
.staff-message { max-width: 85%; align-self: flex-start; min-width: 0; }
.staff-message-mine { align-self: flex-end; border-color: var(--accent); }
.staff-message-text { white-space: pre-wrap; overflow-wrap: anywhere; }
@media (max-width: 40rem) { .staff-message { max-width: 100%; } }

/* 233/234: the admission screens use the app's theme and offer a visible way back. */
.choice-shell-bar { justify-content: space-between; flex-wrap: wrap; padding: 1rem 1.5rem; gap: 1rem; }
.choice-shell-brand { display: inline-flex; align-items: center; gap: .6rem; }
/* 235: the language switcher an auth page needs, now that ChoiceLayout is what wraps one instead
   of MainLayout's TopBar. Grouped with the support link so .choice-shell-bar's space-between still
   sees two children — brand on one end, this pair on the other — rather than three drifting apart
   as the bar wraps. */
.choice-shell-bar-actions { display: inline-flex; align-items: center; gap: .85rem; }
/* Fix-up to 235: the five doors an anonymous visitor to /login, /student-sign-in or
   /forgot-password has into the product (AnonymousNavDoors) — sign in, the public directory,
   register a school, claim an account, the learner's own badge sign-in. Empty (and collapses to
   nothing, `:empty` below) once the visitor has signed in, since ChooseSchool/ChooseOrganisation
   render under this same layout and AnonymousNavDoors self-guards against showing them there. A
   row rather than NavMenu's column: there is no sidebar rail on this layout to hold a column in. */
.choice-shell-doors { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem 1rem;
    padding: .6rem 1.5rem; border-bottom: 1px solid var(--line); background: var(--surface); }
.choice-shell-doors:empty { display: none; }
.choice-shell-doors .side-link { padding: .3rem .5rem; }
.choice-brand-mark { display: inline-grid; place-items: center; inline-size: 1.75rem; block-size: 1.75rem;
    border-radius: .5rem; background: var(--accent); color: var(--surface); font-size: .8rem; }
.choice-intro { margin-block-start: 1rem; padding: 1rem 1.25rem; border-inline-start: .25rem solid var(--accent);
    background: var(--surface); border-radius: .5rem; color: var(--ink-soft); line-height: 1.65; }
.choice-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin-block-start: 2rem;
    padding-block-start: 1.25rem; border-block-start: 1px solid var(--line); }
.choice-failure { margin-block: 1.25rem; padding: 1.25rem; border: 1px solid var(--line);
    background: var(--surface); border-radius: .75rem; }
.choice-failure h2 { font-weight: 700; }
.choice-failure p { margin-block: .5rem 1rem; color: var(--ink-soft); }
.choice-shell-body h1, .identity-card-school { overflow-wrap: anywhere; }
.choice-shell a:focus-visible, .choice-shell button:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
@media (max-width: 480px) { .choice-actions > form, .choice-actions button { inline-size: 100%; } }

.family-choice-content { max-width: 72rem; margin-inline: auto; padding: clamp(1rem, 3vw, 2rem); }
.family-choice-content h1 { margin-top: 1rem; }

/* 164c: the storage preference centre's link, in every AppShell footer, and the consent banner
   that renders nothing until a non-essential storage category exists (StorageConsentBanner.razor). */
.app-footer { padding: .75rem 1.5rem 1.25rem; border-top: 1px solid var(--line); font-size: 12px; color: var(--ink-soft); }
.app-footer a { color: inherit; }
.storage-consent-banner { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem 1.25rem;
    padding: .85rem 1.5rem; background: var(--surface-2); border-bottom: 1px solid var(--line); }
.storage-consent-banner-text { flex: 1 1 16rem; margin: 0; color: var(--ink); }
/* Accept and Reject share one row, one gap and the same two button classes every other screen
   uses — ADR-0050's "Reject exactly as prominent, as fast and as few clicks as Accept" is a fact
   about the markup, and StorageConsentBannerTests checks it directly rather than trusting this rule. */
.storage-consent-banner-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
.family-roster { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); gap: 1rem; }
