/* The app's own CSS: everything layered on top of vendored Bootstrap.
 *
 * It sat inline in layout.pug until 2026-08-08 — 24KB of it, in a document served
 * `Cache-Control: no-store`, so every full page load carried the whole thing again. The app
 * does not use hx-boost, so changing section is a full load: the sidebar is a plain <a href>.
 *
 * It is NOT a fallback for the vendored stylesheet failing, which is what the inline version was
 * mistaken for. Nothing here draws a readable page on its own — it is pills, colour-mode remaps,
 * subgrid layouts and touch targets, all of which assume Bootstrap underneath. When the
 * stylesheet failed, this block is exactly what was left, and the result was the wall of text a
 * user once described as "the code". This file and bootstrap.min.css now come from the same
 * origin through the same handler with the same cache policy, so they fail together or not at
 * all, and there is no fallback left to lose.
 *
 * Cached for a week like every other asset, so the staleness a deploy would otherwise cause is
 * handled by the ?v= hash lib/asset-version.ts puts on the URL. Edit this file freely; the hash
 * moves with the bytes.
 */

/* Icons. Size and colour are ATTRIBUTES on the element (see components/icon.pug) so a
   failed stylesheet cannot blank them; only the baseline nudge is here, and it is the
   same -.125em bootstrap-icons used, so nothing shifts against the text it sits in. */
.bi { display: inline-block; vertical-align: -.125em; flex-shrink: 0; }
/* Tab strips scroll sideways instead of wrapping (see components/tabs.pug). The
   scrollbar is hidden because a visible one sitting under the tab row reads as a
   rendering fault; the tabs are still swipeable and keyboard-scrollable. */
.tab-scroll { scrollbar-width: none; -ms-overflow-style: none; }
.tab-scroll::-webkit-scrollbar { display: none; }
/* A tab label must never wrap to two lines inside a scrolling strip. */
.tab-scroll .nav-link { white-space: nowrap; }
/* The strip is meant to scroll sideways only, but it was scrollable DOWNWARDS by
   exactly 1px, and a wheel or trackpad over the tabs spent that 1px before the page
   moved. The strip then sat one row of pixels up inside its own clip and the tabs'
   top border was shaved off, permanently and for no visible reason.
   The 1px is Bootstrap's: .nav-tabs .nav-link carries margin-bottom: -1px so the
   active tab's bottom border lands on the strip's border-bottom and paints it out,
   which is what makes that tab read as open into the content below. The negative
   margin puts the link's border box 1px past the ul's padding box, and on a scroll
   container that is scrollable overflow rather than the harmless kind.
   1px of bottom padding takes the link's border box back inside, leaving no vertical
   range to scroll. The underline has to stop being a border to survive the change: a
   border would now be drawn below the padding, out of reach of the active tab that is
   supposed to cover it. An inset shadow is painted at the padding box's edge instead,
   so it stays on the same row of pixels the border occupied, and children still paint
   over it. The strip's total height is unchanged, so nothing below it moves. */
.tab-scroll {
  padding-bottom: 1px;
  border-bottom: 0;
  box-shadow: inset 0 -1px 0 var(--bs-nav-tabs-border-color);
}

/* Touch targets. A btn-sm is 31px tall — comfortable with a mouse, a coin-flip with
   a thumb, and a teacher taps four of them per child through a whole register.
   Anything marked .touch-target gets 44px (the iOS minimum) on phones only, so the
   desktop density is untouched. */
@media (max-width: 575.98px) {
  .touch-target,
  .touch-target .btn,
  .touch-target .form-control,
  .touch-target .form-select { min-height: 44px; }
  /* A checkbox ignores min-height — it needs real dimensions. The default 1em box
     is about 16px, which is half a fingertip. */
  .touch-target .form-check-input { width: 1.4rem; height: 1.4rem; }
}

/* The colour-mode toggle: a borderless glyph sitting at the end of the row, next to
   Profile and Sign out. Declared directly rather than through --bs-btn-* variables:
   the same variable approach silently failed on .btn-outline-primary elsewhere in
   this file, and a two-line rule is not worth the risk. Every colour is a theme
   variable, so it follows the mode.

   Hover swaps the glyph for its FILLED twin rather than painting a background behind
   it. A background chip made the toggle read as a third button competing with the two
   real ones beside it; the weight change says "this responds to you" using the icon
   itself. Both weights are in the markup and CSS picks one, so the hover state costs
   no script and cannot lag the pointer. */
.theme-toggle {
  border: 0;
  background-color: transparent;
  color: var(--bs-secondary-color);
  font-size: 1.25rem;
  line-height: 1;
  padding: .25rem .5rem;
}
.theme-toggle:hover,
.theme-toggle:focus-visible {
  background-color: transparent;
  color: var(--bs-body-color);
}
.theme-toggle .theme-icon-fill { display: none; }
.theme-toggle:hover .theme-icon-outline,
.theme-toggle:focus-visible .theme-icon-outline { display: none; }
.theme-toggle:hover .theme-icon-fill,
.theme-toggle:focus-visible .theme-icon-fill { display: inline-block; }

/* Logo variants (components/brand.pug emits both). Light is the default, so a page
   rendered before the theme is known shows the same logo as light mode rather than
   nothing. Printing forces the light one: paper is white. */
