:root {
  --color-primary:        #1F4E78;
  --color-primary-hover:  #2d6aa0;
  --color-danger:         #c0392b;
  --color-danger-hover:   #e74c3c;
  --color-edit-bg:        #e8f0f8;
  --color-edit-bg-hover:  #d0e0f0;
  --color-cancel-bg:      #e0e6ef;
  --color-cancel-bg-hover:#cdd5e0;
  --color-border:         #e0e6ef;
  --color-row-border:     #f0f0f0;
  --color-row-hover:      #f7f9fc;
  --color-badge-bg:       #eef3fa;
  --color-text:           #222;
  --color-text-muted:     #555;
  --color-text-faint:     #888;
  --color-input-border:   #ccc;
  --color-accent-bg:      #eef2f7;
  --color-accent-text:    #3f5a73;

  /* spacing scale (4px base) */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;

  /* type scale */
  --text-xs: 11px;
  --text-sm: 13px;
  --text-base: 14px;
  --text-lg: 15px;
  --text-xl: 18px;

  /* Field width bands. The per-type max-width rules below (input[type="time"]
     etc.) key width to the input's TYPE, which is only a proxy for expected
     content length and fails constantly -- a 4-digit school year and a
     student name are both type="text", so both get the same 240px cap. These
     bands express expected CONTENT length instead, independent of type.
     --field-lg deliberately equals the existing text cap so an unclassed
     field's width does not change when these are introduced. */
  --field-xs:  72px;
  --field-sm: 112px;
  --field-md: 152px;
  --field-lg: 240px;

  /* Checkbox/radio square size, deliberately above the ~13px native box for
     a larger click target. */
  --control-box: 16px;

  /* nav text (WCAG AA fix) */
  --color-nav-text: #e8f0f8;

  /* page-width tokens */
  --page-narrow: 720px;
  --page-default: 960px;
  --page-wide: 1200px;

  /* Shared page-header height floor (see .page-header below). */
  --page-header-h: 56px;

  /* height of ONE 5-minute slot on the screen schedule lenses (staff/room/
     student); print has its own decoupled scale and must not consume this.
     20px is the floor for two independent reasons: it's the minimum that
     fits one line of .schedule-block's 11px/1.25 title text inside its 4px
     padding + 2px border (a 5-min block must show its title uncut), and
     it's above FullCalendar's own internal slot-height floor (14px was
     below it, so the student lens silently rendered ~17.5px instead).
     This equals print's 20px/slot BY COINCIDENCE, not coupling -- print's
     value lives in .print-grid-wrap .schedule-grid (style.css) on its own
     fully decoupled scale. Do not "simplify" by having one reference the
     other; a future change to either floor must not silently move both. */
  --schedule-slot-5min-h: 20px;

  /* Schedule-block fill/border (retires the interim alternating tint, see
     CLAUDE.md "Schedule-lens grid CSS"). --block-fill is the block's
     pre-existing background color, unchanged, now tokenized.
     --block-fill-staff-only is a distinct white fill for block_type ==
     'staff_only' blocks. --block-border is ONE step darker than
     --color-primary-hover (the border color .schedule-block used before this
     change) -- it deliberately compensates for the removed tint: with no
     fill contrast between adjacent same-column blocks, the border is the
     ONLY thing that must read as a seam, so it's darkened rather than kept
     at the hover shade. */
  --block-fill: #d6e6f7;
  --block-fill-staff-only: #ffffff;
  --block-border: var(--color-primary);

  /* Block hover tint -- black, not blue, so an inset overlay darkens ANY
     fill correctly: the standard blue fill now, the white staff_only fill
     now, and role-color fills in v1.1. A fill-specific hover color would
     have to be re-derived per fill; this one works unconditionally. */
  --block-hover-tint: rgba(0, 0, 0, 0.07);

  /* Diagonal hatch shared by every "not available here" surface --
     .legend-absent (both lenses' legends) and .unavailable-bg (staff-lens
     availability bands). Second consumer is why this is a token now rather
     than a literal on .legend-absent alone. */
  --band-hatch: repeating-linear-gradient(45deg, #e2e2e2, #e2e2e2 4px, #f5f5f5 4px, #f5f5f5 8px);

  /* Push-in/pull-out support-type marker colors. Both clear WCAG AA 4.5:1 on
     white AND on --block-fill -- load-bearing because the same token colors
     the rail's TEXT (via currentColor inheritance from the icon wrap), not
     just a glyph. Do not substitute a more saturated value. */
  --color-push-in:  #534AB7;
  --color-pull-out: #993C1D;

  /* Page background -- also consumed by the sticky .page-header (see below),
     which must be OPAQUE and must never disagree with body's own bg. */
  --color-page-bg: #f4f6f9;
}

/* Page shell (base.html) */
* { box-sizing: border-box; margin: 0; padding: 0; }
html { min-height: 100%; }
body { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: var(--text-base); background: var(--color-page-bg); color: var(--color-text); display: flex; min-height: 100vh; }
.sidebar { width: 200px; flex-shrink: 0; background: var(--color-primary); display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; align-self: flex-start; overflow-y: auto; }
header { color: white; padding: var(--space-4) 20px; font-size: var(--text-xl); font-weight: bold; }
nav { background: var(--color-primary-hover); flex: 1; display: flex; flex-direction: column; padding: var(--space-3) 0; }
.nav-group-label { color: var(--color-nav-text); text-transform: uppercase; font-size: var(--text-xs); font-weight: bold; letter-spacing: 0.5px; padding: var(--space-4) 20px 6px; }
nav a { display: block; color: var(--color-nav-text); text-decoration: none; padding: var(--space-2) 20px; font-size: var(--text-sm); }
nav a.nav-child { padding-left: var(--space-6); }
nav a.nav-top { margin-top: 6px; }
nav a:hover, nav a.active { color: white; background: var(--color-primary); }
.year-badge { display: block; text-align: center; background: var(--color-primary); color: white; border: 1px solid #5588aa; padding: var(--space-4) 20px; font-size: 12px; text-decoration: none; }
.year-badge:hover { background: var(--color-primary-hover); }
.nav-user { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: var(--space-3) 20px; color: var(--color-nav-text); font-size: var(--text-xs); }
.btn-logout { background: none; border: none; padding: 0; color: var(--color-nav-text); font-size: var(--text-xs); text-decoration: underline; cursor: pointer; }
.btn-logout:hover { color: white; }
.main-content { flex: 1; min-width: 0; }

/* Scroll-restore paint mask (Polish 2.0 D1 #7D). ILCScrollRestore (base.html
   <head>) stamps data-scroll-restoring onto <html> before first paint when a
   fresh stash exists that would actually move something, and removes it once
   the restore finishes (or after a 900ms failsafe). `main` is the mask
   target because it's the one common ancestor of both scroll dimensions a
   restore ever touches: window scroll of the page content, and every inner
   scroller ILCScrollRestore registers (.schedule-grid-scroll on the staff/
   room/snapshot lenses, .schedule-split on the student lens) -- one selector
   covers every lens without enumerating scrollers here.
   - The sidebar is deliberately NOT masked -- it's a sibling of .main-content
     under body, so the app shell never looks blank, only the content area
     does while it repositions.
   - The flash-toast stack is also outside main (a sibling of the page's own
     <main>, under .main-content) -- a flash message stays visible through
     the mask, which is intended.
   - opacity, not visibility/display: opacity leaves layout completely
     intact, so nothing reflows when the mask lifts, and it's what lets the
     reveal fade instead of pop. display:none would collapse the scroll
     containers to zero height and make the restore this mask is protecting
     impossible to perform in the first place -- never substitute it.
   - transition:none in the masked state (transition only on the base rule)
     is what makes hiding instant and revealing gradual -- the flash itself
     must never be visible, only the fade-in after it's gone.
   - Note: opacity < 1 creates a stacking context on main. Harmless today --
     .page-header's position:sticky/z-index:5 only needs to stack above
     other content inside main, and the toast stack lives outside main
     entirely -- but worth knowing before adding a new fixed-position element
     inside main. */
main { transition: opacity 120ms ease-out; }
html[data-scroll-restoring] main { opacity: 0; transition: none; }

/* Buttons */
.btn { padding: var(--space-2) var(--space-4); border: none; border-radius: 4px; cursor: pointer; font-size: var(--text-sm); font-weight: 500; text-decoration: none; display: inline-block; }
.btn-primary { background: var(--color-primary); color: white; font-weight: bold; }
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-primary:disabled { background: #b0c4d8; cursor: not-allowed; }
.btn-danger { background: var(--color-danger); color: white; font-weight: bold; }
.btn-danger:hover { background: var(--color-danger-hover); }
.btn-edit { background: var(--color-edit-bg); color: var(--color-primary); }
.btn-edit:hover { background: var(--color-edit-bg-hover); }
.btn-cancel { background: var(--color-cancel-bg); color: var(--color-text); }
.btn-cancel:hover { background: var(--color-cancel-bg-hover); }
.btn:focus-visible, nav a:focus-visible { outline: 2px solid var(--color-primary-hover); outline-offset: 2px; }

/* Button system: intent (.btn-primary/.btn-danger/.btn-edit/.btn-cancel) ×
   emphasis (.btn-outline modifier) × size (.btn-sm modifier), composable. */
.btn-sm { padding: 4px 10px; font-size: var(--text-xs); }

/* .btn-outline is a pure marker with no rule of its own — only these compound
   selectors exist. A bare .btn-outline would be (0,1,0), tying with
   .btn-primary/.btn-danger and depending on source order to win; these are
   (0,2,0) and beat both that and .btn's own (0,1,0) border:none outright.
   Do not "simplify" into a single .btn-outline rule. SOLID danger/primary is
   bold because it is a final or primary action; OUTLINE is the quiet variant
   and stays at the base .btn weight (500) — deliberate, not an oversight. */
.btn-primary.btn-outline {
    background: transparent;
    color: var(--color-primary);
    border: 1px solid var(--color-primary);
    font-weight: 500;
}
.btn-primary.btn-outline:hover { background: var(--color-edit-bg); }
.btn-danger.btn-outline {
    background: transparent;
    color: var(--color-danger);
    border: 1px solid var(--color-danger);
    font-weight: 500;
}
.btn-danger.btn-outline:hover { background: var(--color-danger-hover); color: white; }

/* .btn-icon is a standalone bare-icon control, used WITHOUT .btn — shares the
   prefix for discoverability only. Consumed by _macros.html's render_day_grid
   (position edit/delete icons), gated on editable=true, which only the staff
   lens passes. .btn-icon-danger:hover ties .btn-icon:hover at (0,2,0), so it
   must stay after it in source order. */
.btn-icon {
    border: none; background: none; cursor: pointer;
    font-size: 12px; line-height: 1;
    color: var(--color-text-faint);
    padding: 2px 4px; border-radius: 3px;
}
.btn-icon:hover { background: var(--color-edit-bg-hover); color: var(--color-primary); }
.btn-icon-danger:hover { color: var(--color-danger); }

/* Cards */
.card { background: white; border-radius: 6px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); padding: var(--space-5) var(--space-5); margin-bottom: var(--space-6); }
.card h2 { font-size: var(--text-lg); color: var(--color-primary); margin-bottom: 6px; border-bottom: 1px solid var(--color-border); padding-bottom: var(--space-2); }

/* Forms */
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--space-4); align-items: end; }
.form-group { display: flex; flex-direction: column; gap: 5px; }
.form-group.full { grid-column: 1 / -1; }
.form-group-checkbox { align-self: center; }
label { font-size: 12px; font-weight: bold; color: var(--color-text-muted); }
input[type="text"], input[type="time"], input[type="number"], input[type="password"], select, textarea {
    border: 1px solid var(--color-input-border); border-radius: 4px; padding: var(--space-2) var(--space-3); font-size: var(--text-base); width: 100%;
}
input[type="text"]:focus, input[type="time"]:focus, input[type="number"]:focus, input[type="password"]:focus, select:focus, textarea:focus {
    outline: none; border-color: var(--color-primary-hover); box-shadow: 0 0 0 2px rgba(45,106,160,0.15);
}
/* input[type="file"] is deliberately NOT added to the two rules above --
   native file-input chrome is kept on purpose, not an oversight. */