.logo-dark { display: none; }
[data-bs-theme="dark"] .logo-light { display: none; }
[data-bs-theme="dark"] .logo-dark { display: inline-block; }
@media print {
  .logo-dark { display: none !important; }
  .logo-light { display: inline-block !important; }
  /* Pairs with the beforeprint theme flip in app.js: that re-themes Bootstrap, this
     tells the UA to render form controls, scrollbars and the canvas light too. Stated
     app-wide here rather than repeated in each page's print block. */
  :root { color-scheme: light; }

  /* PRINT ONE THING: whatever carries .print-root, and nothing else on the page.
     Every printable surface marks its own document — the report card, the weekly
     schedule, the receipt, the term contract — and the app chrome around it goes.

     The chrome is REMOVED from flow rather than hidden. visibility:hidden leaves the
     full-height sidebar and page occupying space, so the PDF spills onto blank extra
     sheets. What survives is the root, its ancestors (kept by :has so the document is
     still in the document), and its contents.

     This was written twice — in reportprint.pug and scheduleprint.pug, once per id —
     and was simply missing from the receipt and the contract, whose print blocks hid
     the button and nothing else. Logged out those two pages have no chrome, so they
     looked right; opened by a signed-in admin they printed the sidebar down the side
     of a financial document. Uniform now: mark the root, get the behaviour.

     ⚠️ THE body:has() GUARD IS LOAD-BEARING. Without it, a page carrying no print root
     matches nothing to keep and the first rule hides the entire page — every ordinary
     screen in the app would print blank. */
  body:has(.print-root) *:not(:has(.print-root)):not(.print-root):not(.print-root *) { display: none !important; }
  /* Self-guarding: with no print root on the page this matches nothing. */
  :has(.print-root) { margin: 0 !important; padding: 0 !important; }

  /* Working controls — pickers, Print buttons, back-links, the remark editors — are not
     part of any printed document. Wherever they sit, inside a print root or beside one. */
  .no-print, .no-print * { display: none !important; }
}

/* PILLS — the app's one status-chip style (components/pills.pug).
   Every chip was previously a Bootstrap .badge with a solid text-bg-* fill, picked
   per call site: some pill-shaped and some not, some carrying raw SCREAMING_CASE enum
   values and some humanised, and three different tones for the same meaning across
   pages. Solid fills also fought dark mode — text-bg-light stays pale and glares
   against a dark card.

   One outlined shape fixes all of it. The tone still encodes meaning; it just does so
   with a border and text colour instead of a slab of fill, which reads calmer in a
   dense list and lets the same rule serve both modes. Every value is a Bootstrap 5.3
   theme variable, and the *-border-subtle / *-text-emphasis pairs are mode-aware by
   design — they lighten in dark mode — so the pills follow the theme rather than
   needing a second set of overrides. */
.pill {
  display: inline-flex;
  align-items: center;
  gap: .3rem;
  padding: .2em .65em;
  border: 1px solid var(--pill-border);
  border-radius: 50rem;
  background-color: transparent;
  color: var(--pill-color);
  font-size: .75rem;
  font-weight: 500;
  line-height: 1.5;
  white-space: nowrap;
  vertical-align: middle;
  /* Pills started as labels and are now also links (the filter-bar presets). A pill is
     a shape, and an underline running through that shape reads as a mistake, so it is
     dropped here rather than at each call site — the next pill-as-link inherits it. */
  text-decoration: none;
}
/* The two modes need different colour, and for the same underlying reason:
   Bootstrap's *-text-emphasis is a TINT in dark mode and a 60% SHADE in light. The
   tints are right on a dark page — bright, and each hue still recognisably itself.
   The shades are not. Measured against the white card they run 8:1 to 13.65:1, far
   past the 4.5:1 a 12px label needs, and that much darkening takes the hue with it:
   navy, bottle green and maroon at 1px all read as "dark grey", which is no colour
   coding at all. That is what made light mode drab.

   Light mode gets its own set, landing just above the threshold rather than far past
   it, and the border is allowed to be more saturated than the label. A 1px border is
   a non-text indicator and needs only 3:1, so it can carry the brand colour itself
   while the text one step deeper stays comfortably readable — and on a chip this
   small the border is most of what the eye actually registers, so that is where the
   vividness comes from. The ratios below are against #fff and were measured.

   Warning is the one hue that had to move. Bootstrap's yellow cannot be read at 12px
   on white at any lightness that still looks yellow — #ffc107 is 1.63:1, and the
   darkest yellow that does pass is an olive brown. Amber keeps both the warmth and
   the meaning while staying legible.

   These live as variables rather than inside the pill rules because a pill is not the
   only shape these five meanings take: the tuition summary states the same things as
   coloured figures, and it was reaching for Bootstrap's utilities directly, so its
   "currently due" was still the olive .text-warning-emphasis at 7.99:1 while the pill
   beside it had moved to amber. Two systems drifting is what the pill rework was
   meant to end, so both now read from here. */
:root {
  --sem-primary-line: #0d6efd; --sem-primary-text: #0a58ca; /* blue   4.50:1 / 6.44:1 */
  --sem-info-line:    #0a94ad; --sem-info-text:    #087990; /* cyan   3.59:1 / 5.06:1 */
  --sem-success-line: #198754; --sem-success-text: #146c43; /* green  4.53:1 / 6.45:1 */
  --sem-warning-line: #cf6d0a; --sem-warning-text: #b45309; /* amber  3.59:1 / 5.02:1 */
  --sem-danger-line:  #dc3545; --sem-danger-text:  #b02a37; /* red    4.53:1 / 6.50:1 */
}
/* Dark mode is left exactly as it was: the tints are already bright and separable
   there, and the light-mode set would be far too dark against that background. The
   line and text values collapse to one because on a dark ground there is no need to
   hold the label back from the border. */
[data-bs-theme="dark"] {
  --sem-primary-line: var(--bs-primary-text-emphasis); --sem-primary-text: var(--bs-primary-text-emphasis);
  --sem-info-line:    var(--bs-info-text-emphasis);    --sem-info-text:    var(--bs-info-text-emphasis);
  --sem-success-line: var(--bs-success-text-emphasis); --sem-success-text: var(--bs-success-text-emphasis);
  --sem-warning-line: var(--bs-warning-text-emphasis); --sem-warning-text: var(--bs-warning-text-emphasis);
  --sem-danger-line:  var(--bs-danger-text-emphasis);  --sem-danger-text:  var(--bs-danger-text-emphasis);
}

/* Neutral is deliberately outside that set: it is the majority tone (houses, grades,
   class types — labels, not statuses) and matching the borders of the cards it sits
   inside is what lets the coloured ones carry the eye. */
.pill-neutral { --pill-border: var(--bs-border-color);  --pill-color: var(--bs-secondary-color); }
.pill-primary { --pill-border: var(--sem-primary-line); --pill-color: var(--sem-primary-text); }
.pill-info    { --pill-border: var(--sem-info-line);    --pill-color: var(--sem-info-text); }
.pill-success { --pill-border: var(--sem-success-line); --pill-color: var(--sem-success-text); }
.pill-warning { --pill-border: var(--sem-warning-line); --pill-color: var(--sem-warning-text); }
.pill-danger  { --pill-border: var(--sem-danger-line);  --pill-color: var(--sem-danger-text); }

/* The same five meanings as words or icons. Named apart from Bootstrap's .text-*
   deliberately: a call site then says which system it is buying into, and remapping
   one can never quietly fight the other. */
.text-sem-primary { color: var(--sem-primary-text); }
.text-sem-info    { color: var(--sem-info-text); }
.text-sem-success { color: var(--sem-success-text); }
.text-sem-warning { color: var(--sem-warning-text); }
.text-sem-danger  { color: var(--sem-danger-text); }

/* The student list (students/_results.pug) is laid out as ONE grid rather than a
   stack of rows that each size themselves. A pill is as wide as the value inside it
   — Rose against Loustric, Local against Not enrolled — so in a right-aligned row
   every row put its two pills somewhere slightly different, and the eye reads that
   as a broken column. Sharing a grid makes each column as wide as the widest value
   anywhere in it, so the pills line up without hard-coding a width that a new house
   or a reworded category would quietly break. Every row spans the full grid and
   repeats its columns as a subgrid, keeping its own padding, border and hover. */
.list-group.student-rows { display: grid; grid-template-columns: 1fr repeat(3, auto); }
.student-rows > .list-group-item {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: subgrid;
  align-items: center;
  column-gap: .5rem;
}
.student-rows-name  { grid-column: 1; }
.student-rows-flag  { grid-column: 2; }
.student-rows-house { grid-column: 3; }
.student-rows-rate  { grid-column: 4; }
.student-rows-meta  { grid-column: 1 / -1; }
/* A grid item stretches by default, so the pill takes the whole column and it is the
   pill's own edges that align rather than an invisible cell around it. The label then
   has room to spare, so it re-centres instead of hugging the left of the wider shape. */
.student-rows-flag, .student-rows-house, .student-rows-rate { justify-content: center; }

/* The enrollments grid (schoolyears/_enrollment_results.pug), on the same subgrid trick
   as the student list above and for the same reason: it is a .list-group now rather than
   a <table>, so that the L2 school year reads as the same app as the six L1 lists, and
   something still has to make the rate and the term cells line up down the page. That is
   all a table was doing for it.

   What did NOT come across is the whole-row anchor. Every L1 row is one link with one
   destination, which is what .list-group-item-action styles; a row here carries six
   independent controls — a select-checkbox, a name that opens the profile modal, the rate
   select, the house select and one button per term — and interactive content cannot live
   inside an <a>. So these rows are plain .list-group-items with no hover: a row that looks
   clickable and answers in only six small places teaches the reader something untrue.

   No grid-column is declared. Every row has exactly the same cells in the same order and
   none of them is conditional, so auto-placement puts them in tracks 1..n by itself — the
   student list needs explicit columns only because one of its pills is conditional and a
   row missing it would slide the rest over. The classes below are hooks for the phone
   rule, not placement.

   repeat(3, …) is the three terms of a school year. A year with fewer leaves an empty
   track; one with more auto-creates an implicit column that still lays out, just without
   the shared width.

   This paragraph used to end with a rule: keep the prose in this file free of any word a
   page-wide test might search for. That was real while the CSS lived inside layout.pug and
   rendered into EVERY page, where `expect(render()).not.toContain("X")` would fail on a
   comment no reader ever sees — it had already happened twice. Moving the file out ended it.
   Write plainly here; nothing in this file reaches a rendered page. */