input[type="time"] { max-width: 110px; }
input[type="number"] { max-width: 90px; }
input[type="text"] { max-width: 240px; }
select { max-width: 220px; }
textarea { resize: vertical; min-height: 60px; }

/* Field width bands (see --field-* tokens in :root for why these exist).
   SPECIFICITY IS LOAD-BEARING -- do not simplify to bare .field-xs etc.
   input[type="text"] has specificity (0,1,1) (an attribute selector counts
   as a class-level component); a bare class selector is only (0,1,0) and
   would LOSE the cascade to the type cap above, silently doing nothing on
   every text input. Element-qualifying each band selector (input.field-xs)
   makes it (0,1,1), tying with the type cap; the tie is broken by source
   order, which is why this block comes AFTER the per-type rules above. */
input.field-xs, select.field-xs, textarea.field-xs { max-width: var(--field-xs); }
input.field-sm, select.field-sm, textarea.field-sm { max-width: var(--field-sm); }
input.field-md, select.field-md, textarea.field-md { max-width: var(--field-md); }
input.field-lg, select.field-lg, textarea.field-lg { max-width: var(--field-lg); }
input.field-full, select.field-full, textarea.field-full { max-width: none; }

/* Checkbox/radio sizing -- shared by every checkbox and radio in the app. */
input[type="checkbox"], input[type="radio"] {
    width: var(--control-box);
    height: var(--control-box);
    /* .checkbox-row/.radio-option/.block-type-option are flex containers;
       without this a long label can squeeze the box narrower than its
       declared size. */
    flex-shrink: 0;
    accent-color: var(--color-primary);
    /* Absorbs the cursor: pointer previously supplied by three now-deleted
       page-local rules (.student-check/.template-check input[type="checkbox"]
       in block_form.html/edit_gen_ed_classroom.html/schedule_day.html) --
       must stay here so the affordance isn't lost now that those are gone. */
    cursor: pointer;
}