.list-group.enrollment-rows {
  display: grid;
  /* checkbox | name+class | house | rate | T1 T2 T3 — SEVEN tracks for the row's seven cells,
     and the two counts must move together. Grid items never wrap, so a cell with no track
     does not overflow its column: auto-placement drops it onto an implicit SECOND ROW, at the
     far left, under the checkbox. That is what one missing track looks like, and it looks like
     a broken row rather than a missing column.

     The house track is narrower than the rate's 9rem because its four options are one short
     word each ("Loustric" is the longest), where the rate's run to "Caricom rate". */
  grid-template-columns: auto minmax(0, 1fr) 7.5rem 9rem repeat(3, auto);
}
.enrollment-rows > .list-group-item {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: subgrid;
  align-items: center;
  column-gap: .75rem;
}

/* Seven columns do not fit a phone, and .table-responsive is no longer there to scroll
   them sideways — which was never a good answer here anyway, since scrolling the header
   out of view is what made the T1/T2/T3 labels unreadable in the first place. The row
   unwinds into a wrapping flex line instead: the checkbox keeps the name beside it, and
   the two selects and the term pills drop underneath. */
@media (max-width: 767.98px) {
  .list-group.enrollment-rows { display: block; }
  .enrollment-rows > .list-group-item {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .5rem;
  }
  .enrollment-rows-name { flex: 1 1 auto; min-width: 0; }
  /* 100% forces the break, so the house starts the second line, the rate the third, and the
     term pills follow rather than being orphaned one per row.
     Sharing one line between the two selects was tried and dropped: it needs a basis under
     100%, which stops FORCING the break and only makes it likely — a short enough name and
     the first select rides back up onto the name's line. The original 100% was chosen to be
     deterministic, and two predictable lines beat one that is usually right. */
  .enrollment-rows-house,
  .enrollment-rows-rate { flex: 1 0 100%; }
}

/* The Add students grid (schoolyears/_available_results.pug) — the THIRD user of the subgrid
   trick, after the L1 student list and the enrollments grid beside this one. It was the last
   list on the school year still drawn as a <table>, which is the whole of what made the two
   tabs of one page read as two different pages.

   FOUR tracks for four cells: checkbox | name+family | house | rate. Same rule as the
   enrollments grid — grid items never wrap, so a cell with no track is not squeezed, it is
   placed on an implicit SECOND ROW at the far left under the checkbox, which reads as a broken
   row rather than as a missing column. The two counts move together.

   No repeat() here: this list has no per-term cells, so its track count is fixed outright
   rather than sized for a three-term year.

   Rate is 11rem, the width its own <th> carried; house is 7.5rem, matching the enrollments
   grid's. Neither is a select the reader can widen. */
.list-group.available-rows {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) 7.5rem 11rem;
}
.available-rows > .list-group-item {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: subgrid;
  align-items: center;
  column-gap: .75rem;
}
/* Same phone treatment as the enrollments grid: four columns do not fit, and the row unwinds
   into a wrapping flex line rather than scrolling sideways. The checkbox keeps the name beside
   it and the rate takes a line of its own; 100% FORCES that break rather than making it likely,
   which is the lesson the enrollments rule records at length. The house pill is small enough to
   ride along beside the name instead of claiming a line. */
@media (max-width: 767.98px) {
  .list-group.available-rows { display: block; }
  .available-rows > .list-group-item {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: .5rem;
  }
  .available-rows-name { flex: 1 1 auto; min-width: 0; }
  .available-rows-rate { flex: 1 0 100%; }
}

/* A status dot: a flag small enough to sit in a dense list row without competing with
   the row's own text, for a fact that is worth spotting but not worth a whole pill
   (the admissions list's paid fee). Never the only carrier of its meaning — the
   mixin requires a label, which it exposes to assistive tech and as a tooltip.
   --bs-success is a mid green tuned for a white page; against the dark background it
   drops to about 2.6:1, under the 3:1 that non-text indicators need, so dark mode
   swaps in the lighter emphasis tone. */
.status-dot {
  display: inline-block;
  width: .5rem;
  height: .5rem;
  border-radius: 50%;
  flex-shrink: 0;
  background-color: var(--dot-color);
}
.status-dot-success { --dot-color: var(--bs-success); }
.status-dot-danger  { --dot-color: var(--bs-danger); }
[data-bs-theme="dark"] .status-dot-success { --dot-color: var(--bs-success-text-emphasis); }
[data-bs-theme="dark"] .status-dot-danger  { --dot-color: var(--bs-danger-text-emphasis); }

/* TOOLTIPS. Bootstrap's own light/dark handling is left alone: --bs-tooltip-bg is
   var(--bs-emphasis-color) and --bs-tooltip-color is var(--bs-body-bg), both of which
   flip with data-bs-theme, so the tip is always the inverse of the page and separates
   from it in either mode. This is the rare component that needs no dark-mode patch.

   white-space: pre-line so a "\n" in the text renders as a line break. Native tooltips
   break on newlines for free; a Bootstrap tip puts the text in an ordinary text node
   where the run collapses to a space, which would turn the whole-school timetable chip's
   three fields into one run-on line.

   The width is Bootstrap's 200px raised: that default wraps a three-line tooltip into
   six. Opacity goes to 1 — the default 0.9 lets a dense table show through the tip. */
.tooltip {
  --bs-tooltip-max-width: 22rem;
  --bs-tooltip-opacity: 1;
}
.tooltip-inner { white-space: pre-line; text-align: left; }

/* The confirmation dialog sits above an ordinary modal. Nothing raises one from inside
   another today, so this is not stacking support — Bootstrap has no real answer for two
   modals at once and the backdrop would still belong to the first. It only means that if
   a confirm is ever wired into a modal, the question is on top and readable rather than
   behind the thing that asked it. */
#confirm-modal { z-index: 1070; }

/* Semantic text in dark mode. Bootstrap's .text-danger / .text-success are the base
   brand colours, tuned against a white page: on the dark background they measure
   about 3.4:1, under the 4.5:1 minimum. Bootstrap 5.3 ships mode-aware companions
   (--bs-*-text-emphasis, lighter in dark) — remapping the utilities to those fixes
   every use at once rather than editing dozens of templates, and light mode keeps
   the original colours. This matters most on /family/billing, where the red figures
   are a parent's past-due balance. */
[data-bs-theme="dark"] .text-danger  { color: var(--bs-danger-text-emphasis) !important; }
[data-bs-theme="dark"] .text-success { color: var(--bs-success-text-emphasis) !important; }
[data-bs-theme="dark"] .text-warning { color: var(--bs-warning-text-emphasis) !important; }
[data-bs-theme="dark"] .text-primary { color: var(--bs-primary-text-emphasis) !important; }

/* Outline buttons are a known gap in Bootstrap's dark mode: the label keeps its
   light-mode colour and lands around 2.8:1. Setting the button's own CSS variables
   rather than `color` directly means the hover and active states — where the fill
   becomes solid and the label flips — keep working as designed. */
[data-bs-theme="dark"] .btn-outline-secondary {
  --bs-btn-color: var(--bs-body-color);
  --bs-btn-border-color: var(--bs-border-color);
}
[data-bs-theme="dark"] .btn-outline-primary {
  --bs-btn-color: var(--bs-primary-text-emphasis);
  --bs-btn-border-color: var(--bs-primary-text-emphasis);
}
/* The variable alone did not take on .btn-outline-primary — measured 3.43:1 with
   --bs-btn-color correctly resolved to the lighter blue, and even an inline override
   did not move the computed colour. Bootstrap's stylesheet is cross-origin so the
   winning rule could not be inspected. Declaring `color` directly settles it; the
   :not() guards leave hover and active alone, where the fill goes solid and the
   label is meant to flip to white. */
[data-bs-theme="dark"] .btn-outline-primary:not(:hover):not(:active):not(.active) {
  color: var(--bs-primary-text-emphasis);
}
[data-bs-theme="dark"] .btn-outline-danger {
  --bs-btn-color: var(--bs-danger-text-emphasis);
  --bs-btn-border-color: var(--bs-danger-text-emphasis);
}
/* Same direct declaration as .btn-outline-primary above, for the same reason: this
   rule set only the variables, which is the form measured NOT to take. It was left
   that way because nothing red sat on a dark page often enough to notice — the work
   queue's expiring-offer button now does. Unlike the primary case this one was
   reasoned from that precedent rather than measured, so it is worth a look. */
[data-bs-theme="dark"] .btn-outline-danger:not(:hover):not(:active):not(.active) {
  color: var(--bs-danger-text-emphasis);
}

/* vanillajs-datepicker 1.3.4 predates Bootstrap's colour modes and hardcodes its
   palette (#fff panels, #f8f9fa headers, #000 text), so in dark mode the calendar
   opens as a white card. It is loaded from a CDN, so it cannot be fixed at source —
   these overrides re-point it at the theme variables. Scoped to the dark attribute
   so light mode keeps the upstream styling untouched. */
[data-bs-theme="dark"] .datepicker-picker,
[data-bs-theme="dark"] .datepicker-controls .btn {
  background-color: var(--bs-body-bg);
  color: var(--bs-body-color);
  border-color: var(--bs-border-color);
}
[data-bs-theme="dark"] .datepicker-title,
[data-bs-theme="dark"] .datepicker-footer { background-color: var(--bs-tertiary-bg); }
[data-bs-theme="dark"] .datepicker-controls .btn:hover,
[data-bs-theme="dark"] .datepicker-cell:not(.disabled):not(.selected):hover,
[data-bs-theme="dark"] .datepicker-cell.focused:not(.selected) {
  background-color: var(--bs-secondary-bg);
  color: var(--bs-body-color);
}
[data-bs-theme="dark"] .datepicker-cell.disabled { color: var(--bs-secondary-color); }
[data-bs-theme="dark"] .datepicker-cell.prev:not(.disabled),
[data-bs-theme="dark"] .datepicker-cell.next:not(.disabled) { color: var(--bs-secondary-color); }
[data-bs-theme="dark"] .datepicker .week { color: var(--bs-secondary-color); }