/* Tables */
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: var(--space-2) var(--space-3); font-size: var(--text-xs); text-transform: uppercase; color: var(--color-text-muted); border-bottom: 2px solid var(--color-border); }
td { padding: var(--space-3) var(--space-3); font-size: var(--text-sm); border-bottom: 1px solid var(--color-row-border); vertical-align: middle; }
tr:hover td { background: var(--color-row-hover); }
.col-actions { text-align: right; white-space: nowrap; }

/* Badges */
.grade-badge, .badge-1on1 { background: var(--color-badge-bg); color: var(--color-primary); border-radius: 4px; padding: 2px var(--space-2); font-size: 12px; font-weight: bold; }
.badge { display: inline-block; padding: 3px var(--space-2); border-radius: 12px; font-size: var(--text-xs); font-weight: bold; text-transform: uppercase; }
.badge-teacher { background: #e8f5e9; color: #2e7d32; }
.badge-para    { background: #e8f0fb; color: var(--color-primary); }
.badge-custom  { background: var(--color-accent-bg); color: var(--color-accent-text); }
.badge-standard { background: var(--color-row-border); color: var(--color-text-muted); }
.text-muted { color: var(--color-text-muted); }

/* Misc shared chrome */
.empty-state { text-align: center; padding: 40px; color: #999; font-style: italic; }
.button-row { display: flex; gap: var(--space-3); align-items: center; margin-top: 20px; }
.modal-actions { display: flex; gap: var(--space-3); margin-top: 6px; }
.flash-error { background: #fde8e8; color: var(--color-danger); border: 1px solid #f5c6cb; }
.flash-message { background: #d4edda; border: 1px solid #a3d9b1; color: #1e5631; }

.flash-container { position: fixed; top: 16px; right: 16px; z-index: 10000; display: flex; flex-direction: column; gap: var(--space-2); max-width: 360px; }
.flash-toast { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3) var(--space-4); border-radius: 4px; font-size: var(--text-sm); box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
.flash-dismiss { background: none; border: none; color: inherit; cursor: pointer; font-size: 16px; line-height: 1; padding: 0; }

/* Shared page/modal chrome (extracted from per-page extra_style) */
.page { padding: var(--space-6); margin-inline: auto; }

/* Shared page-title header -- sits ABOVE .card, never inside it. An in-card
   title used to stack its own border-bottom against the table's own <th>
   border-bottom, reading as three horizontal rules packed into ~110px; this
   is the single rule that replaces that stack. min-height (not height), keyed
   to --page-header-h, so a page with no meta line renders the same height as
   one with one -- headers align page-to-page, and a later sticky-<th> offset
   is one derived number instead of a per-page magic value. align-items:
   flex-end keeps the action button on the title's baseline edge whether or
   not a meta line is present. The 2px #d0dcea border (not 1px --color-border)
   is deliberate -- it's the one strong rule replacing the old stacked pair
   (originally matched school_hours.html's since-converted .section-header,
   which used the same 2px/#d0dcea rule); tokenizing that color is a
   separate decision, not made here.

   position: sticky pins it to the viewport (no ancestor scrolls -- .main-
   content and .page set no overflow, same precondition the sidebar's own
   sticky already relies on). background is REQUIRED, not cosmetic: without
   an opaque fill, content scrolls visibly beneath a transparent header. No
   padding/negative-margin top-breathing-room hack is needed -- align-items:
   flex-end + min-height already leave ~12px above the title because the
   content is shorter than the min-height box. z-index 5 sits above table
   content (.table-sticky-head th below uses 2) and below the flash toast
   stack (10000); dialogs are top-layer and unaffected.

   CONSTRAINT: --page-header-h must equal this header's ACTUAL pinned height,
   because .table-sticky-head offsets its <th> against it. That holds while
   the title stays one line and the action row doesn't wrap -- a wrapping
   title or action row grows the box past 56px and the <th> would pin
   underneath the header instead of below it. Anything that makes headers
   taller must re-check this token. */
.page-header {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    gap: var(--space-4);
    flex-wrap: wrap;
    min-height: var(--page-header-h);
    padding-bottom: var(--space-2);
    margin-bottom: var(--space-5);
    border-bottom: 2px solid #d0dcea;
    position: sticky;
    top: 0;
    z-index: 5;
    background: var(--color-page-bg);
}
.page-header-text { min-width: 0; }
.page-header h1 {
    font-size: var(--text-xl);
    font-weight: bold;
    color: var(--color-primary);
    line-height: 1.2;
}
.page-header-meta {
    font-size: var(--text-sm);
    color: var(--color-text-muted);
    margin-top: 3px;
}
.page-header-meta span { font-weight: bold; color: var(--color-primary); }
.page-header-actions { display: flex; gap: var(--space-2); flex-shrink: 0; }

/* Opt-in sticky table head, applied per-table (see CLAUDE.md for the
   exclusion list -- a table inside an overflow:auto wrapper, or on a page
   with no .page-header, must not get this class; sticky would either bind
   to the wrong scroller or offset against a token that doesn't apply). The
   offset is DERIVED from --page-header-h rather than hand-measured, so a
   header height change moves both together. background is REQUIRED -- a
   <th> is transparent by default and rows would show through it otherwise. */
.table-sticky-head th {
    position: sticky;
    top: var(--page-header-h);
    z-index: 2;
    background: white;
}

/* Compact workspace title -- NOT a page-header substitute. .page-header
   (above) is the DOCUMENT page header: title + meta + actions + a strong
   rule, sitting above a .card. .page-title is the compact title used by the
   three schedule lenses and snapshot view: pages that already carry their
   own dense chrome stack (day-tabs, lens-switcher, legend, ...) above a
   viewport-bounded grid, where several of them hard-code that grid's
   max-height as `calc(100vh - Npx)` against everything rendered above it --
   a taller .page-header would silently invalidate that hand-measured
   number. Deliberately staying two treatments, not one; see CLAUDE.md. */
.page-title { font-size: 20px; font-weight: bold; color: var(--color-primary); margin-bottom: var(--space-4); }


/* Swatch-and-label legend row, shared by the schedule lens pages.
   Unscoped (not nested under .schedule-grid) -- it's a sibling of the grid,
   not a descendant. */
.legend { display: flex; gap: 18px; margin-bottom: 10px; font-size: 12px; color: #666;
          align-items: center; flex-wrap: wrap; }
.legend-swatch {
    display: inline-block; width: 16px; height: 12px; border-radius: 2px;
    margin-right: 6px; vertical-align: middle;
}
.legend-editable { background: var(--block-fill); border: 1px solid var(--block-border); }
.legend-absent {
    background: var(--band-hatch);
    border: 1px solid var(--color-input-border);
}

.field-hint { font-size: 12px; color: var(--color-text-faint); font-style: italic; margin-top: 4px; }
.field-hint a { color: var(--color-primary-hover); }

.checkbox-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
.checkbox-row label { font-size: 13px; text-transform: none; font-weight: normal; color: var(--color-text); }

/* Generic vertical radio row (e.g. Add Role's ILC Staff / Related Service choice).
   .block-type-picker/.block-type-option below is a separate, block-modal-specific
   component (different divider treatment) -- may be folded in with this later. */
.radio-group { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-4); }
.radio-option { display: flex; align-items: center; gap: 6px; font-size: 13px;
                text-transform: none; font-weight: normal; color: var(--color-text);
                cursor: pointer; }

/* block_type radio picker (block add/edit modals) -- see CLAUDE.md
   "block_type modal disclosure". .field-hidden hides a disclosure-gated
   form-group without clearing its value (see app.py's BLOCK_TYPE_FIELDS
   docstring for why a hidden field must keep its DOM value). */
.block-type-picker { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); margin-bottom: var(--space-4); padding-bottom: var(--space-3); border-bottom: 1px solid var(--color-border); }
.block-type-option { display: flex; align-items: center; gap: 6px; font-size: 13px; text-transform: none; font-weight: normal; color: var(--color-text); cursor: pointer; }
.field-hidden { display: none; }

/* Collapsible sections (Model B) -- see CLAUDE.md "Save model (Model B)" */
details.section-details { background: white; border-radius: 6px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); margin-bottom: 28px; }
details.section-details summary { cursor: pointer; padding: 16px 26px; font-size: 15px; font-weight: bold; color: var(--color-primary); list-style: none; }
details.section-details summary::-webkit-details-marker { display: none; }
details.section-details summary::before { content: '▸ '; }
details.section-details[open] summary::before { content: '▾ '; }
details.section-details .section-body { padding: 4px 26px 22px; }

.back-link { display: inline-block; margin-bottom: 18px; font-size: 13px;
             color: var(--color-primary-hover); text-decoration: none; }
.back-link:hover { text-decoration: underline; }

/* Absence grid -- shared by edit_student.html's Attendance section and
   edit_staff.html's Availability section (formerly two hand-copied
   .attendance-*/.availability-* twins, unified here). Wording ("absent" vs.
   "unavailable") stays in each template's markup; this is layout/visuals
   only. Every child selector is scoped under .absence-grid because
   .time-label/.grid-corner are ALSO used unscoped by the schedule-lens grid
   family (render_day_grid) with different values -- an unscoped rule here
   would leak across grid families. */
.absence-grid-wrap { overflow-x: auto; margin-bottom: 6px; }
.absence-grid {
    display: grid;
    border: 1px solid #d0dcea;
    border-radius: 6px;
    background: white;
    position: relative;
    min-width: max-content;
}
.absence-grid .grid-corner {
    grid-column: 1; grid-row: 1;
    background: var(--color-badge-bg);
    border-bottom: 2px solid #d0dcea;
    border-right: 1px solid #d0dcea;
    position: sticky; left: 0; z-index: 4;
}
.absence-grid .day-header {
    grid-row: 1;
    background: var(--color-badge-bg);
    border-bottom: 2px solid #d0dcea;
    border-left: 1px solid #e6ecf4;
    padding: 6px 8px;
    display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
    text-align: center;
}
.absence-grid .day-header .day-label { font-size: 12px; font-weight: bold; color: var(--color-primary); text-transform: uppercase; }
.absence-grid .day-header .checkbox-row { gap: 4px; padding: 0; }
.absence-grid .day-header .checkbox-row label { font-size: 11px; }
.absence-grid .time-label {
    grid-column: 1;
    font-size: 11px; color: var(--color-text-faint);
    padding: 1px 8px 0 6px;
    text-align: right;
    background: #fafbfd;
    border-right: 1px solid #d0dcea;
    position: sticky; left: 0;
    border-top: 1px solid var(--color-row-border);
}
.absence-grid .time-label.hour { border-top: 1px solid #d0dcea; color: var(--color-text-muted); font-weight: bold; }
.absence-grid .slot-cell { border-top: 1px solid #f5f5f5; border-left: 1px solid #e6ecf4; cursor: pointer; }
.absence-grid .slot-cell.hour { border-top: 1px solid #e6ecf4; }
.absence-grid .slot-cell:hover { background: #f0f6ff; }
.absence-grid .day-column {
    /* Positioned ancestor for that day's .block elements (grid-placed,
       spanning the full data-row area of one column, one per weekday).
       A CSS Grid item's `position: absolute` children are NOT automatically
       contained by that item's grid-area box in all engines -- without this
       wrapper, a block's top/left/right resolve against the WHOLE grid
       (.absence-grid), rendering it across every day column instead of just
       its own. This wrapper makes the containing block unambiguous: one
       real positioned element per day, not a grid-area cell. */
    position: relative;
    pointer-events: none;
}
.absence-grid .day-column > * { pointer-events: auto; }
.absence-grid .block {
    /* Positioned absolute (top/height in px, set inline by JS) rather than
       via grid-row lines: grid rows are a 15-min lattice (ABSENCE_SLOT_MINUTES
       in app.py), but a block's start/end can fall on any 5-min mark (the
       block dialog's time inputs keep step="300") -- grid-row placement can't
       express that sub-row position, since grid lines are whole numbers.
       Positioned relative to its .day-column ancestor (see above), not the
       whole grid, so left:0/right:0 stay within that one day's column width. */
    position: absolute; z-index: 2; box-sizing: border-box;
    background: #fdecea; border: 1px solid var(--color-danger); border-radius: 3px;
    padding: 2px 5px; font-size: 11px; line-height: 1.25; overflow: hidden;
    cursor: pointer; color: var(--color-danger);
}
.absence-grid .block:hover { background: #fbd9d4; }
.absence-grid .block .block-title { font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.absence-grid .block .block-note { color: #934; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.absence-grid .fullday-shade {
    position: relative; z-index: 2;
    background: repeating-linear-gradient(45deg, #f3f3f3, #f3f3f3 10px, #e8e8e8 10px, #e8e8e8 20px);
    display: flex; align-items: center; justify-content: center;
    color: var(--color-text-faint); font-size: 12px; font-style: italic;
}

/* Lens switcher (staff/student/room) -- shared by all three schedule lenses
   (schedule_day, schedule_room_day, schedule_student_day). A joined
   segmented control, distinguished from the plain-tab .day-tabs below it. */
.lens-switcher {
    display: inline-flex; margin-bottom: 12px;
    border: 1px solid var(--color-primary); border-radius: 5px; overflow: hidden;
}
.lens-tab {
    display: inline-block; padding: 6px 18px;
    background: white; color: var(--color-primary); text-decoration: none;
    font-size: 12px; font-weight: bold; text-transform: uppercase; letter-spacing: 0.3px;
}
.lens-tab + .lens-tab { border-left: 1px solid var(--color-primary); }
.lens-tab:hover { background: var(--color-edit-bg); }
.lens-tab.active { background: var(--color-primary); color: white; }

/* Day-of-week tab strip -- shared by all four schedule/snapshot day pages
   (schedule_day, schedule_room_day, schedule_student_day, snapshot_view).
   schedule_day.html keeps its own page-local `.day-tabs { align-items:
   center }` on top of this -- its tab row also carries trailing toolbar
   buttons (Copy day/Print/Export/Reset) that need baseline alignment with
   the tabs, a need the other three pages' plain tab-only rows don't share. */
.day-tabs { display: flex; gap: 4px; margin-bottom: 16px; }
.day-tab {
    display: inline-block; padding: 8px 18px; border-radius: 4px 4px 0 0;
    background: var(--color-edit-bg); color: var(--color-primary); text-decoration: none;
    font-size: 13px; font-weight: bold;
}
.day-tab:hover { background: var(--color-edit-bg-hover); }
.day-tab.active { background: var(--color-primary); color: white; border-bottom: 3px solid #e8a33d; }

/* Block-form modal Save/Cancel + Delete row -- shared by the three lenses'
   #block-dialog/#student-block-dialog forms (schedule_day, schedule_room_day,
   schedule_student_day). justify-content: space-between puts Save/Cancel on
   the left and Delete Block on the right in one visual row. The dialogs'
   own width rules stay page-local/id-scoped (720px, wider than any
   .modal-sm/md/lg size class -- see each template's own #block-dialog /
   #student-block-dialog rule). */
.block-dialog-buttons { justify-content: space-between; }
.block-dialog-buttons-left { display: flex; gap: var(--space-3); align-items: center; }

/* "with <staff name>" meta line on a block face -- shared by the staff lens
   (schedule_day.html) and snapshot_view.html (same staff-lens-style grid).
   The room lens emits .block-meta instead for its equivalent line. */
.block-staff { color: var(--color-primary-hover); font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* No-JS fallback student checkbox list -- shared by block_form.html
   (standalone) and schedule_day.html (staff-lens modal reuses the same
   markup). This styles the multiselect's NO-JS FALLBACK markup only: with
   JS enabled, `.multiselect.multiselect-ready .multiselect-fallback` sets
   `display: none`, so these rules are dormant on every normal page load.
   Load-bearing for the no-JS degradation path -- do not delete as unused. */
.student-groups { display: flex; flex-direction: column; gap: 10px;
                   border: 1px solid #ddd; border-radius: 4px; padding: 10px 12px;
                   max-height: 260px; overflow-y: auto; background: #fafafa; }
.student-grade-label { font-size: 11px; font-weight: bold; color: var(--color-text-faint);
                        text-transform: uppercase; margin-bottom: 4px; }
.student-check { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 2px 0; }
.student-check span { font-size: 13px; color: var(--color-text); }

/* Schedule-lens grid -- shared by the staff lens (schedule_day.html) and
   room lens (schedule_room_day.html), both built from the render_day_grid
   macro (_macros.html). The student lens uses FullCalendar instead and has
   no .schedule-grid markup. Every child selector is scoped under
   .schedule-grid because .time-label/.grid-corner/.slot-cell are ALSO used
   (with different values) by the .absence-grid family above -- an unscoped
   rule here would collide with that family, the same reason the absence-grid
   rules are scoped under .absence-grid instead of being bare selectors.
   Rules that differ between the two lenses for a real reason (staff has
   editable links/drag affordances; room is read-only static divs) stay
   page-local -- see schedule_day.html / schedule_room_day.html's own
   extra_style for those. */

/* Viewport-bounded scroll container (render_day_grid wraps .schedule-grid in
   this). Owns BOTH scrollbars so the grid scrolls within the window instead
   of the page scrolling down to reach a scrollbar sitting under a
   taller-than-viewport grid -- a deliberate behavior change. max-height is
   page-local (extra_style): each lens's header chrome above the grid differs
   (day-tabs, add-position-row, readonly-note, ...), so the "space already
   used above the grid" offset can't be one shared number. The border/radius
   live here (not on .schedule-grid) so they frame the fixed-size viewport
   rather than the full scrollable content. */
.schedule-grid-scroll {
    overflow: auto;
    border: 1px solid #d0dcea;
    border-radius: 6px;
}
.schedule-grid {
    display: grid;
    background: white;
    position: relative;
    min-width: max-content;
}
/* Sticky axes (Problem 1): the time-gutter column stays pinned left, the
   header row stays pinned top, relative to .schedule-grid-scroll (the
   nearest scrolling ancestor). z-index stack, highest to lowest:
   grid-corner (4, both axes intersect -- must win over everything) >
   staff-header (3, top axis) == time-label (3, left axis) > schedule-block
   (2, minute-precise absolute-positioned content) > slot-cell (auto). The
   two axis classes share z-index 3 since they never overlap each other. */
.schedule-grid .grid-corner {
    grid-column: 1; grid-row: 1;
    background: var(--color-badge-bg);
    border-bottom: 2px solid #d0dcea;
    border-right: 1px solid #d0dcea;
    position: sticky; left: 0; top: 0; z-index: 4;
}
.schedule-grid .staff-header {
    grid-row: 1;
    background: var(--color-badge-bg);
    border-bottom: 2px solid #d0dcea;
    border-left: 1px solid #d0dcea;
    font-size: 12px; font-weight: bold; color: var(--color-primary);
    padding: 6px 8px;
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    text-align: center; line-height: 1.3;
    position: sticky; top: 0; z-index: 3;
}
.schedule-grid .staff-header .role-tag {
    font-size: 10px; font-weight: normal; color: var(--color-text-faint);
    text-transform: uppercase; letter-spacing: 0.5px;
}
.schedule-grid .time-label {
    grid-column: 1;
    font-size: 11px; color: var(--color-text-faint);
    padding: 1px 8px 0 6px;
    text-align: right;
    background: #fafbfd;
    border-right: 1px solid #d0dcea;
    position: sticky; left: 0; z-index: 3;
    border-top: 1px solid var(--color-row-border);
}
.schedule-grid .time-label.hour { border-top: 1px solid #d0dcea; color: var(--color-text-muted); font-weight: bold; }
.schedule-grid .slot-cell {
    display: block;
    border-top: 1px solid #f5f5f5;
    border-left: 1px solid #d0dcea;
}
.schedule-grid .slot-cell.hour { border-top: 1px solid #e6ecf4; }
/* Schedulable extension bands (item 2, see CLAUDE.md "Grid extension") --
   15 min before / 45 min after school hours. Still clickable/schedulable
   like any other slot-cell; this is visual shading only. */
.schedule-grid .slot-cell.out-of-hours { background: var(--color-row-border); }
/* Staff-lens ambient availability banding (see CLAUDE.md "Absence grid" /
   staff lens). z-index 1: below .schedule-block (2, must paint OVER a band)
   and above .slot-cell (auto). pointer-events: none is REQUIRED, not
   defensive -- every slot-cell here is an <a> that creates a block; without
   this, clicking empty space inside a banded range would silently do
   nothing. Same hatch as .legend-absent via --band-hatch, so both lenses
   read as the same fact. */
.schedule-grid .unavailable-bg {
    z-index: 1;
    pointer-events: none;
    background: var(--band-hatch);
}
.schedule-grid .schedule-block {
    position: relative;
    z-index: 2;
    box-sizing: border-box;
    /* Bug fix (see CLAUDE.md): a grid item's implicit min-height is `auto`,
       so intrinsic content height (e.g. several text lines in a short block)
       stretches the box past its grid-row span BEFORE overflow:hidden has
       anything to clip against -- text then visually paints over the block
       below. height: 100% pins the box to its track's actual height (the
       grid-row span, in row-track px), making overflow:hidden effective. */
    height: 100%;
    min-height: 0;
    /* Vertical centering (screen lenses only -- print overrides this whole
       rule at higher specificity via .print-grid-wrap .schedule-grid
       .schedule-block, and keeps its own clamp-aware top-align behavior
       unconditionally). Safe here, unlike print: every content line is
       white-space: nowrap (deterministic height, no wrap variance) and the
       retuned density tiers (_macros.html) guarantee content fits its
       block at every tier, so centering can never clip from both ends. No
       align-items override -- default stretch is required so block-title's
       text-overflow: ellipsis has a constrained width to truncate against. */
    display: flex;
    flex-direction: column;
    justify-content: center;
    background: var(--block-fill);
    border: 1px solid var(--block-border);
    border-radius: 3px;
    padding: 2px 5px;
    font-size: 11px;
    line-height: 1.25;
    overflow: hidden;
    text-decoration: none;
    color: var(--color-primary);
}
/* block_type == 'staff_only' gets a white fill instead of the standard blue
   (see CLAUDE.md "block_type modal disclosure" / block-dict builders).
   Snapshot blocks never carry this class (block_type is deliberately not
   frozen -- see the snapshot builder in app.py), so a frozen staff_only
   block renders standard fill; this is intended, not an omission. */
.schedule-grid .schedule-block.block-type-staff-only { background: var(--block-fill-staff-only); }
/* Push-in/pull-out corner marker (see CLAUDE.md "Block support-type
   marker"). pointer-events: none is REQUIRED, not defensive: staff/room-lens
   blocks are <a> elements whose whole surface opens the rail, and on the
   student lens the marker sits over FullCalendar's own top/bottom resize
   handles -- without it the marker eats clicks in one case and drags in the
   other. Corner placement (not inline) costs zero title width, which is what
   lets the marker render even on the minimal density tier. */
.block-support-marker { position: absolute; pointer-events: none; }
.rail-support-icon { vertical-align: middle; margin-right: 4px; }
.block-support-push-in  { color: var(--color-push-in); }
.block-support-pull-out { color: var(--color-pull-out); }
.schedule-grid .schedule-block .block-support-marker { top: 2px; right: 3px; }
/* Title-collision guard (see CLAUDE.md "Block support-type marker"):
   .block-has-marker is emitted by the SAME condition as the marker itself
   (render_day_grid / the student-lens FC hook), so the two can never
   disagree. 20px = the 14px marker + its 2px right offset + ~4px breathing
   room, clearing the glyph so a long ellipsized title truncates visibly
   instead of running underneath it. Title line only -- participants/room
   sit below the marker's own 14px height and don't need the reservation. */
.schedule-grid .schedule-block.block-has-marker .block-title,
.block-content.block-has-marker .block-title { padding-right: 20px; }
/* Consolidated hover (was duplicated identically in schedule_day.html and
   schedule_room_day.html -- the room lens's blocks are rail-clickable now,
   so the old "static div, no hover affordance" split no longer holds).
   Fill-independent overlay, not a `background` swap: fill is semantic
   (standard vs staff_only, role colors later) and a hover that replaces
   fill would make a white staff_only block look like it changed type.
   `filter: brightness()` was considered and rejected -- it creates a
   stacking context, and this grid's z-index stack (grid-corner 4 > axes 3 >
   schedule-block 2 > slot-cell auto) is load-bearing. An inset box-shadow
   has no stacking or layout effect, and with `overflow: hidden` +
   `border-radius: 3px` on .schedule-block above, it's clipped to the
   block's own rounded box automatically. */
.schedule-grid .schedule-block:hover {
    box-shadow: inset 0 0 0 100vh var(--block-hover-tint);
}
.schedule-grid .block-title { font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.schedule-grid .block-students { color: #444; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.schedule-grid .block-room { color: var(--color-text-faint); font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Bug fix (see CLAUDE.md): every block-content line clips to the block's own
   box, unconditionally -- a block's text must never paint over a neighbor.
   .schedule-block already has overflow:hidden as the outer backstop; this
   belt-and-suspenders rule also clips each line individually so a
   wide/unclipped child can't force the parent to reflow past its grid-row
   height first. */
.schedule-grid .schedule-block * { overflow: hidden; text-overflow: ellipsis; }
.schedule-grid .empty-note { font-size: 13px; color: var(--color-text-faint); font-style: italic; margin-top: 12px; }

/* The global `* { margin: 0 }` reset wipes the UA stylesheet's default
   `margin: auto` centering for <dialog> elements, so every .modal dialog
   would otherwise render top-left. This line recenters all of them. */
.modal { border: none; border-radius: 6px; padding: var(--space-5) var(--space-5); box-shadow: 0 4px 24px rgba(0,0,0,0.2); width: 340px; margin: auto; }
.modal::backdrop { background: rgba(0,0,0,0.4); }
.modal h3 { font-size: var(--text-lg); color: var(--color-primary); margin-bottom: var(--space-4); }
.modal .form-group { margin-bottom: var(--space-4); }
.modal-sm { width: 320px; }
.modal-md { width: 380px; }
.modal-lg { width: 420px; }

/* Sticky heading for a scrolling modal body -- keeps the title visible while
   the body scrolls. Used by the three block-add/edit dialogs (schedule_day,
   schedule_room_day, schedule_student_day's #block-dialog/#student-block-dialog).
   Owns the border-bottom AND the bottom spacing itself (rather than leaving
   either to the next element, e.g. .block-type-picker) so a page-local rule
   can't redeclare the border and drift out of sync, and so the next element
   under a modal header always inherits correct clearance for free. */
.modal-header { position: sticky; top: 0; background: white; z-index: 1; padding-bottom: var(--space-2); border-bottom: 1px solid var(--color-border); margin-bottom: var(--space-4); }

/* ------------------------------------------------------------------
   Print (schedule_print.html) -- rules that override .schedule-grid's
   screen behavior for the print page ONLY (this page reuses render_day_grid
   / .schedule-grid, same as the live staff and room lenses). DECOUPLED from
   the live lenses' own screen sizing on purpose: schedule_day.html /
   schedule_room_day.html each set their OWN grid-template-columns/rows in
   their own <style> block (70px gutter + 130px-min columns, 8px per 5-min
   row) -- none of that is read or inherited here, and a future change to
   the live lenses' screen sizing cannot move anything printed.

   PORTRAIT (not landscape). Rationale: the real-world predecessor document
   (the school's own hand-built printed schedule) is portrait. The grid's
   binding constraint is VERTICAL extent -- a full day (7:30-15:15 school
   hours + the 15/45-minute schedulable extension, ~525 minutes at the 5-min
   lattice = 105 rows) cannot be compressed without going illegible, while
   COLUMNS wrap text and tolerate being narrow. Portrait trades ~25% of
   column width (8.5in wide vs. 11in landscape) for nearly double the usable
   height (11in tall vs. 8.5in landscape) -- the right trade when height is
   the thing that can't give and width is the thing that can. See the
   metrics comment above the column-width rule below for the actual
   per-column width this yields at 15 columns, and CLAUDE.md's Print section
   for the full reasoning + verified page-count outcome.

   Column WIDTH must actually be capped, not just narrowed (P0 fix, see
   below) -- the shared .schedule-grid rule (style.css) carries `min-width:
   max-content`, and .block-title/.block-students/.block-room are
   `white-space: nowrap` (both rules exist for the LIVE lenses' screen
   layout, where content should never wrap). Left unscoped, those two rules
   combine on the print page to let the grid's true rendered width balloon
   past the physical page: an unbroken word (e.g. "Dismissal") forces
   max-content sizing wider than the fixed 1fr column it's nominally
   constrained to, so `min-width: max-content` drags the WHOLE grid wider
   than the page. Chromium's print engine does not shrink-to-fit or wrap
   this overflow -- it silently CLIPS content past the page's right edge.
   This was diagnosed (not just patched) from a real PDF: 2 of 15 columns
   were missing entirely (pushed past the clip line, not omitted from the
   DOM -- _build_live_day_columns already builds one column per live
   position unconditionally) and the rightmost surviving column's text was
   clipped mid-word. Fix is two rules, both scoped to .print-grid-wrap so
   the live lenses (which correctly rely on both behaviors) are untouched:
   `min-width: 0` overrides the max-content floor so the grid can shrink to
   its explicit grid-template-columns width instead of being dragged wider
   by content, and `white-space: normal` on the text lines lets long
   words/activities wrap across 2-3 lines instead of forcing a wide
   max-content measurement. Wrapped, multi-line names/activities are
   expected and fine at this scale -- narrow columns are the whole point of
   portrait (see above).
   ------------------------------------------------------------------ */
.print-grid-wrap .schedule-grid {
    min-width: 0;
    /* Row scale bumped 7px->20px/5-min-slot (a 15-min/3-row block: 21px->
       60px) after the first portrait pass measured real Monday using only
       688px of ~989px available portrait height -- headroom the original
       7px scale left unused while sitting below the ~7px print legibility
       floor its own fit math had cited. See CLAUDE.md's Print section for
       the re-measured pages-per-day/headroom outcome at this scale;
       explicitly NOT chasing 1 page if this spills to 2 -- see the
       comment block above. */
    grid-template-rows: 20px repeat(var(--print-rows), 20px);
}

/* No viewport-bounded scroll container, no sticky axes, no hover -- this
   page has no scrolling surface (natural height, full content always
   visible) and no interactivity, so all three screen behaviors that exist
   for the live/editable lenses are turned off. Applies on screen too, since
   there's no separate screen-only styling for this page -- the plain view
   IS the print preview. */
.print-grid-wrap .schedule-grid-scroll {
    max-height: none;
    overflow: visible;
    border: 1px solid #d0dcea;
    border-radius: 6px;
    margin-bottom: 4px;
}
.print-grid-wrap .schedule-grid .grid-corner,
.print-grid-wrap .schedule-grid .staff-header,
.print-grid-wrap .schedule-grid .time-label {
    position: static;
}
.print-grid-wrap .schedule-grid .staff-header {
    /* min-width: 0 is load-bearing, not defensive -- .staff-header is BOTH
       a grid item (of .schedule-grid) AND a flex container (display: flex,
       shared rule) for its own name/role-tag stack. A flex item's default
       automatic minimum width is its content's max-content size (the
       longest unbreakable word), which overrides white-space: normal below
       unless explicitly zeroed -- without this, a long staff name would
       still force this cell (and therefore its whole grid column, and the
       whole grid) wider than the fixed track width set above, reproducing
       the same overflow-clip defect this print pass fixed for block text. */
    min-width: 0;
    font-size: 9px;
    padding: 2px 1px;
    line-height: 1.1;
    white-space: normal;
    overflow-wrap: break-word;
}
.print-grid-wrap .schedule-grid .staff-header .role-tag { font-size: 6.5px; }
.print-grid-wrap .schedule-grid .time-label { font-size: 7px; padding: 0 2px 0 1px; }
.print-grid-wrap .schedule-grid .schedule-block {
    font-size: 8px;
    line-height: 1.1;
    padding: 1px 2px;
    /* Flex column so the time-line + clamped content group can vertically
       CENTER when it fits, and top-align (fill from the top) when the
       content clamp is active. Centering and the clamp coexist because the
       clamp lives on the inner .block-print-content wrapper, not on this
       flex container (the well-known "-webkit-box on a flex child, not the
       flex parent" separation) -- putting -webkit-line-clamp directly on a
       flex container would make `display: flex` and the clamp's required
       `display: -webkit-box` fight over the same element. min-height: 0 lets
       the block actually clip below its content's intrinsic height (a flex
       item's default min-height is auto == content size). */
    display: flex;
    flex-direction: column;
    justify-content: center;
    min-height: 0;
}
/* Clamped blocks TOP-align (fill from the top, ellipsis at the true bottom,
   no residual gap below) instead of centering -- see _macros.html's
   content_clamped estimate for how the clamp is predicted server-side
   (justify-content can't detect it, because -webkit-line-clamp sizes the
   content down to fit and flexbox never sees an overflow). Fitting blocks
   keep the centered default above. */
.print-grid-wrap .schedule-grid .schedule-block.block-print-clamped {
    justify-content: flex-start;
}
/* ------------------------------------------------------------------
   BLOCK-LEVEL clamp model (2026-07-19, corrected pass -- REPLACES the
   short-lived per-line single-line-ellipsis fallback, which itself had
   replaced an even earlier wrap-into-2-lines attempt).

   The corrected diagnosis, proven by isolated Playwright repro against
   this app's actual Chromium (149.x) BEFORE writing this rule (see
   CLAUDE.md's Print section for the full writeup and the retraction of the
   prior false claim): `-webkit-line-clamp` WORKS in this build, in both
   screen and print media. The prior pass's "it's non-functional /
   -webkit-box retired in Chromium 133+" claim was WRONG -- it was inferred
   from `getComputedStyle().display` reporting `flow-root` (a reporting
   artifact of the modern line-clamp implementation; the clamp still
   applies) and from a too-short test string that fit within the clamp and
   so showed no ellipsis. The REAL bug in the app was a cascade
   interaction: the base (unscoped) `.schedule-grid .block-title` rule sets
   `white-space: nowrap`, and the earlier print override left that in the
   cascade -- nowrap forces every clamp target onto a single line, so
   `-webkit-line-clamp: N` had nothing multi-line to clamp. Fixed by
   wrapping in a container that explicitly sets `white-space: normal`.

   Model: `.block-time` is its own intact top line (nowrap, never
   ellipsized -- a printed block's time is non-negotiable, see CLAUDE.md).
   Everything else (title, room-own-line, participants) lives inside ONE
   `.block-print-content` wrapper that WRAPS normally and is clamped as a
   WHOLE to `--clamp-lines` lines (computed per-block in _macros.html from
   the block's own pixel height) -- so there is exactly ONE truncation
   point: an ellipsis on the final visible line, only when the wrapped
   content genuinely exceeds the block's remaining height. Fitting content
   ("Plan/Prep", "Morning Meeting") renders COMPLETE, no ellipsis anywhere.
   ------------------------------------------------------------------ */
.print-grid-wrap .schedule-grid .block-time {
    /* Intact, nowrap, never ellipsized, never part of the clamp. flex-shrink
       0 so it always keeps its full one-line height even in a short block. */
    white-space: nowrap;
    overflow: hidden;
    flex-shrink: 0;
    font-size: 7px;
    font-weight: bold;
    margin-bottom: 1px;
}
.print-grid-wrap .schedule-grid .block-print-content {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: var(--clamp-lines, 2);
    line-clamp: var(--clamp-lines, 2);
    overflow: hidden;
    /* normal (NOT the base rule's nowrap) is what makes the clamp
       multi-line -- the whole point of the corrected diagnosis above. */
    white-space: normal;
    overflow-wrap: break-word;
    min-height: 0;
}
/* Each content piece is its own block-level line WITHIN the clamp flow, so
   title / room / participants stack (not run together inline) while the
   clamp still counts total wrapped lines across all of them and elides once
   at the end. These are the print-only .block-print-content children (spans
   with display:block); the shared unscoped `.schedule-grid .block-title`
   etc. rules still apply their own font-weight/color, but their
   white-space:nowrap is overridden here back to normal so each piece wraps. */
.print-grid-wrap .schedule-grid .block-print-content > .block-title,
.print-grid-wrap .schedule-grid .block-print-content > .block-students,
.print-grid-wrap .schedule-grid .block-print-content > .block-staff,
.print-grid-wrap .schedule-grid .block-print-content > .block-room {
    display: block;
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
}
.print-grid-wrap .schedule-grid .block-room { font-size: 7px; }
/* Print has NO hover (alongside no-sticky-axes, no-scroll-container above)
   -- consolidating .schedule-block:hover into style.css (see CLAUDE.md)
   removed the page-local scoping that used to keep it off this template by
   accident. Override, not a :not()/live-lens-only rescope of the shared
   rule -- there's no class distinguishing the live lenses from print, and
   an override is the pattern print already uses for this element. */
.print-grid-wrap .schedule-grid .schedule-block:hover { box-shadow: none; }

/* @page must be a TOP-LEVEL at-rule, never nested inside @media print --
   found during verification: Chromium silently ignores an @page rule
   nested inside @media print (invalid per spec), so a landscape/portrait
   `size` never took effect there and every print PDF came out at the
   browser's own default orientation despite every other @media print rule
   (break-after, chrome suppression) working fine. size: portrait -- see
   the rationale comment above the .print-grid-wrap rule. */
@page {
    size: portrait;
    margin: 0.35in;
}

@media print {
    body { background: white; }
    .print-page-chrome { display: none; }
    /* break-BEFORE, not break-after -- deliberate fix for a real
       heading-orphan defect found via PDF review (2026-07-18): break-after
       on the PREVIOUS section only guarantees the point a page ends, not
       where the NEXT section starts within the page that follows -- if the
       next day's heading happened to fit in whatever leftover space
       remained after the browser's own pagination of the prior section
       (page height isn't a multiple of section height in general), the
       heading would render there, but .schedule-grid (CSS Grid content) is
       effectively unbreakable in Chromium's print engine -- it can't
       fragment to continue filling that same leftover space, so it punts
       entirely to the next page, stranding the heading alone on a page by
       itself with the grid orphaned onto the one after. Confirmed via a
       real 4-day PDF: Wednesday's heading rendered alone on page 5 (2
       nonempty lines only) with its grid starting fresh on page 6 -- while
       every other day cleanly held heading+grid together on its own first
       page. break-before: page on .print-day-section instead makes EVERY
       day's start point deterministic (top of a fresh page, always),
       independent of the previous day's leftover space -- heading and grid
       can never land on different pages again. :first-child suppresses the
       break before the very first section (no leading blank page). This
       also RETRACTS an earlier "Chromium pagination quirk" claim in
       CLAUDE.md -- that finding was this same orphan bug, further
       compounded by an inaccurate page-counting method in that pass's own
       verification script (a regex matching /Parent references overcounted
       -- e.g. read 12 pages for a PDF pypdf correctly reports as 10). See
       CLAUDE.md's Print section for the corrected writeup. */
    .print-day-section {
        padding: 0;
        break-before: page;
    }
    .print-day-section:first-child { break-before: auto; }
    /* Suppress the screen-preview frame border so the grid isn't boxed in
       on paper -- purely cosmetic. */
    .print-grid-wrap .schedule-grid-scroll { border: none; }
}