/* Where a failed MUTATION reports itself (the beforeSwap listener in app.js, into
   #error-toast). Fixed in the corner rather than in the page flow, because the whole
   point is to leave the form that was just submitted untouched.
   z-index sits on Bootstrap's toast layer, above a modal's 1055: the payment modal
   is one of the places a rejected POST lands, and a message that renders behind the
   backdrop is the same dead click in a new costume. */
.error-toast {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  z-index: 1090;
  width: min(420px, calc(100vw - 2rem));
}
.error-toast:empty { display: none; }

/* Bootstrap ships w-100 but no responsive width utilities: full width on a phone,
   natural width once there is room beside it. */
.w-sm-auto { width: 100%; }
@media (min-width: 576px) { .w-sm-auto { width: auto !important; } }

/* Form controls on phones. Bootstrap's default is 38px and form-control-sm is 31px;
   both are fine with a mouse and cramped under a thumb. The admission form alone is
   four steps, dozens of fields and five uploads, filled in by families on their
   phones — this is the longest sustained typing anyone does in the app.
   Unqualified rather than opt-in: every form benefits, and the dense grids already
   land on the same 44px through .touch-target, so the two agree. */
@media (max-width: 575.98px) {
  .form-control,
  .form-select,
  .form-control-sm,
  .form-select-sm { min-height: 44px; }
  /* Checkboxes need real dimensions, not min-height. Consent boxes especially —
     the admission acknowledgment is a binding agreement and deserves a target the
     reader can hit deliberately rather than by luck. */
  .form-check-input { width: 1.25rem; height: 1.25rem; }
  /* A file input's button is its tap target; keep it comfortable too. */
  .form-control[type="file"]::file-selector-button { min-height: 38px; }

  /* Everything else you tap. Measuring the parent portal made the case: the
     hamburger was 31px, the offcanvas Close 32, Profile and Sign out 31, "View
     billing" 31, a child's row 41 — the chrome every mobile user touches on every
     page, plus the content links. Fixing these per view would be endless
     whack-a-mole across 129 templates, so it is one rule here.
     inline-flex because min-height alone leaves the label stuck to the top of a
     taller button; the buttons must stay vertically centred. */
  .btn,
  .btn-close,
  .list-group-item-action,
  .nav-link,
  .page-link { min-height: 44px; }
  .btn,
  .btn-close { display: inline-flex; align-items: center; justify-content: center; }
}

/* MY WEEK — the teacher's bell-grid on home.
   ------------------------------------------------------------------
   A CSS grid rather than a <table>: break and lunch are rows that span every day column,
   and a table would need colspan on some rows and not others. Every row declares the SAME
   template, which is what keeps the columns aligned without a table's help.

   Colours are --bs-* throughout, so light and dark both come from Bootstrap's own palette
   and there is no second set of values here to drift. */
.tt-row {
  display: grid;
  grid-template-columns: 5rem repeat(5, minmax(0, 1fr));
  gap: .25rem;
  align-items: stretch;
}

.tt-time {
  padding: .35rem .25rem;
  text-align: right;
  line-height: 1.15;
  /* Times line up down the gutter instead of jittering on the width of a 1 against a 0,
     which also makes the column's width predictable rather than font-dependent. */
  font-variant-numeric: tabular-nums;
}

/* Desktop: wide enough for "08:00 – 08:45" on ONE line. The narrow gutter below wrapped it
   onto two, which made every class row taller than the thing in it.
   7.5rem is 120px against roughly 107px of text at .small — margin, not a guess that fits
   exactly. `nowrap` is safe at that width and states the intent; a school with a longer time
   format would overflow visibly rather than silently re-wrapping and undoing this. */
@media (min-width: 768px) {
  .tt-row { grid-template-columns: 7.5rem repeat(5, minmax(0, 1fr)); }
  .tt-time .small { white-space: nowrap; }
}

.tt-col {
  padding: .2rem;
  border-radius: var(--bs-border-radius-sm);
  min-height: 2.25rem;
}

/* The head row's columns carry the date and the day's state, so they need no fill. */
.tt-head .tt-col { min-height: 0; padding-bottom: .35rem; }

/* Break and lunch. Filled rather than left blank, because a gap in a timetable reads as
   free time and the whole point of showing these is that they are not. */
.tt-break .tt-col,
.tt-break .tt-time { background: var(--bs-tertiary-bg); border-radius: var(--bs-border-radius-sm); }
.tt-break .tt-col { min-height: 1.75rem; }

/* Today's column, marked the whole height rather than only in the heading — a tint the eye
   can follow down beats a label it has to find again on every row. Kept faint: this band
   already uses colour to mean "a register is outstanding". */
.tt-today { background: var(--bs-primary-bg-subtle); }
.tt-break .tt-today { background: var(--bs-primary-bg-subtle); }

.tt-chip {
  display: block;
  padding: .2rem .4rem;
  border: 1px solid var(--bs-border-color);
  border-radius: var(--bs-border-radius-sm);
  background: var(--bs-body-bg);
  font-size: .8rem;
  line-height: 1.2;
}
.tt-chip + .tt-chip { margin-top: .2rem; }

/* Five columns plus a gutter do not fit a phone, and squeezing them makes every subject an
   ellipsis. The grid keeps its width and the week scrolls sideways instead — the shape stays
   a week, which is the thing being read. */
.tt-week {
  overflow-x: auto;
  /* Only the grid scrolls; the card around it must not. */
  max-width: 100%;
}
.tt-week .tt-row { min-width: 34rem; }

/* THE WEEKLY TIMETABLE TABLE — components/timetable-grid.pug, shared by the class L2's
   Timetable tab, the student record's schedule panel and the parent portal. Distinct from the
   .tt-* rules above, which style the CSS-grid week on the home page; this one is a real table
   because its rows are bell bands and its cells hold several classes.

   COLUMN RULES. Bootstrap's .table draws horizontal borders only, which is right for a list and
   wrong for a grid: a timetable is read across a row (what happens at 10:00) as often as down a
   column (what happens on Tuesday), and with no verticals the eye has nothing to follow from a
   time to the right day. The last cell in each row skips its rule so the grid does not double up
   against the border of the container it sits in. */
.timetable-grid {
  /* FULLY ENCLOSED. Bootstrap's .table draws a bottom border per cell and nothing else, so the
     grid was open at the top (above the weekday headings) and down both sides — it read as a
     list that happened to have rules in it rather than as a grid. The border goes on the TABLE
     rather than on the outer cells: under border-collapse the two meet as one line, where
     per-cell edges would double up wherever they met a neighbour's. */
  border: var(--bs-border-width) solid var(--bs-border-color);

  /* EQUAL DAYS. Bootstrap's .table leaves table-layout at `auto`, which sizes each column to
     its own content — so the day widths came out ranked by the longest subject name that
     happened to land in each one. Monday and Tuesday top out at "Language Arts" and "Social
     Studies"; Wednesday through Friday carry "Spelling and Vocabulary" and
     "Comprehension/Literature". The grid therefore widened left to right, which reads as a
     layout bug but was the timetable's own vocabulary showing through, and it moved whenever
     a class was rescheduled. A week is a set of equal days and the grid has to say so.

     `fixed` takes every width from the first row alone: the Time cell keeps its 7rem and the
     days split what is left, evenly, however many of them there are and whatever the subjects
     are called. It also makes the .text-truncate already on the subject and teacher names
     behave the same way at every width — under `auto` a cell grows to fit wherever there is
     room, so truncation only ever appeared once the table was squeezed below its content. */
  table-layout: fixed;

  /* ...and a floor under it, because `fixed` honours width:100% all the way down.
     .table-responsive scrolls a table wider than the phone; it cannot rescue one that has
     agreed to BE 375px, where each day is ~50px and the .text-nowrap time printed under every
     class ("08:00–08:45") spills across the cell rules instead of wrapping. The week grid
     above sets the same kind of floor for the same reason (.tt-week .tt-row, 34rem); this one
     is wider because it also carries the 7rem time column those rows do not. Paper is
     unaffected — scheduleprint.pug forces landscape and opens the overflow container, and the
     page is wider than this. */
  min-width: 44rem;
}
.timetable-grid > :not(caption) > * > * {
  border-right: var(--bs-border-width) solid var(--bs-border-color);
}
/* The last column's own rule comes off: the table's border above already draws that edge, and
   two lines at the same place is how a 1px grid ends up looking 2px on one side. */
.timetable-grid > :not(caption) > * > *:last-child {
  border-right: 0;
}

/* BREAK AND LUNCH, tinted rather than left to read as ordinary empty rows.
   --bs-table-bg on the ROW, not a background on the cells, and not .table-light: the
   contextual table variants are FIXED pairs rather than theme-mapped, which is how four rows of
   the report card once came out as a near-white band of black text inside a dark card. Setting
   the variable lets the cells keep the table's own themed colour. --bs-tertiary-bg is the same
   token the home week grid's .tt-break uses, so the two surfaces tint identically. */
.timetable-grid > tbody > tr.tt-band-break {
  --bs-table-bg: var(--bs-tertiary-bg);
}

/* A FLOATING DATE FIELD SAYS SO WHEN IT IS EMPTY.
   Bootstrap floats a label only when the control is focused or has a value, and it paints
   ::placeholder transparent throughout — so an empty date field showed its name sitting large
   in the middle of the box and nothing else. That reads as a field waiting to be typed into
   rather than as a date nobody has set, which on this form is a real difference: a midterm
   cutoff, three deadlines a term and four onboarding defaults are all legitimately empty.
   The old layout answered it with the "yyyy-mm-dd" placeholder, which the floating pattern
   took away.
   So a datepicker inside .form-floating floats its label UNCONDITIONALLY and shows its
   placeholder, which the mixin sets to "No date set". A filled field looks exactly as it did;
   an empty one now says which it is. Scoped to [data-datepicker] because a date is the only
   control here where blank is ambiguous — an empty text box is self-evidently empty.
   The declarations are Bootstrap's own floated-state ones, copied rather than approximated so
   the two states of a field are pixel-identical. app.css loads after bootstrap.min.css, so
   these win at equal specificity.
   Every token here — --bs-secondary-color, --bs-body-bg, --bs-body-color-rgb — is redefined
   under [data-bs-theme=dark], so this follows the theme rather than pinning a light value into
   a dark card, which is the mistake the report card's summary rows once shipped. */
.form-floating > .form-control[data-datepicker] {
  padding-top: 1.625rem;
  padding-bottom: 0.625rem;
}
.form-floating > .form-control[data-datepicker] ~ label {
  color: rgba(var(--bs-body-color-rgb), 0.65);
  transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);
}
/* The chip of page background behind the floated label, so it does not sit on the input's own
   edge. Bootstrap draws it for every floated label and it would be missing from these. */
.form-floating > .form-control[data-datepicker] ~ label::after {
  position: absolute;
  inset: 1rem 0.375rem;
  z-index: -1;
  height: 1.5em;
  content: "";
  background-color: var(--bs-body-bg);
  border-radius: var(--bs-border-radius);
}
.form-floating > .form-control[data-datepicker]::placeholder {
  color: var(--bs-secondary-color);
}

/* A FLOATING LABEL AT REST IS A PLACEHOLDER, AND SHOULD LOOK LIKE ONE.
   Bootstrap colours only two states of .form-floating > label: floated (.65 of the body
   colour) and disabled. At rest it sets nothing, so the label inherits --bs-body-color and
   comes out the same near-black as a value someone typed — an empty field and a filled one
   read identically at a glance, which is the whole thing a floating label is supposed to make
   obvious. It is the more visible on our forms because they are mostly empty boxes: the
   admission intake opens on about forty.
   --bs-secondary-color is the token Bootstrap itself uses for ::placeholder, which is the
   thing this label replaces, so it is the same grey a placeholder would have been — themed for
   dark like everything else in this file.
   ONE DECLARATION, and every state above it still wins on specificity: the floated, disabled
   and always-float-datepicker rules are all (0,3,1) against this (0,1,1). */
.form-floating > label {
  color: var(--bs-secondary-color);
}

/* THE REQUIRED MARKER, FOR A LABEL THAT LIVES INSIDE ITS BOX.
   admissions/_form_styles.pug derives the red asterisk from the control's own `required`
   attribute rather than from a hand-marked label, so it cannot drift from what the browser
   actually enforces. That matters more than it sounds: three of those fields are only required
   at runtime — occupation and the employer block follow the employment status, and the region
   box follows the country — so a marker written into the markup would be wrong on all three.
   The rule reaches the label as `[class*="col-"]:has(> :required) > .form-label`, which the
   floating conversion broke twice over: the control is no longer a direct child of the column,
   and a floating label carries no .form-label class. Every converted field lost its star.
   ⚠️ IT CANNOT BE FIXED WITH ::after ON THE LABEL, which is the obvious repair. Bootstrap
   already uses that pseudo-element for the chip of page background behind a floated label (the
   datepicker block above copies it for the same reason). An #admission-form rule outruns
   Bootstrap's (0,2,2) on the id alone, and setting `content` there leaves its
   `position:absolute; inset:1rem .375rem; z-index:-1; background-color` in force — so the
   asterisk lands behind the control and takes the chip with it. Nothing errors and
   render-check sees none of it.
   So the marker is real markup the field mixins always emit, hidden here and revealed by the
   admission form's own rule. DEFAULT-OFF IS GLOBAL because those mixins are shared:
   components/formfields.pug and components/datepicker.pug also build the school-year forms and
   the student and user Edit modals, and an unstyled span would put a bare asterisk on every
   field of all of them. */
.req-star {
  display: none;
}

/* THE TAG/CHIP BOX (+tagField, +listRepeater): a .form-control that chips sit inside, with a
   borderless entry sharing the box with them.

   MOVED HERE from admissions/_form_styles.pug, which is included by the admission forms and
   nothing else. The markup went app-wide with +tagField before the styling did, so the Health
   record modal rendered three tag boxes with none of these rules: the inner input fell back to
   its browser default width and CLIPPED ITS OWN PLACEHOLDER ("Type an allergy and press E…"),
   kept its own border so the control read as a box drawn inside a box, and the flex row that
   lays chips out beside the entry was not there at all.

   Nothing here was ever scoped to #admission-form — unlike the required-marker rule beside it,
   which is why that one stays behind. These were global rules that simply were not loaded. */
.tags {
  display: flex;
  flex-wrap: wrap;
  gap: .375rem;
  align-items: center;
  cursor: text;
  padding: .375rem .5rem;
  height: auto;
  min-height: calc(1.5em + .75rem + 2px);
}
/* Its own min-height wins over the phone rule in layout.pug, which is why this one control
   stayed at 42px while every neighbour grew. Tapping it focuses the inner input, so it should
   match them. */
@media (max-width: 575.98px) {
  .tags { min-height: 44px; }
}
/* flex: 1 1 8rem is what makes the entry fill the rest of the row after the chips — without
   it the input sits at its default size and the placeholder is cut off mid-word. */
.tags input {
  border: 0;
  outline: 0;
  padding: .125rem;
  flex: 1 1 8rem;
  min-width: 8rem;
  background: transparent;
}
