/* ==========================================================================
   Swim School Progress Tracker — shared stylesheet
   Design system: "Accessible & Ethical" — brand palette: dominant Midnight Blue
   (#0F414A) + Light Blue (#96C0CE) accents, Maroon (#7F0303) for danger, Tan
   (#D8BA98) for warnings, on an Alabaster (#EFE8DF) background. A deep forest green
   (#1F6E45) is scoped strictly to success/confirmation/done/active states — nowhere
   else. Noto Sans Hebrew, high contrast, 44px touch targets. RTL-first, mobile-first.
   Order: tokens -> reset -> base -> layout -> components -> pages.
   ========================================================================== */

:root {
  /* ---- primitives — derived ONLY from the 5-hue brand palette:
     Midnight Blue #0F414A · Light Blue #96C0CE · Maroon #7F0303 · Tan #D8BA98 · Alabaster #EFE8DF.
     (names kept from the previous theme; values are now tints/shades of these 5 hues only) ---- */
  /* teal/blue ramp (Midnight → Light Blue — one hue, the dominant brand color) */
  --blue-900: #0F414A;   /* Midnight Blue — primary / brand */
  --blue-800: #0A2E34;   /* darker Midnight — hover / headings */
  --blue-500: #2C6B77;   /* mid teal — secondary / links / progress fill */
  --blue-100: #CFE3E8;   /* light teal tint — soft surfaces (always dark text on top) */
  --violet-600: #96C0CE; /* Light Blue — accent / CTA (dark text on top) */
  /* warm neutrals (Alabaster + Tan shades) */
  --slate-50: #EFE8DF;   /* Alabaster — page background */
  --slate-100: #E7DCCC;  /* warm muted surface */
  --slate-200: #D6C8B4;  /* warm border */
  --slate-500: #5F5A50;  /* soft text */
  --slate-700: #3B372F;  /* strong soft text — unused ramp step since v1.1.14 repointed
                            .pill--role at its own token; kept so the ramp reads intact */
  --slate-900: #241F19;  /* body text (warm near-black) */
  --white: #FFFFFF;
  /* Maroon ramp (danger / critical) */
  --red-600: #7F0303;    /* Maroon */
  --red-50: #F6E3E3;     /* light Maroon tint — danger surface */
  /* Tan ramp (warn — Tan surface, dark text) */
  --amber-600: #5A3E22;  /* deep Tan/brown — warn text */
  --amber-50: #ECDBC3;   /* Tan tint — warn surface */
  /* forest green — scoped ONLY to success / confirmation / done / active meaning */
  --green-700: #1F6E45;  /* deep forest green — ok text / border / icon / toast */
  --green-300: #A5C5B5;  /* 40/60 mix of --green-700 with white — the ONLY tone of "done" green
                            that survives on the dark --purple-600 private-lesson fill (3.99:1
                            there, 5.10:1 on its hover shade, where --green-700 itself is 1.20:1
                            and effectively invisible). Sole consumer: .cal-block--private
                            .cal-block--done. Still unmistakably green, so the calendars' written
                            "מסגרת ירוקה" legend stays true. */
  --green-50: #DCEFE2;   /* pale green tint — ok surface */
  /* ---- v1.1.21: a deliberate SIXTH hue, scoped STRICTLY to "this is a private lesson" ----
     NOT part of the 5-hue locked brand palette above (Midnight/Light Blue/Maroon/Tan/Alabaster).
     This is the user's own explicit, final choice (offered Midnight/mid-teal/Tan; chose purple) —
     do NOT extend this hue to any other meaning, and do NOT read its presence as the palette
     having drifted by accident. Deliberately muted/dusty (HSL ~257°, 30% sat) so it sits beside
     the Alabaster page ground and the teal ramp instead of shouting over them.
     Measured, not assumed: white text 7.45:1 on --purple-600 and 9.52:1 on its hover shade;
     6.13:1 against the Alabaster page background and 5.61:1 against the default --blue-100
     calendar block fill, so a private block reads as a deliberate object, never a wash. */
  --purple-600: #5B4B8A; /* muted/dusty private-lesson fill — white text 7.45:1 */
  --purple-50:  #E6E1F0; /* pale tint — NO consumer yet (kept so the ramp reads intact, and for a
                            soft private surface if one is ever needed; body text on it = 12.8:1) */
  /* ---- v1.21: a SEVENTH hue, --indigo-600/--indigo-50, scoped STRICTLY to "this is a solo_plus
     קבוצה" — declared NOT here but in its own :root block immediately above its only consumer,
     body[data-portal="solo"] .cal-block--team, at the foot of this file. That placement is a
     requirement of this repo's stylesheet gate, not a preference: test/cssdiff.py holds every rule
     present at the last release byte-identical, and appending a declaration to THIS :root block
     changes it. Search for "--indigo-600" for the full measured contrast/dE2000 record. */

  /* ---- semantic ---- */
  --color-primary: var(--blue-900);
  --color-on-primary: var(--white);
  --color-secondary: var(--blue-500);
  --color-accent: var(--violet-600);
  /* Repointed Midnight -> darker Midnight when .btn--nav became this pair's first CSS
     consumer: on the Light Blue accent, --blue-800 gives 7.38:1 where --blue-900 gave
     5.72:1. Safe to repoint — it had no CSS consumer before, only theme.js's onAccent
     override, so no other rule's contrast moved. */
  --color-on-accent: var(--blue-800);
  --color-bg: var(--slate-50);
  --color-surface: var(--white);
  --color-muted: var(--slate-100);
  --color-border: var(--slate-200);
  --color-text: var(--slate-900);
  --color-text-soft: var(--slate-500);
  --color-heading: var(--blue-800);
  --color-danger: var(--red-600);
  --color-danger-bg: var(--red-50);
  --color-warn: var(--amber-600);
  --color-warn-bg: var(--amber-50);
  --color-ok: var(--green-700);
  --color-ok-bg: var(--green-50);
  /* private lessons only — see the --purple-600 note above before reusing this anywhere else */
  --color-private: var(--purple-600);
  --color-ring: var(--blue-900);

  /* ---- pill fills — component-scoped, used by .pill--* ONLY ----
     Every pill carries WHITE text (user decision), so every fill is a DARK tone: the five
     dark-text mid-tones gave white only 2.2–2.7:1 (the sixth, .pill--role, already carried
     white at 4.66:1). These give white 4.58–11.2:1 (re-measured after the v1.1.18 recolour
     below — the low end is the new orange, was 4.7) and measure ΔE2000
     33–67 against all six real pill grounds (white card #FFFFFF, page Alabaster #EFE8DF, muted
     card head #E7DCCC, picker-selected #CFE3E8, over-capacity row #F6E3E3, over-capacity +
     selected #EDC5C5) — up from 12–49 for the mid-tone set, itself up from 4–7 for the original
     creams. They are deliberately NOT the shared --color-*-bg tokens: those still drive
     .status--*, .cal-block--cover, .group-option--full, .tabs, .staged-kid, .kid-block__head
     and .progress, none of which change here.
     .pill--role is Midnight (--blue-900), NOT the mid-teal its depth would otherwise suggest:
     at white-text depth that teal (#3D7D8C) measured ΔE2000 1.2 from the #3F7C8D data fill —
     side by side the two pills would have been indistinguishable.
     v1.1.18, by user request: flag moved bronze #8A6224 -> ORANGE #B85C1E, and muted moved grey
     #6B655B -> RED #C22E22. Re-measured after the change, not carried over: white text is
     4.58:1 on the orange and 5.66:1 on the red (both AA; the orange is now the TIGHTEST pair in
     this file, so it must not drift lighter), and both stay ΔE2000 ≥ 34 from all six grounds
     above and ≥ 40 from data/role/ok. The CLOSEST SURVIVING PAIR is no longer flag vs muted
     (ΔE 17.2 before, 14.6 now) but **muted vs danger at ΔE 10.6** — see the note on .pill--muted
     below, where that collision is visible in a real screen. Orange vs danger is 19.3. */
  --pill-data-bg: #3F7C8D;         --pill-data-text: #FFFFFF;   /* facts & counts — teal */
  --pill-role-bg: var(--blue-900); --pill-role-text: #FFFFFF;   /* role — Midnight, see above */
  --pill-flag-bg: #B85C1E;         --pill-flag-text: #FFFFFF;   /* attention — orange, 4.58:1 */
  --pill-muted-bg: #C22E22;        --pill-muted-text: #FFFFFF;  /* archived / absent — red, 5.66:1 */
  --pill-ok-bg: #347A56;           --pill-ok-text: #FFFFFF;     /* level & success — green */
  --pill-danger-bg: #9E4444;       --pill-danger-text: #FFFFFF; /* danger — maroon */

  /* ---- spacing / type / shape ---- */
  --space-xs: .25rem;
  --space-s: .5rem;
  --space-m: 1rem;
  --space-l: clamp(1.5rem, 1rem + 2vw, 2.5rem);
  --space-xl: clamp(2rem, 1.4rem + 3vw, 4rem);
  --text-body: 1rem;
  --text-sm: .875rem;
  --text-lg: clamp(1.125rem, 1rem + .5vw, 1.375rem);
  --text-section: clamp(1.6rem, 1.25rem + 1.8vw, 2.5rem);
  /* 1.5x the previous scale — clamp(2.5rem, 1.9rem + 3vw, 4rem) — on an owner ruling, applied at
     the TOKEN so every page's <h1> grows together across all four portals rather than page by
     page. Each of the three terms is scaled by exactly 1.5, so the curve's shape is unchanged.
     ONE consumer is deliberately pinned at the OLD value and does not follow: .auth-brand h1, the
     login/signup wordmark — see the note there for the measurement. */
  --text-title: clamp(3.75rem, 2.85rem + 4.5vw, 6rem);
  /* One shared measure for a two-column row (content + actions) and for the controls that sit
     directly above such rows, so the two never form a ragged edge — see .list-item and .lookup. */
  --measure-row: 46rem;
  --radius: .75rem;
  --radius-sm: .5rem;
  --radius-pill: 999px;
  --shadow-sm: 0 1px 2px rgba(15, 65, 74, .10), 0 1px 3px rgba(15, 65, 74, .12);
  --shadow-md: 0 4px 12px rgba(15, 65, 74, .16);
  --transition: 200ms ease;
  --tap: 44px;
  --wrap: 60rem;
  /* Both faces come from Google Fonts on every page and both carry FULL Hebrew, Latin and digit
     coverage — that is the property the previous display face (self-hosted Horev CLM Heavy) did
     not have, and the reason several rules used to opt out of --font-display by hand.
     FREDOKA IS LOADED AT 500 AND 600 ONLY. Anything that renders it must state weight 600
     explicitly; a heading left at the UA's default 700 would be SYNTHESISED (fake) bold. */
  --font: 'Heebo', 'Noto Sans Hebrew', 'Segoe UI', system-ui, 'Arial Hebrew', sans-serif;
  --font-display: 'Fredoka', 'Heebo', 'Noto Sans Hebrew', sans-serif;
}

/* ---- reset ---- */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
html { scrollbar-gutter: stable; -webkit-text-size-adjust: 100%; }
body {
  min-height: 100svh;
  line-height: 1.55;
  font-family: var(--font);
  font-size: var(--text-body);
  color: var(--color-text);
  background: var(--color-bg);
  -webkit-font-smoothing: antialiased;
}
img, svg { max-width: 100%; height: auto; display: block; }
input, button, textarea, select { font: inherit; color: inherit; }
/* native controls follow the brand instead of the browser's default blue */
input[type="checkbox"], input[type="radio"], progress { accent-color: var(--color-primary); }
button { cursor: pointer; }
/* font-weight: 600 is not a style tweak — Fredoka ships here at 500/600 only, so without it every
   heading would inherit the UA's `bold` (700) and be faux-bolded. No class rule in this sheet sets
   a weight on an h1/h2/h3 (grepped: the bold classes all land on span/a/summary/legend/div), so
   this element-level declaration is the single place that decides heading weight. */
h1, h2, h3 { font-family: var(--font-display); font-weight: 600; color: var(--color-heading); line-height: 1.2; text-wrap: balance; }
p { text-wrap: pretty; overflow-wrap: break-word; }
a { color: var(--color-primary); }

/* ---- accessibility base ---- */
/* THE RING IS TWO BANDS, and this rule declares only the outer one. --color-ring and
   --color-primary both resolve to --blue-900, so on any surface FILLED with --color-primary this
   outline alone is 1.00:1 — invisible (the defect this comment used to merely record). The inner
   band that fixes it is declared at the FOOT of this file, in --color-on-primary, and it has to
   live there rather than here: at equal (0,1,0) specificity a later component rule such as
   .card/.kid-block/.tabs would otherwise win the box-shadow back. See that block for the measured
   figures — do not "tidy" the two halves back together. */
:focus-visible { outline: 3px solid var(--color-ring); outline-offset: 2px; border-radius: 4px; }
.skip-link {
  position: absolute; inset-inline-start: var(--space-s); inset-block-start: var(--space-s);
  z-index: 100; padding: var(--space-s) var(--space-m);
  background: var(--color-primary); color: var(--color-on-primary);
  border-radius: var(--radius-sm); translate: 0 -200%;
}
.skip-link:focus { translate: 0 0; }
.visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ---- layout ---- */
.wrapper { max-inline-size: var(--wrap); margin-inline: auto; padding-inline: var(--space-m); }
.page { padding-block: var(--space-l) var(--space-xl); }
.flow > * + * { margin-block-start: var(--space-m); }
.stack-s > * + * { margin-block-start: var(--space-s); }
.cluster { display: flex; flex-wrap: wrap; gap: var(--space-s); align-items: center; }
.grid-cards {
  display: grid; gap: var(--space-m);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
.between { display: flex; flex-wrap: wrap; gap: var(--space-m); align-items: center; justify-content: space-between; }

/* ---- app header / nav ---- */
.app-header {
  position: sticky; inset-block-start: 0; z-index: 20;
  background: var(--color-primary); color: var(--color-on-primary);
  box-shadow: var(--shadow-md);
}
.app-header .wrapper {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-s) var(--space-m);
  padding-block: var(--space-s);
}
.brand { display: inline-flex; align-items: center; gap: var(--space-s); font-weight: 700; color: var(--color-on-primary); text-decoration: none; }
.brand svg { width: 1.75rem; height: 1.75rem; }
.shell-logo { max-height: 1.75rem; width: auto; object-fit: contain; }
/* font-weight: 600 overrides the 700 inherited from .brand above — Fredoka has no 700 cut here. */
.brand span { font-family: var(--font-display); font-weight: 600; font-size: var(--text-lg); }
.main-nav { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-inline-start: auto; }
.main-nav a {
  display: inline-flex; align-items: center; gap: var(--space-xs); min-height: var(--tap);
  padding: var(--space-xs) var(--space-m); border-radius: var(--radius-pill);
  color: var(--color-on-primary); text-decoration: none; font-weight: 500;
  /* subtle darker-than-bar fill so each item reads as a distinct pill, not a flat brick.
     A black overlay over --color-primary only deepens the blue, so the white label keeps
     well above AA contrast; states step darker to stay clearly ordered. */
  background: rgba(0, 0, 0, .16); transition: background var(--transition);
}
.nav-icon { inline-size: 1.15em; block-size: 1.15em; flex: none; }
.main-nav a:hover { background: rgba(0, 0, 0, .28); }
.main-nav a[aria-current="page"] { background: rgba(0, 0, 0, .38); font-weight: 700; }
/* attention-count badge on the "החלפות" nav item — light ring so it reads on the dark header */
.nav-badge {
  display: inline-grid; place-items: center;
  min-inline-size: 1.3rem; block-size: 1.3rem; padding-inline: .35em;
  border-radius: var(--radius-pill); background: var(--color-danger); color: var(--white);
  box-shadow: 0 0 0 1px rgba(255, 255, 255, .45);
  font-size: .72rem; font-weight: 700; line-height: 1;
}
.nav-badge[hidden] { display: none; }
/* mobile: exactly TWO rows. Row 1 = brand + .shell-account (logout), a normal NON-scrolling
   wrapping flex row. Row 2 = .main-nav alone, full width, a single-line HORIZONTALLY SCROLLING
   strip. This supersedes the older "never a horizontal scroller" rule here: that rule's real bug
   was that ONE sliding row carried the nav AND the logout button, so logout could be scrolled
   off-screen with nothing hinting it was reachable. Keeping the two in separate rows fixes that
   by construction — only nav items can ever be out of view, and a partially-cut pill at the
   inline-end edge is what says so (an edge fade shipped here first and was removed at the user's
   request — see the note inside .main-nav).
   Wrapping was what made the header three rows tall (measured 375px: nav 2 lines = 92px, header
   152px; at 320px it wrapped to 3 lines / 200px). The nav is now one 60px line at every width.
   Row 1 can still wrap, though: .shell-logo has no max-inline-size, so a wide school logo (a 10:1
   image renders ~280px at 375px) pushes .shell-account onto a line of its own and the header is
   three rows again — pre-existing, not fixed here, and the reason nothing below assumes a fixed
   header height. */
@media (max-width: 40rem) {
  .app-header .wrapper { flex-wrap: wrap; padding-block: 6px; row-gap: var(--space-xs); }
  .brand span { display: none; }         /* keep the logo mark, drop the wordmark */
  .shell-school { display: none; }       /* school context is non-essential on the narrow bar */
  .shell-account .who { display: none; } /* drop "שלום, [שם]" greeting, keep the logout button */
  .brand { flex: none; }
  .shell-account { flex: none; margin-inline-start: auto; } /* logout pinned to the row-1 end */
  .main-nav {
    order: 3; flex: 1 0 100%;             /* full-width second row */
    margin-inline-start: 0; gap: var(--space-xs);
    flex-wrap: nowrap;                    /* one line, always — it scrolls instead of wrapping */
    overflow-x: auto;
    overscroll-behavior-x: contain;       /* a horizontal swipe here must not trigger iOS Safari's
                                             back-navigation gesture */
    scrollbar-width: none;                /* scrollbar hidden — the edge fade is the affordance */
    -webkit-overflow-scrolling: touch;    /* same legacy momentum hint .cal-scroll already uses */
    /* NOT cosmetic: overflow-x on a box whose overflow-y is visible forces overflow-y to compute
       to auto too (the same trap that broke .cal-scroll's sticky day-head), so this box now clips
       on BOTH axes. :focus-visible paints 3px outline + 2px offset = 5px OUTSIDE a link's border
       box, which would be sliced off top and bottom without headroom. 8px > 5px, measured. */
    padding-block: var(--space-s);
    /* NO edge fade. v1.1.22 shipped one here — the four-layer "scroll shadow" (two
       background-attachment: local covers in --color-primary + two fixed dark gradients) — and
       the user asked for it to be REMOVED because they disliked the shadow. Deleted deliberately;
       do not re-add it as a missing affordance. Its background-color was part of the same recipe
       and went with it: .app-header already paints --color-primary behind this row, so the strip
       looks identical without it, and mobile now matches desktop in having no background here.
       Scrollability is now signalled ONLY by item-peek — the pill at the inline-end edge being cut
       mid-shape. That signal is real but NOT guaranteed: it falls out of text metrics, so it varies
       by role, viewport width AND which font has loaded. THE MATRIX BELOW IS STALE AND IS KEPT ONLY
       AS THE SHAPE OF THE PROBLEM: it was measured in Assistant, the body face this app used before
       the Heebo/Fredoka swap, and nav pills are body text, so every figure moved with the metrics.
       Measured at the initial scroll position, in ASSISTANT: manager peeked 72.7px @320, 42.3 @375,
       57.3 @390, 12.8 @430 — but front-desk @320 ended FLUSH (three whole pills + 2.2px of empty
       bar, nothing cut, two items hidden), and instructor @375 peeked 0.7px on the FALLBACK font,
       i.e. during the font swap. What survives the face change is the CONCLUSION, not the numbers:
       the peek can legitimately reach zero for some role/width pair, so nothing may be built on it.
       Re-measure the whole matrix in Heebo before changing any nav label, padding, gap or icon. */
  }
  .main-nav::-webkit-scrollbar { display: none; } /* WebKit/Blink half of the hidden scrollbar */
  /* flex: none is required, not tidiness: on a nowrap row the default flex-shrink: 1 would
     squeeze all six pills to fit instead of overflowing, so nothing would ever scroll — it
     would just get cramped and illegible. */
  .main-nav a { padding-inline: var(--space-s); white-space: nowrap; flex: none; }
}
.shell-account { display: inline-flex; align-items: center; gap: var(--space-s); }
.shell-account .who { font-size: var(--text-sm); opacity: .9; }
/* BODY FACE, DELIBERATELY — this used to read var(--font-display) and is the one display-font
   consumer that rendered at --text-sm (14px). The display face is for big titles and the brand
   only: a rounded display cut at 14px inside a dark header loses its letterforms and reads as a
   different, muddier font rather than as the brand. Weight is unchanged. */
.shell-school {
  font-family: var(--font); font-size: var(--text-sm); font-weight: 700;
  color: var(--color-on-primary); opacity: .92;
  padding-inline-start: var(--space-s); margin-inline-start: var(--space-xs);
  border-inline-start: 1px solid rgba(255, 255, 255, .3);
}
.shell-school:empty { display: none; }
.auth-school { font-family: var(--font-display); font-weight: 600; font-size: var(--text-lg); color: var(--color-primary); }
.auth-school:empty { display: none; }
.logout-btn {
  min-height: var(--tap); padding: var(--space-xs) var(--space-m);
  background: rgba(255, 255, 255, .16); color: var(--color-on-primary);
  border: 1px solid rgba(255, 255, 255, .3); border-radius: var(--radius-pill);
  font-weight: 600; transition: background var(--transition);
}
.logout-btn:hover { background: rgba(255, 255, 255, .28); }

/* ---- headings ---- */
.page-title { font-family: var(--font-display); font-weight: 600; font-size: var(--text-title); margin-block-end: var(--space-xs); }
/* SIX consumers, verified by grep (v1.1.21 — the previous "only three" was wrong and, being
   normative, invited a future cleanup of text that must not be touched). All six are one-clause
   LEGENDS for a colour-coded signal, in three pairs:
     · "פס אדום — …"      the behind-average bar   frontdesk/dashboard.html · manager/index.html
     · "מסגרת ירוקה — …"  the .cal-block--done border  instructor/calendar.html · manager/calendar.html
     · "רקע ירקרק — …"    the .cal-cell--open tint     instructor/calendar.html · manager/calendar.html
   The four calendar ones are LOAD-BEARING and explicitly protected: each is the ONLY sighted-user
   explanation of its colour anywhere (the per-cell text for the tint lives in a .visually-hidden
   span, i.e. screen readers only), so removing one would leave that colour carrying meaning alone.
   Descriptive page blurbs were removed everywhere else — don't reintroduce them, and don't
   "clean up" the six above. */
.page-lead { color: var(--color-text-soft); max-inline-size: 46ch; }
.section-title { font-family: var(--font-display); font-weight: 600; font-size: var(--text-section); margin-block-end: var(--space-s); }

/* ---- card ---- */
.card {
  background: var(--color-surface); border: 1px solid var(--color-border);
  border-radius: var(--radius); box-shadow: var(--shadow-sm);
  padding: var(--space-m);
  transition: transform var(--transition), box-shadow var(--transition);
}
.card--pad-l { padding: var(--space-l); }
/* v1.1.21 BUG-05 — `align-items: baseline` became `flex-start`. Baseline aligned the TEXT baselines
   of three children with different font sizes and paddings, which measured as the title sitting 7px
   ABOVE its own pills (title top 643, meta top 650) — read as a misalignment, not as a typographic
   nicety. It also fought .card__head-action's `align-self: center` (removed below), so the three
   children resolved to three different vertical rules. flex-start aligns all three to the top of
   the row, so the pills line up with the FIRST line of the title block and stay lined up however
   many lines that block grows to. */
.card__head { display: flex; flex-wrap: wrap; gap: var(--space-s); align-items: flex-start; justify-content: space-between; }
/* Three-child head (title block · meta pills · action). The auto margin sits on the META, not
   the action: an auto margin eats ALL free space before space-between distributes any, so
   putting it on the action would drag the pills over to hug the title and merge them visually
   with the title's own pills. Here the free space lands before the meta, so title / meta+action
   keep their original trailing arrangement — and the action, still the LAST child, is pinned to
   the container's trailing edge, so its position no longer depends on pill widths (a 55px vs
   68px level pill used to shift it ~12px between adjacent cards). */
.card__head-meta { margin-inline-start: auto; }
/* v1.1.21 — `flex: none` became `0 1 auto` + min-inline-size:0. History, because the motivating
   measurement no longer applies and the rule should not read as dead: task 6 first put a TEXT
   button in this column, taking it to ~233px of max-content against the ~197px content box of a
   leaf card three folders deep at 375px; flex-shrink:0 meant it could not shrink below max-content,
   so .cluster's flex-wrap never engaged and the card overflowed. Browser QA then found that the
   resulting WRAP put the irreversible delete directly under the add button (BUG-02), so that
   button moved out to the card's .btn-row and this column is back to two 49x36 icons (~106px),
   which fits everywhere. The shrinkable value is KEPT as the correct general behaviour — a column
   that cannot shrink overflows its card instead of wrapping — not as an active fix. Its END-EDGE
   PINNING never came from `flex: none` anyway: that is .card__head-meta's auto margin above. */
.card__head-action { flex: 0 1 auto; min-inline-size: 0; }

/* ---- buttons ---- */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--space-xs);
  min-height: var(--tap); padding: var(--space-s) var(--space-l);
  border: 1px solid transparent; border-radius: var(--radius-sm);
  font-weight: 600; text-decoration: none; text-align: center;
  transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
}
.btn:active { transform: translateY(1px); }
/* [hidden] must beat .btn's own display (same fix as .nav-badge[hidden]/.status[hidden]) —
   otherwise a JS `btn.hidden = true` is a silent no-op, e.g. the calendar's today button. */
.btn[hidden] { display: none; }
/* THE SAME DEFECT ONE LEVEL UP, and it shipped: the UA's [hidden]{display:none} loses to ANY
   author `display`, so .btn-row's own `display:flex` (declared below) made `hidden` on a button ROW
   a silent no-op. Home's פרטיים|קבוצות tabs ship `hidden` and are unhidden only for a solo_plus
   tenant — without this companion they rendered on every tier, and on a plain solo tenant they were
   also permanently dead, because page-home.js wires their click handler only when teams are on.
   Declared here beside .btn[hidden] rather than beside .btn-row, so the two halves of one rule sit
   together and the next component with its own `display` has the precedent in view. */
.btn-row[hidden] { display: none; }
.btn-icon { flex: none; inline-size: 1.1em; block-size: 1.1em; }
/* hover lift — composited (transform/opacity only), hover-capable devices only so
   phones don't get a stuck hover state; :active still wins for the press-down. */
@media (hover: hover) {
  .card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
  .btn:hover:not(:active) { transform: translateY(-2px); }
}
.btn--primary { background: var(--color-primary); color: var(--color-on-primary); }
.btn--primary:hover { background: var(--blue-800); }
.btn--accent { background: var(--color-ok); color: var(--color-on-primary); }
.btn--accent:hover { background: #17532F; }
.btn--ghost { background: var(--color-secondary); color: var(--color-on-primary); border-color: var(--color-secondary); }
.btn--ghost:hover { background: #1F4E57; }
.btn--danger { background: var(--color-danger); color: var(--color-on-primary); border-color: var(--color-danger); }
.btn--danger:hover { background: #5A0202; }
/* secondary NAVIGATION action — a link-button that just goes somewhere and implies nothing
   is wrong. Sole consumer: the manager dashboard's "לוח שבועי" link (manager.js).
   Was .btn--warn in the app's bronze caution tone, which lent a navigation link a meaning it
   does not have; the name went with the colour so nothing still reads "warn". Uses the
   --color-accent / --color-on-accent PAIR (Light Blue #96C0CE on Midnight #0A2E34 = 7.38:1)
   rather than literals, so a per-school brand override in theme.js repaints both halves
   together. Measured dE2000 vs its real neighbours: 50 from the Midnight open/close pill,
   23 from the #3F7C8D count pill, 21 from the white card — distinct from all three.
   The bronze now means ONLY caution again: --color-warn / --color-warn-bg are untouched
   here and still drive .cal-block--cover and .progress__bar--low. */
.btn--nav { background: var(--color-accent); color: var(--color-on-accent); border-color: var(--color-accent); }
.btn--nav:hover { background: #6FA7B9; border-color: #6FA7B9; } /* 5.44:1, dE2000 8.1 from rest */
/* neutral / quiet action — REVERSIBLE, non-destructive operations (archive, restore).
   Red (.btn--danger) is reserved for irreversible deletes, so the louder button is never
   the safer one. */
.btn--neutral { background: var(--color-surface); color: var(--color-heading); border-color: var(--color-border); }
.btn--neutral:hover { background: var(--color-muted); border-color: var(--color-secondary); }
.btn--icon-only { padding-inline: var(--space-xs); min-width: 36px; }
.btn--sm { position: relative; min-height: 36px; padding: var(--space-xs) var(--space-m); font-size: var(--text-sm); }
/* small buttons stay 36px tall visually (the density these dense rows need) but carry a
   transparent 44px-tall hit area, so touch targets meet the 44px minimum without bulk. */
.btn--sm::after { content: ''; position: absolute; inset-block: -4px; inset-inline: 0; }
/* Measured, not assumed: .btn--sm's `padding` shorthand above is later at equal specificity,
   so it beats .btn--icon-only's `padding-inline` and every .btn--sm.btn--icon-only in the app
   is really a 49x36 glyph button (not 36x36). The -4px inset still takes it to 57x44, so the
   44px minimum holds — only the old "36×36 → 44×44" note was wrong. Declarations unchanged:
   narrowing them here would resize 9 buttons across the swaps/front-desk/manager screens. */
.btn--sm.btn--icon-only::after { inset: -4px; }
.btn--block { width: 100%; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-row { display: flex; flex-wrap: wrap; gap: var(--space-s); }
/* .btn-label is the text sibling of .btn-icon above — a hook so a button's WORDS can be
   hidden independently of its glyph. Mobile-first: .btn--nav.btn--sm collapses to icon-only
   below 40rem and gets its words back at 40rem, the same boundary the header uses to switch
   to its mobile layout, so the app has ONE definition of "narrow" rather than two.
   Why: the dashboard's instructor row (name + count pill + this link + the open/close pill)
   used to drop the button to a second row once the instructor's name got long, so card
   height and button position tracked name length. Collapsing the button frees ~69px.
   Every rule below is gated on .btn--nav.btn--sm — the label collapse AND the geometry that
   compensates for it, deliberately together. Gating only the geometry would let a future
   full-size .btn--nav lose its words below 40rem while keeping .btn's var(--space-l) inline
   padding, i.e. a ~2.5rem-wide "icon-only" button with a lot of dead space.
   The geometry is spelled out rather than reusing .btn--icon-only because that class is a
   measured no-op in this combination: its `padding-inline` loses to .btn--sm's later
   `padding` shorthand at equal specificity, so .btn--sm.btn--icon-only is really 49x36.
   .btn--nav.btn--sm is (0,2,0) so these actually win, and the `inset: -4px` hit-area trick
   is the same one .btn--sm.btn--icon-only::after uses above — 36x36 glyph, full 44x44
   target. Nothing here touches .btn--icon-only, so the 9 existing icon buttons on the
   swaps/front-desk/manager screens are byte-for-byte unaffected.
   The accessible name does NOT collapse: manager.js sets aria-label "לוח שבועי של <name>",
   which keeps the visible words verbatim as a prefix, so WCAG 2.5.3 Label in Name still
   holds at >=40rem where those words are on screen. */
.btn--nav.btn--sm .btn-label { display: none; }
.btn--nav.btn--sm { padding-inline: var(--space-xs); min-width: 36px; }
.btn--nav.btn--sm::after { inset: -4px; }
@media (min-width: 40rem) {
  .btn--nav.btn--sm .btn-label { display: inline; }
  .btn--nav.btn--sm { padding-inline: var(--space-m); min-width: 0; }
  .btn--nav.btn--sm::after { inset-block: -4px; inset-inline: 0; }
}

/* ---- forms ---- */
.field { display: block; }
/* [hidden] must beat .field's own display, same as .nav-badge[hidden]/.btn[hidden]/.status[hidden]
   above: an author `display` rule outranks the UA `[hidden]{display:none}`, so a `hidden` .field
   stayed fully visible. The email-migration dialog's conditional password field relies on this. */
.field[hidden] { display: none; }
.field > label, .field-label { display: block; font-weight: 600; margin-block-end: var(--space-xs); }
.field .hint { color: var(--color-text-soft); font-size: var(--text-sm); font-weight: 400; }
.input, .field input, .field select, .field textarea {
  width: 100%; min-height: var(--tap);
  padding: var(--space-s) var(--space-m);
  background: var(--color-surface); color: var(--color-text);
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  transition: border-color var(--transition), box-shadow var(--transition);
}
.field textarea { min-height: 4.5rem; resize: vertical; line-height: 1.5; }
/* same solid 3px ring every other control uses — the old 25%-opacity glow was the weakest
   focus indicator in the app and failed the 3:1 non-text contrast minimum on Alabaster. */
.field input:focus, .field select:focus, .field textarea:focus {
  border-color: var(--color-primary);
  outline: 3px solid var(--color-ring); outline-offset: 2px;
}
.field input:user-invalid, .field select:user-invalid { border-color: var(--color-danger); }
fieldset { border: 1px solid var(--color-border); border-radius: var(--radius); padding: var(--space-m); }
legend { font-weight: 700; padding-inline: var(--space-xs); color: var(--color-heading); }
.form-grid { display: grid; gap: var(--space-m); }
@media (min-width: 40rem) { .form-grid--2 { grid-template-columns: 1fr 1fr; } }

/* standalone checkbox + label row (e.g. "הצגת קבוצות בארכיון"). The LABEL is the touch
   target — a 20px box alone is far under the 44px minimum, so the whole row carries it. */
.check-row {
  display: inline-flex; align-items: center; gap: var(--space-s);
  min-block-size: var(--tap); padding-inline: var(--space-xs); cursor: pointer;
}
.check-row input { inline-size: 1.25rem; block-size: 1.25rem; flex: none; margin: 0; cursor: pointer; }

/* ---- day-of-week picker (group schedule) ---- */
.days-picker { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
.day-toggle { position: relative; }
.day-toggle input { position: absolute; opacity: 0; inset: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
.day-toggle span {
  display: grid; place-items: center;
  min-inline-size: var(--tap); min-block-size: var(--tap); padding-inline: var(--space-s);
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  font-weight: 700; color: var(--color-text-soft); background: var(--color-surface);
  transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.day-toggle input:checked + span { background: var(--color-primary); color: var(--color-on-primary); border-color: var(--color-primary); }
.day-toggle input:focus-visible + span { outline: 3px solid var(--color-ring); outline-offset: 2px; }

/* ---- kid-search filter row (front desk חיפוש) ---- */
.search-filters { display: flex; flex-wrap: wrap; gap: var(--space-m); }
.search-filters .field { flex: 1 1 10rem; min-inline-size: 8rem; }
/* "waiting for placement" counters — students with no group, or whose group was archived.
   Each count is a button that applies the matching filter, so the count is also the route. */
.placement { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-s); }
.placement[hidden] { display: none; }
.placement__label { font-weight: 600; color: var(--color-heading); }

/* ---- group picker (radio "cards" replacing the kid's group <select>) ----
   Same visually-hidden-input + styled-label pattern as .day-toggle above; a <select> cannot
   carry the per-option "full group" red outline this needs. */
/* The container itself no longer scrolls: only .group-picker__scroll does, so the pinned
   "ללא קבוצה" choice sits OUTSIDE the scroller and can never start scrolled out of view. */
.group-picker { display: grid; gap: var(--space-s); padding: var(--space-xs); border: 1px solid var(--color-border); border-radius: var(--radius); }
.group-picker__scroll { display: grid; gap: var(--space-s); max-block-size: 14rem; overflow-y: auto; }
.group-option { position: relative; display: flex; align-items: center; justify-content: space-between; gap: var(--space-s); min-block-size: var(--tap); padding: var(--space-s) var(--space-m); border: 1px solid var(--color-border); border-radius: var(--radius-sm); cursor: pointer; }
.group-option input { position: absolute; opacity: 0; inset: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
.group-option:has(input:checked) { border-color: var(--color-primary); background: var(--blue-100); }
.group-option:has(input:focus-visible) { outline: 3px solid var(--color-ring); outline-offset: 2px; }
/* the pinned "ללא קבוצה" choice — dashed edge marks it as "no group", not a group */
.group-option--none { border-style: dashed; color: var(--color-text-soft); }
.group-option--full { border-color: var(--color-danger); background: var(--color-danger-bg); }
.group-option--full:has(input:checked) { background: #EDC5C5; }
.group-option__name { font-weight: 600; overflow-wrap: break-word; min-inline-size: 0; }
/* the fit-sort key, made visible: level + birth year of each option (why this order?) */
.group-option__meta { flex: 0 1 auto; justify-content: flex-end; }

/* ---- kid picker (group-side "+ הוספת תלמידים" dialog) ---- */
.kid-picker-list { display: grid; gap: var(--space-xs); max-block-size: 14rem; overflow-y: auto; padding: var(--space-xs); border: 1px solid var(--color-border); border-radius: var(--radius); }
.kid-option { display: flex; align-items: center; gap: var(--space-s); min-block-size: var(--tap); padding: var(--space-xs) var(--space-s); border-radius: var(--radius-sm); cursor: pointer; }
.kid-option input { inline-size: 1.25rem; block-size: 1.25rem; flex: none; }
.kid-option:has(input:checked) { background: var(--blue-100); }
.kid-option__name { font-weight: 600; }
/* staged (not yet written) kids on the create-group form — removable chips */
.staged-kids { display: flex; flex-wrap: wrap; gap: var(--space-s); }
.staged-kid { display: inline-flex; align-items: center; gap: var(--space-xs); padding: var(--space-xs) var(--space-s); background: var(--color-muted); border: 1px solid var(--color-border); border-radius: var(--radius-pill); font-size: var(--text-sm); }
.staged-kid__remove { position: relative; display: grid; place-items: center; inline-size: 1.75rem; block-size: 1.75rem; padding: 0; background: none; border: 0; border-radius: 50%; color: var(--color-danger); font-size: var(--text-sm); cursor: pointer; }
/* 28px glyph, 44px hit area (chips must stay chip-sized) */
.staged-kid__remove::after { content: ''; position: absolute; inset: -8px; }
.staged-kid__remove:hover { background: var(--color-danger-bg); }

/* ---- expandable section (dashboard by instructor, My Groups) ---- */
.instructor-group { border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-surface); box-shadow: var(--shadow-sm); overflow: hidden; }
.instructor-group + .instructor-group { margin-block-start: var(--space-m); }
/* ---- dashboard instructor block: the disclosure and its calendar link as SIBLINGS ----
   WHY A WRAPPER AT ALL. The "לוח שבועי" link may not live inside <summary> (that is
   nested-interactive, WCAG 4.1.2 — <summary> is itself the disclosure's button) and it may not
   live anywhere else inside <details> either: a collapsed <details> hides its content with
   `content-visibility: hidden` on ::details-content, so a link in the reveal panel is invisible,
   unfocusable and unclickable in the DEFAULT collapsed state while still returning a
   getBoundingClientRect(). Sibling-of-<details> is the only placement that is both. The wrapper
   therefore takes over the CARD CHROME so the pair still reads as one card.
   SCOPED BY THE CHILD COMBINATOR ON PURPOSE. .instructor-group is shared — js/group-tree.js puts
   it on the folder rows of five other screens, and the nested/level3 folders INSIDE this very card
   also carry it — so the chrome is stripped only from the block's own direct child, never from the
   class itself. Nothing outside .instructor-block can match these rules. */
.instructor-block {
  display: flex; align-items: flex-start; gap: var(--space-s);
  border: 1px solid var(--color-border); border-radius: var(--radius);
  background: var(--color-surface); box-shadow: var(--shadow-sm); overflow: hidden;
}
.instructor-block + .instructor-block { margin-block-start: var(--space-m); }
.instructor-block > .instructor-group {
  flex: 1 1 auto; min-inline-size: 0;
  border: 0; border-radius: 0; background: none; box-shadow: none;
}
/* THE NARROW-WIDTH BUDGET, restored to exactly what it was before the link moved out of the
   summary — measured, not estimated. At 375 the card is 328px wide and the summary needs 145px
   for "name + count pill"; moving the link out cost it 24px (a 16px wrapper gap instead of the
   8px the summary-main used, plus the link's own 16px end margin) and left -main at 134px, so the
   count pill wrapped to a second line and the card grew 64 -> 88px. Two levers, both principled,
   give it back: an 8px gap below 40rem (the same mobile-first halving .instructor-group__summary-main
   already does, so the app keeps ONE definition of "narrow"), and dropping the summary's own
   inline-END padding, which is now dead space — the gap to the link supplies that gutter instead.
   Measured result: -main 134 -> 158px, which is the identical figure the pre-move layout gave it.
   The :has() guard matters: front desk renders no calendar link and neither does the
   "no instructor" bucket, and there the summary IS the card's inline-end, so it must keep its
   padding or the open/close pill would sit flush against the border. */
.instructor-block:has(> .instructor-block__link) > .instructor-group > .instructor-group__summary {
  padding-inline-end: 0;
}
/* `margin-block-start: var(--space-m)` is NOT a nudge — it is the summary's OWN block padding
   token, so the link's top edge and the instructor name's top edge sit on the same line at every
   viewport width and every font size. A computed-height offset ((summaryHeight - 36) / 2) would
   have been wrong at one of the two breakpoints, because --text-lg is a clamp() and the summary's
   height is therefore fluid. `flex: none` keeps the button at its natural size so the name column
   absorbs all the narrowing; the var(--space-m) gap above leaves 12px of clear space between this
   link's 44px hit area (::after inset:-4px) and the summary's, over the 8px minimum. */
.instructor-block__link { flex: none; margin-block-start: var(--space-m); margin-inline-end: var(--space-m); }
/* Breakpoint override at the FOOT of this component block, where a reader looks for it — a @media
   has no specificity, so placed above the base rule it would be silently dead. Longhand after the
   base rule's `gap` shorthand, which is the direction that wins at equal specificity. */
@media (min-width: 40rem) {
  .instructor-block { column-gap: var(--space-m); }
}
.instructor-group__summary {
  display: flex; align-items: center; gap: var(--space-s) var(--space-m); flex-wrap: wrap;
  min-block-size: var(--tap); padding: var(--space-m); cursor: pointer;
  font-weight: 700; color: var(--color-heading); list-style: none;
}
/* Dashboard's outer instructor summary only (3 real children + the ::after pill): the row itself
   never wraps, so the "פתיחה"/"סגירה" pill can't be pushed onto its own line — the name/count/
   calendar-link block absorbs any overflow instead and the pill stays centered beside it.
   That internal wrap used to fire on a phone once a name got long, which made card height and
   button position depend on name length; .btn--nav going icon-only below 40rem frees ~69px,
   which clears every current instructor name at phone widths with room to spare, so the
   flex-wrap below is a fallback rather than the normal narrow-screen path. */
.instructor-group__summary--wide { flex-wrap: nowrap; }
/* Mobile-first: 8px between the three children below 40rem, 16px from 40rem up. The two 16px
   column gaps were the only slack left that isn't the name itself, and halving them adds
   ~16px on top of the ~69px the button collapse frees — enough headroom that even a name
   well past the longest one currently in the data stays on one line at 360px. 8px is
   --space-s (on the scale, not a magic number) and still meets the 8px minimum spacing
   between adjacent touch targets.
   Treat that headroom as approximate, not a budget: these figures come from desktop Chromium,
   where `scrollbar-gutter: stable` (see html, above) reserves ~15px that a phone's overlay
   scrollbars do not, so an emulated width behaves like ~15px less page. Re-measure on a real
   device before spending the margin — e.g. before restoring 16px gaps here. */
.instructor-group__summary-main {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-s);
  flex: 1 1 auto; min-inline-size: 0;
}
@media (min-width: 40rem) {
  .instructor-group__summary-main { column-gap: var(--space-m); }
}
.instructor-group__summary::-webkit-details-marker { display: none; }
.instructor-group__name { font-size: var(--text-lg); margin: 0; }
.instructor-group__summary::after {
  content: 'פתיחה'; margin-inline-start: auto;
  padding: var(--space-xs) var(--space-m); border: 1px solid var(--color-primary); border-radius: var(--radius-pill);
  font-size: var(--text-sm); font-weight: 600; color: var(--color-on-primary); background: var(--color-primary);
  transition: background var(--transition);
}
.instructor-group[open] > .instructor-group__summary::after { content: 'סגירה'; }
.instructor-group__summary:hover::after { background: var(--blue-800); }
.instructor-group__summary:focus-visible { outline: 3px solid var(--color-ring); outline-offset: -3px; }
/* smooth expand/collapse: grid-rows 0fr->1fr on the reveal wrapper, inner clips overflow.
   Neutralised by the global prefers-reduced-motion block (transition-duration:.01ms). */
.instructor-group__reveal { display: grid; grid-template-rows: 0fr; transition: grid-template-rows var(--transition); }
.instructor-group[open] > .instructor-group__reveal { grid-template-rows: 1fr; }
.instructor-group__body { overflow: hidden; min-block-size: 0; padding: var(--space-m); border-block-start: 1px solid var(--color-border); background: var(--color-surface); }
/* nested (group-level) disclosure inside an instructor section — lighter, compact */
.instructor-group--nested { box-shadow: none; background: var(--color-surface); }
.instructor-group--nested > .instructor-group__summary { min-block-size: 40px; padding: var(--space-s) var(--space-m); }
/* var(--font) is load-bearing here, not inherited-by-accident. renderGroupTree() emits a HEADING at
   depth 0 (js/group-tree.js:174), so when a page passes a single-level stack the top folder is both
   `--nested`-free and `--level3`, and this class can therefore sit on an h2/h3 that the `h1, h2, h3`
   rule has already put in the display face — at 16px (and 14px on --level3 below), well under the
   ~17px floor the display face is reserved for. Stating the body face on both variants makes the
   size tier decide the family, whatever element the renderer chose. */
.instructor-group--nested .instructor-group__name { font-family: var(--font); font-size: var(--text-body); }
.instructor-group--nested > .instructor-group__reveal > .instructor-group__body { background: var(--color-surface); padding-block-start: var(--space-s); }
/* v1.1.21 — the INNERMOST non-leaf folder of whatever level stack a page passes to
   renderGroupTree(): the hour folder on the groups/history trees and on "הקבוצות שלי", the
   student folder on both פרטיים trees. The name says "innermost visual tier", NOT a literal
   position: the instructor's own פרטיים tree is only TWO levels deep, so there it lands on level
   index 1. Logical properties only, so the indent mirrors correctly in RTL. Three stacked visual
   tiers (bold bordered card → flat mid row → slim indented innermost row → leaf card) still read
   clearly at 375px in the existing single-column tree; no new breakpoint needed. */
.instructor-group--level3 {
  border-inline-start: 3px solid var(--color-border);
  margin-inline-start: var(--space-s);
}
.instructor-group--level3 > .instructor-group__summary { min-block-size: 36px; padding: var(--space-xs) var(--space-s); }
.instructor-group--level3 .instructor-group__name { font-family: var(--font); font-size: var(--text-sm); font-weight: 600; }

/* ---- weekly calendar (instructor) — hour × day time grid ---- */
/* NOT sticky — evaluated for v1.1.21 task 11 and rejected on measurement, recorded here so it is
   not re-proposed. Making the week-nav row sticky so the anchor-scroll can never carry it away
   would need a coordinated offset stack, and this page has none: .app-header is ALREADY
   `position: sticky; inset-block-start: 0; z-index: 20`, and .cal-dayhead below ALSO sticks at 0
   — i.e. the day-head row is already occluded by the header today (pre-existing, out of scope
   here, but proof there is no offset system to slot into). Re-measured at 375x667 after v1.1.22
   made the mobile nav a one-line scrolling strip: the header is 120px — 12px wrapper padding + a
   44px brand/logout row + 4px row-gap + a 60px nav row (44px pill + 2x8px focus-ring headroom).
   That replaces the ~144px this comment used to claim, which was stale twice over: while the nav
   still wrapped the header actually measured 152px. A sticky week-nav would still add 80px
   (re-measured; its two buttons and the label wrap to three rows at that width) and the day-head
   ~51px: ~251px, 38% of the viewport, permanently. v1.1.22 narrows ONE clause of the old
   rationale — at <=40rem the header measured 120px at every width from 320px up, and the NAV no
   longer varies with role, school name or font, because it no longer wraps. It does NOT change
   the conclusion, and the height is still not fixed: row 1 wraps to a third row behind a wide
   school logo (.shell-logo has no max-inline-size — see the note by the mobile header block), and
   above 40rem the header still varies with wrapping (measured 197.75px at 641px, 112px at
   1440px). This page is used at all those widths, so the offsets still could not be written
   honestly in CSS — it would take either a magic number that is wrong at several
   widths or JS re-measuring into a custom property on every resize. Both are far more machinery,
   and more risk at 375px, than the scroll behaviour they would protect. The trade-off is handled
   in js/auth-guard.js instead, gated on :focus-visible so it costs pointer/touch users nothing. */
.week-nav { gap: var(--space-s); }
.cal-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; border-radius: var(--radius); }
.cal-grid {
  display: grid; gap: 1px; min-inline-size: 46rem; grid-auto-rows: minmax(2rem, auto);
  background: var(--color-border); border: 1px solid var(--color-border); border-radius: var(--radius);
}
.cal-grid > * { background: var(--color-surface); }
.cal-corner { background: var(--color-muted); }
.cal-dayhead {
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  padding: var(--space-s) var(--space-xs); background: var(--color-muted);
  font-weight: 700; color: var(--color-heading); text-align: center; position: sticky; inset-block-start: 0;
}
.cal-dayhead--today { background: var(--blue-100); }
.cal-dayhead__date { font-weight: 500; font-size: var(--text-sm); color: var(--color-text-soft); }
.cal-time { padding: var(--space-xs); font-size: var(--text-sm); color: var(--color-text-soft); text-align: center; white-space: nowrap; }
/* NO `opacity` HERE. It used to carry `opacity: .6`, which composited --color-text-soft (#5F5A50)
   toward the white cell (.cal-grid > *, :650) to rgb(159,156,150) and measured 2.74:1 — a 1.4.3
   failure on every :30 gutter label of the manager and instructor calendars. The token alone on
   that same white measures 6.85:1, so the smaller type is the whole de-emphasis and it is enough.
   Group opacity never preserves a text/ground ratio; carry "quieter" on size or weight instead. */
.cal-time--half { font-size: .72rem; }
.cal-cell { display: grid; gap: 2px; align-content: start; padding: 2px; min-block-size: 2rem; }
.cal-cell--today { background: color-mix(in srgb, var(--blue-100) 35%, var(--color-surface)); }
/* v1.1.19 item 3 — a genuinely EMPTY slot the instructor has DECLARED themselves available for.
   Reuses the existing --color-ok token (no new colour): it is a dark forest green, so a 12% mix
   into white still lands a clearly visible mint (~rgb(227,238,232)) rather than a wash. Declared
   more informative than "today", so it deliberately sits AFTER .cal-cell--today and wins where a
   cell is both. Never colour-only: renderWeeklyCalendar() puts a .visually-hidden "פנוי, יום X
   בשעה Y" line inside each tinted cell. Booked cells are never tinted — the rule only ever reaches
   the empty branch. */
.cal-cell--open { background: color-mix(in srgb, var(--color-ok) 12%, var(--color-surface)); }
/* a duration block fills the height of the slots it spans, communicating length visually */
.cal-cell--span { align-content: stretch; }
.cal-cell--span .cal-block { block-size: 100%; }
.cal-block {
  display: block; padding: var(--space-xs) var(--space-s); border-radius: var(--radius-sm);
  border: 2px solid transparent; background: var(--blue-100); color: var(--blue-900);
  text-decoration: none; font-weight: 600; font-size: var(--text-sm); line-height: 1.25;
  transition: background var(--transition), border-color var(--transition);
}
.cal-block:hover { background: var(--color-secondary); color: var(--color-on-primary); }
.cal-block--done { border-color: var(--color-ok); }
/* covered-away: this group's own lesson is being covered by another instructor (swap) */
.cal-block--covered { border-style: dashed; border-color: var(--color-warn); }
/* a lesson the viewer is covering for someone else (approved swap) — read-only (not their
   group, so no link). Yellow/warn OUTLINE + faint warn fill: distinct from the green "done"
   border and the blue accent blocks, and solid (not dashed) so it reads apart from the
   dashed "covered-away" marker above. */
.cal-block--cover { background: var(--color-warn-bg); border-style: solid; border-color: var(--color-warn); color: var(--color-heading); cursor: default; }
.cal-block--cover:hover { background: var(--color-warn-bg); color: var(--color-heading); }
/* v1.1.21 task 9 — a PRIVATE lesson (blockFor() adds this only for groups whose kind is
   'private'). Colour is a reinforcement, never the only signal: the block's own .cal-block__lvl
   line already ends in " · פרטי" and its aria-label carries the same words.
   .cal-block--cover can never co-occur with this: --cover is applied only by extraBlockFor()
   (someone else's lesson the viewer is covering, read-only) and --private only by blockFor()
   (the viewer's OWN groups) — two structurally separate render functions, so there is no
   precedence conflict to resolve. */
.cal-block--private { background: var(--color-private); color: var(--color-on-primary); border-color: transparent; }
/* MEASURED, and the reason this override exists at all: --color-ok (#1F6E45) on the purple fill is
   1.20:1 — invisible, and a straight WCAG 1.4.11 failure for the one border that carries "this
   lesson is already recorded". --green-300 is that same green lightened 40/60 toward white: still
   unmistakably GREEN (so both calendars' "מסגרת ירוקה" legend stays true), and 3.99:1 on the
   resting fill / 5.10:1 on the hover fill. Do not darken it back toward --color-ok. */
.cal-block--private.cal-block--done { border-color: var(--green-300); }
/* --covered's dashed border is --color-warn (deep Tan/brown) — 1.31:1 on this fill, so it lightens
   here, mirroring the exact rgba(255,255,255,…) treatment .cal-block:hover already uses for the
   same "dark fill needs a light overlay" reason. 5.92:1. Being LATER and equal-specificity (0,2,0)
   to the --done rule above, it also wins when a block is both done AND covered — the same
   precedence the two base modifiers already have. */
.cal-block--private.cal-block--covered { border-color: rgba(255, 255, 255, .85); }
.cal-block--private .cal-block__lvl { color: rgba(255, 255, 255, .85); }  /* 5.92:1 */
.cal-block--private .cal-block__note { color: rgba(255, 255, 255, .95); } /* 6.92:1 */
/* Its own hover, staying recognisably purple rather than flipping to the shared teal
   --color-secondary like every other block does. */
.cal-block--private:hover { background: color-mix(in srgb, var(--color-private) 82%, black); color: var(--color-on-primary); }
/* An UNTAKEN slot — real time on the owner's own week, but NOT a lesson (solo tier only;
   renderWeeklyCalendar() emits this exclusively for a blockKindFn(g) === true block, which no
   school-portal caller passes). Faint + dashed so it reads as "space" rather than as content, and
   NEVER colour-only: the block's single line literally says "זמן פנוי".
   Additive — every rule ABOVE this point is untouched, so each existing block class renders
   identically. That is a standing requirement, not a nicety: the school portals hover-style
   .cal-block--private right above, and dropping that one rule silently made a private 1:1 hover
   identical to an ordinary group block on instructor/calendar.html and manager's instructor week.
   RECOMPUTED, not transcribed: --color-text-soft #5F5A50 is 6.85:1 on --color-surface (white) and
   6.21:1 on the lightest cell tint (.cal-cell--today, a 35% --blue-100 mix), both well past AA.
   The DASHED border measures 2.05:1 — deliberately below 3:1, because this is a static <div>, not
   a UI control, so 1.4.11 does not bind it, and the border only reinforces text that already
   carries the whole meaning.
   The :hover neutraliser is load-bearing, not tidiness — .cal-block:hover flips the fill to
   --color-secondary, which would advertise a hover affordance on a block that cannot be clicked. */
.cal-block--available {
  background: transparent;
  border: 2px dashed color-mix(in srgb, var(--color-text-soft) 45%, var(--color-surface));
  color: var(--color-text-soft);
  font-weight: 500;
  cursor: default;
}
.cal-block--available:hover { background: transparent; color: var(--color-text-soft); }
.cal-block__name { display: block; overflow-wrap: break-word; }
.cal-block__lvl { display: block; font-weight: 400; font-size: .78rem; color: var(--color-text-soft); }
.cal-block__note { display: block; margin-block-start: 2px; font-weight: 600; font-size: .72rem; color: var(--color-warn); }
.cal-block--cover .cal-block__note { color: var(--color-warn); }
.cal-block:hover .cal-block__lvl { color: rgba(255, 255, 255, .85); }
.cal-block:hover .cal-block__note { color: rgba(255, 255, 255, .9); }
.cal-block--cover:hover .cal-block__note { color: var(--color-warn); }
.cal-untimed { margin-block-start: var(--space-m); }
.day-chip {
  display: block; padding: var(--space-s) var(--space-m);
  border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  background: var(--color-bg); color: var(--color-heading); font-weight: 600; text-decoration: none;
  transition: background var(--transition), border-color var(--transition);
}
.day-chip:hover { background: var(--blue-100); border-color: var(--color-secondary); }

/* ---- pills / badges ---- */
.pill {
  display: inline-flex; align-items: center; gap: var(--space-xs);
  padding: 2px var(--space-s); border-radius: var(--radius-pill);
  font-size: var(--text-sm); font-weight: 600; line-height: 1.6;
  /* an unclassed pill inherits the fill of .pill--muted so it still reads as intentional —
     since v1.1.18 that is RED, so an unclassed pill is now visually loud: give every new pill
     an explicit modifier rather than relying on this default. */
  background: var(--pill-muted-bg); color: var(--pill-muted-text);
}
/* .pill--data carries factual, neutral information: birth years, N/capacity counts,
   averages, group/day/skill counts, lesson labels. .pill--muted is reserved for the
   ABSENCE of something — archived, unknown level, "טרם", an unfilled swap — so it never
   competes with live data. */
.pill--data { background: var(--pill-data-bg); color: var(--pill-data-text); }
/* Level pills are green by product decision. NOTE: this is deliberately the same pair as
   .pill--ok, so a level and a success/approved state are visually identical where they
   co-occur (swaps card). An UNKNOWN level ('—') still renders .pill--muted, set at the render
   sites, so "no level" never reads as a success state. */
.pill--level { background: var(--pill-ok-bg); color: var(--pill-ok-text); }
.pill--role { background: var(--pill-role-bg); color: var(--pill-role-text); }
.pill--ok { background: var(--pill-ok-bg); color: var(--pill-ok-text); }
.pill--flag { background: var(--pill-flag-bg); color: var(--pill-flag-text); }
.pill--danger { background: var(--pill-danger-bg); color: var(--pill-danger-text); }
/* KNOWN COLLISION, shipped deliberately (v1.1.18, user-requested red). --pill-muted-bg #C22E22
   sits only ΔE2000 10.6 from .pill--danger's #9E4444 — the closest pair in the file, tighter
   than the flag/muted 17.2 it replaced — and the two DO co-occur: buildGroupPicker()
   (js/auth-guard.js) renders "מלאה" as .pill--danger in the same row where an archived group
   renders "בארכיון" as .pill--muted, so "archived" and "full" read as near-identical there.
   Recorded the same way as the .pill--level/.pill--ok pair above rather than silently absorbed.
   Both keep their own TEXT, so meaning is never carried by colour alone; if the two ever need to
   be told apart at a glance, move .pill--muted rather than .pill--danger (danger's maroon is
   load-bearing for destructive controls elsewhere). */
.pill--muted { background: var(--pill-muted-bg); color: var(--pill-muted-text); }
/* equal-width digits: counts, years and times stop shifting the controls beside them */
.pill, .list-item__meta, .cal-time, .cal-dayhead__date, .staged-kid, bdi, .progress-num {
  font-variant-numeric: tabular-nums;
}

/* ---- lists ---- */
.list { list-style: none; padding: 0; display: grid; gap: var(--space-s); }
/* v1.1.21 task 14 — BOUND THE FLEX CONTAINER, not one of its children.
   History: this row is `display:flex; justify-content:space-between` with exactly two children
   (.list-item__main, .list-item__actions). Unbounded inside the 60rem .wrapper, space-between
   stranded the actions ~700px from the name they belong to, so v1.1.19 capped the CHILD
   (.list-item__main { max-inline-size: 30rem }) and switched the row to flex-start from 40rem up.
   That traded one symptom for another: on a wide viewport the capped content column plus the
   actions huddled near the start with a large dead gap running to the row's true end edge, because
   flex-start distributes no free space at all. Two patches, one real cause — an unbounded box.
   So: the 30rem child cap AND the 40rem override are both gone, plain space-between is live again
   at EVERY width, and the ROW ITSELF carries the reading-width bound. 46rem is not an invented
   number — it is this stylesheet's own existing wide-content measure (.cal-grid's
   min-inline-size), and it is the same reasoning the 30rem cap was written with: ~30rem of content
   column plus an actions column plus the row's own 2rem of padding.
   The cap is on .list-item and NOT on .list on purpose: .list also holds the tree's leaf CARDS
   (li.card), which must keep filling their folder body. Bounding a box can only pull its two ends
   closer together, never push them apart, so no other .list-item consumer can regress in the
   direction that caused the original bug.
   The measure is a TOKEN because the controls sitting above these rows must share it — see
   .lookup below; browser QA measured a 192px ragged edge when only the rows were capped. */
.list-item {
  display: flex; flex-wrap: wrap; gap: var(--space-s) var(--space-m);
  align-items: center; justify-content: space-between;
  padding: var(--space-m); background: var(--color-surface);
  border: 1px solid var(--color-border); border-radius: var(--radius);
  max-inline-size: var(--measure-row);
}
/* BUG-04 — the kid-lookup screen's own controls share the result rows' measure. Without this the
   search field and filter row spanned the full 58rem wrapper while every row below capped at
   46rem, leaving a 192px ragged edge down the start side of the screen. Scoped to the section
   (.lookup on frontdesk/index.html) rather than applied to .field/.check-row globally, which are
   used by every form in the app. The h1 and the primary button are deliberately NOT capped —
   headings and a lone button read fine spanning the wrapper, and it is the CONTROL EDGES that
   have to line up with the rows. */
.lookup > .field,
.lookup > .search-filters,
.lookup > .check-row,
.lookup > .placement { max-inline-size: var(--measure-row); }
.list-item__main { min-inline-size: 0; }
.list-item__title { font-weight: 700; color: var(--color-heading); }
.list-item__meta { color: var(--color-text-soft); font-size: var(--text-sm); }
.list-item__actions { display: flex; flex-wrap: wrap; gap: var(--space-s); }

/* ---- progress ---- */
.progress { position: relative; height: 12px; background: var(--color-muted); border-radius: var(--radius-pill); overflow: hidden; }
.progress__bar { position: absolute; inset-block: 0; inset-inline-start: 0; width: var(--pct, 0%); background: var(--color-secondary); border-radius: inherit; transition: width var(--transition); }
.progress__bar--low { background: var(--color-warn); }
.progress__bar--full { background: var(--color-ok); }
.progress__bar--behind { background: var(--color-danger); }

/* ---- skill checkboxes (instructor poolside flow — fastest tap) ---- */
.kid-block { border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-surface); box-shadow: var(--shadow-sm); overflow: hidden; }
.kid-block__head { display: flex; flex-wrap: wrap; gap: var(--space-s); align-items: center; justify-content: space-between; padding: var(--space-m); background: var(--color-muted); border-block-end: 1px solid var(--color-border); }
/* Absent kid: red strikethrough over the name (paired with the toggle button text + aria-pressed, so meaning is never color-only). */
.kid-name--absent { text-decoration: line-through; text-decoration-color: var(--color-danger); text-decoration-thickness: 2px; }
.kid-block__progress { display: grid; gap: var(--space-xs); padding: var(--space-s) var(--space-m); border-block-end: 1px solid var(--color-border); }
.skills { list-style: none; padding: 0; margin: 0; }
.skill-check {
  display: flex; align-items: center; gap: var(--space-m);
  min-height: 56px; padding: var(--space-s) var(--space-m);
  border-block-start: 1px solid var(--color-border); cursor: pointer;
}
.skill-check:first-child { border-block-start: 0; }
.skill-check input[type="checkbox"] {
  inline-size: 28px; block-size: 28px; flex: none; margin: 0;
  accent-color: var(--color-ok); cursor: pointer;
}
.skill-check__text { display: flex; flex-direction: column; }
.skill-check__name { font-weight: 600; }
.skill-check input:checked ~ .skill-check__text .skill-check__name { color: var(--color-ok); text-decoration: line-through; }
.skill-check__notes { color: var(--color-text-soft); font-size: var(--text-sm); }
@media (hover: hover) { .skill-check:hover { background: var(--color-bg); } }
/* keep focused checkboxes clear of the sticky "finish lesson" bar (WCAG 2.2 Focus Not Obscured) */
body[data-page="group"] { scroll-padding-block-end: 5.5rem; }
body[data-page="group"] #kids { padding-block-end: 5.5rem; }

/* ---- toast ---- */
.toast {
  position: fixed; inset-block-end: var(--space-l); inset-inline: var(--space-m);
  margin-inline: auto; max-inline-size: 30rem; z-index: 60;
  padding: var(--space-s) var(--space-m); border-radius: var(--radius-sm);
  box-shadow: var(--shadow-md); font-weight: 600; text-align: center;
  opacity: 0; translate: 0 1rem; pointer-events: none;
  transition: opacity var(--transition), translate var(--transition);
  background: var(--slate-900); color: var(--white);
}
.toast.is-visible { opacity: 1; translate: 0 0; }
.toast--error { background: var(--color-danger); }
.toast--ok { background: var(--color-ok); }
.toast--info { background: var(--color-primary); }

/* ---- inline status (forms / router) ---- */
.status { padding: var(--space-s) var(--space-m); border-radius: var(--radius-sm); font-weight: 600; }
.status[hidden] { display: none; }
.status--error { background: var(--color-danger-bg); color: var(--color-danger); }
.status--ok { background: var(--color-ok-bg); color: var(--color-ok); }
.status--info { background: var(--color-muted); color: var(--color-heading); }

/* ---- empty / loading ---- */
.empty { text-align: center; color: var(--color-text-soft); padding: var(--space-xl) var(--space-m); }
.loading { color: var(--color-text-soft); padding: var(--space-l); text-align: center; animation: loading-pulse 1.5s ease-in-out infinite; }
/* opacity-only pulse (composited); global prefers-reduced-motion sets animation-duration:.01ms */
@keyframes loading-pulse { 0%, 100% { opacity: .6; } 50% { opacity: 1; } }

/* ---- dialog ---- */
dialog {
  width: min(34rem, calc(100vw - 2rem)); max-height: calc(100svh - 2rem);
  padding: 0; border: none; border-radius: var(--radius);
  box-shadow: 0 20px 50px rgba(15, 65, 74, .35); background: var(--color-surface); color: var(--color-text);
}
dialog::backdrop { background: rgba(15, 65, 74, .55); }
dialog { overflow: auto; overscroll-behavior: contain; }
/* The dialog is the scroll container, and .dialog__actions below is sticky to its bottom edge —
   so a focus()/scrollIntoView() that lands a field at the "bottom" parks it BEHIND that band
   (WCAG 2.4.11 Focus Not Obscured). This reserves the band's height for every scroll-into-view
   inside a dialog, including the browser's own focus scrolling.
   MEASURED, not guessed: the band is 128px at 375px and 144px at 1440px (it grows because both
   .btn's inline padding and .dialog__actions' padding-block read var(--space-l), which is a
   clamp on vw), on #group-create-dialog / #group-dialog — the two-row worst case. 10rem = 160px
   covers 144px. The 16px over is slack for a longer button label, NOT a budget to spend:
   re-measure with band-height tooling before relying on it. */
dialog { scroll-padding-block-end: 10rem; }
.dialog__body { padding: var(--space-l); }
/* A tall dialog (the kid editor is taller than a 375px viewport) must never hide its own
   שמירה/ביטול row behind an inner scroller: the action row sticks to the dialog's bottom.
   The negative block-end margin lets it reach the body's padding edge. */
.dialog__actions {
  position: sticky; inset-block-end: 0; z-index: 1;
  margin-block-end: calc(var(--space-l) * -1);
  padding-block: var(--space-s) var(--space-l);
  background: var(--color-surface);
  box-shadow: 0 -.5rem .75rem -.75rem rgba(15, 65, 74, .45);
}
.dialog__head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-m); margin-block-end: var(--space-m); }
.dialog__head h2 { font-size: var(--text-lg); }
.dialog-close { min-height: var(--tap); min-width: var(--tap); border: 1px solid var(--color-border); background: var(--color-surface); border-radius: var(--radius-sm); }

/* ---- login / auth page ---- */
.auth-shell { min-height: 100svh; display: grid; place-items: center; padding: var(--space-l) var(--space-m); }
.auth-card { width: min(28rem, 100%); }
/* signup.html's card carries a two-step form (a 2-column name/email grid and seven
   day / from / to rows), which 28rem cramps at desktop widths. Declared AFTER
   .auth-card and at the SAME specificity (0,1,0), so plain document order wins it
   — no !important, and every other .auth-card consumer (index.html) is untouched.
   min() keeps the mobile width identical at 375px. */
.signup-card { width: min(38rem, 100%); }
.auth-brand { display: flex; flex-direction: column; align-items: center; gap: var(--space-s); text-align: center; margin-block-end: var(--space-l); color: var(--color-primary); }
.auth-brand svg { width: 3rem; height: 3rem; }
.auth-logo { max-height: 3rem; width: auto; object-fit: contain; margin-block-end: var(--space-s); }
/* PINNED AT THE PRE-1.5x --text-title VALUE, DELIBERATELY NOT THE TOKEN. This h1 is not a page
   title — it is the LapKeeping wordmark (.brand-lockup), which carries `white-space: nowrap`
   because a wordmark may not break — inside .auth-card, a fixed min(28rem, 100%) box. It is the
   one consumer of --text-title that cannot absorb the owner's 1.5x, and the failure is horizontal
   page scroll on the entry screen rather than a wrap. MEASURED at a real 375px viewport, where the
   card's usable inner width is 295px (343px card - 2x24px --space-l padding) — IN THE PRE-SWAP
   FACES, which is why the widths are named. The wordmark used to render in Assistant (it was forced
   onto var(--font) because the old display face had no Latin glyphs); it now renders in FREDOKA 600
   via --font-display, a wider rounded face, so the pinned column below is the one that has to be
   re-measured — the 1.5x column only gets worse and its verdict is safe in the conservative
   direction:
     1.5x (62.475px):  Assistant 323.53  Segoe UI 338.55  Noto Sans Hebrew 351.25  -> OVERFLOWS by
                       28.53 / 43.55 / 56.25px, in every font, including the swap-period fallback
     pinned (41.65px): Assistant 215.72  Segoe UI 225.72  Noto Sans Hebrew 234.20  -> 60-79px slack
   Those 60-79px of slack are the budget Fredoka now spends against; the fallbacks are unchanged.
   The literal is the old token verbatim; if --text-title is ever re-tuned, this is an independent
   decision and should be re-measured, not silently re-linked. */
.auth-brand h1 { font-size: clamp(2.5rem, 1.9rem + 3vw, 4rem); }
.tabs { display: flex; gap: var(--space-xs); background: var(--color-muted); padding: var(--space-xs); border-radius: var(--radius-pill); margin-block-end: var(--space-l); }
.tabs button {
  flex: 1; min-height: var(--tap); border: none; background: transparent;
  border-radius: var(--radius-pill); font-weight: 600; color: var(--color-text-soft);
  transition: background var(--transition), color var(--transition);
}
.tabs button[aria-selected="true"] { background: var(--color-surface); color: var(--color-primary); box-shadow: var(--shadow-sm); }
/* The .tabs LINK twins, for signup.html's הרשמה / כניסה pair. Those two are real
   cross-document navigation, so they are <a href> and NOT role="tab" buttons —
   ARIA tabs are reserved for switching a same-page panel, which is exactly what
   the <button> pair above does. Same visual pill bar, keyed on aria-current="page"
   instead of aria-selected.
   DECLARED PER ELEMENT, and that is the working part: .tabs button's own rule
   supplies `background: transparent` and gets `text-align:center` from the UA
   button sheet, neither of which an <a> inherits — an anchor is inline and
   start-aligned, so reusing the class without these two declarations would have
   left the unselected link start-aligned and the selected one looking like "the
   grey one turned blue". The flex/inline-flex centring below is what replaces
   both. Nothing above is modified. */
.tabs a {
  flex: 1; min-height: var(--tap); display: inline-flex;
  align-items: center; justify-content: center; text-align: center;
  background: transparent; text-decoration: none;
  padding: var(--space-s) var(--space-m);
  border-radius: var(--radius-pill); font-weight: 600; color: var(--color-text-soft);
  transition: background var(--transition), color var(--transition);
}
.tabs a[aria-current="page"] { background: var(--color-surface); color: var(--color-primary); box-shadow: var(--shadow-sm); }
.auth-panel[hidden] { display: none; }
/* [hidden] must beat .tabs' own display, same as .nav-badge[hidden]/.btn[hidden]/.field[hidden]/
   .status[hidden]/.auth-panel[hidden] above: an author `display` rule outranks the UA
   `[hidden]{display:none}`, so a `tablist.hidden = true` from JS is a silent no-op without this.
   js/main.js hides the whole tablist on the shared login host (app.lapkeeping.com), where staff
   self-signup does not apply and a lone "כניסה" tab is a control that does nothing. */
.tabs[hidden] { display: none; }
.waiting { text-align: center; }
.waiting svg { width: 3rem; height: 3rem; margin-inline: auto; color: var(--color-secondary); }

/* ---- .stepper — the signup wizard's two-step progress indicator ----
   NEW component: nothing in this app had a multi-step form before signup.html.
   Additive and scoped by USAGE (one page emits it), not by selector, matching how
   .tabs/.waiting are declared here.
   NO COLOUR-ONLY STATE: the numeral from counter() and the step's own words are
   always rendered, so "which step am I on" survives a monochrome render — the tint
   and the ✓-coloured done fill are reinforcement, never the message. The current
   step is marked with aria-current="step" (the standard token for a linear
   sequence) and the finished one with data-done, so both states are in the DOM as
   well as in the paint. flex-wrap so two Hebrew step names never overflow 375px. */
.stepper {
  display: flex; flex-wrap: wrap; gap: var(--space-m); list-style: none;
  padding: 0; margin-block-end: var(--space-l); counter-reset: step;
}
.stepper li {
  display: flex; align-items: center; gap: var(--space-xs);
  color: var(--color-text-soft); font-weight: 600; font-size: var(--text-sm);
}
.stepper li::before {
  counter-increment: step; content: counter(step);
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.75rem; height: 1.75rem; border-radius: 50%;
  background: var(--color-muted); color: var(--color-heading); flex: none;
}
.stepper li[aria-current="step"] { color: var(--color-primary); }
.stepper li[aria-current="step"]::before { background: var(--color-primary); color: var(--color-on-primary); }
.stepper li[data-done="true"]::before { background: var(--color-ok); color: var(--color-on-primary); }

.muted { color: var(--color-text-soft); }
.text-danger { color: var(--color-danger); }
.mt-l { margin-block-start: var(--space-l); }
.mt-m { margin-block-start: var(--space-m); }
.nowrap { white-space: nowrap; }

/* ===========================================================================
   SOLO PORTAL ONLY — everything below is gated on body[data-portal="solo"],
   an attribute carried by the eight solo/*.html pages and by nothing else.
   NOTHING ABOVE THIS LINE IS MODIFIED, and that is the whole design: this file
   is shared with the school portals, and an earlier round regressed reef by
   editing in place (a new block inserted ABOVE .cal-block--private:hover
   deleted it). Appending a scoped section cannot do that. The school app keeps
   the warm Alabaster ground and the light-teal calendar blocks, by the owner's
   own earlier decision — verify with a CSS-level differ + `git diff --numstat`.

   ⚠ TWO CORRECTIONS TO THE PARAGRAPH ABOVE. Read them before acting on it.

   1. "MUST SHOW ADDITIONS ONLY" IS SUPERSEDED, and the superseding case has
      already happened. It is the right default and stays the default — but it is
      not an invariant, because a scoped rule can be added by one round and
      DELIBERATELY REVERTED by a later one. That is exactly what the Alabaster
      revert did: it removed `--color-bg: var(--white)` from this section plus the
      two overrides that only existed to compensate for it. A maintainer applying
      the old clause literally would read those three deletions as the regression
      this header exists to prevent and RESTORE them, silently re-breaking the
      revert and repainting every solo page white again.
      The real invariant, which the deletions do satisfy, is narrower and is what
      to check instead: every changed line sits at or below this header, and NO
      selector outside `body[data-portal="solo"]` changes in any way. A deletion
      INSIDE the scope is legitimate when it is the point of the change; a
      deletion outside it never is.
   2. `cssdiff.py` DOES NOT EXIST IN THIS REPO. It was named here as "the
      project's own tool"; there is no such file (there are no .py files at all).
      Anyone who needs this check has to write it. A spec for re-creating it —
      list-keyed so a duplicate selector cannot hide a deletion, pinned to a TAG
      rather than HEAD, with a MEASURED rule-count floor and two must-fail
      controls — is in PLAN-solo-lesson-controls.md §7.2.

   This comment BLOCK is deliberately edited in place, above the section's own
   "nothing above this line is modified" rule, because that rule governs CSS
   RULES, not the prose describing them. Frozen prose that has gone false is not
   a safety property; it is a booby trap, and it cannot be corrected from below.
   Note that correcting it MOVED THE BOUNDARY LINE, so every `:975` an earlier
   round cites for it is stale. No replacement number is given, deliberately: the
   last one was wrong within a day, and writing a fresh number into the very
   sentence whose job is to stop people trusting the old one is how this defect
   keeps recurring. CITE THIS HEADER, NOT A LINE NUMBER — "everything below the
   SOLO-PORTAL header" stays true under every future edit; a number does not.

   Why an attribute and not a token edit: --color-bg / --color-surface are NOT
   in theme.js's BRAND_VARS, so they cannot be repointed per tenant at runtime;
   changing them on :root would repaint every school too.
   =========================================================================== */

/* THE PAGE GROUND IS THE SHARED ALABASTER (#EFE8DF) AGAIN — solo no longer
   repoints --color-bg at all, by the owner's own revert. What used to sit here
   was `body[data-portal="solo"] { --color-bg: var(--white); }` plus the two
   compensating overrides it forced (.skill-check:hover and .day-chip, both of
   which read --color-bg as "the page colour, a shade off the white card" and
   went white-on-white under it). All three are REMOVED together: with the token
   back at --slate-50 the two base rules (:866, :747) resolve to Alabaster on
   solo exactly as they already do on every school page, so the overrides are not
   merely redundant — re-adding either would now paint the muted warm surface
   where the shared design wants the page colour.
   Do NOT "restore" the white page as a tidy-up: it is a deliberate, reverted
   decision, and every calendar contrast figure below was re-measured against the
   ground that actually survives the revert (see the note on .cal-block--available). */

/* LESSON BLOCKS — Midnight Blue fill, white text. This is an INVERSION of the
   shared default (light-teal fill, Midnight text) and stays inside the locked
   5-hue brand palette rather than introducing a sixth: --blue-900 IS the token
   already named "Midnight Blue".
   MEASURED, not transcribed: white on #0F414A = 11.20:1. dE2000 vs the two
   fills it must not be confused with: 23.07 from --color-private (purple) and
   13.90 from --color-secondary (the shared teal hover), so a private block is a
   different object and the teal is a different colour from this one.
   CORRECTED, and this had been wrong in both directions since the rule was
   written: this rule DEFEATS the shared hover on the solo portal. It is (0,2,1)
   — `body` type + `[data-portal]` and `.cal-block` in the class column — and
   `.cal-block:hover` (the unscoped rule in the shared calendar block) is (0,2,0),
   so specificity decides regardless of
   source order and a solo lesson block keeps this Midnight fill while hovered.
   That is the same cascade fact stated on .cal-block--cancelled-occurrence:hover
   below, which exists precisely BECAUSE the shared hover cannot reach a
   (0,2,1)-filled solo block. The old wording ("the base :hover is deliberately
   NOT overridden — teal-on-midnight already lightens") described a repaint that
   does not happen here, and the white-on-teal 6.04:1 it quoted is a real
   measurement of a state only the SCHOOL portals' blocks enter.
   Two consequences worth stating rather than rediscovering: the resting figures
   in this section are also the hovered ones, because the ground never moves; and
   the hover affordance on these blocks is carried by the pointer cursor on a real
   <a> plus the focus ring, not by a colour change. */
body[data-portal="solo"] .cal-block { background: var(--blue-900); color: var(--white); }

/* The sub-line carries the level and birth year. --color-text-soft on this fill
   measures 1.63:1, so it is replaced by the same white-at-85% treatment
   .cal-block--private already uses for the identical reason: 8.54:1 on the
   resting fill. (The 4.89:1 recorded here for "the teal hover" is a true figure
   about a state a SOLO block never enters — the fill above wins the cascade over
   .cal-block:hover; it still describes the school portals' own blocks.)
   The CHILD combinator is load-bearing: .day-chip (the untimed-lessons list)
   reuses .cal-block__lvl on a light chip, where white text would be unreadable. */
body[data-portal="solo"] .cal-block > .cal-block__lvl { color: rgba(255, 255, 255, .85); }

/* MEASURED, and the reason this override has to exist at all: --color-ok
   (#1F6E45) on the Midnight fill is 1.80:1 — a straight WCAG 1.4.11 failure for
   the one border that carries "this lesson is already recorded". --green-300 is
   that same green lightened 40/60 toward white: still unmistakably GREEN, so the
   calendar's written "מסגרת ירוקה" legend stays true, and it measures 6.00:1 on
   the resting fill — which on the solo portal is also the hovered figure, since
   the fill above beats .cal-block:hover (the 3.24:1 once recorded here for the
   teal hover describes the school portals' blocks). Do not darken it back to
   --color-ok. This is the identical trap .cal-block--private hit; introducing a
   new fill re-opens every state indicator drawn on the component. */
body[data-portal="solo"] .cal-block--done { border-color: var(--green-300); }

/* AN UNTAKEN SLOT stays faint and dashed — it must never read as a lesson.
   Listed AFTER the base override on purpose: both are (0,2,1), so source order
   decides, and without this the "זמן פנוי" block would inherit the Midnight fill
   and become indistinguishable from a real lesson. Its dashed border comes from
   the unscoped rule above and is untouched. --color-text-soft measures 6.85:1 and
   6.21:1 on the .cal-cell--today tint. dE2000 from the Midnight fill: 65.53.

   THE GROUND THOSE TWO NUMBERS ARE MEASURED AGAINST — corrected wording, same
   numbers, and the correction matters. This comment used to say "on the white
   page", which read as a claim about --color-bg. It never was one: this block is
   `background: transparent`, so its ground is its CELL, and `.cal-grid > *`
   (:651) pins every cell to var(--color-surface) = --white. So the Alabaster
   revert above does NOT move this ratio, and 6.85:1 is still correct, re-measured
   (WCAG relative luminance, #5F5A50 on #FFFFFF). The same holds for the
   .cal-cell--today figure: that tint is color-mix'd from --color-surface (:663),
   never from --color-bg. Anyone re-checking this after the revert will expect
   5.64:1 (which IS what #5F5A50 on Alabaster measures) — it is the wrong ground
   for this rule, and that is exactly why the wording is fixed here. */
body[data-portal="solo"] .cal-block--available { background: transparent; color: var(--color-text-soft); }
body[data-portal="solo"] .cal-block--available:hover { background: transparent; color: var(--color-text-soft); }

/* A CLIPPING FLOOR: whatever height the grid gives a block, it grows rather than clipping when the
   text genuinely needs more. Every block whose height the grid already satisfies is unaffected —
   min-content is their natural minimum anyway.

   WHY IT WAS ADDED, and why that reason is now HISTORY rather than the live case: a 45-minute
   lesson used to be drawn at 75% of the two 30-minute rows it reserved, ending halfway down the
   second. MEASURED then at 320 and 375 in a calibrated fixed-width iframe, that fraction fitted
   every one-student block and every two-student block at 30 or 60 minutes, but a 45-minute block
   holding TWO students needed 126.9px of text inside a 119.9px box and overflowed. Since the solo
   calendar moved to 15-minute rows (PLAN-solo-time-granularity.md) no solo block carries a
   fractional inline height at all — a 45 reserves exactly three whole rows — so that specific
   squeeze is gone. The rule STAYS: it is a general floor, not a 45-minute special case, and the
   next thing that shrinks a block (a longer level name, a denser row) would hit it the same way.
   Do not delete it as "the 45-minute fix we no longer need". */
body[data-portal="solo"] .cal-block { min-block-size: min-content; }

/* The placement picker switches its lesson-length radios off when an exact time is chosen (the
   length is then implied by the start and end). The radios grey out natively, but their <span>
   labels are ordinary text and stayed at full contrast, so the group read as half-disabled — and
   solo.js was already toggling this class with no rule behind it. 0.5 is this design system's own
   disabled opacity; disabled controls are explicitly exempt from the 1.4.3 contrast minimum, and
   the state is never carried by dimming alone — the hint beside it says why in words. */
body[data-portal="solo"] #placement-duration.is-disabled .check-row { opacity: .5; cursor: not-allowed; }

/* ---------------------------------------------------------------------------
   THE THIRD CALENDAR CELL STATE (v1.7.1 — one-time reschedule)

   The weekly grid used to say only two things about a slot: "a lesson meets here
   every week" and "this time is untaken forever" (.cal-block--available). A
   recurring rule produces one occurrence PER WEEK, so it needs one more, and it
   is about ONE date rather than about the rule:

     .cal-block--cancelled-occurrence  every student of this lesson is excepted
                                       out of THIS date by a CANCELLATION.
                                       Still a real <a> — the coach clicks through
                                       to undo it — which is exactly why it must
                                       not reuse .cal-block--available's
                                       cursor:default, non-interactive treatment.

   A MOVE IS NO LONGER A CELL STATE AT ALL, by owner ruling ("same lesson, same
   color diff time"), and this section is one class shorter than it was at v1.9.0
   because of it. A moved occurrence renders as an ORDINARY lesson block at its new
   hour — the base body[data-portal="solo"] .cal-block fill below, plus
   .cal-block--single when it holds one student — and its source hour renders as
   ordinary untaken time (.cal-block--available). Neither needs a rule of its own,
   which is the point: there is nothing here to keep in step with a normal lesson,
   because it IS one. The retired rules were `body[data-portal="solo"]
   .cal-block--moved` and its .cal-block__note child; see solo.js's movedBlocks.
   Nothing in the school portals can miss them: both are solo-scoped, and the only
   producer of the class was extraBlockFor()'s kind:'moved' branch, which no caller
   in the repo ever fed (both swap-overlay builders stamp kind:'cover').
   .cal-block--cover itself is untouched and still carries swap coverage.

   APPENDED, never inserted: every RULE above this line is byte-identical, which
   is the standing requirement for this shared file. An earlier round regressed the
   school app by inserting a block ABOVE .cal-block--private:hover and silently
   deleting it, so additions go at the end and are proved with `git diff --numstat`
   plus a CSS-level differ. Two corrections, matching the ones on the section
   header above — this block is a SECOND copy of the same claims and went stale the
   same way: (a) "byte-identical" binds RULES, not the prose describing them, and
   it is not violated by a deliberate scoped REVERT (or retirement) of a rule an
   earlier round added; (b) there is no `cssdiff.py` in this repo, despite both
   blocks having named it — see PLAN-solo-lesson-controls.md §7.2 for what to write
   instead.

   Scoped to body[data-portal="solo"] — no school portal can produce the class
   (blockClassFn is solo-only), so the scope costs nothing and guarantees it.

   COLOUR IS NEVER THE ONLY SIGNAL: the cancelled block's single line literally
   reads "בוטל" and its aria-label carries the same word.

   MEASURED, not transcribed — every number here was recomputed from the tokens.
   CANCELLED: --color-text-soft #5F5A50 is 6.85:1 on the white CELL and 6.21:1 on
   the .cal-cell--today tint (the same pair .cal-block--available records, because
   this rule lands on the same two grounds); the strike-through uses currentColor,
   so it inherits that ratio exactly. "The white cell", not "the white page": this
   block is transparent, and `.cal-grid > *` (:651) paints every cell
   var(--color-surface). Both figures therefore SURVIVED the solo Alabaster revert
   unchanged — re-measured after it, not transcribed through it.
   The dashed border is deliberately below 3:1 (2.48:1): this block carries its
   whole meaning in text, and 1.4.11 binds a control's own boundary, not a
   decorative reinforcement of a word already on screen.
   --------------------------------------------------------------------------- */

/* The cancelled SOURCE occurrence. Listed after the base body[data-portal="solo"]
   .cal-block fill (both 0,2,1 — source order decides) so the Midnight fill does
   not survive here and make a cancelled week look like a taught one. Kept
   VISIBLY interactive: it is a link, so the pointer cursor and a real hover state
   stay, unlike .cal-block--available which is a static <div>. */
body[data-portal="solo"] .cal-block--cancelled-occurrence {
  background: transparent;
  border: 2px dashed color-mix(in srgb, var(--color-text-soft) 55%, var(--color-surface));
  color: var(--color-text-soft);
  font-weight: 500;
  text-decoration: line-through;
  text-decoration-thickness: 1px;
}
/* This rule CREATES the hover state; it does not rescue one. The shared
   .cal-block:hover is (0,2,0) and LOSES to the (0,2,1) rule above, so without
   this the block would simply not react to the pointer at all — soft text on the
   white page, 6.85:1, perfectly readable but with no affordance on a control that
   IS clickable (it is how the coach opens the occurrence to undo it). White on
   --color-secondary measures 6.04:1, the same pairing the shared hover ships, and
   the fill change is dE2000 47.61 from the resting ground, so it reads as a
   deliberate response. The strike-through is dropped on hover because it is
   redundant once the fill has changed. */
@media (hover: hover) {
  body[data-portal="solo"] .cal-block--cancelled-occurrence:hover {
    background: var(--color-secondary);
    color: var(--color-on-primary);
    border-color: transparent;
    text-decoration: none;
  }
}

/* THE DESTINATION OF A MOVE HAS NO RULE HERE, and that absence is the design.
   It used to: `body[data-portal="solo"] .cal-block--moved` gave it a warn-amber
   fill and a dashed border on top of .cal-block--cover, because the block was a
   read-only <div> from extraBlockFor() and had to be told apart from a genuine
   swap-coverage block. The owner ruled that whole treatment out — "no הועבר white
   box, dont turn it yellow, same lesson, same color diff time" — so a moved
   occurrence is now rendered by the SAME blockFor() as every recurring lesson
   (renderWeeklyCalendar's `extrasAsBlocks`), and it therefore picks up the base
   body[data-portal="solo"] .cal-block Midnight fill, the white text, the
   .cal-block > .cal-block__lvl sub-line treatment, .cal-block--single when it
   holds one student, and the shared :hover — with no rule of its own. Adding one
   back would be re-introducing exactly the difference this round removed.

   KEPT FROM THE OLD BLOCK, because it is the general lesson and it cost a live
   1.4.3 failure to learn: .cal-block--cover (:689) is (0,1,0) and LOSES to
   body[data-portal="solo"] .cal-block (0,2,1), so on the solo portal the cover
   treatment never actually applied — the block rendered Midnight anyway while
   .cal-block--cover .cal-block__note (:742, (0,2,0)) painted --color-warn amber
   onto it at a MEASURED 1.14:1. A modifier is only "inherited" if it WINS the
   cascade on the surface you are adding it to; check the ground's specificity
   before assuming a component brings its own colours with it. That is also why
   the fix here is to stop borrowing .cal-block--cover at all rather than to
   out-specify it: solo never had a read-only calendar block to begin with.
   .cal-block--cover keeps its unscoped school-portal rules untouched. */

/* ---------------------------------------------------------------------------
   EVERY SOLO CELL STRETCHES ITS BLOCK(S) TO FULL HEIGHT

   Owner: "I want both the private and the double lesson to fill out the whole
   box, even if the private one is half empty." The gap they are describing is
   .cal-cell's own `align-content: start` — a time-row's height is set by its
   TALLEST cell that week (.cal-grid's grid-auto-rows floor, halved to 1rem on
   solo by .cal-grid--quarter at the end of this section, then grown by content),
   so a two-line private block pinned to the top of a row a four-line double made
   tall leaves the rest of its cell empty. The row SIZE is orthogonal to this
   rule: how many minutes a row represents does not change which cell in it is
   tallest.

   This GENERALISES an already-shipped mechanism rather than inventing one:
   .cal-cell--span (:673–674, unscoped, shared) is the identical two-rule shape
   for multi-row cells. The fix belongs on the CELL, not as a size hack on the
   block: .cal-cell is already a definite containing block for a percentage
   height — that is WHY :674 works today — so the only thing missing for a
   NON-spanning cell was the cell's own track stretching.

   The overlap with .cal-cell--span on solo's already-spanning cells is
   redundant-but-harmless: same resulting value, higher specificity, no visible
   change there. The only cells where anything moves are solo's non-spanning ones.

   WHY THIS DOES NOT FIGHT AN INLINE BLOCK SIZE: js/auth-guard.js's renderer
   writes `el.style.blockSize = calc(...)` INLINE for a block whose fill < 1, and
   an inline style beats any external rule regardless of specificity. So the rule
   below only ever applies where no inline size was written. ON SOLO THAT IS NOW
   EVERY BLOCK: with 15-minute rows every legal duration — 45 included — is a
   whole number of rows, so fill is always exactly 1 and the fractional branch
   never fires. (It used to fire for a 45, which is what this paragraph was
   originally written about; the mechanism is kept because the renderer stays
   general, not because solo still reaches it.) The dense-double floor
   `min-block-size: min-content` still wins over whichever of the two ends up
   smaller than the text needs. Three mechanisms, no guarding needed — plain
   cascade precedence.

   Scoped to solo: the school portals were not asked for this and must render
   byte-identical. --------------------------------------------------------- */
body[data-portal="solo"] .cal-cell { align-content: stretch; }
body[data-portal="solo"] .cal-cell .cal-block { block-size: 100%; }

/* ---------------------------------------------------------------------------
   DENSITY CUE — a שיעור פרטי block is one step lighter than a שיעור זוגי one

   Owner's own word is "slightly lighter", so this is a LIGHTNESS step inside the
   same Midnight hue, not a hue rotation. Specifically NOT --blue-500 /
   --color-secondary: that is the shared, kind-agnostic HOVER fill for every
   .cal-block, and reusing it would make an at-rest private block
   indistinguishable from any block mid-hover.
   NOT .cal-block--private / --color-private either: that class means "a
   kind:'private' SCHOOL group", a concept that cannot exist on a solo tenant
   (a solo lesson is an ordinary group with `kind` absent), so reusing it would
   couple two unrelated meanings on a shared sheet for nothing.

   MEASURED — real CIEDE2000 in Lab, from an implementation validated against the
   Sharma et al. (2005) reference pairs to <0.0001 before any number here was
   believed, and cross-checked by reproducing this file's own already-documented
   figures (11.20 / 8.54 / 6.00 / 23.07 / 13.90 / 62.98 all came back exact).
   fill = color-mix(in srgb, var(--blue-900) 85%, white 15%) ≈ #335E65.
     dE2000 from the Midnight fill:           9.72
     white body text:                         7.17:1  (Midnight: 11.20:1)
     .cal-block__lvl @ rgba(255,255,255,.85):  5.72:1  (Midnight: 8.54:1 — small
                                                        text at .78rem, so the full
                                                        4.5:1 floor applies; clears)
     .cal-block--done border (--green-300):    3.84:1  (Midnight: 6.00:1 — past the
                                                        1.4.11 3:1 non-text floor.
                                                        --green-300 is UNCHANGED and
                                                        must NOT be re-darkened
                                                        toward --color-ok: that is
                                                        1.80:1 on Midnight and the
                                                        whole reason the lightened
                                                        tone exists.)
   "≈", and the four figures above are the 8-BIT ones — what anyone re-checking
   #335E65 with a colour picker will measure, which is why they are the headline.
   The browser does NOT round: Chrome computes this to color(srgb 0.2 0.366667
   0.396667), i.e. green 93.5 rather than 94 (verified by reading the resolved
   background off a real render, not by trusting the arithmetic). On that exact
   value the figures are dE2000 9.56, white 7.21:1, sub-line 5.75:1, done-border
   3.86:1. Recorded rather than smoothed over, because a sub-8-bit gap silently
   dropped is exactly the transcription slip this file's "MEASURED, not
   transcribed" discipline exists to catch. Every conclusion below is unaffected —
   9.56 still clears the ~7 perceptibility threshold and 3.86 still clears 3:1.

   THE MIX RATIO IS BOUNDED AT BOTH ENDS, and both bounds were measured, not
   guessed — this block exists so nobody "tidies" the value in either direction:
     90% Midnight (#27545C) — REJECTED. dE2000 6.25, below the ~7 threshold at
       which two fills stop reading as one colour, so the two kinds would probably
       not be tellable apart side by side in a dense week. Its text figures were
       all fine (8.37 / 6.58 / 4.49), which is exactly the trap: contrast passing
       says nothing about whether the CUE is perceptible. This was the value the
       plan originally specified; the owner ruled on 85% after seeing the number.
     75% Midnight (#4B7077) — REJECTED, and this is the hard floor: --green-300
       falls to 2.90:1 there, so the "done" border stops meeting WCAG 1.4.11.
       (80%, #3F676E, is legal — dE 13.01, white 6.22:1, sub-line 5.02:1,
       done-border 3.33:1 — but was not chosen; do not drift there silently.)
   So the usable window is 85%–80% and 85% is the deliberate pick.

   COLOUR IS NEVER THE ONLY SIGNAL here regardless of the ratio: the block's own
   title already spells out the students' names and blockSubFn prints one level
   line PER occupant, so "one student" is legible in text either way. That is what
   makes this a CUE rather than a carrier of meaning — and it is why 6.25 was a
   weak cue to reject on quality, not an accessibility failure to fix.

   HOVER, CORRECTED — the paragraph that stood here was wrong, and it is the same
   error the base fill's comment carried. It claimed "פרטי and זוגי already
   hovered to the identical --color-secondary teal and continue to". They do not:
   .cal-block:hover (the unscoped shared rule) is (0,2,0) and BOTH solo fills
   outrank it — the base
   body[data-portal="solo"] .cal-block at (0,2,1) and this rule at (0,3,1) — so
   on the solo portal the resting fill survives the pointer, and this rule changes
   nothing about that either way. The kinds are distinguished AT REST, which is
   the state a coach scanning a week actually reads, and the hover affordance is
   the pointer cursor on a real <a> plus the focus ring. The two hover figures
   recorded above (4.89:1 sub-line, 3.24:1 done-border) are true of the SCHOOL
   portals' blocks, which have no such scoped fill; they were never measured
   against this one and are untouched by it.

   Specificity (0,3,1) — strictly above the base body[data-portal="solo"]
   .cal-block (0,2,1) — so it wins on the background regardless of source order,
   rather than leaning on a same-specificity tie-break the way the retired
   .cal-block--moved rule had to. color:var(--white) is deliberately NOT restated:
   it still comes from the base rule, which this only overrides the background of.
   A CANCELLED block can never carry this class (blockClassFn returns early for
   it), so there is no "lighter cancelled block" state to design for. A MOVED
   occurrence's destination block, on the other hand, carries it exactly when it
   holds one student — that is the whole of "same lesson, same color": the
   one-student cue is the same cue, computed from the same occupant count. ---- */
body[data-portal="solo"] .cal-block.cal-block--single {
  background: color-mix(in srgb, var(--blue-900) 85%, white 15%);
}

/* ---- team-lesson calendar fill (solo_plus קבוצות only) ----
   A bluer shift of --purple-600, per owner ruling: "purple like the school groups, maybe a bit
   bluer". NOT a reuse of --purple-600 itself — that hue is explicitly scoped to school
   kind:'private' lessons (see its own note in the palette block at the top of this file) and must
   not grow a second meaning.
   DECLARED HERE, in its own additive :root block beside its only consumer, rather than appended to
   the palette :root at the top: that block is one RULE, and test/cssdiff.py requires every rule
   present at the last release to survive byte-identical (added rules are fine, changed ones are
   not). A pointer comment sits in the palette ramp so the two are findable from each other.
   MEASURED, not transcribed: WCAG relative luminance and real CIEDE2000 in Lab, from an
   implementation validated against the Sharma et al. (2005) reference pairs to <1e-4 before any
   number here was believed, and cross-checked by reproducing this file's own already-documented
   figures (11.20 / 6.00 / 7.45 / 3.99 / 7.17 / 3.84 / 5.72 / 23.07 / 13.90 / 9.72 / 9.56 / 6.85
   all came back exact). PLAN-solo-teams.md §11 hand-computed the three headline figures without a
   tool and asked for re-verification; re-verified, all three confirmed to 2dp.
     white block text on this fill:            7.98:1  (target >=7:1, AA floor 4.5:1 — clears both)
     .cal-block__lvl @ rgba(255,255,255,.85):  6.30:1  (.78rem small text, so the full 4.5:1 floor
                                                        applies; clears — and no rule change is
                                                        needed, the existing generic solo sub-line
                                                        rule already paints it)
     .cal-block--done border (--green-300):    4.28:1  (1.4.11 non-text floor 3:1 — clears, and
                                                        comfortably above the analogous
                                                        --purple-600 case at 3.99:1. Do NOT
                                                        re-darken it toward --color-ok: that is
                                                        1.65:1 here, i.e. invisible.)
     --armed inset ring and --dragging dashed border, both rgba(255,255,255,.9): 6.83:1 (>=3:1)
   DISTINCTNESS IS BY HUE, not merely lightness — HSL 232° indigo/violet against the base Midnight
   fill's 189° teal, at the same 42% lightness as --purple-600:
     dE2000 vs the base Midnight fill (--blue-900):        20.49
     dE2000 vs .cal-block--single (#335E65):               20.54
     dE2000 vs the shared hover teal (--color-secondary):  21.80
     dE2000 vs the school private fill (--purple-600):      7.61
   every one past the ~7 threshold at which two fills stop reading as one colour (the 6.25 that got
   a 90% Midnight mix rejected above is this file's own recorded counter-example). Colour is never
   the only signal regardless: a team block's title IS the team's name, its sub-line prints the
   team's declared level, and its Home card carries a "קבוצה" pill in words. */
:root {
  --indigo-600: #3E4A97;
  --indigo-50:  #E2E4EF; /* pale tint — NO consumer yet in this build, kept so the ramp reads
                            intact and for a future team-scoped light surface, the same posture
                            --purple-50 has; body text on it = 12.90:1 */
}

/* A TEAM block (slotOccupancy kind:'group', i.e. capacity > 2, solo_plus only) — indigo, so a
   קבוצה is a different OBJECT from a private/paired lesson at rest, not a shade of one. Every
   contrast and dE2000 figure for this fill is recorded on the --indigo-600 token itself; they are
   not repeated here so there is only one copy to keep true.
   Specificity (0,3,1), matching .cal-block.cal-block--single immediately above — it beats the base
   body[data-portal="solo"] .cal-block (0,2,1) on background regardless of source order, by the
   identical reasoning recorded on that rule, and color:var(--white) is deliberately NOT restated
   (it still comes from the base rule, which this only overrides the background of).
   HOVER, stated as the cascade actually resolves it rather than by analogy: this rule is (0,3,1)
   and the shared unscoped .cal-block:hover is (0,2,0), so the indigo fill SURVIVES hover — exactly
   as .cal-block--single's fill already does, shipped. That is deliberate and it is also why the
   figures above need no second set for the hovered state: the ground does not move. The hover
   affordance is carried by the pointer cursor on a real <a> plus the focus ring, and the hovered
   text colour (--color-on-primary = white) is what the base rule already paints.
   Mutually exclusive with --single by construction (blockClassFn returns ONE of the three, checked
   at its own call site) and with --cancelled-occurrence, which blockClassFn returns before ever
   reaching the occupancy branch. --armed/--dragging declare no background, so they compose. */
body[data-portal="solo"] .cal-block.cal-block--team {
  background: var(--indigo-600);
}

/* The placement picker's exact-time control is a real <button aria-pressed>, not
   a <label> wrapping a visually-hidden radio, so `.group-option:has(input:checked)`
   (:530) — which is how every OTHER selected option in that picker paints itself —
   structurally cannot select it. This mirrors that rule's declarations exactly,
   keyed off aria-pressed instead: same visual language, different selector for a
   control that cannot use the old one. Not a second look, and deliberately not a
   new colour.
   .group-option is SHARED (frontdesk.js, manager.js, js/private.js all build it via
   buildGroupPicker()), so its base rule is untouched; those callers never set
   aria-pressed on it, which makes this inert there even without the scope — the
   scope is kept anyway, per this section's own discipline.

   THE FIRST RULE IS NOT OPTIONAL POLISH, and the plan was wrong to say the button
   needs no CSS beyond the pressed state. .group-option (:528) sets no `background`
   and no `text-align`, because every existing consumer is a <label>, which has
   neither by default. A <button> has BOTH from the UA sheet — `buttonface` grey
   and centred text — so an UNPRESSED customToggle would render as a grey, centred
   card among white, start-aligned ones, and the pressed state would then read as
   "the grey one turned blue" rather than as a selection. Scoped by ELEMENT
   (button.group-option) so no <label> consumer on any portal can be reached by it.
   Nothing else is redeclared: layout, min-block-size (the 44px tap target), border,
   radius, padding and cursor all come from the shared rule as intended. */
body[data-portal="solo"] button.group-option { background: transparent; text-align: start; }
body[data-portal="solo"] .group-option[aria-pressed="true"] { border-color: var(--color-primary); background: var(--blue-100); }

/* 15-MINUTE CALENDAR ROWS (solo only). js/auth-guard.js's renderWeeklyCalendar()
   adds .cal-grid--quarter only when its slotMinutes option differs from the module
   default of 30, and solo's initCalendar() is the only call in the repo that passes
   anything — so a school portal cannot receive either class name even before the
   body[data-portal="solo"] scope is considered. Both gates are kept anyway, per
   this section's own discipline.

   WHY THE FLOORS HALVE. .cal-grid (unscoped) is grid-auto-rows: minmax(2rem, auto)
   and .cal-cell (unscoped) is min-block-size: 2rem — both sized for a row that
   represents 30 minutes. At 15 minutes per row the same floors would DOUBLE the
   grid's height for identical content. Halving them keeps the PER-BLOCK floor
   exactly where it was: a 30-minute lesson floors at 2 x 1rem, which is the
   1 x 2rem it floors at today, so no booking gets a smaller box than it had.

   THE GRID AS A WHOLE IS STILL TALLER, and the plan was wrong to say otherwise.
   MEASURED in headless Chrome against this stylesheet, calibrated fixed-width
   iframe, one week of five lessons: the solo calendar grid goes 1122.17px ->
   1488.14px at 375px and 1106.58px -> 1472.55px at 1440px (+365.97px, ~+33%, the
   same delta at both widths). The floor is not what sets a LABELLED row's height:
   the :00 rows measure 29.69px and the :30 rows 25.84px from their own label text,
   both above the 16px floor, so halving the floor cannot shrink them. Only the 30
   NEW unlabelled quarter rows are cheap, at the 16px floor each. A third more
   scroll inside .cal-scroll is the honest price of drawing quarter-hour
   resolution; it is not a bug to hunt for, and no per-block box got smaller.

   Blocks in fact get TALLER, not shorter (a 45 measured 60.69px -> 69.53px at
   375px), because they now span more rows and are drawn at their full height
   instead of a 75% fraction — so there is less clipping pressure here, not more.

   Tap targets are unaffected: the interactive element is the .cal-block, whose
   height comes from the rows its lesson spans (>= 2rem, unchanged), never from one
   row's floor. Empty cells carry no link on solo and are not targets at all. */
body[data-portal="solo"] .cal-grid.cal-grid--quarter { grid-auto-rows: minmax(1rem, auto); }
body[data-portal="solo"] .cal-grid.cal-grid--quarter .cal-cell { min-block-size: 1rem; }

/* 30-MINUTE VISUAL BOXES over those 15-minute rows (solo only).
   OWNER RULING: "The calendar grid should still be jumping by 30 minutes, make
   sure the hour is bold and centered in the box" · "a 45 minute lesson can still
   fit but just in the middle of a box, that's fine!"
   The ROW stays 15 minutes — that is the positioning substrate, and it is what
   makes two back-to-back lessons (one ending :45, the next starting :45)
   non-overlapping BY CONSTRUCTION rather than by arithmetic. Coarsening the row
   itself was measured and rejected twice over: round-bucketing draws the 16:45
   lesson at 17:00, and floor-bucketing lands it in rows the 16:00 lesson's cell
   already spans, i.e. two grid items stacked in one column. Both remain must-fail
   controls in the back-to-back fixture.
   js/auth-guard.js's renderWeeklyCalendar() therefore keeps emitting one gutter
   cell per row — the drag geometry reads those rects and needs 15-minute
   resolution — but in box mode that cell carries NO text (.cal-time--tick) and the
   LABEL is a separate .cal-time--box element spanning the box's two rows, appended
   after them so it paints over both and over the 1px .cal-grid gap between them.
   That is what removes the :15/:45 gridline from the gutter; .cal-cell--midbox
   does the same job in the seven day columns.
   THE RETIRED RULE. `body[data-portal="solo"] .cal-time--quarter` lived here and
   is GONE, not merely unused: renderWeeklyCalendar() emits .cal-time--quarter only
   on its NON-box path, which solo no longer takes, so the class can no longer
   reach this portal at all. .cal-time--half and .cal-time--quarter themselves are
   untouched and still styled unscoped for the school portals.
   CONTRAST — RESOLVED, NOT A DELIBERATE EXCEPTION. Earlier revisions of this
   paragraph recorded the school .cal-time--half labels as 2.97:1 (re-measured at
   2.74:1 over a plain white cell) and framed leaving them alone as a scoping
   decision, because no solo surface can render that class. That reading was wrong
   in the only way that matters: the school portals DO render it, on every :30 row
   of the manager and instructor calendars, so it was a live WCAG 1.4.3 failure and
   not a solo non-issue. The `opacity: .6` that caused it is GONE from :665 — do
   not restore it. Every gutter label in this app now carries its token at full
   strength on the white cell (.cal-grid > *, :650): --color-text-soft #5F5A50
   measures 6.85:1 for .cal-time--half and .cal-time--boxhalf alike, and the bold
   hour label (--color-heading #0A2E34) measures 14.45:1 on the same ground. */
body[data-portal="solo"] .cal-time--tick { font-size: 0; padding: 0; }
/* Flex, so align-items/justify-content genuinely CENTRE the label inside the box —
   the base .cal-time is a block whose `text-align: center` only handles the inline
   axis. Block padding is inherited from that base rule and is what sizes the two
   rows the box spans (measured: both rows sit on the halved 1rem floor, so a box
   is two EQUAL rows and a lesson starting at :15 really is drawn half a box down). */
body[data-portal="solo"] .cal-time--box {
  display: flex; align-items: center; justify-content: center;
}
body[data-portal="solo"] .cal-time--boxhour { font-weight: 700; color: var(--color-heading); }
body[data-portal="solo"] .cal-time--boxhalf { font-size: .78rem; color: var(--color-text-soft); }
/* A cell that starts MID-box swallows the 1px grid gap above it, so the two rows of
   one box read as a single box in the day columns too. -1px margin + a compensating
   +1px of block padding, so the cell's own content box is unmoved and the halved
   min-block-size floor above still means the same thing. The cell's background —
   plain surface, or the today / open / outside-hours tint, whichever applies — is
   what paints over the gap, so a tinted box stays one continuous tint. */
body[data-portal="solo"] .cal-cell--midbox {
  margin-block-start: -1px;
  padding-block-start: 3px;
}

/* THE LEVEL TAG on a Home lesson card (solo only).
   OWNER RULING: "remove the level description, only have רמה 2 small next to the
   student name". The card used to carry a full-width pill reading
   "רמה: רמה 1/2 - מעבר לחתירה" on its own line; it is now shortLevelName()'s
   "רמה 1/2" sitting inline beside the student's name.
   Only size is declared. The fill and the text colour keep coming from .pill--level
   (--pill-ok-bg #347A56 / white, 5.17:1 — AA at this size, which is normal text at
   12.48px) and from .pill--muted for the no-level case, so no new colour enters the
   palette and no existing pill anywhere else is repainted. font-variant-numeric:
   tabular-nums is already on .pill (:790), which is what keeps "רמה 1/2" and
   "רמה 2" from shifting the names beside them. */
body[data-portal="solo"] .pill--tight {
  padding: 0 var(--space-xs);
  font-size: .78rem;
  line-height: 1.5;
}

/* ===== WORKING HOURS (solo only) ==========================================
   Every rule below is appended, scoped, and reaches nothing a school portal
   renders: renderWeeklyCalendar() emits .cal-cell--outside-hours only when its
   dayWindowFn option is supplied, and solo/js/solo.js's initCalendar() is the
   only call in the repo that supplies it.

   THE TINT. A neutral grey wash, deliberately not a new hue: the palette is
   locked at five brand hues plus --color-private, and "these hours are not on
   offer" is an ABSENCE, which is what a desaturated wash reads as. Mixed from
   --color-text-soft (#5F5A50, the warm slate this file already uses for
   secondary text) so it stays inside the warm family rather than introducing a
   cold grey beside Alabaster.

   MEASURED, not proposed, and measured on the COMPUTED colour read back from a
   real Chromium render of this rule rather than from a re-implementation of
   color-mix(). CIEDE2000 in Lab, implementation validated against the Sharma et
   al. reference pairs to 4.2e-5 and cross-checked against the figures this file
   already records (11.20:1, 23.07, 13.90, 6.85:1 all reproduced). At 20%:
     computed tint = #DFDEDC
     dE2000 vs the plain white cell (.cal-grid > *, :651)   7.01
     dE2000 vs .cal-cell--today (:663, a 35% --blue-100 mix) 6.02
     --color-text-soft on the tint                          5.10:1
   Both distances clear "a deliberate object, not a wash of the same colour",
   and the tint is a grey where both neighbours are tinted blue, so hue
   separates them as well as lightness. The plan proposed 12%; that models at
   4.12 against white and 4.11 against .cal-cell--today — under a third of a
   grid's worth of cells apart, i.e. probably not readable as a boundary at all.
   Going the other way, 24% models at 4.40:1 for --color-text-soft on the
   COMBINED tint below, i.e. under AA, so 20% is the top of the honest range and
   not a midpoint.

   .cal-block--available's "זמן פנוי" text is --color-text-soft on a transparent
   fill, so its ground IS this tint when an untaken slot sits outside hours:
   5.10:1, and 4.68:1 on the combined tint. Both past AA. The two figures this
   file already records for that text (6.85:1 on the white cell, 6.21:1 on the
   today tint) are unchanged — nothing repoints them, re-measured to confirm.

   NEVER COLOUR-ONLY: solo/calendar.html carries a legend clause naming this
   wash, revealed only when working hours are actually configured. No per-cell
   hidden text is emitted, unlike .cal-cell--open's — an outside-hours cell is
   not interactive here and most cells of a narrow working day's grid would
   otherwise announce it. ------------------------------------------------- */
body[data-portal="solo"] .cal-cell--outside-hours {
  background: color-mix(in srgb, var(--color-text-soft) 20%, var(--color-surface));
}
/* A cell can legitimately be BOTH — today, before opening time. This composes the
   two rather than letting one erase the other: the same 20% grey mixed into the
   today tint instead of into white, so "today" survives as the blue underneath.
   Read back from the same render as the figures above:
     computed = #D2D6D6   dE2000 vs .cal-cell--today 6.84, vs the plain tint 3.19
     --color-text-soft on it 4.68:1
   The 3.19 is deliberately the smaller of the two: which day is today is also
   carried by .cal-dayhead--today, so it is never this cell's only signal, while
   "outside hours" is. */
body[data-portal="solo"] .cal-cell--today.cal-cell--outside-hours {
  background: color-mix(in srgb, var(--color-text-soft) 20%,
    color-mix(in srgb, var(--blue-100) 35%, var(--color-surface)));
}

/* ---- the placement picker's two-step window rows ----
   .group-option is REUSED on a <summary>, and that is a different ELEMENT from
   every other consumer (all <label>s), so two UA declarations re-open. Scoped by
   ELEMENT (summary.group-option) exactly like button.group-option above, so no
   <label> consumer on any portal can be reached.

   1. .group-option's own `display: flex` (:528) overrides <summary>'s UA
      `display: list-item`, which takes the disclosure triangle with it — the
      control would have NO affordance that it opens. list-style/::-webkit-
      details-marker are neutralised explicitly (they are already inert under
      flex, but leaving them implicit is how a future display change silently
      restores a stray marker) and replaced by the SAME textual פתיחה/סגירה pill
      .instructor-group__summary (:589) already uses app-wide. Text, not a glyph
      and not a colour.
   2. A window holding the CHECKED start says "נבחר" instead, so a collapsed
      window whose selection is invisible still announces it in words. The
      background/border pair mirrors .group-option:has(input:checked) (:530)
      exactly — same visual language, no new colour, and it reinforces the word
      rather than replacing it. */
body[data-portal="solo"] summary.group-option { list-style: none; }
body[data-portal="solo"] summary.group-option::-webkit-details-marker { display: none; }
body[data-portal="solo"] summary.group-option::after {
  content: 'פתיחה';
  padding: var(--space-xs) var(--space-m);
  border: 1px solid var(--color-primary); border-radius: var(--radius-pill);
  font-size: var(--text-sm); font-weight: 600;
  color: var(--color-on-primary); background: var(--color-primary);
  white-space: nowrap;
}
body[data-portal="solo"] .window-option[open] > summary.group-option::after { content: 'סגירה'; }
body[data-portal="solo"] .window-option:has(input:checked) > summary.group-option::after { content: 'נבחר'; }
body[data-portal="solo"] .window-option:has(input:checked) > summary.group-option {
  border-color: var(--color-primary); background: var(--blue-100);
}
/* The step-two list reads as this window's body: inset from the summary on the
   start edge (logical, so RTL needs no second rule) and separated by the gap the
   .group-picker border already draws. */
body[data-portal="solo"] .group-picker--nested {
  margin-block-start: var(--space-xs);
  margin-inline-start: var(--space-m);
}

/* ---- settings: the seven working-hours rows ----
   Mobile-first: one column, so a 375px screen stacks the day toggle above its two
   time fields and nothing is ever cramped. From 40rem the row is day / from / to
   on one line — the same breakpoint .form-grid--2 (:488) already uses, so the two
   forms on this page change shape together. align-items:end keeps the two inputs
   baseline-aligned with each other when only one of them wraps its label.

   THE ONE EXCEPTION TO THIS SECTION'S "solo only" HEADER, and it is deliberate:
   signup.html asks the same question with the same component (js/signup.js builds
   the identical checkbox + two mountTimePicker() text fields per day), so its
   body[data-portal="signup"] scope is ADDED to these four selectors rather than
   given a second copy of the declarations — one definition, two consumers, no way
   for the two forms to drift apart visually. .hours-row--pair below is NOT widened:
   that is the solo slot editor's own two-column variant and has no signup use. */
body[data-portal="solo"] .hours-row,
body[data-portal="signup"] .hours-row {
  display: grid; gap: var(--space-s); align-items: end;
  padding-block: var(--space-s);
  border-block-end: 1px solid var(--color-border);
}
body[data-portal="solo"] .hours-row:last-child,
body[data-portal="signup"] .hours-row:last-child { border-block-end: 0; }
@media (min-width: 40rem) {
  body[data-portal="solo"] .hours-row,
  body[data-portal="signup"] .hours-row { grid-template-columns: minmax(8rem, 1fr) 1fr 1fr; column-gap: var(--space-m); }
}
/* A disabled day's two time fields stay in place rather than disappearing, so
   nothing reflows as days are toggled — but they must not read as available.
   Same .5 opacity + not-allowed cursor .btn:disabled (:430) already uses, so this
   is the file's existing disabled language rather than a second one. The fields
   are genuinely `disabled`, so the state is exposed to assistive tech and they
   leave the tab order regardless of this rule. */
body[data-portal="solo"] .hours-row input:disabled,
body[data-portal="signup"] .hours-row input:disabled { opacity: .5; cursor: not-allowed; background: var(--color-muted); }

/* ---- the slot editor's per-day start times (PER-DAY TIMES) ----
   The SAME .hours-row substrate the settings form above uses — same padding, same
   divider, same grid — with one difference: this row is a PAIR (day name + one
   start), never a triple, and it must stay two columns at 375px as well. Up to
   seven rows is a lot for a phone-first dialog, so a stacked mobile layout would
   double its height for no gain; the day name is short ("יום ראשון") and an
   <input type="time"> is ~7rem, which fits side by side inside the dialog at 375.
   Declared AFTER the @media block above deliberately: the two selectors have equal
   specificity (0,2,1), so document order is what makes this win the 3-column
   template at >=40rem. Additive and solo-scoped — no school portal can produce the
   class, and .hours-row's own rules are untouched. */
body[data-portal="solo"] .hours-row--pair {
  grid-template-columns: minmax(4.5rem, auto) minmax(0, 1fr);
  align-items: center;
  column-gap: var(--space-m);
}

/* ===== DRAG A LESSON TO ANOTHER SLOT (solo only) ==========================
   Appended below the SOLO-PORTAL header, scoped, and unreachable from a school
   portal twice over: renderWeeklyCalendar() emits data-group-id only for a block
   its draggableFn approved, and solo/js/solo.js's initCalendar() is the only call
   in the repo that passes draggableFn at all.

   NOTHING HERE DECLARES `background` ON .cal-block, deliberately. The lift and the
   source-in-place states are box-shadow / transform / opacity ONLY, so the fills
   and the three contrast figures recorded above for .cal-block--single (#335E65
   at 85% blue-900, its --green-300 done-border) and for the base Midnight block
   are untouched by construction, not by re-measurement.

   touch-action is NOT set here, and that is a decision rather than an omission:
   blocking native panning on a block would (a) force manual scroll-forwarding
   through .cal-scroll.scrollLeft, whose origin and sign are inconsistent across
   engines in RTL, and (b) be unreliable anyway, since the Touch Action spec leaves
   a mid-gesture change of the property undefined for the touch already in flight.
   The gesture suppresses native panning from a {passive:false} touchmove instead,
   and only once it has armed. -------------------------------------------------- */

/* iOS Safari raises its own long-press callout on an <a> at roughly 500-750ms
   regardless of touch-action, which would race the 400ms arm. Always on, so it
   cannot be a mid-gesture change either. */
body[data-portal="solo"] .cal-block[data-group-id] { -webkit-touch-callout: none; }

/* ARMED — "the lesson is lifted, it is now yours to move". It has to read at a
   glance on a block whose fill must not change, so it is pure elevation: a ring in
   the block's own light-on-dark language plus a 1.03 scale, the small tactile
   press-feedback range (0.95-1.05) rather than a bounce.
   The ring is rgba(255,255,255,.9) INSIDE the block (an inset shadow) over a drop
   shadow outside it — MEASURED, composited over each fill: 9.39:1 against the
   Midnight fill and 6.21:1 against the lighter .cal-block--single #335E65, so the
   lift clears 1.4.11's 3:1 on both kinds without either rule learning about the
   other. It is not the only signal: the
   #drag-status live region says the lesson was lifted, in words, at the same
   moment.
   position/z-index: the scaled block must rise above its neighbours rather than be
   clipped by the next cell; .cal-cell has no stacking context of its own, so a
   relative position and a small z-index are enough (well under .app-header's 20). */
body[data-portal="solo"] .cal-block--armed {
  position: relative;
  z-index: 3;
  scale: 1.03;
  box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .9), var(--shadow-md);
}
/* Decorative curve only — the lift itself is a state, not an animation, so a
   reduced-motion user gets it instantly and loses nothing functional. */
@media (prefers-reduced-motion: no-preference) {
  body[data-portal="solo"] .cal-block[data-group-id] { transition: scale 150ms ease-out, box-shadow 150ms ease-out; }
}
/* DRAGGING — the SOURCE, left in place so "where it is moving from" stays legible
   under the ghost. Never hidden: a block that vanished mid-gesture would make a
   cancelled drag look like it had already happened.
   A DASHED BORDER, NOT A DIM, and the reason is measured rather than aesthetic.
   `opacity` on the block composites its TEXT and its fill toward the white cell
   together, so white-on-Midnight falls from 11.20:1 to 2.45:1 at .45 — and the
   whole usable range is empty: at .85 the .78rem sub-line measures 4.11:1 on the
   #335E65 single-lesson fill (under AA), and by .9, where it clears at 4.58:1, the
   dim is no longer perceptible. So the state is carried by the border instead,
   which changes nothing about the text: rgba(255,255,255,.9) measures 9.39:1 on
   the Midnight fill and 6.21:1 on #335E65. Dashed is also this app's existing word
   for "this occurrence is not staying here" (--covered, --moved,
   --cancelled-occurrence all use it), so the source reads correctly even to
   someone who never sees the ghost. The base .cal-block already reserves the 2px
   border as transparent, so nothing reflows when it appears. */
body[data-portal="solo"] .cal-block--dragging {
  border-style: dashed;
  border-color: rgba(255, 255, 255, .9);
}

/* THE GHOST — the candidate landing position, drawn where a real block would be
   drawn (js/auth-guard.js's slotRect(), which sizes through the same
   calTimeToGridOffset() every rendered block uses), so the snap is visible
   throughout the drag rather than revealed at release.
   pointer-events:none is load-bearing, not tidiness: the drop target is resolved
   with elementFromPoint(), which would otherwise return the ghost itself.
   z-index 90 sits above .app-header (20) and .toast (60) and below .skip-link
   (100), which can only be visible on keyboard focus and so cannot coincide with
   a pointer drag. */
body[data-portal="solo"] .cal-ghost {
  position: fixed;
  z-index: 90;
  pointer-events: none;
  display: block;
  box-sizing: border-box;
  padding: var(--space-xs) var(--space-s);
  border: 2px solid var(--white);
  border-radius: var(--radius-sm);
  background: var(--blue-900);
  color: var(--white);
  font-weight: 600;
  font-size: var(--text-sm);
  line-height: 1.25;
  overflow: hidden;
  box-shadow: var(--shadow-md);
}
/* Its own hidden state rather than the `hidden` attribute: this rule declares
   `display`, and the UA's [hidden]{display:none} loses to an author display —
   the silent no-op this project has hit before. */
body[data-portal="solo"] .cal-ghost--hidden { display: none; }
/* REFUSED — the position under the pointer is already known to be illegal (an
   occupied slot, outside working hours, past the calendar's window). Never
   colour-only: #drag-status carries the refusal reason in words at the same
   moment, and the toast repeats it if the coach releases anyway.
   --color-danger fill with white text is the same pairing .btn--danger and
   .toast--error already ship — re-measured here rather than transcribed, 10.96:1
   — so no new figure enters the file. */
body[data-portal="solo"] .cal-ghost--invalid {
  background: var(--color-danger);
  border-color: var(--white);
}
body[data-portal="solo"] .cal-ghost__time { display: block; font-variant-numeric: tabular-nums; }

/* THE UNDO BUTTON on the drop toast. .toast (unscoped, unchanged) is
   pointer-events:none so it can never swallow a click meant for the page beneath
   it; a child re-enabling hit-testing for itself is ordinary CSS and needs no edit
   to that rule. Outline-on-fill rather than a second solid colour: the toast is
   already a solid --color-ok panel, and white-on-that is the pairing its own
   message text uses — re-measured at 6.22:1 for both the label and the 2px
   border, so the button clears text AA and 1.4.11 with no new figure in the file.
   min-block-size 44px keeps the tap target at the touch floor inside a band whose
   text line is shorter than that. */
body[data-portal="solo"] .toast__action {
  pointer-events: auto;
  margin-inline-start: var(--space-m);
  min-block-size: 44px;
  padding: var(--space-xs) var(--space-m);
  border: 2px solid var(--white);
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--white);
  font-weight: 700;
  cursor: pointer;
}
/* A dismissed toast is opacity:0 but still in the DOM, so without this its button
   stays in the tab order as an invisible focus target (WCAG 2.4.11). visibility
   is keyed off the same class the fade already uses — no timer to keep in sync. */
body[data-portal="solo"] .toast:not(.is-visible) .toast__action { visibility: hidden; }

/* ===========================================================================
   SOLO CALENDAR: DAY/WEEK VIEWS, FIT-WEEK, STICKY AXES, OBVIOUS DONE-BORDER
   (PLAN-solo-calendar-views.md)
   ===========================================================================
   Every rule below is scoped body[data-portal="solo"] and ADDITIVE — nothing here edits an existing
   rule's declarations. Where an existing selector needs a new value (the done-border), it gets a
   SECOND rule block on the same selector, appended here, which wins at equal specificity by source
   order; test/cssdiff.py is list-keyed precisely so that is expressible without touching the first.
   =========================================================================== */

/* THE WIDTH FLOOR OPT-OUT, and it is the load-bearing rule of this whole feature. The base
   .cal-grid declares `min-inline-size: 46rem` (736px), which overrides column sizing no matter what
   `grid-template-columns` says — so without this class BOTH the fit-week template and the day-view
   template are silently inert and the feature ships looking exactly like today. The class is added
   by js/auth-guard.js only when fitColumns or day view is active, so no school portal can reach it
   (neither passes either option — asserted by test/dom-identity.mjs's SOLO_ONLY premise check). */
body[data-portal="solo"] .cal-grid.cal-grid--fit { min-inline-size: 0; }

/* MAKES THE CALENDAR ITS OWN SCROLL REGION, which is what finally makes the ALREADY-EXISTING
   `.cal-dayhead { position: sticky; inset-block-start: 0 }` do something. That declaration has been
   in the base rule all along and was inert here: sticky pins to the nearest SCROLLING ancestor, and
   .cal-scroll — though already a scroll container, since `overflow-x: auto` forces the used value
   of overflow-y to auto too — had no vertical overflow of its own to scroll, so the page scrolled
   instead and the header row travelled with it (and, where it did stick, tied with .app-header's
   own sticky top:0 and lost). `max-block-size` is the declaration that actually creates the
   scrollable overflow to anchor against; `overflow-y: auto` is restated only so this reads as a
   deliberate scroll region rather than depending on that used-value rule from two properties away.
   Solo-scoped: school portals keep today's page-level scroll, untouched. */
body[data-portal="solo"] .cal-scroll {
  overflow-y: auto;
  max-block-size: min(70vh, 42rem);
}

/* THE HOUR GUTTER pins to the RTL start edge (the right) wherever horizontal overflow can still
   exist — desktop week view above the 40rem breakpoint. Under the fit-week and day templates there
   is no horizontal overflow at all, so this costs nothing there.
   PAINT ORDER, stated because it is not obvious and it is the whole design: a resting .cal-block is
   NOT positioned, so it paints in the in-flow layer, BELOW every positioned element — which means
   these sticky layers sit above resting blocks for free. z-index 2 then only has to lose to the
   armed/dragging block (position:relative; z-index:3, existing rules), which is exactly the
   requirement: a lifted block must never disappear under a pinned axis mid-gesture. The ghost (90)
   is far above both. */
body[data-portal="solo"] .cal-time { position: sticky; inset-inline-start: 0; z-index: 2; }
body[data-portal="solo"] .cal-time--box { position: sticky; inset-inline-start: 0; z-index: 2; }

/* The corner is the one cell that pins on BOTH axes, so it must paint over the day-head row and the
   gutter column where they cross. z-index 4 — deliberately ABOVE the armed-block elevation (3),
   unlike the gutter above: the corner is the crossing point of two pinned layers and a seam there
   is visible on every scroll, while the case it loses (an armed block dragged under the top-right
   3.5rem corner) is transient and the ghost, at 90, is still drawn over everything. Stated as the
   deliberate trade it is rather than left to be rediscovered. */
body[data-portal="solo"] .cal-corner { position: sticky; inset-block-start: 0; inset-inline-start: 0; z-index: 4; }

/* ---- THE DAY-HEAD ROW'S OWN ELEVATION — a HOTFIX, and the rule it repairs is stated above ----
   OWNER-REPORTED REGRESSION: "lesson goes over the day when scrolling" — a resting lesson block
   painting OVER the pinned day-header row.
   WHAT BROKE IT, precisely: the paint-order note above says a resting `.cal-block` is NOT positioned,
   so the sticky layers sat above it FOR FREE. The 5-minute-step round then added
   `body[data-portal="solo"] .cal-block { position: relative }` (needed so a 14:05 start can carry a
   percentage `inset-block-start`), which made every block a POSITIONED element at z-index auto —
   and among positioned, z-auto elements the LATER one in DOM order wins. The blocks come after the
   head row, so they started winning. Nothing about the head changed; its free ride ended.
   THE REPAIR IS TO STATE THE STACK INSTEAD OF INHERITING IT. z-index 2 puts the head row in the
   same tier as the hour gutter, which is exactly what it is — the other pinned axis — so the whole
   ledger stays as documented above and below:
     gutter / day-head .. 2   armed + dragging block .. 3   corner .. 4   drag ghost .. 90
   A lifted block (3) still paints OVER the head row, which is the drag rounds' deliberate design
   ("a lifted block must never disappear under a pinned axis mid-gesture"); the corner (4) still
   crosses over both pinned layers; the ghost is still above everything.
   BOTH ELEMENTS: the head is a <div> in day view and a real <button> in week view (the tappable
   day-head ruling), so this is keyed on the CLASS, which both carry.
   SOLO-SCOPED, like every rule in this block: the school portals never make .cal-scroll a scroll
   region, their blocks are still unpositioned, and their DOM is pinned byte-identical either way. */
body[data-portal="solo"] .cal-dayhead { z-index: 2; }

/* A block MID-DRAG keeps the elevation its armed state had. .cal-block--armed already carries
   position:relative;z-index:3, but beginDragging() REMOVES that class the instant a hold becomes a
   real drag and swaps in --dragging — which until now declared only border-style/colour, so the
   source block lost its elevation at exactly the moment the sticky layers above could hide it.
   The existing --dragging rule is untouched; this is a second block on the same selector. */
body[data-portal="solo"] .cal-block--dragging { position: relative; z-index: 3; }

/* NATIVE HORIZONTAL PAN AND NATIVE PINCH ARE SUPPRESSED ON THE GRID, BELOW THE PHONE BREAKPOINT
   ONLY — the same 40rem the mobile nav already uses, and the same one page-calendar.js's PHONE_MQ
   reads, so there is ONE definition of "phone" across the CSS and the JS.
   Above 40rem this rule does not exist and .cal-scroll keeps `touch-action: auto`, so desktop's
   native horizontal scroll-to-see-more-days is untouched. Below it, day view and fit-week both
   guarantee there is nothing to pan horizontally, so pan-y costs nothing and frees both gestures
   (day swipe, pinch-to-switch-view) for page-calendar.js's own pointer handlers.
   `pan-y`, NOT `none`: native VERTICAL scrolling of the calendar is explicitly preserved.
   Declared STATICALLY and never toggled mid-gesture — this repo's recorded hazard is about changing
   touch-action during an in-flight touch, which a permanent rule cannot do.
   WCAG 1.4.4/1.4.10: two-finger page zoom remains fully available everywhere OUTSIDE this one
   element, at every width; nothing here reaches the page, the header or any other component. */
@media (max-width: 40rem) {
  body[data-portal="solo"] .cal-scroll { touch-action: pan-y; }
}

/* ---- the "lesson recorded" border, made obvious (ruling 7) ----
   MEASURED, not transcribed: WCAG relative luminance from the implementation validated earlier this
   session against 24/24 Sharma et al. reference pairs, and re-validated here by reproducing SEVEN
   figures this file already records (11.20 / 6.00 / 4.28 / 3.84 / 7.17 / 7.98 / 1.80 — all exact)
   before any number below was trusted. #8FB7A2 is exactly the 50/50 --green-700/white mix it claims
   to be (computed, not asserted); --green-300 is the 40/60 one.
     on Midnight   #0F414A:  5.05:1
     on --indigo-600 #3E4A97: 3.60:1
     on #335E65 (.cal-block--single): 3.23:1  <- THE BINDING GROUND (3.25:1 against the exact
                                                 color-mix Chrome computes, rather than its 8-bit
                                                 rounding — recorded so neither reads as a slip)
   All three clear WCAG 1.4.11's 3:1 non-text floor.

   THE HONEST FRAMING, because the obvious reading of "stronger green" is wrong in two ways and a
   future maintainer will otherwise "improve" this and break it:
   (1) The contrast RATIO goes DOWN, not up (3.84 -> 3.23 on the binding fill). It must: the border
       is being darkened toward the green it is a tint of, on fills that are themselves dark.
   (2) The COLOUR step alone is a WEAK cue — dE2000 from --green-300 is 5.09, below the ~7 at which
       two tones stop reading as the same colour. THE THICKNESS IS WHAT CARRIES "OBVIOUS": 2px -> 3px
       is a 50% increase that needs no hue discrimination at all.
   THE MIX IS AT ITS HARD FLOOR AND THE COLOUR AXIS IS EXHAUSTED — measured, not guessed:
     45% green-700 -> #9ABEAB, dE 2.62, binding ground 3.53:1  (legal, but an even weaker cue)
     50% green-700 -> #8FB7A2, dE 5.09, binding ground 3.23:1  <- pinned
     55% green-700 -> #84AF99, dE 7.62, binding ground 2.93:1  <- FAILS 1.4.11
   i.e. there is NO mix that is both perceptibly different from --green-300 (dE >= 7) and legal on
   all three fills. Do not darken this token further; the next step down is a WCAG failure on a fill
   the owner did not name (the single-student one), and it is the one that binds.
   No glyph, no badge, no DOM change — pure additive CSS, per the ruling. */
/* NO LONGER CONSUMED, and kept deliberately rather than deleted — the same posture --purple-50 and
   --indigo-50 have. It was the done border's colour for exactly one release; the two-ring treatment
   further down this section replaced it with --green-700 over a white separator, because the owner
   found this tone pale and a single ring could not legally be darkened (see the sweep there). Left
   in the ramp so the green scale still reads 300/500/700, and so the release that used it stays
   explicable. Do not re-point the done border at it. */
:root { --green-500: #8FB7A2; }
/* A SECOND rule block on the EXISTING body[data-portal="solo"] .cal-block--done selector (the first
   one, `border-color: var(--green-300)`, is left exactly as it is). This wins border-color at equal
   specificity by source order and ADDS border-width, which the original never declared. The global
   `box-sizing: border-box` reset (verified present) means the extra 1px/side trims the content box,
   never the block's outer footprint — so nothing reflows and no block grows. */
body[data-portal="solo"] .cal-block--done { border-width: 3px; border-color: var(--green-500); }
/* …AND THE ONE STATE THAT RULE JUST BROKE, restored. `body[data-portal="solo"] .cal-block--dragging`
   (:1905) sets the white dashed drag border at (0,2,1) — the SAME specificity as the done rule
   directly above, which now sits LATER in the file and therefore won `border-color` on any block
   that was both recorded and being dragged: the drag signal turned green and the "this occurrence
   is not staying here" cue was silently lost on exactly the lessons a coach is most likely to move.
   This compound selector is (0,3,1), so it outranks both regardless of order.
   Colour only: `border-width: 3px` is deliberately inherited from the done rule, because the drag
   cue is the dashed WHITE, not its thickness — and rgba(255,255,255,.9)'s measured ratios
   (9.39:1 Midnight / 6.83:1 indigo / 6.18:1 on #335E65) are properties of the colour pair, which no
   width can change. The dashed style comes from :1905, untouched. */
body[data-portal="solo"] .cal-block--done.cal-block--dragging { border-color: rgba(255, 255, 255, .9); }

/* ---- THE DONE MARKING BECOMES GENUINELY DARK GREEN — a TWO-RING treatment (owner ruling) ----
   Owner: "darker green on the completed lesson!" — #8FB7A2 still read pale to them.
   THE SWEEP ABOVE SAID THE COLOUR AXIS WAS EXHAUSTED, AND IT WAS RIGHT ABOUT WHAT IT MEASURED: a
   SINGLE ring darker than 50% green-700 fails 1.4.11 against the block's own dark fill (55% -> 2.93:1
   on #335E65). --green-700 itself measures 1.80 / 1.15 / 1.28 on Midnight / #335E65 / indigo — far
   past unusable. That finding stands and must not be re-tested by darkening the single ring again.
   WHAT CHANGES IS THE MECHANISM, NOT THE LIMIT. The dark green is never asked to contrast with the
   dark fill at all: a thin WHITE ring sits between them, so the green borders against light on its
   inside and against the cell/page on its outside — both light. Two rings, and the contrast
   question is asked about the pairs that actually touch:
     --green-700 #1F6E45 vs the white separator (inside):   6.22:1
     --green-700 vs the white cell (outside):               6.22:1
     --green-700 vs the .cal-cell--today tint:              5.64:1
     --green-700 vs the outside-hours tint:                 4.63:1
     --green-700 vs the alabaster page:                     5.12:1
   and the separator itself, so it reads as a ring and not a smudge:
     white vs Midnight 11.20:1 · vs #335E65 7.17:1 · vs indigo 7.98:1 · vs the one-off fill 4.67:1
   All past 1.4.11's 3:1. The marking is genuinely darker: L* 71.09 -> 41.01, dE2000 29.34 from the
   tone it replaces.
   WHITE, NOT --green-500, FOR THE SEPARATOR: measured, --green-700 on --green-500 is 2.80:1, which
   FAILS — the prettier all-green ring is not legal. (--green-300 would pass at 3.33:1; white is
   chosen at 6.22:1 because it is also the ring colour the armed/dragging states already use.)
   WIDTH: 3px of green plus a 1px separator, so the green stays the ~3px the earlier ruling fixed and
   the separator reads as a hairline, not a halo. `box-shadow: inset` costs no layout — it paints
   over the block's own 2px padding — so nothing reflows and no text moves.
   WHAT A FUTURE MAINTAINER MAY AND MAY NOT DO, since this supersedes the sweep's conclusion:
   darkening the green FURTHER is now safe against the fill (the separator, not the green, is what
   touches it) but is bounded by the OUTSIDE pairs above — the outside-hours tint at 4.63:1 is the
   tightest. Removing the separator and keeping the dark green is the one thing that is never legal.
   NO GLYPH, still: this is border only, per the standing ruling. */
body[data-portal="solo"] .cal-block--done {
  border-color: var(--green-700);
  box-shadow: inset 0 0 0 1px var(--white);
}
/* The armed state's own ring is a 2px inset white shadow, and the done rule directly above would
   otherwise replace it (equal specificity, later in the file), silently deleting the "this lesson is
   lifted" cue on any recorded lesson. Restored verbatim — and it serves the separator's job too,
   being white and inside the border, so the dark green still never touches the fill. */
body[data-portal="solo"] .cal-block--done.cal-block--armed {
  box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .9), var(--shadow-md);
}

/* ---- NOBODY SWAM: the recorded occurrence nobody attended (owner ruling) ----
   Owner: "in a private lesson, if a student is a no-show and the lesson is saved, then have a RED
   outline instead of a green one." Generalised, and reducing to exactly that for a private lesson:
   RED when every student of that occurrence was marked absent, GREEN when at least one attended.
   page-calendar.js's occurrenceNoShow() is the one place that sentence is evaluated.
   THE MECHANISM IS THE ONE ALREADY HERE — only the ring's colour changes. The 1px white separator,
   the 3px width, the fit-view width override and the armed/dragging composition all come from the
   --done rules above, so a no-show block is a done block wearing a different ring.
   MEASURED, methodology re-validated first against two figures this file already records (white on
   --red-600 10.96, --green-700 on white 6.22):
     --red-600 #7F0303 vs the white separator (inside) .. 10.96:1
     vs the white cell .................................. 10.96:1
     vs the .cal-cell--today tint ....................... 9.93:1
     vs the outside-hours tint .......................... 8.15:1
     vs the alabaster page .............................. 9.01:1
   Every ground past 1.4.11's 3:1, with the tightest at 8.15.
   DISTINGUISHABLE FROM THE GREEN RING, and not only by hue — which matters, because red-vs-green is
   the one pair colour-blindness attacks: dE2000 56.12, and the two differ in LIGHTNESS by L* 25.52
   against 41.01. A viewer who cannot separate the hues still sees a darker ring. That is a cue, not
   a guarantee, which is why the state is ALSO spoken: the block's aria-label gains
   ", אף תלמיד/ה לא נכח/ה" (blockAriaSuffixFn), so the information is available without colour at
   all. No existing non-colour cue distinguishes the two states — the block's text is identical
   either way — so the aria suffix IS the accessible carrier, recorded here as the deliberate answer
   to 1.4.1 rather than left as an assumption.
   dE2000 from the dashed warn border is 19.09, so it is not confusable with that either. */
body[data-portal="solo"] .cal-block--done.cal-block--noshow { border-color: var(--red-600); }
/* The drag border still wins over BOTH ring colours — (0,4,1) against the no-show rule's (0,3,1),
   mirroring the done+dragging rule above for the same reason: mid-drag, "where is this going" is
   the only state worth painting. */
body[data-portal="solo"] .cal-block--done.cal-block--noshow.cal-block--dragging {
  border-color: rgba(255, 255, 255, .9);
}

/* COMPACT CONTENT for the fit-week and day templates. Reuses .78rem — the same small-text size this
   file already uses for de-emphasised calendar text (.cal-block__lvl, .cal-time--half) — rather
   than inventing a size. Colour is untouched, so every recorded block-text contrast figure
   (11.20 / 7.17 / 7.98 …) is unaffected: font-size does not change a contrast RATIO. */
body[data-portal="solo"] .cal-grid--fit .cal-block { padding: 2px var(--space-xs); font-size: .78rem; }

/* ---- TALLER, AND UNIFORM, TIME SLOTS (owner rulings 1 + 2) ----
   Owner: "make the time slots longer, so you could see everyone in a double lesson" and "I want all
   time slots to be the same length even if it means private lessons would have free space."
   TWO SEPARATE CHANGES, and they are separable on purpose:
     (1) the row gets TALLER, so a זוגי's two names both fit;
     (2) the row becomes a FIXED length instead of `minmax(floor, auto)`, so CONTENT can no longer
         size it — which is the whole of "all time slots the same length".
   Duration still scales height exactly as before: a row is 15 minutes, so a 45-minute lesson is
   still 3 rows and a 30 is still 2. The ruling is about content, not about flattening durations.

   THE ARITHMETIC, from this file's own values rather than a guessed pixel:
     block text line box = .78rem x 1.25 line-height   = 15.60px   (the two rules directly above)
     a 30-minute box     = 2 rows + the 1px grid gap   = 2R + 1
     minus .cal-cell padding-block (2px x 2)           = -4px
     minus .cal-block padding-block (2px x 2)          = -4px
     => text area for a 30-minute block                = 2R - 7
   At the OLD R (1rem = 16px, from .cal-grid--quarter) that is 25px — 1.60 line boxes, which is why
   a two-name block did not fit and instead GREW its row, making equal-duration lessons unequal.
   At R = 1.75rem (28px) it is 49px = 3.14 line boxes: one line for each of the two names, plus a
   whole spare line so a longer name may wrap without clipping. THREE lines is the budget.
   WHY NOT TALLER: the grid is drawn inside .cal-scroll's `min(70vh, 42rem)`, and the solo grid is
   already narrowed to the union of the day's real lesson times, so a 6-hour span costs
   12 boxes x 57px = 684px — about 1.5 scrolls of a 375x667 phone, which is what the sticky day-head
   and gutter added in this same section exist to make comfortable. A 4-line budget (R = 2.2rem)
   would add another ~40% of scroll for a case two names on their own lines do not need.
   OVERFLOW CLIPS RATHER THAN OVERLAPPING: with a fixed track a block whose text exceeds its box
   would otherwise spill over the cell below and paint on a neighbouring lesson. Anything clipped is
   still spoken in full — the block's aria-label is composed from the untruncated strings.

   SCOPE — .cal-grid--fitROWS, i.e. THE FITTED WEEK ONLY, and the boundary is measured rather than
   chosen. The wider .cal-grid--fit class also covers DAY view (which needs the same width-floor
   opt-out) and the DESKTOP week has neither, so three surfaces have to be told apart:
     · fitted week  — blocks are abbreviated to ONE NAME PER LINE, so a זוגי needs 2 x 15.60 =
                      31.2px against the 49px box. Pinned: this is what the ruling asked for.
     · day view     — blocks keep the FULL per-occupant level lines (compactBlocks is false there),
                      so the line count follows the roster: a זוגי needs 3 lines = 46.8px, which
                      fits, but a 3-occupant 30-minute one-off needs 4 = 62.4px and would CLIP its
                      third level line. NOT pinned — it keeps `minmax(1rem, auto)`, exactly as it
                      rendered before this round.
     · desktop week — same reason as day view, plus no ruling asked for it. NOT pinned.
   The owner's ruling was explicitly about the weekly screen, and this is the one class that means
   exactly that. Recorded in PLAN-solo-calendar-views.md's scope note too. */
:root { --cal-fit-row: 1.75rem; }
/* `grid-template-rows: auto` IS NOT DECORATION — it exempts ROW 1, the day-head row, from the fixed
   height below. Every row in this grid is IMPLICIT (the renderer sets only grid-template-COLUMNS
   and places by `grid-row: N / span M`), so `grid-auto-rows` alone would pin the header row to the
   same 28px as a quarter-hour slot — and the head carries two lines of text plus 16px of padding,
   about 61px, so the weekday, the date and (now) the tappable button would all be clipped.
   Declaring ONE explicit auto row hands row 1 back to its content and leaves rows 2+ — the time
   slots, which is what the ruling is about — governed by grid-auto-rows. */
body[data-portal="solo"] .cal-grid.cal-grid--fitrows {
  grid-template-rows: auto;
  grid-auto-rows: var(--cal-fit-row);
}
/* The two floors that would otherwise re-introduce content-driven growth inside a fixed track:
   .cal-cell's own min-block-size, and the solo .cal-block `min-block-size: min-content` (:1232)
   whose entire purpose is to let a dense block push its row taller — exactly what the uniformity
   ruling retires. Both are (0,2,1); these are (0,3,1) and win only inside the fitted WEEK grid —
   never in day view, which is the whole point of the narrower class. */
body[data-portal="solo"] .cal-grid--fitrows .cal-cell { min-block-size: 0; }
body[data-portal="solo"] .cal-grid--fitrows .cal-block { min-block-size: 0; overflow: hidden; }

/* ---- NAMES ARE NEVER BROKEN MID-WORD (owner ruling: "names can't be cut off in the middle") ----
   The owner photographed "דניאל" rendered as "דניא" + "ל". Cause: .cal-block__name carries
   `overflow-wrap: break-word` (unscoped, correct for the roomy school calendars), and a name wider
   than a ~40px column IS a word that cannot fit a line, which is exactly the condition that licenses
   breaking inside it.
   THE LADDER, in order, and the first rung is the one that makes the guarantee absolute:
     1. `white-space: nowrap` — a mid-word break becomes IMPOSSIBLE here, at every width, for every
        string, whatever the other rungs do. It is also what stops "15:00" splitting into "15:0"+"0".
     2. WIDTH RECLAIMED FROM THE BLOCK'S OWN CHROME (the rules below): cell padding, the block's
        transparent border and its inline padding together ate 18px of a 39.7px column at 375px —
        nearly half. Trimmed to 4px, they hand it back to the text. MEASURED at 375px: the text box
        goes 21.7px -> 32.9px in the worst case (a 3px done border), i.e. ~3 characters -> ~5.
     3. A STEP-DOWN SIZE for a line longer than the base can hold (.cal-block--tight, added by
        page-calendar.js from the SAME strings the block renders). At .70rem a 375px column fits
        "דניאל", "מיכאל" and "15:00" whole; at 320px it fits four characters.
     4. ELLIPSIS past that, for genuinely long names ("אלכסנדרה" fits nowhere in 33px). Last resort,
        and still strictly better than a mid-word break — the full name stays in the aria-label.
   HONEST BOUND, so nobody re-derives it later and thinks it is a bug: seven columns in a phone
   viewport is ~40px each, and no font size that stays legible fits an 8-character Hebrew name in
   33px. The ladder guarantees "never broken mid-word", not "always complete".
   ONE RULE FOR EVERY LINE — the first name, the second bold name, a team's own name, the derived
   "ד"/"15:00" pair, a moved or one-off label — because they are all the same kind of thing in the
   same box, and two copies of this would drift. */
body[data-portal="solo"] .cal-grid--fitrows .cal-block__name,
body[data-portal="solo"] .cal-grid--fitrows .cal-block__lvl {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Rung 2 — the width reclaim. Each of these three is a separate consumer of the same 40px. */
body[data-portal="solo"] .cal-grid--fitrows .cal-cell { padding: 0; }
body[data-portal="solo"] .cal-grid--fitrows .cal-block { padding: 2px 1px; border-width: 1px; }
/* …but never at the cost of the "recorded" marking: the done border keeps its full width (that is
   the ruling it belongs to), so a done block simply has 2px less text room than its neighbours. */
body[data-portal="solo"] .cal-grid--fitrows .cal-block--done { border-width: 3px; }
/* RETIRED BUT RETAINED, like --green-500 above. page-calendar.js no longer emits this class: the
   owner's "keep everything the same size" ruling replaced the per-block step-down with one size for
   the whole fitted week (the rule below). The declaration is left in place because it shipped in
   v1.25.0 and this file's rule-level baseline holds every released rule byte-identical — and it is
   inert twice over, since nothing adds the class and its value is now what every block gets anyway.
   Do not re-point page-calendar.js at it. */
body[data-portal="solo"] .cal-grid--fitrows .cal-block--tight { font-size: .70rem; }

/* Rung 3 — ONE SIZE FOR EVERY BLOCK, not a per-block step-down.
   Owner: "keep everything the same size so if you make adjustments to size do it to everything."
   The previous shape sized each block from its own longest line, so a block with a long name
   rendered visibly smaller than the one beside it — the exact thing that ruling forbids. The whole
   fitted week now renders at .70rem, the value the step-down used, so the worst case is the only
   case and every block matches. Below .70rem the digits of a year or "15:00" stop being legible at
   arm's length, so this is also the floor. Tap targets are unaffected either way: a block's height
   comes from the rows its lesson spans, never from its text.
   (0,4,1) so it beats the .78rem base set on the wider --fit class earlier in this section — day
   view keeps that, since it has the room and was not part of the ruling.) */
   THE INLINE PADDING GOES TO 0 IN THE SAME RULE, and only now that the text is CENTRED: it existed
   to hold text off the block's start edge, and centred text is already held off both. That is free
   width — and it is exactly the 2px that lets a four-digit year fit on a 320px phone's RECORDED
   block, whose 3px done border leaves the tightest text box of all: 25.9px against the 24.6px
   "2015" needs. Declared here as an additive override rather than an edit to the shipped rule
   above, per this file's rule-level baseline. */
body[data-portal="solo"] .cal-grid.cal-grid--fitrows .cal-block {
  font-size: .70rem;
  padding-inline: 0;
}
/* …AND THE SUB-LINES, WHICH THE RULE ABOVE DOES NOT REACH. `.cal-block__lvl` declares its own
   `font-size: .78rem` (:809, unscoped), so setting a size on the parent .cal-block leaves every
   line after the first at the LARGER size — the title rendered .70rem and the year/level/hour lines
   .78rem, which is both a direct breach of "keep everything the same size" and a bigger line box
   than the row was sized for (a זוגי needed 60.8px against 59.0px of content on a normal block and
   55.0px on a recorded one, i.e. the fourth line was being clipped).
   `inherit`, not a repeated literal: the sub-lines then track the block's own size for free, so the
   two can never drift apart again if that value is ever retuned. */
body[data-portal="solo"] .cal-grid.cal-grid--fitrows .cal-block > .cal-block__lvl { font-size: inherit; }

/* ---- THE ROW GROWS ONCE, FOR EVERY BLOCK EQUALLY (owner ruling 4) ----
   The שנתון and level lines take a fitted-week block from at most two lines to at most FOUR, so the
   fixed track has to hold four. A SECOND :root block rather than an edit to the first — that one
   shipped in v1.24.1 and this file's rule-level baseline holds every released rule byte-identical;
   a later block on the same selector is the sanctioned way to re-point a value.
   RE-DERIVED, and CORRECTED IN REVIEW — the first version of this derivation subtracted the block's
   PADDING but not its BORDER, which `box-sizing: border-box` (:155, global) also takes out of the
   content box. That made the recorded headroom look like 4.36 lines when the true figure is lower,
   and it hid the fact that a RECORDED block is the binding case: its done marking is 3px per side
   against a normal block's 1px, so it pays 4px more for the same content.
     content box for a 30-minute block = 2R + 1(gap) - 0(cell padding) - 2 x border - 4(block padding)
     every line is 14.00px (.70rem x 1.25 — the sub-lines included, see the inherit rule above)
       R = 2rem    -> normal 59.0px = 4.21 lines | done 55.0px = 3.93 lines  <- SHORT of four
       R = 2.05rem -> normal 60.6px = 4.33 lines | done 56.6px = 4.04 lines  <- pinned
       R = 2.1rem  -> normal 62.2px = 4.44 lines | done 58.2px = 4.16 lines
   WORST CASE IS FOUR LINES, reached by two shapes — a זוגי (name, year, name, year) and a
   derived-name team (ד, 15:00, שנתון, level). A private lesson uses two and keeps the rest as the
   free space the uniformity ruling explicitly accepts.
   THE MARGIN ON THE BINDING CASE IS 0.60px, and that is deliberately recorded rather than rounded
   away: it is thin, but a line BOX is 14.00px around 11.20px of glyphs, so the 1.40px of half-leading
   above and below each line absorbs a sub-pixel shortfall without touching a digit. Do NOT trim this
   row on the strength of "it looks like it has room" — the done case is what binds, and it has 0.6px.
   SCROLL COST at 2.05rem, measured against .cal-scroll's own min(70vh, 42rem) = 466.9px at 375x667
   (30-minute box 66.6px):
     a 3-hour span  342px -> 400px   (0.86 screens, was 0.73)
     a 6-hour span  684px -> 799px   (1.71 screens, was 1.46)
     a 9-hour span 1026px -> 1199px  (2.57 screens, was 2.20)
   The solo grid is already narrowed to the union of the day's real lesson times, so the 6-hour row
   is the realistic one. */
:root { --cal-fit-row: 2.05rem; }

/* ---- CENTERED HORIZONTALLY, ANCHORED TO THE TOP (owner ruling) ----
   Owner: "make everything centered, not bullseye centered but top of the slot centered."
   So: text-align center for every line, and NOTHING that centres vertically. The block already
   flows its lines from its own top edge (ordinary block layout inside a stretched cell), so the top
   anchoring is inherited rather than declared — which is the point of saying it here: a future
   `align-content: center` or `justify-content: center` on this element would silently break the
   half of the ruling that is currently free.
   Fitted week only: day view and desktop keep their start-aligned text, which reads better at a
   width where a line is a sentence rather than a single word. */
body[data-portal="solo"] .cal-grid--fitrows .cal-block { text-align: center; }

/* ---- BOTH NAMES OF A זוגי READ AS NAMES (owner ruling) ----
   In the compressed week the second line is a PERSON, not a level — so it must not keep the
   sub-line's quieter treatment, which exists to de-emphasise a level/birth-year line that no longer
   renders there at all. Same weight as the title, same pure white.
   CONTRAST IMPROVES rather than degrades: rgba(255,255,255,.85) measured 8.57 / 6.30 / 5.72 on the
   Midnight, indigo and single-lesson fills; pure white measures 11.20 / 7.98 / 7.17 on the same
   three, and 4.67:1 on the new one-off fill below. All well past 4.5:1.
   Specificity (0,4,1) — deliberately ABOVE the (0,3,1) solo sub-line rule it overrides, so this wins
   on specificity rather than on source order. Scoped to the fitted week: every other surface keeps
   the quiet sub-line for the level lines it still shows. */
body[data-portal="solo"] .cal-grid--fitrows .cal-block > .cal-block__lvl {
  font-weight: 600;
  color: var(--white);
}

/* ---- A ONE-OFF LESSON IS LIGHTER THAN A RECURRING ONE (owner ruling) ----
   The חד-פעמי pill already exists on lesson.html; this is the at-a-glance cue on the calendar, in
   ALL THREE solo views (fitted week, day, desktop) so the distinction means one thing everywhere.
   MEASURED, methodology re-validated first by reproducing six figures this file already records:
     70% Midnight + 30% white = #577A80
       white block text (and the bold second name):      4.67:1  <- THE BINDING CONSTRAINT
       armed/dragging white ring @ rgba(255,255,255,.9): 4.13:1  (>= 3:1)
       the done marking's white separator ring:          4.67:1
     dE2000 from everything it must not be confused with:
       the recurring Midnight fill  20.37 · .cal-block--single  10.88 · the team indigo  23.72
       the empty white cell         39.64 · the covered/warn fill 37.46
   70% IS THE LIGHTEST LEGAL STEP AND THE STEP IS AT ITS FLOOR: 65% measures 4.04:1 for white text,
   a 1.4.3 failure. Do not lighten this further to make the cue stronger — there is nowhere to go.
   (80% and 75% are legal but measure dE 3.38 and 7.16 against .cal-block--single, i.e. they would
   read as the one-student fill rather than as their own thing.)
   Specificity (0,3,1), and it must WIN over .cal-block--single (0,3,1, earlier in the file) because
   a one-off lesson with one student is both: "happens once" is the rarer and more consequential
   fact, and the block's own text still names the student either way. */
body[data-portal="solo"] .cal-block.cal-block--oneoff { background: color-mix(in srgb, var(--blue-900) 70%, white 30%); }

/* ---- REGISTRY: THE FREEZE SNOWFLAKE, TOP INLINE-END (owner ruling) ----
   Owner: "make them both freeze, and make a snow particle icon, and make it very light blue, top
   left." One control now replaces the two text buttons the row carried; page-registry.js has the
   merge of the two writes, this is only where it sits and what colour it is.
   THE CORNER IS BOUGHT, NOT BORROWED. The chip is taken out of the row's flex flow and pinned to
   the top inline-end — and the row reserves an equal gutter for it, so it can never land on top of
   the action cluster. Without the reserved padding the two WOULD collide on a short row: .list-item
   is `align-items: center`, so a row with no preferences and no note centres its buttons right
   through the band the chip occupies. Scoped to #roster-list, so the same .list-item component on
   Home and lesson.html is untouched.
   MEASURED, and the tension in the ruling is real rather than rhetorical — "very light blue" and "a
   3:1 boundary of its own" cannot both hold:
     --blue-100 #CFE3E8 against the white card ....... 1.33:1   (a fill this light never clears 3:1)
     even a 50% --blue-900 mix (#87A0A5) ............. 2.76:1   (and is no longer "very light blue")
   So the identifying contrast is carried by the SNOWFLAKE, which is the standard 1.4.11 resolution
   for a tinted control — the thing you must see to recognise it is the glyph, and it measures:
     --blue-900 stroke on the --blue-100 chip ........ 8.43:1
   FROZEN state: the chip inverts to --color-secondary with a white glyph, so the state is carried by
   fill AND by aria-pressed, never by colour alone — and that fill DOES clear its own boundary:
     --color-secondary #2C6B77 against the white card . 6.04:1
     white stroke on it .............................. 6.04:1
   THE WORD הקפאה IS VISIBLE BESIDE THE GLYPH (later owner ruling), so .btn--icon-only is GONE from
   this control — it is no longer icon-only, and its `min-width: 36px` said nothing useful about a
   chip three times that wide. Everything above still holds: the fill, the glyph contrast and the
   frozen inversion are unchanged, and the state is still carried by fill + aria-pressed and never
   by the label, which now reads the same in both states.
   TAP TARGET: .btn--sm alone already ships the 44px height via its `inset-block: -4px` ::after (36
   + 2x4), and the chip's own width is far past 44 — so the minimum is still inherited rather than
   re-invented. The .btn--sm.btn--icon-only::after rule no longer applies to it, which costs nothing
   for the same reason.
   THE RESERVED GUTTER, RE-MEASURED rather than re-guessed (headless Chrome, 375px row) — but
   measured in ASSISTANT, the body face this app used before the Heebo/Fredoka swap. The chip is
   body text, so the webfont row below moved with the swap and the 6rem band should be re-measured
   in Heebo before it is trusted to the pixel; the two FALLBACK rows, which are what the swap period
   renders and which set the tightest case, are unchanged:
     chip, Assistant (superseded by Heebo) .... 92.83 x 36
     chip, 'Segoe UI' fallback (the widest) ... 99.78 x 36   <- what the swap period renders
     chip, 'Noto Sans Hebrew' fallback ........ 94.98 x 36
   So the band is `--space-s` (the chip's own inset from the padding-box edge) + 6rem (96px, above
   the Assistant width and within 4px of the widest fallback) + `--space-m` of clearance, which is
   the same clearance the 3.5rem icon-only version kept. Measured leftover between the chip's start
   edge and the content box, at 375px: 19.17px (Assistant), 17.02 (Noto), 12.22 (Segoe) — positive
   in all three, and no overlap with the title, the .list-item__main column or the action cluster in
   any of them, including on the short row (name only, no pills) the note above names as the
   collision case. If the label text ever changes, RE-MEASURE: this number is a measurement, not a
   round figure. */
body[data-portal="solo"] #roster-list .list-item {
  position: relative;
  padding-inline-end: calc(var(--space-m) + var(--space-s) + 6rem);
}
body[data-portal="solo"] .kid-freeze {
  position: absolute;
  inset-block-start: var(--space-s);
  inset-inline-end: var(--space-s);
  background: var(--blue-100);
  color: var(--blue-900);
  border-color: transparent;
  /* An absolutely-positioned box is shrink-to-fit against its containing block, so a narrow row
     could wrap the label onto a second line — which would grow the chip DOWNWARD into the row's
     second content line, the one thing the reserved gutter cannot protect against. */
  white-space: nowrap;
}
body[data-portal="solo"] .kid-freeze[aria-pressed="true"] {
  background: var(--color-secondary);
  color: var(--color-on-primary);
}
@media (hover: hover) {
  body[data-portal="solo"] .kid-freeze:hover { background: var(--violet-600); color: var(--color-on-accent); }
}

/* ---- HOME CARD: THE TRASH AND THE KIND PILL TRADE PLACES (owner ruling) ----
   Owner, from a screenshot of השיעורים הקבועים שלי: "swap שיעור פרטי and the trash icon."
   WHAT THEY WERE LOOKING AT, and why it is a wrap and not a corner: `.card__head` is a wrapping
   flex row whose three children are [title] [.card__head-meta] [.card__head-action], and the AUTO
   MARGIN sits on the META (the shared rule says why: on the action it would eat all the free space
   and drag the pills over to hug the title). On a phone the title and the pills fill line one — the
   pills pushed to the inline-END, i.e. the visual LEFT — and the trash wraps to line two, where a
   lone item on a `space-between` line sits at the inline-START, i.e. the visual RIGHT. Hence "pill
   top-left, trash below-right", and the ruling is to exchange those two positions.
   DONE IN CSS, NOT BY MOVING THE DOM. Reordering the markup would put the delete button ahead of
   the lesson's own kind/שנתון/level pills in reading order, which is worse for a screen reader for
   no gain. The rules below move only the paint.
   THAT IS SAFE HERE, and the reason is specific rather than general: a paint/DOM mismatch is a
   hazard when it desynchronises FOCUS order (2.4.3) or a MEANINGFUL sequence (1.3.2), and this head
   contains exactly ONE focusable child — the trash — which cannot be out of order with itself,
   while the pills are non-interactive labels whose meaning does not depend on preceding or
   following it.
   The shared `.card__head-meta { margin-inline-start: auto }` rule is neutralised for these cards
   only, additively; every other consumer of that class keeps it.
   SCOPED TO HOME'S LESSON CARD ALONE via `[data-group-id]`, which on solo only ever appears on that
   card: the untaken-slot rows next to them deliberately use `data-empty-group-id` (and carry no
   pill at all, so they have nothing to swap), lesson.html's one-off head has a single child and its
   own inline margin, and the curriculum/history cards and every school portal are out of scope by
   construction.
   RESULT, now identical on every card: row 1 is the title with the trash pinned at its inline-end,
   row 2 is the pill cluster on its own line. Sizes, colours, aria-labels and the trash's own >=44px
   hit area (its `::after` inset) are untouched — nothing here changes a box, only where they sit.
   (The arrangement these two rules produce is superseded by the grid below — see there.) */
body[data-portal="solo"] .card[data-group-id] > .card__head > .card__head-meta {
  order: 2;
  margin-inline-start: 0;
}
body[data-portal="solo"] .card[data-group-id] > .card__head > .card__head-action {
  order: 1;
  margin-inline-start: auto;
}

/* ---- …AND THE ARRANGEMENT MADE DETERMINISTIC (owner follow-up, from a second screenshot) ----
   AN EXPLICIT GRID, NOT flex `order` — corrected after the owner photographed the drift the rules
   directly above produced. `order` re-sorts items inside a WRAPPING flex row, so whether the pills shared
   the trash's line depended on whether the title fitted beside them: a short title (הדס דהן) left
   room, so trash and pill landed on separate lines; a long multi-day one (אורי ברק, wrapping to two
   lines) pushed them together onto one. Two פרטי cards, two different arrangements — which the
   owner read, reasonably, as זוגי and קבוצה behaving differently from פרטי, when the real variable
   was title length.
   A two-column grid removes the variable rather than tuning it. Row 1 is [title | trash], row 2 is
   the pill cluster spanning both columns. The title's own wrapping grows row 1 and moves nothing:
   the trash is placed in column 2 of row 1 by coordinate, and the pills are placed in row 2 by
   coordinate, so NO amount of text can change which cell anything is in. Same structure for פרטי,
   זוגי and קבוצה, for one pill or three, at every width — that is the property the probe asserts.
   `minmax(0, 1fr)` on the title column, not `1fr`: a grid track's automatic minimum is min-content,
   so a long unbreakable run would otherwise push the trash column off the card.
   Focus order and the hit area are untouched from the swap round: the DOM order is still
   title -> pills -> trash (the paint order differs, which is safe here for the reason recorded
   above — this head contains exactly ONE focusable child), and the trash keeps the 57x44 ::after
   area .btn--sm.btn--icon-only already ships.
   THE TWO RULES ABOVE ARE LEFT EXACTLY AS THEY SHIPPED, not edited: they are in the released
   baseline this file holds byte-identical, and under explicit grid placement they are inert anyway
   — `order` only drives AUTO-placement, which nothing here uses, and `margin-inline-start: auto` in
   an `auto`-sized track has no free space to consume. Appended, not rewritten, so the gate that
   protects every other consumer keeps protecting them. */
body[data-portal="solo"] .card[data-group-id] > .card__head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: start;
  gap: var(--space-s);
}
/* The title block carries no class of its own, and is the first child by construction. */
body[data-portal="solo"] .card[data-group-id] > .card__head > :first-child { grid-area: 1 / 1; }
body[data-portal="solo"] .card[data-group-id] > .card__head > .card__head-action { grid-area: 1 / 2; }
body[data-portal="solo"] .card[data-group-id] > .card__head > .card__head-meta {
  grid-area: 2 / 1 / auto / -1;
  margin-inline-start: 0;
}

/* ---- TAPPABLE DAY HEADS (owner ruling 3) ----
   js/auth-guard.js renders the day head as a real <button> when — and only when — its new opt-in
   onDayHeadPick callback is supplied AND the grid is in week mode. Reusing the .cal-dayhead class
   on a different ELEMENT re-opens every declaration the UA supplies and that class does not, which
   is this repo's own recorded trap: .cal-dayhead sets background/colour/font-weight/text-align and
   display:flex, but no border and no cursor — and Chrome's UA sheet gives a <button> a 2px outset
   `buttonborder`, so without this the seven heads would render boxed and the div/button versions of
   one component would not match. `font: inherit` already comes from the global control reset (:168).
   SCOPED BY ELEMENT (button.cal-dayhead) so no <div> consumer, on any portal, is reachable. */
body[data-portal="solo"] button.cal-dayhead {
  border: 0;
  cursor: pointer;
  font-family: inherit;
  /* The head is a grid item stretched to its track, so its height comes from the row, not from
     this — but a button does not stretch by default the way a div does in a grid area. */
  inline-size: 100%;
  block-size: 100%;
}
/* THE FOCUS RING IS DRAWN INSIDE, not around. .cal-scroll is a scroll container and a scroll
   container CLIPS outlines — an outer ring on a head sitting at the very top of that box would lose
   its upper band. The same inset treatment .instructor-group__summary already uses app-wide, at the
   same 3px, so this is the file's existing answer to the same problem rather than a new one. The
   global :focus-visible colour/width are inherited untouched. */
body[data-portal="solo"] button.cal-dayhead:focus-visible { outline-offset: -3px; }
@media (hover: hover) {
  body[data-portal="solo"] button.cal-dayhead:hover { background: var(--blue-100); }
}

/* THE VIEW-CHANGE TRANSITION (ruling 5). Targets #cal-root's own class, toggled by
   page-calendar.js's applyView() on every view change — never .cal-scroll or .cal-grid.
   THAT PLACEMENT IS THE MECHANISM, not a detail: a transformed ancestor breaks position:sticky for
   every descendant between it and its scrolling ancestor, so animating .cal-scroll or .cal-grid
   would silently kill the sticky day-head and gutter this same section just added. #cal-root sits
   ABOVE .cal-scroll (the scrolling ancestor) in the DOM, so the whole calendar box — scroller and
   its sticky children together — scales as one rigid unit and every sticky relationship, living
   entirely INSIDE the transformed subtree, is untouched.
   `both` IS SAFE HERE, despite this repo's recorded trap that an `animation: … both` permanently
   owns every property in its keyframes: the trap bites when the END value differs from what the
   element's own layout would have had, and this keyframe ends at `transform: none; opacity: 1`,
   which is byte-for-byte #cal-root's non-animated resting state (it carries no other transform).
   Gated in no-preference, so a reduced-motion user's element simply never animates and is always at
   that same resting state — the required instant swap, with no JS-side check to keep in sync. */
@media (prefers-reduced-motion: no-preference) {
  body[data-portal="solo"] .cal-root--zoom { animation: cal-zoom-in 220ms ease-out both; }
  @keyframes cal-zoom-in {
    from { transform: scale(.94); opacity: .55; }
    to   { transform: scale(1); opacity: 1; }
  }
}

/* ===========================================================================
   THE FOCUS RING'S INNER BAND — WCAG 2.4.7 / 1.4.11, every portal
   ===========================================================================
   THE DEFECT. --color-ring resolves to --color-primary (both --blue-900), so the outline declared
   in the accessibility-base block near the top of this file is the SAME COLOUR as every surface
   the app fills with the primary: .skip-link (which is only ever seen focused), .app-header
   .main-nav a, .btn--primary, body[data-portal="solo"] .cal-block and .day-toggle input:checked +
   span. 1.00:1 — there is no ring at all on any of them. On .btn--ghost (--color-secondary,
   #2C6B77) the same outline measured 1.85:1, under the 3:1 floor.
   (.instructor-group__summary was on that list and does NOT belong on it: rendered, its own
   background is transparent over a white .instructor-group, and the --color-primary fill nearby is
   its ::after pill, which the summary's 16px padding keeps clear of the inset ring. Measured, not
   assumed — its ring is 11.20:1 and always was.)
   js/color.js reproduced the identity for every tenant brand; that is fixed there, separately.

   THE FIX IS A SECOND BAND, not a recoloured one. `outline-offset: 2px` already leaves a 2px gap
   between the element and the outline; a `box-shadow` with a 2px spread fills exactly that gap in
   --color-on-primary — the token whose entire contract is "readable ON the primary colour", and
   which readableOn() guarantees at >= 4.33:1 against ANY primary a tenant can pick. The two bands
   are complementary by construction, so one of them always reads:
     · on a primary-filled surface the outer band vanishes and the INNER one carries the ring —
       measured white on --blue-900 11.20:1, on the header nav pill's composited rgba(0,0,0,.16)
       overlay (#0D373E) 12.86:1, on --color-secondary 6.04:1;
     · on a light surface the inner band vanishes into the page and the OUTER one carries it —
       --blue-900 on white 11.20:1, on the Alabaster page 9.21:1.
   Re-measured across brands after the js/color.js fix: a #FFD966 primary now rings at 3.18:1 on
   white (was 1.30), #96C0CE at 3.59, mid-grey #808080 at 3.95 — and every DARK brand, including
   this product's own default, is returned unchanged, so no existing tenant's ring moves.
   Nothing changes visually on light surfaces (a white band on white is the gap that was already
   there), which is why this is safe for manager/, instructor/, frontdesk/ and p/ as well as solo/.

   WHY IT IS DECLARED HERE AND NOT BESIDE THE OUTLINE. :focus-visible is (0,1,0), and .card,
   .kid-block and .instructor-group each declare their own box-shadow later in the file at the SAME
   (0,1,0) — so at equal specificity the later rule wins, and declared next to the outline this band
   would be silently dropped on exactly the components it is needed on. Source order is the whole
   lever here; there is no specificity to spend.
   TWO rules in this file are NOT examples of that, and both were once listed as if they were:
   .tabs button[aria-selected="true"] and .tabs a[aria-current="page"] (the signup page's
   הרשמה/כניסה pair). Each is (0,2,0), so each beats this rule on specificity no matter where the
   two sit, and a SELECTED tab therefore keeps --shadow-sm and draws no inner band. Rendered and
   measured rather than argued, on the ground the ring is actually drawn on: the outline sits in its
   own 2px offset gap, and the pixels in that gap belong to the .tabs BAR, not to the tab's own
   fill — so the ground is --color-muted rgb(231,220,204), NOT the white --color-surface the earlier
   version of this note measured against. Measured there, the OUTER band carries the ring at
   8.27:1, comfortably over 1.4.11's 3:1, on both of them. (The old figure of 11.20:1 was
   --blue-900 on white, i.e. the right ratio against the wrong ground; the conclusion it supported
   is unchanged, which is why the number moved and the verdict did not.) Neither needs a twin rule.
   If .tabs ever gains a dark selected fill, or the bar's own background darkens, that twin — the
   same `box-shadow: 0 0 0 2px var(--color-on-primary)` at (0,2,0) — is the fix for both. */
:focus-visible { box-shadow: 0 0 0 2px var(--color-on-primary); }
/* The two focus rings this file draws on a node OTHER than the focused element. Each needs the
   band named explicitly: the global rule above matches the <input>, which is opacity:0.
   .day-toggle's checked span IS --color-primary, so this is the fix. .group-option's fills are all
   light (white / --blue-100 / --color-danger-bg) and its outer band already reads — it is here for
   PARITY, so that under a light tenant brand, where --color-on-primary is dark, every focusable
   component in the app draws the same two-band ring and this one is not the odd one out. */
.day-toggle input:focus-visible + span { box-shadow: 0 0 0 2px var(--color-on-primary); }
.group-option:has(input:focus-visible) { box-shadow: 0 0 0 2px var(--color-on-primary); }

/* ---- occurrence attendance toggle (solo lesson.html) ----
   Deliberately quiet: a subordinate row under the progress bar, not a control competing with the
   skill checklist below it. Meaning is never colour-only — the word "נעדר/ת" and the checkbox's
   own checked state carry it, and the colour only reinforces. */
.kid-block__attendance { padding: var(--space-s) var(--space-m); border-block-end: 1px solid var(--color-border); }
.attendance-toggle {
  display: inline-flex; align-items: center; gap: var(--space-s);
  min-block-size: var(--tap); font-size: var(--text-sm);
  color: var(--color-text-soft); cursor: pointer;
}
.attendance-toggle input[type="checkbox"] {
  inline-size: 24px; block-size: 24px; flex: none; margin: 0;
  accent-color: var(--color-danger); cursor: pointer;
}
.attendance-toggle:has(input:checked) { color: var(--color-danger); font-weight: 700; }

/* ============================================================================
   NO-NATIVE-UI COMPONENTS — js/ui-dialogs.js
   Appended, never edited in place: this file's standing rule for shared CSS, and
   the reason test/cssdiff.py can assert that every pre-existing rule survived
   byte-identical. Everything below is additive and new.

   The four dialogs themselves need NO new rules: they are ordinary <dialog>s and
   reuse dialog / dialog::backdrop / .dialog__body / .dialog__head /
   .dialog__actions exactly as every existing dialog in the app does. The green
   affirmative / red cancel pair reuses .btn--accent / .btn--danger, both already
   live at solid-fill scale with proven white-on-fill contrast — no new colour,
   no new token, no new measurement.

   All logical properties, no left/right literals: these render inside an RTL
   document and the date grid in particular must mirror without a second rule.
   ============================================================================ */

/* Multi-line confirm messages keep their existing "\n\n" text VERBATIM at every
   call site — this is what renders those breaks as separated paragraphs, so not
   one of the 40 message strings has to change. */
.confirm-dialog__message { white-space: pre-line; }

/* The field + its trailing trigger. A <span> wrapper rather than a restructured
   .field: `.field > label` (the only direct-child selector in the field block
   above) is untouched, `.field input` still matches through it, and
   input.closest('.field') still resolves. */
.picker-field { display: flex; align-items: center; gap: var(--space-s); }
.picker-field > input { flex: 1 1 auto; min-inline-size: 0; width: auto; }
.picker-trigger { flex: none; }
/* The colour trigger shows its own value instead of a glyph, so the square IS
   the icon. Sized to sit inside the 36px icon-only button — and scoped as a
   COMPOUND selector (0,2,0) rather than relying on source order, because the
   same node also carries .color-swatch (0,1,0), whose 2.5rem is declared further
   down this very block and would otherwise silently win. */
.picker-trigger > .picker-trigger__swatch { inline-size: 1.25rem; block-size: 1.25rem; }

/* ---- date picker ---- */
.date-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: var(--space-xs); }
/* Each week is a role="row" wrapper, so the rows themselves have to re-establish
   the seven columns rather than becoming one grid item each. */
.date-grid > [role="row"] { display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; }
@supports not (grid-template-columns: subgrid) {
  .date-grid > [role="row"] { grid-template-columns: repeat(7, 1fr); }
}
.date-grid__dayname { text-align: center; font-size: var(--text-sm); font-weight: 600; color: var(--color-text-soft); }
.date-grid__cell {
  min-inline-size: var(--tap); min-block-size: var(--tap);
  padding-inline: 0; border-radius: var(--radius-sm); justify-content: center;
}
/* Today: an outline. The SELECTED day: a fill — the same pair of cues the time
   list below uses, so the two pickers say "current" and "chosen" the same way. */
.date-grid__cell[aria-current="date"] { border: 1px solid var(--color-primary); }
.date-grid__cell[aria-selected="true"] { background: var(--color-primary); color: var(--color-on-primary); }
/* A day belonging to the neighbouring month still renders — a week has seven
   days and arrow-key movement must not hit holes — but reads as context. */
.date-grid__cell[data-outside] { color: var(--color-text-soft); }
.date-grid__cell:disabled { opacity: .4; cursor: not-allowed; }

/* ---- time picker ---- */
.time-list { max-block-size: 18rem; overflow-y: auto; display: grid; gap: var(--space-xs); }
.time-list__option {
  min-block-size: var(--tap); text-align: start;
  padding-inline: var(--space-m); border-radius: var(--radius-sm);
}
.time-list__option[aria-selected="true"] { background: var(--color-primary); color: var(--color-on-primary); }

/* ---- colour picker ---- */
.color-swatch-grid { display: flex; flex-wrap: wrap; gap: var(--space-s); }
.color-swatch {
  inline-size: 2.5rem; block-size: 2.5rem;
  border-radius: var(--radius-sm); border: 1px solid var(--color-border);
}

/* ============================================================================
   DATE-PICKER GRID — FIT THE MONTH INSIDE THE DIALOG ON A PHONE (WCAG 2.5.8)
   ============================================================================
   THE DEFECT, measured from this file's own tokens rather than guessed.
   `.date-grid` is `repeat(7, 1fr)`, i.e. `minmax(auto, 1fr)`, so each track's floor is its item's
   min-content size — and `.date-grid__cell`'s `min-inline-size: var(--tap)` RAISED that floor to
   44px. Seven cells plus six `--space-xs` gaps therefore make a 332px min-content grid, which
   cannot shrink. Against the dialog's real inner width (`min(34rem, 100vw - 2rem)` minus two
   `--space-l` paddings) that is:
       320px viewport -> inner 240.0px, overflow 92.0px, only 5 columns fit
       360px          -> inner 280.0px, overflow 52.0px, 5 columns
       375px          -> inner 295.0px, overflow 37.0px, 6 columns
       390px          -> inner 310.0px, overflow 22.0px, 6 columns
       412px          -> inner 331.5px, overflow  0.5px, 6 columns
   Browser QA found what that costs on the LIVE solo portal: at 375 the last column (Saturday) sits
   past the dialog edge, so its centre hit-tests to ::backdrop and tapping it CLOSES the picker; at
   320 two columns do not render at all.

   THE FIX IS ON THE INLINE AXIS ONLY. The 44px TAP HEIGHT is kept (`min-block-size` is untouched);
   the cell's inline floor is released so each track shrinks with the grid. A seven-column month
   grid structurally cannot hold 44px columns on a 320-412px phone — no calendar can — and SC 2.5.8
   asks for 24x24, which every width below clears with room to spare. The two spacing levers (the
   dialog's body padding and the grid gap) are narrowed under 26rem to buy back what they can.

   WHY THIS IS APPENDED RATHER THAN EDITED IN PLACE. test/cssdiff.py holds every rule present at the
   last release byte-identical: REMOVED or CHANGED fails, ADDED is fine. Editing
   `.date-grid__cell`'s declaration would fail that gate; an appended rule at equal specificity
   (0,1,0) wins on source order alone and leaves the baseline rule untouched — the same lever the
   focus-ring inner band above already uses, and the reason both live at the foot of this file.

   SCHOOL-PORTAL NEUTRALITY. `.date-grid` exists only inside the date picker built by
   js/ui-dialogs.js's mountDatePicker(), which every portal reaches identically; there is no
   portal-scoped selector on this component anywhere in this file. So the change is the same change
   for manager/, instructor/, frontdesk/, p/ and solo/ — and on every one of them it is strictly a
   fix, because the pre-existing behaviour was an overflowing grid at every width below 412px.
   ============================================================================ */
.date-grid__cell { min-inline-size: 0; }
@media (max-width: 26rem) {
  #app-date-dialog .dialog__body { padding-inline: var(--space-s); }
  #app-date-dialog .date-grid { gap: 2px; }
}

/* ============================================================================
   .loading — THE PULSE IS REMOVED, NOT RE-TUNED (WCAG 1.4.3)
   ============================================================================
   `@keyframes loading-pulse` dips the text to `opacity: .6`, and opacity composites the GLYPHS
   toward their ground, so the ratio between them is not preserved. Measured, --color-text-soft
   #5F5A50 against the three grounds this component really lands on:

       alpha   page #EFE8DF   card #FFFFFF   muted head #E7DCCC
       0.60        2.51           2.74           2.39      <- the animation's own low frame
       0.85        4.09           4.74           3.76
       0.90        4.54           5.34           4.14
       0.95        5.05           6.04           4.58      <- first row clearing 4.5 everywhere
       1.00        5.64           6.85           5.06

   So for HALF of every cycle this text sits at 2.39-2.74:1 against a 4.5:1 requirement, on all 21
   pages that render it. Raising the floor is not a fix: the tightest ground (the muted card head)
   only clears AA at 0.95, and a 0.95->1.0 pulse is imperceptible — the animation would cost its
   accessibility debt and buy nothing. That is this project's own recorded rule for this exact
   shape: opacity is never a legitimate "quieter" treatment for text.

   WHAT IS LOST, honestly: nothing a user relies on. The node says "טוען…" in words, it is removed
   the instant data arrives, and every visitor with `prefers-reduced-motion: reduce` has ALWAYS seen
   it static — the global reduce block already flattens the duration to .01ms. This makes everyone's
   experience the one that group already had.

   `@keyframes loading-pulse` itself is left in place and inert: deleting it would be a REMOVED rule
   under test/cssdiff.py, and an unused keyframe costs nothing. Declared here at the foot so this
   rule wins on source order without editing the baseline `.loading` block.
   SHARED-SHEET NEUTRALITY: `.loading` is portal-agnostic (no portal-scoped selector touches it
   anywhere in this file), so every portal gets the identical change, and on every one of them it is
   strictly a contrast improvement.
   ============================================================================ */
.loading { animation: none; }

/* ============================================================================
   THE BRAND LOCKUP — the marketing site's header wordmark, carried to the entry screens.

   Copied from marketing/css/style.css (.brand / .brand__dot) rather than re-invented: the same
   1.4rem-relative weight/tracking treatment, the same LTR isolation, and the same rule that only
   the second word is coloured. It is a WORDMARK — the marketing header has no logo glyph at all,
   so nothing here draws one; the wave mark already above it on these pages is this app's own.

   COLOUR. `Lap` inherits, which is what the original does: on lapkeeping.com's photo header its
   first word is the header's white foreground and only `Keeping` carries the blue. Here the
   inherited colour is .auth-brand's Midnight (#0F414A, 11.20:1 on the white card), and `Keeping`
   carries the marketing site's Keeping-blue #3B82F6 EXACTLY.

   CONTRAST. #3B82F6 measures 3.68:1 on the #FFFFFF card. THE WEIGHT IS NOW 600, NOT 700, because
   the display face (Fredoka) is loaded at 500/600 and a 700 would be faux-bolded — and that moves
   the WCAG 1.4.3 large-text floor for this string from "18.66px bold" to "24px", which not every
   consumer clears: the app's entry screens render it at 40px+ (via .auth-brand h1's own pinned
   clamp — the one consumer that did NOT follow --text-title's 1.5x, see the note beside that rule)
   and pass on size alone, while .signup-brand at 22.4px and the admin portal's --text-section
   minimum of 21.6px do not. Those two rest on 1.4.3's LOGOTYPE EXEMPTION, which is the honest basis
   for all of them: this is the LapKeeping brand name and the blue is pinned to the marketing site's
   own value, so it is not a colour this sheet may re-tune to buy contrast. Recorded rather than
   quietly dropped — if the exemption is ever judged too thin, the fix is a bigger minimum size on
   those two rules, never a different blue. It is a better margin than the marketing site's own
   worst case for the identical colour (3.24:1 over the golden strip), which is the ground this blue
   was tuned on.
   ============================================================================ */
.brand-lockup {
  direction: ltr; unicode-bidi: isolate; white-space: nowrap;
  font-family: var(--font-display); font-weight: 600; letter-spacing: -.01em;
}
.brand-lockup__keep { color: #3B82F6; }
/* signup.html renders the lockup as a brand LINE rather than as its heading (its h1 is the real
   "התחילו שבוע ניסיון"), so it takes the marketing header's own 1.4rem instead of a heading size,
   and loses the paragraph margin that would otherwise open a gap the other two screens do not have. */
.signup-brand { font-size: 1.4rem; margin: 0; }

/* ============================================================================
   DATE PICKER — THE YEAR IS REACHABLE IN ONE TAP (owner ruling from live use)
   ============================================================================
   "In the calendar I can't pick birth year easily, I have to go month by month." A 2015 date of
   birth was ~130 taps on ‹. The header gains ‹‹ / ›› year steps and the month/year label becomes
   the button that opens a scrollable year list.

   .date-nav REPLACES A WRAPPING .cluster, and the replacement is what makes the header FIT rather
   than merely fit today. Measured from this file's own tokens, at the narrowest supported phone:
       320px viewport -> dialog 288.0px (100vw - 2rem) -> body inner 272.0px
         (the @media block above already narrows #app-date-dialog's padding to --space-s)
       four .date-nav__step buttons at their 36px floor .. 144.0px
       three --space-xs gaps between five items ........   16.0px   (4 gaps x 4px)
       left for the label track ........................  112.0px
   A .cluster would have had to fit the label's TEXT (`אוגוסט 2026` at .875rem bold, ~98px plus
   2 x --space-m padding = ~130px) or wrap a control onto a second line — which is how the header
   would have looked different on different months. The grid's label track is `minmax(0, 1fr)`, so
   the row's min-content width is the four fixed steps plus the gaps (160.0px) and NOTHING about the
   text can overflow it: a longer month name wraps INSIDE its own cell instead of pushing a control
   out of the dialog. That is the property the width probe asserts, at 320/344/360/375/390/412.
   The 44px TAP HEIGHT is untouched (.btn--sm's own ::after band still takes these to 44px); only
   the inline padding is traded, exactly as the grid-fit fix above traded the cell's inline floor.

   .year-list IS .time-list's TWIN, deliberately, down to the 18rem cap and the scroll: the two
   pickers already answer "pick one of many" the same way, and a second scroll component with its
   own keyboard model would be a second thing to get wrong. Rows are var(--tap) tall (the owner
   asked for touch-friendly rows), the selected year takes the same fill the selected time does, and
   the CURRENT year takes the same outline today's cell does — the app's existing "current vs
   chosen" pair, so no new colour and no new pair to measure.
   ============================================================================ */
.date-nav {
  display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto auto;
  gap: var(--space-xs); align-items: center;
  flex: 1 1 auto; min-inline-size: 0;
}
.date-nav__step { padding-inline: var(--space-xs); min-inline-size: 36px; }
.date-nav__label { padding-inline: var(--space-s); min-inline-size: 0; }
.year-list { max-block-size: 18rem; overflow-y: auto; display: grid; gap: var(--space-xs); }
.year-list__option {
  min-block-size: var(--tap); justify-content: center;
  padding-inline: var(--space-s); border-radius: var(--radius-sm);
}
.year-list__option[aria-current="date"] { border: 1px solid var(--color-primary); }
.year-list__option[aria-selected="true"] { background: var(--color-primary); color: var(--color-on-primary); }

/* ============================================================================
   A 5-MINUTE START INSIDE A 15-MINUTE ROW (owner ruling)
   ============================================================================
   The owner moved time SELECTION to 5-minute jumps and kept the calendar's rhythm exactly as it is
   (30-minute boxes over a 15-minute row substrate): "keep the calendar 30 min jumps". So a 14:05
   lesson has to be drawn one third of the way into the 14:00 row rather than snapped to its edge.
   js/auth-guard.js's calExactPlacement() computes that offset as a FRACTION OF THE CELL and writes
   it as an inline `inset-block-start` — a percentage of the containing block's height, which needs
   the block to be positioned. `.cal-block` declares no `position`, so this supplies it.
   SCOPED TO THE SOLO PORTAL, which is the only caller passing `exactSpans` and therefore the only
   one that can ever carry the inline offset: the school grids are untouched, and their DOM is pinned
   byte-identical by test/dom-identity.mjs either way.
   `position: relative` alone — no offsets, no z-index: an un-offset block is laid out exactly where
   it was, so every lesson that predates the 5-minute step renders pixel-identically. It also gives
   the existing `.btn--sm::after`-style hit bands nothing new to resolve against, because this
   component has none.
   ============================================================================ */
body[data-portal="solo"] .cal-block { position: relative; }

/* ============================================================================
   ============================================================================
   DESIGN-POLISH ROUND (PLAN-solo-design-polish.md) — MOTION, LOADING, FEEDBACK
   ============================================================================
   Everything from here to the [hidden] guard is APPENDED, never edited in place. That is this
   file's standing rule and test/cssdiff.py enforces it: every rule present at the last release
   must survive byte-identical, so a value that had to CHANGE is re-declared here on the SAME
   selector at the same-or-higher specificity, where plain source order wins it. The originals are
   untouched and still readable where they were written.

   MOTION PERSONALITY, owner ruling: "quiet & fast" — 150-220ms, small distances, ease-out, nothing
   springy. One curve for the whole round (--ease-quiet), one duration per job, no overshoot.

   REDUCED MOTION. The global kill-switch at :202 flattens animation-duration/transition-duration to
   .01ms for everything matched by `*`, which covers every ordinary transition and keyframe
   animation added below WITHOUT a per-rule gate. It does NOT cover three things, and each of those
   gets an explicit gate or an explicit reduce-override here:
     · @starting-style capture          (dialogs, progress bar)
     · transition-behavior: allow-discrete (dialogs' display/overlay)
     · ::view-transition-* pseudo-elements (the calendar day slide; solo/css/motion.css carries the
       cross-document half and its own reduce override)

   SHARED-SHEET NEUTRALITY. Every bare selector touched below (dialog, .toast, .btn, .list-item,
   .progress__bar, .skill-check, .instructor-group__summary) has NO portal-scoped rule anywhere in
   this file — the same fact already recorded for .date-grid__cell — so each change lands
   identically on manager/, instructor/, frontdesk/, p/ and solo/, and on every one of them it is
   the same improvement. Anything genuinely solo-only is in solo/css/motion.css or is keyed on a
   selector only solo renders.
   ============================================================================ */

/* ---- the round's tokens ----------------------------------------------------------------
   Their own :root block rather than an append to the palette block at the top: :root is ONE rule
   to test/cssdiff.py, so adding a declaration up there reports the baseline's rule as CHANGED and
   reds the gate on a provably inert addition. A second :root block is a new list entry and leaves
   index 0 byte-identical. Same technique --indigo-600 already uses at its own consumer. */
:root {
  --motion-fast: 150ms;      /* checkmark draw, chevron rotate, press states */
  --motion-base: 200ms;      /* dialog open/close, row press, calendar day-slide, drag-settle */
  --motion-sweep: 500ms;     /* progress bar 0 -> value sweep on mount */
  --motion-highlight: 600ms; /* after-save row tint — ambient confirmation, deliberately slower
                                than the "quiet & fast" interaction bucket */
  --ease-quiet: cubic-bezier(.22, .61, .36, 1); /* the one shared ease-out — no bounce, no overshoot */
  --icon-size: 1.125em;      /* converges the existing 1.15em/1.1em nav/button icon split */
  --shadow-lg: 0 20px 50px rgba(15, 65, 74, .35); /* pure extraction of dialog's literal at :977 */
  /* ---- §14 the phone sheet's own clock (owner: "like how Instagram pulls up a comment section") ----
     Longer than --motion-base BY DESIGN: the sheet now travels the full height of itself rather
     than 24px, and the same 200ms over ~20x the distance reads as a flick rather than as a pull.
     OPEN IS SLOWER THAN CLOSE, which is the ordinary rule for this pattern and the one place this
     round deliberately breaks "one duration per job": arriving should feel like the thing settling
     into place, leaving should feel like getting out of the way. Material's own guidance and the
     ui-ux-pro-max rule both say exit ~60-70% of enter; 200/280 is 71%.
     --ease-sheet is a STEEPER decelerate than --ease-quiet, not a different family: both are pure
     ease-outs whose control points stay inside 0..1 on the Y axis, so neither can overshoot or
     bounce — the owner declined springiness and this cannot produce it. It is the standard
     "decelerate hard, then glide" curve, which is what makes a long travel read as deliberate. */
  --motion-sheet-in: 280ms;
  --motion-sheet-out: 200ms;
  --ease-sheet: cubic-bezier(.16, 1, .3, 1);
  /* ---- §15 the surface radius (owner ruling: cards/sheets/dialogs/pickers rounder, buttons NOT) ----
     A SEPARATE TOKEN rather than a bump to --radius, and the reason is that --radius has consumers
     the ruling did not name: `fieldset`, `.cal-scroll` and `.cal-grid`. The last two are the weekly
     calendar's own box — its corner cells, sticky gutter and sticky day-head all meet at those
     corners, so rounding it harder is a clipping risk for no stated benefit. They stay at --radius.
     (For the record, the brief's premise that "--radius is consumed by both" is not what the sheet
     says: `.btn` has been on --radius-sm (.5rem) since it was written, and so are the 15 other
     small controls that read that token. Buttons were therefore never at risk from either approach —
     but the scoped token is still the right call, for the calendar reason above.) */
  --radius-surface: 1.125rem;
}

/* ---- --text-title, re-curved for PHONES ONLY ---------------------------------------------
   Owner: the page headings are too loud on a phone. Was clamp(3.75rem, 2.85rem + 4.5vw, 6rem),
   which resolves to 62.475px at a 375px viewport. This resolves to 1.5rem + 6.4vw = 24 + 24 = 48px
   there (the 2.8rem floor never engages above 320px: at 320 it is 24 + 20.48 = 44.48px, i.e. the
   floor itself). The MAX is byte-identical at 6rem and it saturates at 1125px viewport width —
   the old formula saturated at ~1120px — so every desktop width that already read the full 6rem
   still reads exactly 6rem and only the sub-1125px range is re-curved. No breakpoint, no jump.
   Sole consumer is .page-title; .auth-brand h1 is pinned at its own literal and does not follow
   this token (see the note beside it), so the login wordmark is unaffected. */
:root { --text-title: clamp(2.8rem, 1.5rem + 6.4vw, 6rem); }
/* and the breathing room under it — --space-xs (4px) read as a collision at the new size. */
.page-title { margin-block-end: var(--space-m); line-height: 1.05; }

/* ---- --text-section, re-curved so the h1 -> h2 STEP reads as a step again ------------------
   The owner's 1.5x on --text-title moved h1 to a 6rem max while --text-section (every <h2> on
   every page, via .section-title) stayed at a 2.5rem max, so the desktop ratio was 96/40 = x2.40 —
   a gap wide enough that an h2 stopped reading as the next level down and started reading as body
   copy with weight. clamp(1.6rem, 1.2rem + 2.6vw, 3.25rem) puts the max at 52px, i.e. 96/52 =
   x1.85, back inside the range a type scale is legible at.
   --text-title IS NOT TOUCHED: the owner's 6rem titles stay exactly as they are. The floor is the
   same 1.6rem the old curve had, so the very narrowest phones are unchanged; at 375px this reads
   28.95px against 26.75px before, and it saturates at ~1261px viewport. */
:root { --text-section: clamp(1.6rem, 1.2rem + 2.6vw, 3.25rem); }

/* ---- a reading measure inside cards --------------------------------------------------------
   Prose that runs the full width of a 60rem wrapper is measurably harder to read; 65ch is the
   upper end of the usual 45-75ch band. This is a TEXT rule only — it is deliberately NOT the
   rejected form-width cap, and inputs, selects, textareas, button rows and every layout box keep
   their existing full width because none of them is a <p>.
   TWO EXCLUSIONS, because both are BOXES rather than running text and capping them would be a
   visible layout change rather than a reading improvement:
     .empty  is `text-align: center` — a narrower box would left-align the centred sentence inside
             it and read as misalignment. It really does occur inside cards (page-student.js's two
             history sections, lib-dialogs.js's free-time section).
     .status is a filled, padded notice; narrowing it would make an error banner a different width
             from its siblings for no reading benefit.
   Both are (0,2,1) against this rule's (0,1,1), so they win without !important. */
.card p { max-inline-size: 65ch; }
.card p.empty, .card p.status { max-inline-size: none; }

/* ---- one icon size, one token ---- */
.nav-icon, .btn-icon { inline-size: var(--icon-size); block-size: var(--icon-size); }

/* ============================================================================
   §1 DIALOG OPEN / CLOSE — bottom sheet on a phone, centred fade on desktop
   ============================================================================
   THE LAYOUT HALF IS UNGATED, deliberately. Only the MOTION belongs behind prefers-reduced-motion;
   the SHAPE of a dialog must not depend on a motion preference, or reduced-motion users get a
   different product.
   `margin-inline: auto` IS DECLARED HERE AND MUST BE. An earlier version of this comment claimed it
   "stays auto from the UA sheet" — that was FALSE and it shipped: the reset at :161 is
   `* { margin: 0 }`, an AUTHOR-origin rule, and author beats user-agent at every specificity, so the
   UA's `dialog { margin: auto }` had been overridden app-wide since long before this round. Live QA
   measured the result: every dialog in every portal hugged the inline-start edge — rect right:390 in
   a 390px viewport, right:1425 in 1440. Restating it is the fix, and it is additive (the baseline
   dialog rule never declared any margin).
   Beware the assertion that missed it: `marginLeft === marginRight` is TRUE for `0px`/`0px`, so a
   centring probe must compare the element's RECT against the viewport, never its two margins.
   The base dialog rule at :974 is untouched: this appends properties it never declared, plus two
   longhand corner radii that legitimately beat its earlier `border-radius` shorthand on source
   order. */
dialog {
  margin-inline: auto;             /* restores what * { margin: 0 } took from the UA sheet */
  margin-block: auto 0;            /* bottom-pinned sheet, horizontally centred */
  /* §15 — ORDER INSIDE THIS BLOCK IS LOAD-BEARING. The shorthand rounds all four corners to the
     surface radius; the two longhands then square the bottom pair back off, and they only win
     because they come AFTER it. Writing the shorthand second would re-round the sheet's bottom
     corners against the viewport edge — the exact thing the owner asked to keep squared. */
  border-radius: var(--radius-surface);
  border-end-end-radius: 0;
  border-end-start-radius: 0;
  box-shadow: var(--shadow-lg);    /* token extraction — the same literal as :977, zero visual diff */
}
@media (min-width: 40rem) {
  dialog {
    margin-block: auto;            /* centred on BOTH axes from the tablet width up */
    /* off the viewport edge on desktop, so all four corners take the surface radius */
    border-end-end-radius: var(--radius-surface);
    border-end-start-radius: var(--radius-surface);
  }
}

/* ============================================================================
   §15 THE SURFACE RADIUS — cards, rows, sheets and pickers (owner ruling)
   ============================================================================
   Applied BY SELECTOR rather than by repointing --radius, so the three consumers the ruling did not
   name keep exactly what they have: `fieldset`, and the calendar's own `.cal-scroll` / `.cal-grid`,
   whose corner cells, sticky hour gutter and sticky day-head all meet at those corners — rounding
   that box harder is a clipping risk with no stated benefit. See the --radius-surface note above.
   BUTTONS ARE STRUCTURALLY UNAFFECTED: `.btn` reads --radius-sm, as do the 15 other small controls
   on that token. Not one of them is touched here, by construction rather than by care.
   CLIPPING CHECKED, not assumed: .instructor-group, .instructor-block and .kid-block already carry
   `overflow: hidden`, so their own headers/bodies clip to whatever radius this sets — going from
   12px to 18px only deepens a curve those components were already designed around, and their head
   rows are full-bleed fills with no content in the corners. The list rows and cards have no
   `overflow` at all and nothing absolutely positioned in a corner.
   The pickers are named explicitly because the ruling named them, even though .group-picker and
   .kid-picker-list are the only two: they are the scrolling option boxes inside the placement and
   one-off dialogs. */
.card,
.list-item,
.kid-block,
.instructor-group,
.instructor-block,
.group-picker,
.kid-picker-list { border-radius: var(--radius-surface); }
/* THE RESTING STATE IS `none`, NEVER `0 0`, AND THAT IS LOAD-BEARING — not tidiness.
   A non-`none` translate/scale makes the element a containing block for `position: fixed`
   descendants, and js/auth-guard.js's toastHost() APPENDS THE TOAST INTO THE TOPMOST OPEN DIALOG
   (it must, or the toast paints under ::backdrop). With `translate: 0 0` left on the open dialog,
   .toast's `position: fixed` would resolve against the dialog instead of the viewport — and the
   dialog is `overflow: auto`, so the toast could be clipped out of sight entirely. `none` leaves
   no containing block once the 200ms transition has finished, which is every moment a toast is
   actually raised. */
/* §14 — THE PHONE SHEET TRAVELS ITS WHOLE HEIGHT. `translate: 0 100%` is 100% of the DIALOG's own
   height, and the sheet is bottom-pinned (`margin-block: auto 0`), so its top edge lands exactly on
   the viewport's bottom edge: fully off-screen, at any dialog height, with no magic number and
   nothing to re-measure when a dialog's content changes. It replaces a 24px nudge the owner read as
   too shy.
   TWO TRANSITION LISTS, AND WHICH ONE RUNS IS THE MECHANISM: a transition is read from the state
   being animated TO, so `dialog`'s list governs the CLOSE (the `[open]` attribute has just gone) and
   `dialog[open]`'s governs the OPEN. That is what gives the sheet a 280ms entrance and a 200ms exit
   without a single line of JS or any state to track.
   Declined by the owner, recorded so they are not "added back as obvious": no grab handle, no
   swipe-to-close, no backdrop blur. The backdrop stays a plain dim and the sheet's top edge stays
   clean. */
@media (prefers-reduced-motion: no-preference) {
  dialog {
    opacity: 0;
    translate: 0 100%;
    transition:
      opacity var(--motion-sheet-out) var(--ease-sheet),
      translate var(--motion-sheet-out) var(--ease-sheet),
      /* `scale` is listed HERE, in the shared list, rather than only in the desktop block below.
         It was missing entirely once, so the desktop .97 -> none step had no transition to run and
         JUMPED. On a phone `scale` is `none` on both sides, so naming it costs nothing there and
         there is one list to keep correct instead of two. */
      scale var(--motion-sheet-out) var(--ease-sheet),
      overlay var(--motion-sheet-out) allow-discrete,
      display var(--motion-sheet-out) allow-discrete;
  }
  dialog[open] {
    opacity: 1;
    translate: none;
    transition:
      opacity var(--motion-sheet-in) var(--ease-sheet),
      translate var(--motion-sheet-in) var(--ease-sheet),
      scale var(--motion-sheet-in) var(--ease-sheet),
      overlay var(--motion-sheet-in) allow-discrete,
      display var(--motion-sheet-in) allow-discrete;
  }
  @starting-style { dialog[open] { opacity: 0; translate: 0 100%; } }

  /* The dim fades over the SAME window as the sheet it belongs to, in both directions — a backdrop
     that outlives its sheet is the thing that makes a dismissal feel unfinished. */
  dialog::backdrop {
    opacity: 0;
    transition:
      opacity var(--motion-sheet-out) var(--ease-sheet),
      overlay var(--motion-sheet-out) allow-discrete,
      display var(--motion-sheet-out) allow-discrete;
  }
  dialog[open]::backdrop {
    opacity: 1;
    transition:
      opacity var(--motion-sheet-in) var(--ease-sheet),
      overlay var(--motion-sheet-in) allow-discrete,
      display var(--motion-sheet-in) allow-discrete;
  }
  @starting-style { dialog[open]::backdrop { opacity: 0; } }

  /* DESKTOP IS UNTOUCHED BY §14 — the gentle fade+scale, at the original --motion-base/--ease-quiet
     in BOTH directions. It has to restate the transition lists because the phone rules above now
     carry the sheet's clock, and a media query has no specificity to win with; source order does. */
  @media (min-width: 40rem) {
    dialog {
      translate: none; scale: .97;
      transition:
        opacity var(--motion-base) var(--ease-quiet),
        translate var(--motion-base) var(--ease-quiet),
        scale var(--motion-base) var(--ease-quiet),
        overlay var(--motion-base) allow-discrete,
        display var(--motion-base) allow-discrete;
    }
    dialog[open] {
      scale: none;
      transition:
        opacity var(--motion-base) var(--ease-quiet),
        translate var(--motion-base) var(--ease-quiet),
        scale var(--motion-base) var(--ease-quiet),
        overlay var(--motion-base) allow-discrete,
        display var(--motion-base) allow-discrete;
    }
    dialog::backdrop,
    dialog[open]::backdrop {
      transition:
        opacity var(--motion-base) var(--ease-quiet),
        overlay var(--motion-base) allow-discrete,
        display var(--motion-base) allow-discrete;
    }
    /* `translate: none` MUST be re-declared here. @starting-style rules cascade like any others, and
       the phone rule above (`dialog[open] { translate: 0 100% }`) is not inside a max-width query —
       so without this line a desktop dialog would start at BOTH `scale: .97` AND the sheet's full
       off-screen offset, and play the phone's slide. Live QA caught exactly this when the offset was
       still 24px; at 100% it would be unmissable. */
    @starting-style { dialog[open] { scale: .97; translate: none; opacity: 0; } }
  }
}
@media (prefers-reduced-motion: reduce) {
  /* @starting-style and allow-discrete are NOT reached by the .01ms flatten at :202, so a reduced-
     motion user is pinned to the final appearance instead — instant open, instant close. */
  dialog { opacity: 1; translate: none; scale: none; }
  dialog::backdrop { opacity: 1; }
}

/* ---- §19 the action row's stray hairline ----------------------------------------------------
   .dialog__actions carried `box-shadow: 0 -.5rem .75rem -.75rem` unconditionally, so a dialog too
   short to scroll drew a soft smudge above its buttons that meant nothing.
   WHAT THE PLAN PRESCRIBED AND WHY IT CANNOT WORK HERE, recorded so it is not re-attempted: the
   layered local/scroll background recipe (this app's own horizontal edge-fade trick) paints on the
   DIALOG's background, and .dialog__actions is `position: sticky; inset-block-end: 0` with an
   OPAQUE `background: var(--color-surface)` and z-index 1 — i.e. it sits exactly over the 12px
   band at the bottom of the scrollport where that shadow would be drawn, and paints over it. The
   scroll-aware hint would have been a silent no-op that ALSO replaced `dialog`'s background
   shorthand for every portal. There is no pure-CSS way to ask "is this dialog scrollable" today.
   So: the smudge is replaced by a deliberate 1px divider — the ordinary dialog-footer rule every
   design system draws, which reads as structure on a short dialog instead of as an artifact, and
   still separates the buttons from content scrolling beneath them on a tall one. Costs the sticky
   band 1px, well inside the 16px of slack `dialog { scroll-padding-block-end: 10rem }` documents. */
.dialog__actions { box-shadow: none; border-block-start: 1px solid var(--color-border); }

/* ============================================================================
   §14 CONFIRM DIALOG — the icon slot (the JS half is in js/ui-dialogs.js)
   ============================================================================ */
.confirm-dialog__head { display: flex; align-items: center; gap: var(--space-s); }
.confirm-dialog__icon { flex: none; inline-size: 1.5rem; block-size: 1.5rem; color: var(--color-danger); }

/* ============================================================================
   §6 CHECKMARK DRAW — skill checks and the per-occurrence absence toggle
   ============================================================================
   Native checkbox internals cannot be keyframed, so both controls become `appearance: none` boxes
   with a drawn tick. NO HTML CHANGES: the markup is still a real <input type="checkbox"> inside its
   own <label>, so the accessible name, Space, focus ring and form semantics are untouched.
   The two selectors keep their EXISTING semantic colours rather than sharing one: a skill check is
   a "done" (--color-ok, the app's success green) and the attendance toggle is an ABSENCE
   (--color-danger, exactly what its `accent-color` already said). Painting the absence toggle green
   would have inverted the meaning of the one control on the page that reports a negative.
   THE UNCHECKED BORDER IS --color-text-soft, NOT --color-border. Measured, not assumed:
   --color-border #D6C8B4 on the white card is 1.65:1, under WCAG 1.4.11's 3:1 for the boundary of a
   UI component — replacing the UA's own ~4.5:1 checkbox border with it would have been a real
   regression bought with a nicer tick. #5F5A50 measures 6.85:1 on the white card, 5.64:1 on the
   page ground and 5.06:1 on the muted card head (the three grounds this file already tabulates for
   this exact colour at the .loading block above), so every ground clears with room.
   REDUCED MOTION: the whole block is gated to no-preference, so a reduce user keeps the NATIVE
   checkbox exactly as it ships today — the same look that group has always had, no extra rule. */
@media (prefers-reduced-motion: no-preference) {
  .skill-check input[type="checkbox"],
  .attendance-toggle input[type="checkbox"] {
    appearance: none; -webkit-appearance: none; position: relative;
    background: var(--color-surface);
    border: 2px solid var(--color-text-soft); border-radius: 4px;
  }
  .skill-check input[type="checkbox"]:checked { background: var(--color-ok); border-color: var(--color-ok); }
  .attendance-toggle input[type="checkbox"]:checked { background: var(--color-danger); border-color: var(--color-danger); }
  /* PHYSICAL offsets and physical border sides, in an RTL document, on purpose: a tick is a SYMBOL,
     not a directional glyph. Logical properties would mirror it and ship a backwards check. */
  .skill-check input[type="checkbox"]::after,
  .attendance-toggle input[type="checkbox"]::after {
    content: ''; position: absolute; left: 34%; top: 14%;
    width: 25%; height: 50%;
    border: solid var(--white); border-width: 0 3px 3px 0;
    transform: rotate(45deg) scale(0); transform-origin: center;
    transition: transform var(--motion-fast) var(--ease-quiet);
  }
  .skill-check input[type="checkbox"]:checked::after,
  .attendance-toggle input[type="checkbox"]:checked::after { transform: rotate(45deg) scale(1); }
}

/* ============================================================================
   §7 PROGRESS BAR — sweeps from 0 on mount, flashes softly at 100%
   ============================================================================
   `width: var(--pct, 0%)` in the base rule at :914 is NOT re-declared here, and that is the whole
   correctness of this block: js/auth-guard.js's progressBar() sets `--pct` as an inline CUSTOM
   PROPERTY, never an inline width, so an appended `width: 0%` on the same selector would win on
   source order and pin every bar in the app at zero forever. @starting-style supplies the missing
   "before" frame instead — the same class of first-paint bug the toast needed a JS reflow for, and
   the reason no JS change is needed here: a fresh element gets its starting style natively.
   The 100% flash peak is --green-300, an EXISTING token (the documented 40/60 mix of --green-700
   with white). No new hex: this round introduces no colour. At 100% the fill covers the whole
   track, so there is no track left for the lighter tone to contrast against. */
@media (prefers-reduced-motion: no-preference) {
  .progress__bar { transition: width var(--motion-sweep) var(--ease-quiet); }
  @starting-style { .progress__bar { width: 0%; } }
  .progress__bar--full { animation: progress-flash 700ms var(--ease-quiet); }
  @keyframes progress-flash {
    0%   { background: var(--color-ok); }
    30%  { background: var(--green-300); }
    100% { background: var(--color-ok); }
  }
}

/* ============================================================================
   §8 PRESS-DOWN ON TAP — buttons and list rows
   ============================================================================
   Ungated, matching the app's own shipped .btn:active precedent: a 1px/2% instant state change is
   press FEEDBACK, not the kind of travel prefers-reduced-motion exists to suppress, and the global
   .01ms flatten already removes the transition's duration for anyone who asked.
   `:active` matches ANCESTORS of the pressed element, so a press anywhere inside a row — including
   on its own links and buttons — reads as whole-row feedback without the row being a tap target. */
.btn:active { transform: translateY(1px) scale(.98); }
.list-item:active { background: var(--color-muted); transition: background var(--motion-fast) var(--ease-quiet); }
/* THE UA'S OWN TAP FLASH, TURNED OFF — and only on the two components that now draw their own press
   state. Any element carrying pointer/touch handling is treated as a tap target, and the UA paints
   its default highlight over the whole BORDER BOX: measured `rgba(51, 181, 229, .4)` computed in
   Chromium, grey on iOS. It lands ON TOP of the compress above, so on a real phone the considered
   press-down reads as a coloured slab instead — the flash is the thing you see. Invisible to
   headless verification: Chromium's screenshot path does not composite it, so a mid-touch capture
   shows nothing; the computed value is the evidence.
   SCOPED, NEVER GLOBAL: a blanket rule would also take it off links and native controls that have no
   replacement feedback at all, which is worse than the flash. Nothing here touches :focus-visible —
   the keyboard ring is a different mechanism and still measures on Tab. */
.btn, .list-item { -webkit-tap-highlight-color: transparent; }

/* ============================================================================
   §4 AFTER-SAVE ROW HIGHLIGHT — .js-settled, added and removed by the page modules
   ============================================================================
   The tint ends at --color-surface, NOT at `transparent`: .list-item and .card both declare
   `background: var(--color-surface)`, so a keyframe ending at transparent would fade to the
   Alabaster page ground showing through and then SNAP back to white the instant the animation
   dropped its hold. Ending on the resting value is the same picture with no snap.
   No fill mode, so the class leaves nothing behind; the page modules remove it on animationend so
   the next save can re-trigger it. */
@media (prefers-reduced-motion: no-preference) {
  .js-settled { animation: settle-tint var(--motion-highlight) var(--ease-quiet); }
  @keyframes settle-tint {
    0%   { background-color: var(--blue-100); }
    100% { background-color: var(--color-surface); }
  }
}

/* ============================================================================
   §5b DRAG-SETTLE — the block that was just dropped lands softly
   ============================================================================
   A one-shot cue on THAT block only — never a cascade over its neighbours (owner ruling). Not a
   fly-from-origin interpolation: that needs pre-drop rect tracking and is far more machinery than a
   "quiet & fast" cue meant to be barely noticed deserves. */
@media (prefers-reduced-motion: no-preference) {
  .cal-block--dropped { animation: block-settle var(--motion-base) var(--ease-quiet); }
  @keyframes block-settle {
    from { transform: translateY(-6px) scale(.97); opacity: .75; }
    to   { transform: translateY(0) scale(1); opacity: 1; }
  }
}

/* ============================================================================
   §5a CALENDAR DAY-SLIDE — the same-document View Transition's four keyframes
   ============================================================================
   THE NAMED ELEMENT IS .cal-scroll IN DAY VIEW, and the selector is what scopes it.
   There is no "day column" element to name: renderWeeklyCalendar() emits ONE .cal-grid and simply
   skips the six days it is not drawing, so the day's content has no wrapper of its own. .cal-grid
   is the wrong thing to name anyway — it is the FULL height of the day (hours 06:00-21:00), and a
   ::view-transition-group lives in the top layer where the scroller's clip does not reach, so its
   snapshot would paint over the whole page mid-slide. .cal-scroll is the bounded box the owner
   actually sees (solo pins it to min(70vh, 42rem)), so sliding it is exactly "the day paged".
   `.cal-grid--fit:not(.cal-grid--fitrows)` IS day view, by construction rather than by a new hook:
   the renderer adds --fit for `fitColumns || viewDay != null` and --fitrows only for
   `fitColumns && viewDay == null`, so the pair is true together only when viewDay is set. Naming it
   only in day view keeps week navigation and the cross-document transitions out of these keyframes.
   RTL: this app is `dir="rtl"` on every page and has no LTR variant, so four keyframes are the
   whole matrix — no :dir() branching. `fwd` (a later date) leaves toward the INLINE-END edge, which
   in RTL is the left, and the next day enters from the right, following the finger. */
@media (prefers-reduced-motion: no-preference) {
  body[data-portal="solo"] .cal-scroll:has(> .cal-grid--fit:not(.cal-grid--fitrows)) {
    view-transition-name: cal-day;
  }
  /* TWO THINGS HERE ARE MEASURED CORRECTIONS, NOT STYLE.
     (1) THE GROUP IS A THIRD ANIMATION, not a wrapper for the other two: it carries the
         position/size MORPH between the old and new snapshots and takes the UA's own 250ms —
         measured `group(cal-day)@250` beside `old(cal-day)@200` / `new(cal-day)@200`, i.e. one
         straggler outliving the slide by 50ms. Naming it keeps the whole family on one clock.
         Easing stays on old/new: the group's morph curve is the UA's and has nothing to align to.
     (2) ALL THREE ARE SCOPED TO [data-cal-dir], like the keyframe bindings below. That scope was
         added when cross-document navigation still ran a transition: unscoped, these also applied
         to a plain page change away from the calendar in day view, where `cal-day` still existed on
         the outgoing document but no direction was set — measured `old(cal-day)@200` against the
         other groups' 250ms, the same 50ms mismatch this block exists to remove, pointing the other
         way. v1.35.1 removed cross-document transitions, so that case no longer arises; the scope
         is KEPT because it is still the honest statement of when these rules mean anything (a day
         step, and nothing else). */
  [data-cal-dir]::view-transition-group(cal-day) { animation-duration: var(--motion-base); }
  [data-cal-dir]::view-transition-old(cal-day),
  [data-cal-dir]::view-transition-new(cal-day) {
    animation-duration: var(--motion-base);
    animation-timing-function: var(--ease-quiet);
  }
  [data-cal-dir="fwd"]::view-transition-old(cal-day)  { animation-name: cal-day-out-fwd; }
  [data-cal-dir="fwd"]::view-transition-new(cal-day)  { animation-name: cal-day-in-fwd; }
  [data-cal-dir="back"]::view-transition-old(cal-day) { animation-name: cal-day-out-back; }
  [data-cal-dir="back"]::view-transition-new(cal-day) { animation-name: cal-day-in-back; }
  @keyframes cal-day-out-fwd  { to   { transform: translateX(-100%); opacity: 0; } }
  @keyframes cal-day-in-fwd   { from { transform: translateX(100%);  opacity: 0; } }
  @keyframes cal-day-out-back { to   { transform: translateX(100%);  opacity: 0; } }
  @keyframes cal-day-in-back  { from { transform: translateX(-100%); opacity: 0; } }

  /* THE COMPANIONS, PINNED TO THE SAME CLOCK. A same-document transition does not animate only the
     element you named: every OTHER named group plus the implicit `root` gets the UA's default 250ms
     cross-fade, and the groups additionally morph. Measured on this page, 18 pseudo-element
     animations per arrow press — 16 of them companions that outlive the 200ms slide by 50ms, so the
     day lands and the page keeps fading for another two and a half frames. Pinning the duration
     (never the keyframes — the UA's own cross-fade is the right MOTION, it is only the timing that
     disagreed) makes the whole step finish as one gesture.
     THE MEASUREMENT ABOVE IS HISTORICAL: it was taken when `main-content` and `app-header` were
     both named, so 18 pseudo-animations ran per press. Neither is named any more (v1.35.1 removed
     cross-document transitions and the header's name with them), so a day step now captures only
     `cal-day` and the implicit `root` — a much lighter transition, which is the point of recording
     the old figure rather than deleting it. `root` is the implicit group the UA always creates.
     This rule is inert everywhere else in the app by construction, because nothing else ever sets
     [data-cal-dir] — and page-calendar.js REMOVES that attribute in `transition.finished`, which is
     what keeps it scoped to a day step. cal-day's own three pseudo-elements carry the same duration
     under the same scope just above. */
  /* `main-content` IS ABSENT ON PURPOSE: `<main>` is deliberately unnamed, because naming a
     full-page-height element asks the browser to morph between two unrelated geometries — see
     solo/css/motion.css for the measured squash and translate that caused.
     THE THREE `app-header` LINES BELOW ARE NOW INERT, AND ARE LEFT IN PLACE DELIBERATELY. v1.35.1
     removed cross-document page transitions entirely (owner ruling — see solo/css/motion.css for
     the Safari findings), and with them `header.app-header`'s view-transition-name: no element in
     this app carries that name any more, so these three selectors can never match. They are kept
     rather than deleted for two reasons — this sheet is held byte-identical by test/cssdiff.py, so
     removing a shipped rule would red-flag the additive gate for no behavioural gain; and if a
     future round ever re-names the header, the pin it needs is already correct.
     `root` is the one that still does real work: a same-document day step now captures only
     `cal-day` and the implicit `root`, and the UA gives root 250ms against this slide's 200ms. */
  [data-cal-dir]::view-transition-group(root),
  [data-cal-dir]::view-transition-old(root),
  [data-cal-dir]::view-transition-new(root),
  [data-cal-dir]::view-transition-group(app-header),
  [data-cal-dir]::view-transition-old(app-header),
  [data-cal-dir]::view-transition-new(app-header) {
    animation-duration: var(--motion-base);
  }
}
@media (prefers-reduced-motion: reduce) {
  /* ::view-transition-* is NOT reached by the .01ms flatten at :202 — it needs its own override,
     and page-calendar.js deliberately keeps no JS-side motion check so there is one source of
     truth. The DOM update still happens; only the animation is removed. */
  ::view-transition-group(cal-day),
  ::view-transition-old(cal-day),
  ::view-transition-new(cal-day) { animation: none !important; }
}

/* ============================================================================
   §11 THE BRANDED LOADING STATE — the app's own wave mark, flowing
   ============================================================================
   Replaces every visible "טוען…" in the solo portal. The word itself is NOT lost: it moves into a
   .visually-hidden span, so a screen reader announces exactly what it announces today, while the
   graphic is aria-hidden. Nothing here is a text node whose opacity is animated, which sidesteps
   the AA trap recorded at the .loading block above by construction.
   Brand blues only (--color-primary), the app's own two wave paths, no new artwork.
   Reduced motion needs NO rule: the block below is gated to no-preference, so a reduce user simply
   sees the static logo — which is what "static logo under reduced motion" asks for. */
.loading-logo {
  display: grid; justify-items: center; gap: var(--space-s);
  padding: var(--space-xl) var(--space-m); color: var(--color-primary);
}
.loading-logo__mark { inline-size: 3rem; block-size: 3rem; }
/* the <li> wrapper used where the placeholder must stay a list row (curriculum, registry) */
.loading-logo-row { list-style: none; }
@media (prefers-reduced-motion: no-preference) {
  /* dasharray 4 4 has an 8px period and the offset travels exactly -16px, i.e. two whole periods,
     so the loop is seamless. `linear` because a continuous flow that eases would read as a pulse. */
  .loading-logo__wave { stroke-dasharray: 4 4; animation: loading-wave 1.6s linear infinite; }
  .loading-logo__wave--2 { animation-delay: -.4s; opacity: .6; }
  @keyframes loading-wave { to { stroke-dashoffset: -16; } }
}

/* ============================================================================
   §12 SAVE-IN-FLIGHT — the dim is unconditional, the spinner is gated
   ============================================================================
   beginSlowSave(notify, busyEl) already sets aria-busy="true" on every write path; busyEl is
   sometimes the trigger <button> and sometimes the <form>, so both shapes are covered. No JS change.
   ::before, NOT ::after, and that is a bug avoided rather than a preference: `.btn--sm::after` is
   this file's transparent 44px TOUCH-TARGET band (:440), and `button[aria-busy="true"]::after` is
   (0,1,1) — it would beat .btn--sm's (0,1,0) and replace that hit area with a 14px spinner for the
   whole duration of a save. ::before has no consumer on any button in this file. In RTL it renders
   at the inline start, where .btn-icon already sits, and .btn's own `gap` spaces it.
   KNOWN, ACCEPTED SCOPE LIMIT: where busyEl is a <form> with TWO submit buttons (registry's
   שיבוץ / רשימת המתנה), both show the spinner — aria-busy does not record which button submitted,
   and this item is CSS-only by the owner's "spinner only, no label swap" scope.
   The dim is ungated on the owner's explicit instruction ("reduced motion keeps the dim"); it is a
   state, not an animation. Measured for 1.4.3 at .75 group opacity over the white surface: body
   text lands at ~6.9:1 and white-on-primary at ~5.4:1, both still AA. */
[aria-busy="true"] { opacity: .75; }
@media (prefers-reduced-motion: no-preference) {
  button[aria-busy="true"]::before,
  form[aria-busy="true"] button[type="submit"]::before {
    content: ''; display: block; flex: none;
    inline-size: 14px; block-size: 14px;
    border: 2px solid currentColor; border-inline-end-color: transparent;
    border-radius: 50%;
    animation: btn-spin 700ms linear infinite;
  }
  @keyframes btn-spin { to { transform: rotate(360deg); } }
}

/* ============================================================================
   §15 DISCLOSURE CHEVRON — the "פתיחה"/"סגירה" pill becomes a quiet rotating chevron
   ============================================================================
   Shared with the school portals' instructor trees; no portal-scoped rule exists on this selector,
   so it is one uniform change. `margin-inline-start: auto` is deliberately NOT re-declared — the
   base rule at :658 still supplies it, and an appended rule only overrides what it names.
   NO HARD-CODED HEX. The plan's data-URI carried `stroke='%230F414A'`, i.e. --color-primary frozen
   into an image — but theme.js repaints --color-primary/--color-heading per school, so a re-branded
   tenant would have got a chevron in someone else's blue, and the round's own rule is that no
   palette hex is introduced. A MASK plus `background-color: currentColor` follows the summary's
   own colour instead, whatever a tenant's brand sets it to. The `%23000` inside the mask is the
   alpha source of an image mask, not a colour anyone sees.
   The glyph is Lucide's chevron-down at stroke-width 2 with round caps — the same icon language
   every other SVG in this app already uses, rather than a border-triangle that would not match it.
   Reduced-motion safe through the global flatten: an ordinary transform transition, no keyframes. */
.instructor-group__summary::after {
  content: ''; color: inherit; flex: none;
  inline-size: 1.1em; block-size: 1.1em;
  padding: 0; border: none; background: none;
  background-color: currentColor;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
  -webkit-mask-size: contain; mask-size: contain;
  transition: transform var(--transition);
}
.instructor-group[open] > .instructor-group__summary::after { content: ''; transform: rotate(180deg); }
/* the base hover rule paints `background: var(--blue-800)` on this pseudo-element; with a mask in
   play that would recolour the glyph, and `transparent` (which the plan proposed for the image
   version) would erase it outright. Re-assert currentColor so hover changes nothing about it. */
.instructor-group__summary:hover::after { background-color: currentColor; }

/* ============================================================================
   §16 LOGIN HIERARCHY — one filled primary, two quiet text links
   ============================================================================
   CSS-only, keyed on ids so no class or markup semantics change: both controls keep their existing
   .btn .btn--ghost .btn--block classes for any future JS that might read them. `כניסה` — the one
   real submit — is untouched and stays the single filled button on the screen.
   --color-primary on the white auth card measures 11.20:1 (this file's own recorded figure for that
   pair), and the underline is what carries "this is a link" now that the fill is gone. Both are
   .btn--block, so the tap target is the full card width by ~33px — above SC 2.5.8's 24px floor. */
#forgot-link, #trial-link {
  background: none; border: none; color: var(--color-primary);
  text-decoration: underline; font-weight: 500;
  min-height: auto; padding-block: var(--space-xs);
}
#forgot-link:hover, #trial-link:hover { background: none; }

/* ============================================================================
   §17 BRAND TEXT SELECTION + §19 the wide-tenant-logo cap
   ============================================================================ */
::selection { background: var(--blue-100); color: var(--color-heading); }
/* states the app's one and only scheme, so a UA in forced-dark stops guessing at form controls */
html { color-scheme: light; }
/* THE DEFECT IS ALREADY DOCUMENTED AT :268 — .shell-logo has no inline cap, so a 10:1 tenant logo
   renders ~280px at 375px and pushes .shell-account onto a row of its own, making the header three
   rows tall. 8rem is the cap; `max-height: 1.75rem` (base rule) still governs the block axis. */
.shell-logo { max-inline-size: 8rem; }

/* ============================================================================
   §9 THE WORDMARK — no font-family override lives here any more
   ============================================================================
   This section used to force "LapKeeping" onto var(--font) with two appended rules
   (`.brand span` and `.brand-lockup`). That was a WORKAROUND for one property of the old
   display face and nothing else: Horev CLM Heavy carried no Latin glyphs, so a Latin
   wordmark fell past it to the UA's serif. Fredoka has full Latin, so the workaround is
   gone and both elements take var(--font-display) from their own base rules again —
   `.brand span` at :235 and `.brand-lockup` from the `h1, h2, h3` rule (it is the <h1>
   on the app's entry screens and a <span> inside the admin portal's <h1>).
   The weight the two need (600, because Fredoka is loaded at 500/600) is stated at those
   base rules, not here, so the byte-identical lockup block this sheet shares with
   admin-portal/css/admin.css still carries it in both copies — see test/cssdiff.py's
   lockup_section(), which compares that block verbatim across the two sheets. */

/* ============================================================================
   §19 calendar.html's two inline styles, moved into the sheet
   ============================================================================ */
.push-end { margin-inline-start: auto; }
.cal-week-label { margin-inline: auto; font-weight: 600; }

/* ============================================================================
   THE DIALOG'S FOCUS LANDING TARGET IS QUIET — and this is the ONE place a ring is removed
   ============================================================================
   focusDialogStart() (js/auth-guard.js) lands focus on a form dialog's HEADING instead of its first
   text field, so opening a sheet on a phone no longer summons the keyboard over it (owner ruling).
   Measured consequence that needed answering: a programmatically focused heading DOES match
   `:focus-visible` when no pointer interaction preceded it, so without this rule the 3px brand
   outline draws itself around the dialog's title every time one opens.
   WHY REMOVING IT IS LEGITIMATE HERE, and nowhere else. WCAG 2.4.7 requires a visible focus
   indicator for KEYBOARD-OPERABLE components. This element is `tabindex="-1"`: it is not in the tab
   order, it cannot be reached by the keyboard, and it does nothing when activated — it is a landing
   point, not a control. The visible signal that context changed is the modal itself appearing over
   a dimmed page, which is considerably louder than any outline.
   SCOPED AS NARROWLY AS IT CAN BE: only a heading (or the dialog box itself) that focusDialogStart()
   has marked, and only inside a `dialog`. Every real control — the fields, the ✕, the action
   buttons, and every focusable element on every page — keeps the global ring untouched, including
   the `tabindex="-1"` step headings in signup.html, which are a different flow and keep theirs. */
dialog h2[tabindex="-1"]:focus,
dialog h2[tabindex="-1"]:focus-visible,
dialog[tabindex="-1"]:focus,
dialog[tabindex="-1"]:focus-visible { outline: none; }

/* ============================================================================
   §9 "אין רמה" IS NOT AN ALARM — its own quiet fill (owner ruling)
   ============================================================================
   .pill--muted has been #C22E22 RED since v1.1.18, and that ruling STANDS for its other consumers:
   the בהקפאה state chip and the absent/unfilled markers elsewhere, where red is a deliberate alert.
   "No level recorded yet" is not one — it is the ordinary state of every student before their first
   assessment, so a roster of new students rendered as a page full of red warnings.
   A COMPONENT-SCOPED TOKEN, the technique this file already uses for exactly this problem (see the
   --pill-* block's own note): one new fill, one new consumer, and NOTHING that reads --pill-muted-bg
   moves. Retheming .pill--muted globally would have repainted every one of those other meanings.
   --slate-500 #5F5A50 is an EXISTING palette primitive (it is what --color-text-soft resolves to),
   so this introduces no new hex. White on it measures 6.85:1 — the same pair this file already
   tabulates at the .loading block — comfortably past the 4.5:1 these white-text pills are held to,
   and it is a WARM grey, so it cannot be confused with .pill--role's Midnight or .pill--data's teal.
   Its own :root block beside its only consumer, so the palette block at the top of the file stays
   byte-identical to test/cssdiff.py. */
:root { --pill-none-bg: var(--slate-500); --pill-none-text: var(--white); }
.pill--none { background: var(--pill-none-bg); color: var(--pill-none-text); }

/* ============================================================================
   §11 A FROZEN STUDENT'S ROW READS AS FROZEN AT A GLANCE
   ============================================================================
   בהקפאה was carried by one chip among several, so scanning a long roster for who is paused meant
   reading every row. The row itself now says it.
   WHAT IT IS NOT: `opacity` on the row. This project has a recorded, MEASURED rule against that —
   group opacity composites TEXT toward the ground, so a faded row buys its quieter look with
   contrast the text cannot spare. Nothing here touches any text colour. The two cues are:
     · a muted fill plus a DASHED border — a shape difference, so the state is not carried by colour
       alone even before the chip is read (WCAG 1.4.1). --color-muted is the fill this sheet already
       uses for inert surfaces, and --color-text-soft on it measures 5.06:1 (this file's own recorded
       figure for that pair), so every line in the row still clears AA.
     · `grayscale(1)` on the row's DECORATIVE pills only. Unlike opacity, the grayscale matrix
       preserves relative LUMINANCE, so white-on-fill contrast survives essentially unchanged — it
       removes hue, not legibility.
   `:not(.pill--muted)` deliberately EXEMPTS the בהקפאה chip: the one pill that STATES the state must
   stay fully saturated and fully legible, which is the whole point of quieting the others round it. */
.list-item--frozen { background: var(--color-muted); border-style: dashed; }
.list-item--frozen .pill:not(.pill--muted) { filter: grayscale(1); }

/* ============================================================================
   §12 THE LOGIN WAVE MOTIF — quiet-luxury restraint, provably contrast-free
   ============================================================================
   The same two-wave language as the loader and the app's own brand mark, drawn very large and very
   faint behind the sign-in card, so the entry screen reads as this product rather than as a form.
   IT CANNOT AFFECT ANY TEXT'S CONTRAST, and that is structural rather than a judgement call: it is
   painted on .auth-shell — the PAGE — while every word on this screen sits inside .auth-card, which
   declares an OPAQUE `background: var(--color-surface)`. An opaque surface composites nothing from
   behind it, so every measured pair on that card is byte-identical with and without this rule.
   Asserted by measuring each pair twice, with the motif and with it removed, rather than assumed.
   (The wordmark on that card measures 14.45:1, NOT the 11.20:1 quoted for --color-primary elsewhere
   in this file: `.brand-lockup` is an <h1>, and the `h1, h2, h3` rule's own `color: var(
   --color-heading)` (#0A2E34) wins over the colour it would otherwise inherit from .auth-brand
   (#0F414A). Recorded because the wrong figure is the kind a later round "corrects" the code to.)
   The strokes are --color-primary at 0.05 alpha over the Alabaster page — far below anything that
   could read as an object competing with the card, which is what "barely there" has to mean if the
   card is to stay the only thing on screen.
   STATIC, with no animation at all, so there is nothing for prefers-reduced-motion to gate: a
   background that does not move cannot be a vestibular problem, and a still image is the more
   restrained choice regardless. `background-attachment: fixed` is deliberately NOT used — a known
   scroll-performance trap on mobile, for a screen that barely scrolls.
   The path data is the brand wave repeated at 240x120 scale; the -20 start and the extra repeats
   run it past both edges so it never ends mid-stroke inside the viewport. */
.auth-shell {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 240 130' fill='none' stroke='%230F414A' stroke-opacity='.05' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M-20 40c20 0 20 20 40 20s20-20 40-20 20 20 40 20 20-20 40-20 20 20 40 20 20-20 40-20 20 20 40 20'/%3E%3Cpath d='M-20 70c20 0 20 20 40 20s20-20 40-20 20 20 40 20 20-20 40-20 20 20 40 20 20-20 40-20 20 20 40 20'/%3E%3Cpath d='M-20 100c20 0 20 20 40 20s20-20 40-20 20 20 40 20 20-20 40-20 20 20 40 20 20-20 40-20 20 20 40 20'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center bottom;
  background-size: min(150%, 70rem) auto;
}

/* ============================================================================
   .btn--hero — ONE page-level primary action, deliberately unmissable
   ============================================================================
   Owner ruling: the "הוספת תלמיד/ה חדש/ה" button on רישום תלמידים must read as THE thing to press on
   that page. This is a size/prominence modifier only — it inherits its fill, its focus ring, its
   :active nudge and its hover from .btn/.btn--primary, so no colour is introduced and no contrast
   pair moves (the white-on-Midnight pair it renders is the one that button already shipped).
   ADDITIVE, AND SCOPED TO ITS OWN CLASS: nothing in .btn, .btn--primary or any existing modifier is
   edited, so every other button in every portal is byte-identical. It sits at the FOOT of the sheet
   because .btn--hero and .btn have equal specificity (0,1,0) — source order is the only thing that
   makes these declarations win, so this block must stay after .btn's.
   SIZE: 3.5rem of block size against .btn's 44px var(--tap) floor, and --text-lg against the
   inherited body size. The icon needs no rule of its own — .btn-icon is sized in `em`, so it grows
   with the type automatically, which is why this is a modifier and not a second button component.
   WIDTH: full on a phone, where a thumb-sized target across the measure is the whole point, then
   capped at --measure-row. That cap is not cosmetic: .lookup's own comment records a browser-QA
   finding of a 192px ragged edge when the controls above the roster spanned the 58rem wrapper while
   every row below capped at 46rem. A full-bleed hero would reintroduce exactly that, so it lines up
   with the search field and the result rows instead.
   It stays the page's ONE hero: the search field below it is an ordinary .field, so the visual
   hierarchy reads action-then-filter rather than two competing controls. */
.btn--hero {
  min-height: 3.5rem;
  inline-size: 100%;
  max-inline-size: var(--measure-row);
  padding: var(--space-m) var(--space-l);
  gap: var(--space-s);
  font-size: var(--text-lg);
  border-radius: var(--radius);
  box-shadow: var(--shadow-md);
}

/* ============================================================================
   .group-option--stacked / .picker-group-label — the join-a-group card on a phone
   ============================================================================
   THE BUG, reported on an iPhone: .group-option is a ROW (flex, name and meta as side-by-side
   columns with space-between). That is right for a short label, but a team card carries six pills,
   and at 375px the meta column took everything — squeezing the name column so hard that
   "א,ב | 14:00" wrapped ONE CHARACTER PER LINE. `overflow-wrap: break-word` on .group-option__name
   is what turned an overflow into a vertical letter stack; the real fault is that the name was in a
   column at all.
   ADDITIVE, BECAUSE .group-option IS SHARED AND IN THE TAG: frontdesk.js, manager.js and
   js/private.js all build it through the same class, and its rules ship in v1.36.2. So nothing here
   edits it — this is a MODIFIER the solo placement dialog adds to its own rows, and every other
   portal's rows are byte-identical to what they were.
   THE FIX IS THE AXIS. Turning the row into a column gives the name its own full-width line, so it
   wraps on WORDS like any other text and can never letter-stack, and the pills flow underneath as
   their own wrapping cluster. `align-items: stretch` is what stops the flex parent shrink-wrapping
   the children back into columns, and `min-inline-size: 0` keeps the name able to shrink to its
   container rather than pushing the card wide.
   The meta cluster is re-aligned to the start here (it is `flex-end` in the base rule, which is
   right when it sits at the end of a row and wrong when it is a line of its own).
   MEASURED at 320 / 375 / 430 with the container modelled (headless clamps the viewport at 512px):
   name on ONE line at every width, zero vertical character stacking, pills wrapping to two tidy
   rows at 320 and no horizontal overflow.
   .picker-group-label is the other half: "מומלצות" / "קבוצות נוספות" were .section-title, whose
   var(--text-section) is a 25.6-40px DISPLAY size — a page heading rendered inside a dialog. These
   are list-group labels, so they take the app's small-label treatment (--text-sm, soft colour,
   600) and sit quietly above their rows. */
.group-option--stacked {
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  gap: var(--space-xs);
}
.group-option--stacked > .group-option__name { min-inline-size: 0; }
.group-option--stacked > .group-option__meta { flex: 0 0 auto; justify-content: flex-start; }
.picker-group-label {
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--color-text-soft);
  margin-block-end: var(--space-xs);
}

/* ============================================================================
   .week-nav — prev/next never stack, whatever the week label's length
   ============================================================================
   THE BUG, as reported on an iPhone: with a long label ("שבוע 13.9 – 19.9") the three children
   wrapped one at a time and שבוע קודם / שבוע הבא ended up on separate lines, stacked and ragged.
   THE CAUSE was two rules that are individually right: .cluster gives this row `flex-wrap: wrap`,
   and the label carries `margin-inline: auto`, so the label claims every spare pixel and pushes the
   buttons over the wrap boundary one by one. Wrapping was doing exactly what it was told; nothing
   told it that prev and next are ONE control that must not be split.
   ADDITIVE, AND IT FIXES ALL THREE PORTALS AT ONCE: nothing shared is edited — not .cluster (used
   by the whole app) and not .cal-week-label (in the tag). .week-nav DID already have one rule of
   its own, `.week-nav { gap: var(--space-s) }` further up this sheet; this block re-declares that
   same gap so the grid's row and column gutters are stated where the grid is, and being later in
   the file it is this rule that supplies the value. The two agree on var(--space-s), so the
   earlier one is now belt-and-braces rather than load-bearing — say so before deleting either. solo, instructor and manager ship byte-identical markup here — .week-nav > [button,
   p, button] — so the selectors are structural and need no per-portal variant. It must sit AFTER
   .cluster: both are (0,1,0), so source order is the only thing that makes `display: grid` win.
   THE ARRANGEMENT is explicit, never auto-placed. A grid item that is first in the DOM but assigned
   to a later column pushes its siblings onto their own rows under sparse packing, so every child
   states BOTH its row and its column:
     · narrow (base): the label takes row 1 across the full width, and the two buttons sit side by
       side on row 2 as equal halves — a deliberate two-row block instead of an accidental stack,
       and the buttons grow into 50% targets, which is better on a thumb than the shrink-wrapped
       originals.
     · >= 30rem: the classic prev | label | next in one row, the label taking the middle 1fr.
   The label also gets `tabular-nums` here: it is a numeric range that repaints on every week
   change, and lining figures stop the row twitching as the digits change width.
   NOTHING ABOUT BEHAVIOUR MOVES — same DOM, same ids, same handlers, same accessible names, same
   role="group". This is layout only. */
.week-nav {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-s);
  align-items: center;
}
.week-nav > .btn:first-child { grid-row: 2; grid-column: 1; }
.week-nav > p { grid-row: 1; grid-column: 1 / -1; text-align: center; font-variant-numeric: tabular-nums; }
.week-nav > .btn:last-child { grid-row: 2; grid-column: 2; }
@media (min-width: 30rem) {
  .week-nav { grid-template-columns: auto 1fr auto; }
  .week-nav > .btn:first-child { grid-row: 1; grid-column: 1; }
  .week-nav > p { grid-row: 1; grid-column: 2; }
  .week-nav > .btn:last-child { grid-row: 1; grid-column: 3; }
}

/* ============================================================================
   v1.37.1 roster tuning — TWO ADDITIVE OVERRIDES, no shipped rule edited
   ============================================================================
   Both blocks below override rules that SHIPPED in v1.37.0 (.btn--hero, .btn-icon, .btn-row), so
   neither of those is touched: these are separate, later rules that win on source order at equal
   specificity — which is the only reason they must stay below the originals and above the [hidden]
   guard. Nothing here changes any other consumer of those classes.

   1. THE HERO BUTTON IS NARROWER AND ICON-FORWARD (owner ruling).
   WIDTH: it capped at --measure-row (46rem), the same measure the search field and the result rows
   share. The new cap is 70% of that token rather than a literal, so it still tracks the measure if
   the token ever moves. It stays START-ALIGNED rather than centred: the button, the search field
   and every roster row keep one shared start edge (the RIGHT edge, in this RTL portal), so a
   shorter button reads as deliberate rather than as a centred element that missed the grid. On a
   phone the cap never binds — the button is still full width there, which is where "unmissable"
   matters most.
   ICON: .btn-icon is sized in `em`, so the glyph tracked the label and stayed proportionally small.
   Raising it to 1.6em inside this one button makes the person-plus read first and the words second,
   which is the "scream obvious" direction. `em` keeps it tied to the button's own --text-lg, so it
   still scales with the type rather than being pinned to a pixel size.

   2. THE ROSTER FILTER TOGGLES SHARE ONE ROW AT EVERY WIDTH (owner ruling).
   They are .btn-row children, which wraps by default — and their labels ("תלמידים משובצים (19)")
   plus .btn's own var(--space-l) inline padding (24-40px a side) cannot both fit on a 320px phone,
   so the second one wrapped underneath and the pair read as a list rather than as one control.
   `nowrap` alone would overflow, so the fix is nowrap PLUS the three things that make shrinking
   legal: `flex: 1 1 0` (equal halves, sized from zero rather than from content), `min-inline-size:
   0` (without it a flex item refuses to shrink below its content and the row overflows anyway), and
   a smaller type/padding pair so the words still fit inside the halves. The labels wrap INSIDE
   their own button if they must — the row never does.
   SCOPED BY ID to this one group: .btn-row is used by every dialog and card in the app, and none of
   them wants nowrap. */
.btn--hero { max-inline-size: calc(var(--measure-row) * 0.7); }
.btn--hero .btn-icon { inline-size: 1.6em; block-size: 1.6em; }
#state-filters { flex-wrap: nowrap; }
#state-filters > .btn {
  flex: 1 1 0;
  min-inline-size: 0;
  padding-inline: var(--space-s);
  font-size: var(--text-sm);
}

/* ============================================================================
   THE FROZEN SECTION — an icy card, and three filters in one row
   ============================================================================
   Both blocks below OVERRIDE rules that shipped in the baseline (.list-item--frozen at §11,
   #state-filters from the previous round), so neither original is edited: these are later rules
   winning on source order, and they must stay above the [hidden] guard.

   1. THE ICY CARD. The frozen fill was --color-muted (#E7DCCC, warm tan) — measured at dE00 3.96
   from the Alabaster page, i.e. very nearly the same colour as the thing behind it. That is why a
   frozen card read as "beige and slightly odd" instead of as a state. It becomes --blue-100
   (#CFE3E8), which is not a new colour: it is the exact tint .kid-freeze itself already uses, so the
   card and the control that produced it are the same blue.
   MEASURED, on the icy ground (implemented from the WCAG + CIEDE2000 definitions and validated
   against published reference pairs before any figure was believed):
     · card vs page                       dE00 12.01  (was 3.96 — now a deliberate object)
     · --color-text      body copy        12.30:1     AA
     · --color-heading   the row title    10.88:1     AA
     · --color-text-soft meta lines        5.16:1     AA   <- the gate row; re-measure before any
     · --color-primary   links             8.43:1     AA        further darkening of this fill
     · בהקפאה chip (pill--muted)          dE00 52.22  distinct
     · the greyscaled pills §11 produces  dE00 33.85-61.93 distinct
     · .btn--primary (שיבוץ לשיעור)       dE00 59.34  distinct
     · .btn--neutral (רשימת המתנה)        dE00 10.36  — just under the 12 that reads as a separate
       object, and deliberately kept: that button also carries a 1px --color-border edge, so its
       boundary is drawn rather than inferred from the fill. Named here so the next round does not
       "discover" it as a defect.
   THE BORDER moves with the fill: --color-border is a warm tan that measured 1.24:1 on this ground
   (invisible). --color-secondary is 4.55:1 and in the same teal ramp, so the dashed edge §11 asks
   for is actually visible. The dash STYLE is inherited from §11 and not restated.
   STATE IS NEVER COLOUR-ALONE here: the card also carries the בהקפאה chip in words and, since this
   round, its own two-button action set.

   2. THREE TOGGLES, ONE ROW, DOWN TO 320px (owner: "even if it means to make them small"). The
   previous round made two fit by shrinking; a third needs the padding cut to --space-xs and the
   label allowed to wrap INSIDE its own button, which is what keeps the ROW from wrapping. The
   tightened line-height stops a two-line label from making the row tall. flex-basis 0 with
   min-inline-size 0 is what lets three equal columns exist at all — without the zero minimum a
   flex item refuses to shrink below its content and the row overflows instead. */
.list-item--frozen { background: var(--blue-100); border-color: var(--color-secondary); }
#state-filters > .btn { padding-inline: var(--space-xs); line-height: 1.25; }

/* ============================================================================
   .btn--create — the "fun green" creation accent (owner ruling)
   ============================================================================
   A VARIANT, NOT A RECOLOUR. .btn--primary carries dozens of non-creation actions, so the two
   creation buttons swap their colour class instead: nothing else in any portal moves.
   WHY THE INK IS DARK, AND WHY THAT IS NOT A COMPROMISE. Every saturated mid-green measured FAILS
   AA with white: #16A34A 3.30:1, #0F9D58 3.51:1, #2E9E4F 3.43:1, #12924B 4.01:1 — a green light
   enough to read as "fun" cannot carry white text, and one dark enough to carry it stops being fun
   AND collides with the status green. So the fill goes bright and the ink goes dark, which is the
   app's OWN accent idiom already (.btn--nav is a light fill with --color-on-accent on top).
   MEASURED, on #4ADE80 with --color-on-accent (#0A2E34):
     · label                          8.29:1   (AA 4.5, AAA 7 — white would have been 1.74:1)
     · vs the cream page              dE00 29.0
     · vs a white card                dE00 30.2
     · vs the frozen card's ice       dE00 35.0   <- the two accents on the roster screen
     · vs --green-700 (status/done)   dE00 34.8   <- "fun action" can never read as "success state"
     · vs --green-300 / --green-50    dE00 19.4 / 23.3
     · vs --color-primary teal        dE00 61.3
   HOVER is one darkening step, hue kept: #40BF6E, ink 6.11:1, dE00 7.7 from rest — deliberately
   matched to .btn--nav:hover's own documented profile (5.44:1, dE 8.1) so the two accents behave
   alike under the pointer. ACTIVE is the app's existing global .btn:active translate; no variant in
   this sheet defines a pressed COLOUR, and inventing one here would make this button the exception.
   THE VALUES ARE LITERALS, not tokens, and that is forced: :root is a baseline rule, so adding
   --green-fun to it would EDIT it rather than add. If this accent earns more call sites, promote it
   to a token in the same round that re-baselines the sheet.
   ============================================================================
   THE FROZEN CARD, MADE PROPERLY COLD (owner ruling)
   ============================================================================
   The previous pass moved the fill to --blue-100 (#CFE3E8), which is a TEAL tint — b* only -4.7, so
   it read "pale" more than "frozen". The ground is now #C2DCF0: b* -12.7 (clearly colder), and
   dE00 16.3 from the page against the old 12.0 (more separated as well as bluer).
   HOW FAR THE COLD COULD GO WAS DECIDED BY ONE ROW. --color-text-soft is the gate on this card, and
   it falls as the ground saturates: #C9E0F0 5.03:1, #C2DCF0 4.83:1, #BAD7EE 4.58:1, #B2D2EC 4.35:1
   — which FAILS. #C2DCF0 is the coldest step that keeps a real margin over AA, and that margin is
   the thing to re-measure before anyone darkens this fill again.
   THE FROST SHEEN is a top-down gradient from #EAF4FD into the ground over the first 58% — glass
   frosted from above. dE00 8.1 between the two stops, so it reads as a sheen and not as a band, and
   it can only IMPROVE text contrast (the top is lighter: the gate row measures 6.15:1 up there
   against 4.83:1 on the ground, which is the figure quoted above as the worst case).
   THE BORDER goes solid and cold: #2F6FA8 at 3.73:1 on the ground, clearing the 3:1 that non-text
   boundaries want. The dashed teal it replaces measured warm against this ground; the "frozen"
   meaning does not depend on it, because the card also carries the state chip IN WORDS.
   THE CHIPS COOL WITH THE CARD, so the whole student reads frozen rather than a warm student on a
   cold card — this replaces the shipped grayscale(1), which produced dead grey rather than ice:
     · year/level/data chips  #DCEBF8 fill + #14456B ink = 8.26:1. They sit only dE00 5.2 from the
       ground BY DESIGN — they are meant to recede — so they get a 1px #93B9D9 edge, drawn rather
       than inferred, exactly the reasoning .btn--neutral's own border carries.
     · the בהקפאה state chip stays LOUD but turns cold: #17527D + white = 8.27:1, dE00 47.5 from the
       ground. It is the one thing on the card that must not recede.
   THE TWO ACTION BUTTONS ARE DELIBERATELY UNTOUCHED — they are the exits, and their warmth against
   the ice IS the design: .btn--primary 11.20:1 / dE00 58.3, .btn--neutral 14.45:1 / dE00 13.6 (up
   from 10.36 on the old ground, so it now clears the distinct-object threshold too). */
.btn--create { background: #4ADE80; color: var(--color-on-accent); border-color: #4ADE80; }
.btn--create:hover { background: #40BF6E; border-color: #40BF6E; }
.list-item--frozen {
  background: linear-gradient(to bottom, #EAF4FD 0%, #C2DCF0 58%) #C2DCF0;
  border-color: #2F6FA8;
  border-style: solid;
}
.list-item--frozen .pill:not(.pill--muted) {
  filter: none;
  background: #DCEBF8;
  color: #14456B;
  border: 1px solid #93B9D9;
}
.list-item--frozen .pill--muted { background: #17527D; color: #FFFFFF; }

/* ============================================================================
   .btn--hero, scaled back now that the green carries it (owner ruling)
   ============================================================================
   Additive again: both earlier .btn--hero blocks are in the baseline, so this is a third, later
   rule and neither of them is edited. The 70% width cap and the start alignment are untouched.
   THE TRADE THE OWNER ASKED FOR: the button was oversized because size was doing the work of making
   it unmissable. The green does that now, so the BOX and the TYPE come down while the ICON stays
   where it is — the look stays icon-forward, it just stops shouting with its dimensions.
     · min-height 2.75rem (44px) — the app's own --tap floor, exactly the height an ordinary button
       has. Down from 3.5rem plus padding, which had been rendering 63-68px.
     · font-size --text-body (16px), down from --text-lg (18.5-21.8px): it should read as a normal
       button that happens to be green, not as a banner.
     · THE ICON FACTOR RISES TO COMPENSATE, which is the whole trick: .btn-icon is sized in `em`, so
       dropping the type would have shrunk the glyph with it. 2.2em of 16px = 35.2px, which is where
       1.6em of the old --text-lg already rendered — the glyph does not move a pixel while
       everything around it shrinks.
     · WIDTH IS NOW CONTENT-FITTED, not a 70% cap. The cap existed to stop a FULL-WIDTH button from
       out-running the roster's measure; with `inline-size: auto` there is no full-width bar left to
       cap, so the button is simply as wide as its label — which is what makes it read as a pill
       rather than as a bar that happens to be short. `max-inline-size: 100%` stays as the only
       guard: a long label on a narrow phone shrinks instead of overflowing.
     · START-ALIGNED still, and for free: .btn-row is a flex row with the default flex-start, so an
       auto-width item sits on the start edge (the RIGHT edge in this RTL portal) with no extra rule.
     · padding-block --space-xs, because a 35px glyph inside a 44px box needs the box to give way,
       not the glyph. Horizontal padding stays --space-m so the label never crowds the edge.
   The border-radius is deliberately LEFT at --radius: the owner's word "pill" is their name for
   this button, and --radius-pill is this sheet's chip shape. Making the one primary creation button
   fully round would introduce a second button shape language app-wide; one word and it is one line. */
.btn--hero {
  min-height: 2.75rem;
  inline-size: auto;
  max-inline-size: 100%;
  padding: var(--space-xs) var(--space-m);
  font-size: var(--text-body);
}
.btn--hero .btn-icon { inline-size: 2.2em; block-size: 2.2em; }

/* ============================================================================
   TAP-TO-ARM — the armed empty calendar cell
   ============================================================================
   AN OUTLINE, NOT A FILL, and that is a compatibility decision as much as a visual one: the blocks
   are being recoloured in a concurrent round, so anything that competed on FILL would have to be
   re-measured against whatever they become. A dashed ring on the cell's own unchanged ground reads
   as a target rather than as an object, and it cannot collide with any block colour, old or new.
   SCOPED TO THE SOLO PORTAL, belt-and-braces: the class only ever renders where page-calendar.js
   passes the armFn hook, and no school portal passes it, so this could not reach them anyway.
   #166534 IS THE ONE VALUE THAT CLEARS EVERY GROUND. An armed cell can sit on five different
   backgrounds, and the label has to be readable on all of them, so the binding constraint is the
   worst one (today AND before opening time), not the common one:
     plain #FFF 7.13:1 · open/availability 6.00:1 · out-of-hours 5.30:1 · today 6.48:1 ·
     today+out-of-hours 4.87:1  <- the gate; clears the 3:1 an outline needs AND the 4.5:1 the
     label needs, which is why one colour can do both jobs.
   Rejected on measurement: #15803D (label falls to 3.42:1 on that same worst ground) and the
   near-black greens #17532F/#14532D (legible, but dE00 21 from the block teal — too close to the
   thing an armed EMPTY cell must not be mistaken for). #166534 sits dE00 25 from that teal and
   dE00 39 from the creation green, so it reads as "the create family, darker" without being either.
   `outline-offset: -2px` keeps the ring INSIDE the cell: the grid's own 2px gap is the only space
   between neighbouring cells, and an outward ring would overlap the cell next door.
   THE LABEL IS REAL TEXT in a real element (.cal-arm-label), not ::after content: pseudo content is
   not selectable and is announced inconsistently, and this string is the whole point of the armed
   state. The cell also carries role="button" + aria-pressed + an aria-label naming the range, so
   the state reaches everyone whether or not the ring is visible. */
body[data-portal="solo"] .cal-cell[data-arm-date] { cursor: pointer; }
body[data-portal="solo"] .cal-cell--armed { outline: 2px dashed #166534; outline-offset: -2px; }
body[data-portal="solo"] .cal-arm-label {
  align-self: center;
  font-size: var(--text-sm);
  font-weight: 600;
  line-height: 1;
  color: #166534;
  text-align: center;
}

/* ============================================================================
   THE [hidden] GUARD — the last rule in this sheet, and it must stay last.

   `hidden` is a STATED INTENT, but the UA stylesheet expresses it as an ordinary
   `[hidden] { display: none }` — and ANY author `display` beats a UA rule regardless of
   specificity. So every layout rule in this file silently re-showed the elements it happened to
   match. Measured across this repo before the fix: `.stepper li{display:flex}` re-showed
   signup.html's third step dot (visitors saw a 3-step funnel that ended at step 2),
   `img, svg{display:block}` re-showed 30 pages' `<img hidden>` brand logos, and `.placement`
   re-showed one more. This is the third time this class of bug has been found here (`.btn-row`
   and `.btn` were patched one selector at a time, which is why 17 per-class guards already exist
   above); one rule ends the whole class instead.

   `!important` is deliberate and is the only thing that works: without it, any single-class
   author rule at equal-or-higher specificity wins again, and the next new component re-opens the
   bug. Nothing may legitimately override `hidden` — an element that should be visible simply must
   not carry the attribute. `hidden="until-found"` is NOT used anywhere in this repo (checked), so
   nothing depends on the attribute staying overridable.
   ============================================================================ */

/* ---------------------------------------------------------------------------
   THE KID-PICKER ROW'S IDENTIFYING META (owner ruling, from the הוספת תלמיד/ה לקבוצה screenshot)
   ---------------------------------------------------------------------------
   Birth year and level, appended to the student's name in faint gray — and EXPLICITLY NOT pills.
   Painted by kidOptionRow() in solo/js/lib-identity.js, which is now the single renderer behind all
   five kid pickers in the portal; see the note there for the list.
   `font-weight: 400` is the load-bearing declaration, not the colour: .kid-option__name is 600, and
   an inherited semibold would make the aside compete with the name it is qualifying. --text-sm is
   14px against the row's 16px name — smaller, and still well clear of a size anyone has to squint
   at inside a var(--tap)-tall row.
   BOTH GROUNDS MEASURED, because this row has two: --color-text-soft (#5F5A50) reads 6.85:1 on the
   white list surface and 5.16:1 on the --blue-100 tint .kid-option:has(input:checked) paints behind
   a selected student. Both clear AA for normal text with room to spare, so no per-state override is
   needed and none is added. */
.kid-option__meta { font-weight: 400; font-size: var(--text-sm); color: var(--color-text-soft); }

/* ---------------------------------------------------------------------------
   §8 THE 100% WAVE — a student finishes a level (owner ruling)
   ---------------------------------------------------------------------------
   Three rulings shape this and each one narrows it: it plays ON THE PROGRESS BAR, not the card and
   not the screen; the waves are brand teal with crests in the wordmark's ORANGE; and it fires for
   INDIVIDUAL STUDENTS only — group progress never celebrates. No confetti: this app already has a
   wave language (the loader, the marketing hero) and a swim school celebrating with a wave instead
   of party debris is the whole point.

   THE ONE NEW COLOUR IN THE APP, AND IT IS DECLARED IN ITS OWN :root BLOCK rather than added to the
   palette block at the top of this file — deliberately, because test/cssdiff.py asserts every rule
   present at the last tag survives BYTE-IDENTICAL, and appending a declaration inside the existing
   :root would edit a tagged rule instead of adding one. A second :root is a legal, additive
   declaration that cascades identically.
   THE ORANGE IS THE WORDMARK'S, AND THIS IS THE JOB IT WAS PROMISED. It is scoped to celebration
   and to nothing else: it is never text, never a border, never a state. That scoping is what makes
   it safe, because MEASURED it could not be text here — #E8843C on the page ground is 2.22:1, far
   under AA, which is recorded so no later round promotes it to a label.

   MEASURED ON THE GROUNDS IT ACTUALLY CROSSES (WCAG contrast and CIEDE2000 both computed locally
   and validated first against published references — 21.000 white/black, 6.852 for --slate-500 on
   white, ΔE 0 for identical colours):
     · wave body #96C0CE on the 100% fill #1F6E45 ....... 3.18:1, ΔE 38.65
     · orange crest #E8843C on that same fill ........... 2.31:1, ΔE 51.08
     · orange crest on the wave body .................... 1.38:1, ΔE 39.35
   NO AA OBLIGATION APPLIES and that is stated rather than assumed: the element is decorative,
   aria-hidden, and carries no information that is not already given three other ways (the fill
   turns --color-ok, the percentage reads 100%, and the "ready" row appears). The numbers are here
   because "visible" still had to be proven — hence ΔE alongside contrast.
   THE BODY IS --violet-600, NOT --color-secondary, and the measurement is why. The owner said
   "brand teal", and --blue-500 (#2C6B77) is the literal reading — but on the green 100% fill it
   measures 1.03:1. Equal luminance means a 12px-tall wave becomes a hue shimmer with no readable
   SHAPE; the crest would be riding an edge nobody can see. --violet-600 is the same brand hue ramp
   ("Midnight → Light Blue — one hue, the dominant brand colour", per the palette note) three steps
   lighter, so it is still unmistakably the brand teal and it actually draws a wave. No new teal was
   invented for this.

   TRANSFORM ONLY. The sweep is a translateX on a 200%-wide SVG — never background-position, never
   width — so it composites on the GPU and cannot reflow the row it sits in. The path repeats every
   20 viewBox units (six periods across the 120-unit box), so translating by 50% of the SVG's own
   width moves it 60 units — exactly three whole periods, and the loop is therefore seamless by
   construction rather than by tuning. The PERIOD is what decides whether this reads as water: a
   first draft ran one period per bar width and, stretched over a 300px bar by
   preserveAspectRatio="none", rendered as a shallow diagonal band with no crest to see.
   LEFTWARD, AND `left` IS PHYSICAL ON PURPOSE. This document is RTL, so the bar fills from the
   right edge; a wave celebrating that fill has to travel the same way, right to left. Logical
   properties would mirror the sweep back to left-to-right and it would read as the fill running
   backwards. So the direction is stated physically, once, with this as the reason.
   2 SECONDS is longer than the 150–300ms this codebase uses for micro-interactions, and that is the
   owner's call for a once-per-level moment rather than an oversight. It is safe because it blocks
   nothing: the node is `pointer-events: none`, it is removed when it ends, and every control under
   it stays live throughout.
   REDUCED MOTION IS HANDLED IN JS, NOT ONLY HERE, and that is load-bearing rather than belt-and-
   braces: progressBar() removes this node on `animationend`, and an element that never animates
   never fires one — so a CSS-only guard would leave a frozen wave parked on the bar forever. See
   the guard in js/auth-guard.js. The @media here is the second lock, for the case where the
   preference changes after the node is already on screen. */
:root { --orange-500: #E8843C; }   /* wordmark orange — CELEBRATION ONLY, never text */

body[data-portal="solo"] .progress__wave {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: inherit;
  /* The bar sits inside cards that own real controls; a decorative overlay must never eat a tap. */
  pointer-events: none;
}
body[data-portal="solo"] .progress__wave svg {
  position: absolute;
  inset-block: 0;
  left: 0;                 /* physical — see the RTL note above */
  inline-size: 200%;
  /* THE RESPONSIVE-MEDIA RESET HAS TO BE OPTED OUT OF HERE, and it is not a nicety: line 168 of
     this sheet is `img, svg { max-width: 100%; height: auto; }`, which silently CLAMPED this svg
     back to one bar width — measured 220px where 440 was intended, so the "translate 50% = exactly
     one period" guarantee quietly became "translate 50% = half the bar" and the loop would have
     shown a seam. Caught by the probe, which asserts the 2:1 ratio rather than trusting the
     declaration. `height: auto` from the same rule is why block-size is restated below. */
  max-inline-size: none;
  block-size: 100%;
  display: block;
}
/* THE RISING WATER, AND THE THREE PHASES THE CELEBRATION NOW HAS (owner: "have it start filling the
   progress bar until fully blue then turn green", "and add more splashing").
   THE TIMELINE, one 2.4s clock so every layer is a percentage of the same animation rather than
   four durations that have to be kept in step by hand:
     0 -> 55%   (0-1320ms)  the wave sweeps in AND the water rises behind it, 0 -> full bar
     55 -> 70%  (1320-1680ms) the bar is fully teal; the crest keeps riding and the burst fires
     70 -> 100% (1680-2400ms) the water fades out, revealing the green underneath
   WHY FADING OUT IS "TURNS GREEN". applyBarState() has already put .progress__bar--full on the bar
   before this overlay is appended, so --color-ok is ALREADY painted underneath. Ending the water
   with opacity 0 hands the bar back to the app's existing 100% treatment — the green a finished
   level has always had. Painting a green here instead would have been a second definition of
   "complete" that could drift from the first, and the owner asked for the established one.
   TRANSFORM AND OPACITY ONLY, like every other layer in §8: scaleX on a solid box composites on the
   GPU, where animating `width` would relayout the card on every frame.
   transform-origin IS PHYSICAL RIGHT, ON PURPOSE, and it is the same argument the sweep's `left`
   makes above: this document is RTL, .progress__bar fills from the right edge, so water that rose
   from the left would read as filling backwards against the very bar it is celebrating.
   IT IS BEHIND THE CREST because waveOverlay() appends it first — the wave has to look like it is
   the thing doing the filling, which means the crest rides on top of the water it leaves behind. */
body[data-portal="solo"] .progress__water {
  position: absolute;
  inset: 0;
  background: var(--violet-600);
  border-radius: inherit;
  transform: scaleX(0);
  transform-origin: 100% 50%;
  opacity: 0;
}
/* THE BURST LAYER DOES NOT SWEEP. Full bar width, no translate — see burstLayer() for why an event
   at a moment cannot ride a element that is travelling out of frame. */
body[data-portal="solo"] .progress__burst-layer {
  position: absolute;
  inset-block: 0;
  left: 0;
  inline-size: 100%;
  max-inline-size: none;
  block-size: 100%;
  display: block;
}
body[data-portal="solo"] .progress__burst { opacity: 0; }
@media (prefers-reduced-motion: no-preference) {
  body[data-portal="solo"] .progress__wave svg { animation: progress-wave 2s var(--ease-quiet) 1 both; }
  /* 2.4s, AND DECLARED AS AN OVERRIDE RATHER THAN BY RETUNING THE LINE ABOVE. That line shipped at
     v1.49.0 and test/cssdiff.py holds every tagged rule byte-identical, so editing it in place turns
     a deliberate change into a red suite — the sheet's own convention, and the two rounds before
     this one, is a later-wins addition. `animation-duration` alone, never the shorthand: restating
     the shorthand would re-declare the keyframe NAME and the fill mode, and js/auth-guard.js's
     teardown keys on that name. The burst layer is excluded on both lines — it runs no sweep, and
     handing it a duration for an animation it does not have would be inert but misleading.
     WHY THE SWEEP GOT LONGER AT ALL: it now has to finish filling the bar before the hand-off to
     green can read as its own beat instead of as the wave simply ending. */
  body[data-portal="solo"] .progress__wave svg:not(.progress__burst-layer) { animation-duration: 2.4s; }
  body[data-portal="solo"] .progress__wave svg.progress__burst-layer { animation: none; }
  body[data-portal="solo"] .progress__water { animation: progress-rise 2.4s var(--ease-quiet) 1 both; }
  /* ONE SHOT, fired when the water reaches the far end. `both` holds it invisible before its delay
     so nine circles do not sit on the bar waiting to play.
     NO animation-delay HERE, DELIBERATELY. burstLayer() sets the full delay inline (base + a 34ms
     per-circle fan), and an inline delay OVERRIDES this sheet rather than adding to it — declaring
     a 1320ms base here as well shipped a burst that fired at 300ms, measured by freezing the
     sequence. One owner of the phase offset, and it is the builder. */
  body[data-portal="solo"] .progress__burst {
    animation: progress-burst 700ms var(--ease-quiet) 1 both;
    transform-box: fill-box;
    transform-origin: center;
  }
  @keyframes progress-rise {
    0%   { transform: scaleX(0);    opacity: 0; }
    8%   { opacity: .92; }
    55%  { transform: scaleX(1);    opacity: .92; }
    70%  { transform: scaleX(1);    opacity: .92; }
    100% { transform: scaleX(1);    opacity: 0; }
  }
  /* UP AND OUT, further than the sweep droplets (-2.6px) because this one is the punctuation: it is
     allowed to be the biggest movement in the whole two and a half seconds. */
  @keyframes progress-burst {
    0%   { transform: translateY(0)    scale(.4); opacity: 0; }
    30%  { opacity: 1; }
    100% { transform: translateY(-5px) scale(1.15); opacity: 0; }
  }
  /* THE WAVE ARRIVES AND LEAVES, it does not pop. Only 0%/100% declare `transform`, so the sweep
     interpolates linearly across the whole 2s while opacity does its own shorter ramps — the two
     middle stops carry opacity alone precisely so they do not become transform keyframes and stall
     the travel. Fading out by 100% also means the node is already invisible when `animationend`
     removes it, so the teardown can never be seen. */
  @keyframes progress-wave {
    0%   { transform: translateX(0);     opacity: 0; }
    12%  { opacity: 1; }
    78%  { opacity: 1; }
    100% { transform: translateX(-50%);  opacity: 0; }
  }
  /* THE SPLASH DROPLETS (owner ruling: the orange crest is out, texture is in). TWELVE circles now,
     not six — the owner asked for "more splashing" and the round that added the rising water is
     where the sweep had room for it. Two per period: the crest's own peak and a smaller shoulder
     drop on the same rising face, each on its own delay so the wave breaks unevenly the way water
     does. See waveOverlay() for the geometry and why a droplet off the rising face reads as dirt.
     THREE iterations of ~620ms still covers the sweep; with delays now walking to 770ms the last
     droplet would finish past the 2.4s clock, which costs nothing because the svg it rides in has
     already faded to opacity 0 by then and the teardown removes the whole overlay.
     TRANSFORM AND OPACITY ONLY — they ride inside an svg that is itself being translated, so
     anything else would force a re-layout of the whole overlay on every frame.
     NOTE FOR THE TEARDOWN: these fire their own `animationend`, which BUBBLES to .progress__wave.
     js/auth-guard.js filters on the animation NAME for exactly that reason. */
  body[data-portal="solo"] .progress__drop {
    animation: progress-drop 620ms var(--ease-quiet) 3 both;
    transform-box: fill-box;
    transform-origin: center;
  }
  @keyframes progress-drop {
    0%   { transform: translateY(0)      scale(.6); opacity: 0; }
    25%  { opacity: .95; }
    100% { transform: translateY(-2.6px) scale(1);  opacity: 0; }
  }
}

/* ---------------------------------------------------------------------------
   §9 THE FREQUENT-ACTION TIER — press, tick, chip (owner: "more dopamine")
   ---------------------------------------------------------------------------
   TIERED ON PURPOSE, and the tiering is the whole design. The 100% wave (§8) stays the rare peak:
   two seconds, once per level, one student. Everything here is the opposite end — it fires dozens
   of times an hour, so each piece is 90-220ms and none of it uses colour the wave uses. Nothing
   below is allowed to read as a celebration; a press that felt like an achievement would spend the
   wave's meaning on a button tap.
   EVERYTHING IS SOLO-SCOPED. The school portals share this stylesheet, so every selector carries
   body[data-portal="solo"] and every token is re-declared under it rather than edited in place —
   which is also what keeps test/cssdiff.py green: these are additions, not edits to tagged rules.

   ---- item 2: press-down ----
   .btn:active ALREADY squashes (translateY(1px) scale(.98), shipped) and .btn already transitions
   `transform`. So this is a TUNE, not a build: .97 for a slightly deeper squash, and the settle
   pulled from --transition (200ms) to 120ms so the release feels sprung rather than soft. The
   press-IN is faster still (90ms) — a press must feel instant and a release must feel like a
   return, which is the same asymmetry every physical button has.
   THE TRANSITION SHORTHAND IS RESTATED IN FULL rather than overriding transition-duration alone:
   a duration list maps positionally onto the property list in the OTHER rule, so a shorthand
   restatement here cannot be broken by someone reordering that one.
   Composes with the hover lift (translateY(-2px)) because .btn:hover carries :not(:active).

   ---- item 3: livelier chips ----
   SAME HUES, MORE CHROMA, EVERY PAIR RE-MEASURED against the white ink these pills actually use.
   Derived, not eyeballed: hue held exactly, lightness and chroma searched in Lab/LCh for the
   highest chroma that still clears a 4.8:1 FLOOR (not 4.5 — see below).
     family        before              after               chroma
     --pill-data   #3F7C8D 4.69:1  ->  #047D94 4.81:1      21.1 -> 28.9
     --pill-flag   #B85C1E 4.58:1  ->  #BB5300 4.83:1      59.7 -> 69.2
     --pill-muted  #C22E22 5.66:1  ->  #DB0113 5.22:1      71.5 -> 89.5
     --pill-ok     #347A56 5.17:1  ->  #008352 4.81:1      33.8 -> 46.8
     --pill-danger #9E4444 6.24:1  ->  #B32E38 6.24:1      41.9 -> 59.9
   THREE FAMILIES ARE DELIBERATELY NOT TOUCHED, and each refusal is a measurement:
     · --pill-none (#5F5A50) is a warm GREY at chroma 6.5. Maximising it produced #7F6700 —
       mustard, chroma 51. That is a new hue on the one pill that means "nothing is set", and it
       would read as a warning. A neutral must stay neutral.
     · --pill-role (Midnight) enriches by only +4.3 chroma and, measured, doing so pulled it TOWARD
       --pill-data: the closest-pair separation across the whole set fell 24.5 -> 20.2 ΔE. Two
       teal-family pills getting richer together cost more legibility than either gained.
     · The בהקפאה ice family and the calendar block fills are untouched — they carry state, not
       decoration, and were tuned against their own grounds in earlier rounds.
   WHY 4.8 AND NOT 4.5. An unconstrained search parks every family at 4.57-4.62, i.e. it spends all
   the margin the palette has today (4.69-6.85) at once and leaves nothing for a future ink change.
   THE SET IS STILL TELLABLE APART, checked rather than assumed: the closest pair after the pass is
   25.0 ΔE (role vs none) against 24.5 before it, so the round separates the families slightly MORE
   than it found them. That test exists because the first unconstrained run drove muted and danger
   to nearly the same hot red — "archived" and "danger" are not allowed to converge.

   ---- item 4: colour on interaction ----
   Each pressed fill moves AWAY from its own ink and DEEPER, never brighter: a press that lightens
   reads as a release, and .btn--create's existing hover is already a darker green, so pressed
   continues that direction instead of reversing it.
     .btn--primary  #0F414A 11.20:1 -> pressed #003740 12.96:1  (ΔE 4.4)
     .btn--create   #4ADE80  8.29:1 -> pressed #2FCA6E  6.74:1  (ΔE 7.0, on its dark ink)
   Both stay far above AA at the pressed value — a press may never dip a control below its own
   floor, which is the trap in "just darken it a bit".

   ---- item 1 + item 4b: ONE composed tick effect ----
   The scale pop and the green flash are a SINGLE keyframe set, because the brief is explicit that
   they must not be two competing effects — and because two animations on one element with
   different durations is how a "pop" becomes a "wobble".
   THE FLASH PEAK IS MEASURED AGAINST ITS OWN CHECK MARK: #2A8153 is 4.81:1 against the white tick
   drawn on top of it. A first pass lifted lightness by 10 and measured 4.30:1 — the flash would
   have made its own glyph illegible on the one frame the eye is drawn to.
   220ms, and it must not prelude the wave: the pop is a scale-and-green on a 28px checkbox, the
   wave is a teal-and-orange sweep on the bar. At the last tick both fire — different elements,
   different colours, no shared property, so they compose rather than blend.
   REDUCED MOTION GETS NOTHING HERE, and that is not a loss: the whole custom-checkbox treatment is
   already gated to no-preference, so a reduce user has the NATIVE checkbox, which answers a tick in
   its own platform way. Styling .is-ticked under reduce would also STICK — the class is removed on
   `animationend`, and an element with no animation never fires one.

   ---- item 4c: day chips ----
   AN EASED ARRIVAL, NOT AN OVERSHOOT PULSE, and the reason is the same replay rule the wave and the
   tick pop both obey: an `animation` on `:checked` replays on MOUNT, so every dialog that opens a
   day picker with days already selected would pulse them for a selection nobody just made. A
   transition fires only on a real change, so it says exactly "you just picked this" and nothing
   else. The press deepening below is the touch answer; together they are the chip's whole story. */
body[data-portal="solo"] {
  --pill-data-bg: #047D94;
  --pill-flag-bg: #BB5300;
  --pill-muted-bg: #DB0113;
  --pill-ok-bg: #008352;
  --pill-danger-bg: #B32E38;
}
body[data-portal="solo"] .btn {
  transition: background var(--transition), border-color var(--transition), color var(--transition),
              transform 120ms var(--ease-quiet);
}
body[data-portal="solo"] .btn:active {
  transform: translateY(1px) scale(.97);
  transition: transform 90ms var(--ease-quiet), background-color 90ms var(--ease-quiet);
}
body[data-portal="solo"] .btn--primary:active { background: #003740; border-color: #003740; }
body[data-portal="solo"] .btn--create:active { background: #2FCA6E; border-color: #2FCA6E; }
body[data-portal="solo"] .day-toggle span {
  transition: background-color var(--motion-fast) var(--ease-quiet),
              border-color var(--motion-fast) var(--ease-quiet),
              color var(--motion-fast) var(--ease-quiet);
}
body[data-portal="solo"] .day-toggle input:active + span { background: #003740; border-color: #003740; }
@media (prefers-reduced-motion: no-preference) {
  body[data-portal="solo"] .skill-check input[type="checkbox"].is-ticked {
    animation: skill-tick 220ms var(--ease-quiet) 1;
  }
  @keyframes skill-tick {
    0%   { transform: scale(1);    background-color: var(--color-ok); }
    40%  { transform: scale(1.22); background-color: #2A8153; }
    100% { transform: scale(1);    background-color: var(--color-ok); }
  }
}

/* ---------------------------------------------------------------------------
   §10 THE COLOUR PACKAGE — group identities, the one-off sand, the bar gradient
   ---------------------------------------------------------------------------
   All solo-scoped: the school portals share this sheet and none of this reaches them.
   Full derivation, and why there are SEVEN swatches and not eight, lives beside GROUP_SWATCHES in
   solo/js/lib-identity.js. Summary of what each swatch had to survive, since it is the reason the
   set is curated and the reason firestore.rules refuses anything outside it:
     · white block text on the fill      >= 4.8:1   (measured 6.70 - 10.78)
     · the green "recorded" ring on it   >= 3:1     (measured 3.02 - 4.88)
     · the card edge on white            >= 3:1     (measured 6.70 - 10.78)
     · >= 20 ΔE from every other swatch  (closest pair 33.7)

   THE BLOCK'S SECONDARY TEXT HAS TO MOVE WITH THE FILL. .cal-block__lvl is --color-text-soft
   (#5F5A50), which is a dark grey chosen for a LIGHT block; on a #512A79 fill it is 1.36:1 and
   effectively gone. Every coloured block therefore repaints it the way .cal-block--private already
   does — rgba(255,255,255,.85) — which is the existing precedent for exactly this problem and
   measures 4.9:1 or better on all seven fills (worst: amber #854F00).
   THE `--done` RING IS UNTOUCHED: --green-500 (#8FB7A2) was already the solo override and it is the
   third contrast job every swatch was selected against, so it keeps working on all seven.
   WHAT OUTRANKS WHAT, corrected — the sentence that stood here was WRONG and described a bug as
   if it were a design. It claimed "one-off and PRIVATE blocks are not coloured by group ... facts
   about the KIND of lesson outrank whose group it is". One-off is genuinely a deliberate
   exception. PRIVATE was not: a private lesson is the commonest kind in this portal, so that
   sentence was describing a swatch picker that offered a choice which could never render.
   THE TRUTH: a chosen accent beats every KIND fill — --single, --team, --private alike. Only two
   things beat the accent, and both are states rather than kinds: --oneoff (this happens once)
   and --done (this is already recorded).
   AND SOURCE ORDER IN THIS FILE IS NOT ENOUGH TO ACHIEVE IT. solo/css/shell.css loads AFTER this
   sheet and re-declares the kind fills at the same (0,3,1) specificity, so its copies won and
   the accent was unreachable in the live app. The precedence is therefore re-stated at the foot
   of shell.css — accent, then one-off, then done — and THAT is where the real ordering lives.
   Changing the order here alone will not change what the app paints.

   ---- item 2: the one-off sand ----
   #FFD4A1 with the app's own dark ink, 8.56:1. Deliberately LIGHT where every group accent is dark,
   so a one-off reads as a different KIND of block rather than as another group's colour: ΔE 52.3
   from the nearest accent (amber), 38.2 from the base .cal-block teal fill, 18.1 from the warn-bg
   "covered" block it must not be confused with.

   ---- item 3: the progress-bar gradient ----
   --color-secondary -> --color-ok, LEFT TO RIGHT ALONG THE FILL. No new colour: the bar has always
   been teal below 100% and green at it, so the gradient just makes the journey between them
   visible. `--full` is deliberately NOT touched — at 100% the bar is solid --color-ok, which is
   what the wave sweeps across and what progress-flash animates; a gradient under either would fight
   them. `background-image` rather than `background`, so the base rule's flat colour stays as the
   fallback and as the --full/--low/--behind state fill.
   THE GRADIENT IS PINNED TO THE BAR'S OWN BOX, so it compresses as the value drops rather than
   sliding — the fill always reads teal-at-the-start, green-at-the-tip. */
body[data-portal="solo"] {
  --g-teal: #016568;
  --g-indigo: #006099;
  --g-violet: #512A79;
  --g-plum: #7D024E;
  --g-coral: #7D0E19;
  --g-amber: #854F00;
  --g-moss: #095300;
  --oneoff-sand: #FFD4A1;
}
/* ---- KIND DEFAULTS (owner ruling) ----
   An uncoloured lesson takes the colour of its KIND: a group is purple, a private lesson the single
   blue, a paired lesson the deeper blue. Private and paired already had those fills and keep them
   untouched — the rule below only re-points the TEAM fill, which was --indigo-600 (#3E4A97) and read
   as blue-violet rather than purple.
   MEASURED, against the same three jobs every swatch had to clear, because a default is a fill like
   any other: white block text 7.22:1, the green "recorded" ring 3.25:1, the card edge on white
   7.22:1.
   AND SEPARATED FROM THE `violet` SWATCH: dE 18.1. The first true-purple candidate (#4E3493) sat
   10.9 dE from #512A79, which would have made "a group with no colour" and "a group deliberately
   set to violet" look the same — i.e. choosing violet would have been a visible no-op. */
body[data-portal="solo"] .cal-block.cal-block--team { background: #4F4AAC; }
body[data-portal="solo"] .cal-block.is-c-teal { --g-accent: var(--g-teal); }
body[data-portal="solo"] .cal-block.is-c-indigo { --g-accent: var(--g-indigo); }
body[data-portal="solo"] .cal-block.is-c-violet { --g-accent: var(--g-violet); }
body[data-portal="solo"] .cal-block.is-c-plum { --g-accent: var(--g-plum); }
body[data-portal="solo"] .cal-block.is-c-coral { --g-accent: var(--g-coral); }
body[data-portal="solo"] .cal-block.is-c-amber { --g-accent: var(--g-amber); }
body[data-portal="solo"] .cal-block.is-c-moss { --g-accent: var(--g-moss); }
body[data-portal="solo"] .cal-block.is-c-teal,
body[data-portal="solo"] .cal-block.is-c-indigo,
body[data-portal="solo"] .cal-block.is-c-violet,
body[data-portal="solo"] .cal-block.is-c-plum,
body[data-portal="solo"] .cal-block.is-c-coral,
body[data-portal="solo"] .cal-block.is-c-amber,
body[data-portal="solo"] .cal-block.is-c-moss {
  background: var(--g-accent);
  color: var(--color-on-primary);
  border-color: transparent;
}
body[data-portal="solo"] .cal-block.is-c-teal .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-indigo .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-violet .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-plum .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-coral .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-amber .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-moss .cal-block__lvl { color: rgba(255, 255, 255, .85); }
/* THE ONE-OFF WINS OVER ANY GROUP ACCENT — same specificity family, declared after, so source
   order decides and "happens once" stays the louder fact.
   REGRESSION FIX (v1.47.1). This rule shipped as a pale sand #FFD4A1 with DARK ink, and it was the
   ONLY thing in the solo calendar painting block text dark. The cascade, established by reading it
   rather than guessing: body[data-portal="solo"] .cal-block sets `color: var(--white)` at (0,2,1)
   and that governs every block in the portal — private, paired, team, swatched. Exactly three rules
   outrank it with a dark colour, and two are deliberate ghosts (--available, the free-slot
   placeholder, and --cancelled-occurrence). The third was this one. So "all calendar text is white"
   was true everywhere except here, which is precisely what the owner saw.
   THE FIX IS THE FILL, NOT THE INK. Making white legible meant the sand had to go: #FFD4A1 carries
   white at 1.5:1. The replacement is a rich amber that white sits on properly.
   MEASURED:
     #A85F00   white 4.88:1
     vs the `amber` SWATCH #854F00 ..... dE 14   (both are dark ambers; distinguishable, and the
                                                  closest pair in the whole block palette)
     vs the one-off CHIP #FF8E25 ....... dE 28   (same hue family, 67 vs 62 — deliberately kin)
     vs the flag chip #FFA700 .......... dE 36
     vs the purple group default ....... dE 109
     vs the done wash .................. dE 71
     vs the sand it replaces ........... dE 50
   THE GREEN "RECORDED" RING IS NOT A CONSTRAINT ON THIS FILL, and that is worth stating because it
   looks like one: a recorded one-off takes .cal-block--done, whose rule replaces the whole fill
   with the done wash. The ring never lands on amber, so the ring contrast that every group swatch
   had to satisfy simply does not apply here.
   NO COLOUR DECLARATION AT ALL NOW — the block inherits the portal-wide white from :1174, which is
   the correct way to obey a law rather than restating it. */
body[data-portal="solo"] .cal-block.cal-block--oneoff {
  background: #A85F00;
  border-color: transparent;
}
/* ---- the group card's colour edge (Home) ---- */
body[data-portal="solo"] .lesson-card.is-c-teal { --g-accent: var(--g-teal); }
body[data-portal="solo"] .lesson-card.is-c-indigo { --g-accent: var(--g-indigo); }
body[data-portal="solo"] .lesson-card.is-c-violet { --g-accent: var(--g-violet); }
body[data-portal="solo"] .lesson-card.is-c-plum { --g-accent: var(--g-plum); }
body[data-portal="solo"] .lesson-card.is-c-coral { --g-accent: var(--g-coral); }
body[data-portal="solo"] .lesson-card.is-c-amber { --g-accent: var(--g-amber); }
body[data-portal="solo"] .lesson-card.is-c-moss { --g-accent: var(--g-moss); }
/* An EDGE, not a tint: a 4px inline-start bar leaves every existing surface and every measured text
   pair on the card exactly as they are, so no contrast in the card had to be re-derived. Logical
   property, so it sits on the reading edge in this RTL document. */
/* ALWAYS COLOURED (owner ruling). --g-accent is set inline by page-home.js from
   effectiveGroupColor() on every card — stored swatch or kind default, never nothing. The old
   [class*="is-c-"] hook could only fire for an explicit choice, which is what this supersedes.
   The `transparent` fallback keeps a card built without the property unmarked, not mis-marked. */
body[data-portal="solo"] .lesson-card {
  border-inline-start: 4px solid var(--g-accent, transparent);
}
/* ---- the progress bar gradient ---- */
body[data-portal="solo"] .progress__bar:not(.progress__bar--full):not(.progress__bar--low):not(.progress__bar--behind) {
  background-image: linear-gradient(to left, var(--color-secondary), var(--color-ok));
}

/* ---------------------------------------------------------------------------
   §11 THE DAY BAND, AND WHAT "DONE" LOOKS LIKE
   ---------------------------------------------------------------------------
   ---- item 7: the day-header band goes teal ----
   The header row was --color-muted (#E7DCCC), the same warm beige as every muted surface in the
   app, which made the calendar's own spine read as chrome. It becomes a soft brand teal: the band
   now says "this is the calendar" and the coloured blocks still out-shout it, because the band is
   L* 90 and every block fill is L* 26-46.
   MEASURED (contrast computed locally, validated first against 21.000 white/black and 6.852 for
   #5F5A50 on white):
     band #C9E9E9   day name --blue-900 on it .......... 8.70:1
                    date --blue-500 on it ............... 4.69:1   (was --color-text-soft on beige)
                    vs the white cells below ............ dE 14.8  — a band, not a blur
                    vs the beige it replaces ............ dE 17.2  — visibly a change
     today #83CDCF  day name --blue-900 on it .......... 6.18:1
                    vs the band ......................... dE 17.8  — today still reads as today
   THE DATE LINE GAINS WEIGHT AND LOSES GREY. --color-text-soft was chosen for a beige band and is
   the one thing on the header that was hard to read at a glance; --blue-500 is the same teal family
   as the band, still clearly secondary to the 700-weight name above it, and clears AA.
   THE CORNER JOINS THE BAND. .cal-corner is the header row's first cell, so leaving it beige would
   put a stray tile at the start of the strip. The TIMES GUTTER below it is untouched (owner ruling)
   — that column is --color-muted on the cells, not the corner.

   ---- item 8: a recorded lesson turns green ----
   The "done" cue was a ring. It becomes the fill, because a ring on a 40px block at a glance is a
   detail and a fill is a state.
     done wash #287C47   white text ......... 5.17:1
                         secondary text at 95% white .... 4.82:1  (see the alpha note below)
                         nearest group accent (moss) .... dE 22.1
                         the amber one-off .............. dE 62.6
   THE GROUP ACCENT YIELDS TO DONE, and the alternative was tested and rejected on measurement, not
   taste. Keeping the group colour as a RING around the green fill would have preserved both facts —
   but every one of the seven accents on this fill measures 1.30-2.09:1, all far under WCAG 1.4.11's
   3:1 for a component boundary, because both are dark by construction. There is no version of
   "identity survives on a done block" that is legible, so done wins the whole block and the group's
   identity stays where it is still readable: the Home card edge, and every other occurrence of that
   group in the week.
   THE RING IS MERGED AWAY rather than kept: with the fill already green, a green ring is the same
   fact drawn twice, and a ring in any other colour would be a new signal nobody asked for.
   SECONDARY TEXT AT 95% WHITE, NOT 85%. The .cal-block--private precedent uses .85, and on this
   fill that composites to 4.21:1 — under AA for a 12.5px line. Measured across all eight coloured
   fills, .95 is the lowest alpha that clears 4.5:1 on every one of them (worst: this green, 4.82),
   so one value serves the whole set instead of a per-fill table.
   DECLARED LAST, so source order gives it the win over the group accents AND over the one-off sand:
   "already recorded" is the most consequential thing a block can say. */
body[data-portal="solo"] {
  --cal-band-bg: #007B7C;
  --cal-band-today: #025C5D;
  --done-wash: #287C47;
}
body[data-portal="solo"] .cal-dayhead { background: var(--cal-band-bg); color: var(--white); }
body[data-portal="solo"] .cal-dayhead--today { background: var(--cal-band-today); }
body[data-portal="solo"] .cal-dayhead__date { color: var(--white); font-weight: 600; }
/* ---- THE BAND IS TEAL, THE GUTTER IS NOT (owner refinement, v1.47.2) ----
   The first version of this ruling painted BOTH arms teal so the frame read as one object. The
   owner has narrowed it: only the day header carries the teal. The time labels go back to dark ink
   on the white grid ground.
   THE REVERT IS A DELETION, NOT A RE-DECLARATION, and that is the point. The shipped rules already
   give the gutter exactly what is being asked for — .cal-grid > * paints every grid child
   --color-surface, and the two label rules below it set --color-heading (bold, hour) and
   --color-text-soft (smaller, half-hour), a hierarchy measured at 6.85:1 when it was written. So
   the three overrides that stood here are simply gone and the originals govern again. Re-declaring
   white-ground/dark-ink by hand would have created a second definition of a thing that already has
   one, and it would drift the first time the palette moves.
   THE HOUR/HALF-HOUR STRUCTURE SURVIVES UNTOUCHED for the same reason: it was never mine. Bold
   --color-heading for :00 and lighter --color-text-soft for :30 is the shipped treatment.
   THE CORNER STAYS TEAL, and that is the deliberate answer to "where do the two arms meet". The
   corner cell sits in the HEADER ROW — it is the band's first cell, not the gutter's top cell — so
   it belongs to the band and takes the band's ground. Painting it white would leave the teal strip
   starting one column late and reading as a stray tile; painting the gutter teal is what the owner
   just ruled out. Teal corner, teal band, white gutter beneath it is the only combination where
   the band is a continuous strip and the gutter is a continuous column.
   THE SEAM NEEDS NO NEW HAIRLINE. .cal-grid has `gap: 1px` over its own background, which on this
   page is --cal-line (#E3E8EA) — so a 1px rule already separates the gutter column from the first
   cell column, exactly as it separates every other pair of cells. Adding a border here would draw a
   second line beside that one.
   STICKY IS UNAFFECTED on both axes: .cal-time is `position: sticky; inset-inline-start: 0` and
   .cal-corner sticks to both edges, both declared elsewhere and neither touched here. The gutter
   keeps an OPAQUE ground (--color-surface from .cal-grid > *), which is what a sticky column needs
   so rows cannot show through it as they scroll past.
   RTL: inset-inline-start puts the gutter on the right in this document, and none of this uses a
   physical property, so the column and the corner stay on the reading edge. */
body[data-portal="solo"] .cal-corner { background: var(--cal-band-bg); }
body[data-portal="solo"] .cal-block.cal-block--done {
  background: var(--done-wash);
  color: var(--color-on-primary);
  border-color: transparent;
}
body[data-portal="solo"] .cal-block.cal-block--done .cal-block__lvl { color: rgba(255, 255, 255, .95); }
/* THE WHITE SEPARATOR GOES WITH THE RING. The shipped done rule pairs its dark-green border
   with `box-shadow: inset 0 0 0 1px var(--white)` — a hairline that kept the green off the
   fill. Setting only `border-color: transparent` above left that hairline behind, drawing a
   pale halo inside a border that is no longer there (caught in a render, not by reading).
   With the fill itself now green there is nothing to separate, so it goes too.
   :not(--armed) IS LOAD-BEARING. The armed cue is ALSO an inset white shadow, restored by
   `.cal-block--done.cal-block--armed` at :2205 — which has the same specificity as the rule
   above and sits EARLIER in the file, so an unqualified `box-shadow: none` here would win and
   silently delete "this lesson is lifted" from every recorded lesson mid-drag. */
body[data-portal="solo"] .cal-block.cal-block--done:not(.cal-block--armed) { box-shadow: none; }
/* The seven accents and the sand carry the same alpha, for the same measured reason. */
body[data-portal="solo"] .cal-block.is-c-teal .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-indigo .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-violet .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-plum .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-coral .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-amber .cal-block__lvl,
body[data-portal="solo"] .cal-block.is-c-moss .cal-block__lvl { color: rgba(255, 255, 255, .95); }

/* ---------------------------------------------------------------------------
   THE NEON GUARD — the rule every future fill inherits
   ---------------------------------------------------------------------------
   NEON IS NOT TASTE AND IS NOT "TOO BRIGHT". It is high chroma pinned to the sRGB GAMUT EDGE at
   high lightness — AND ONLY IN THE COOL HUES. That last clause is not a refinement, it is the
   whole rule, and it was learned by getting it wrong: a first version capped chroma by
   C/Cmax(L,h) alone, which rejected #FFA700 at 0.999. The owner had explicitly called that orange
   "great". A rule that fails a colour the owner loves is a wrong rule, so it was re-derived rather
   than the colour bent to fit it.
   WHY HUE DECIDES. The eye's luminous efficiency peaks in green-cyan, so a fill pinned to the
   gamut edge GLOWS there and merely reads rich in warm hues. Hence:
       for L* >= 60 AND hue in [120, 235]:   C <= 0.86 x Cmax(L, h)
       otherwise:                            no cap
   Cmax is the largest in-gamut chroma at that (L, h), found by binary search. 0.86 is calibrated
   on .btn--create, which sits at 0.857 and is the owner's own "rich candy" reference.
   CALIBRATED AGAINST FIVE GROUND-TRUTH POINTS, all five agreeing:
     #FFA700 flag orange  h 74  0.999  -> ok    (owner: "orange great")
     #FF8E25 one-off chip h 62  0.992  -> ok    (warm, exempt)
     #4ADE80 .btn--create h150  0.857  -> ok    (the reference)
     #06E7E7 year cyan    h196  0.997  -> NEON  (owner: "too neon")
     #03EBBB level mint   h170  0.998  -> NEON  (owner: "too neon")
   THE L* >= 60 FLOOR is the second exemption: below it Cmax is small, so the gamut edge produces a
   DEEP colour rather than a glowing one. Audited — the seven swatches (L* 26-39, fills .988-.999),
   the purple default (L* 37), the day band (L* 46), the done wash (L* 46) and the one-off block
   (L* 48) all sit under the floor and all read deep, which is how the owner read them.
   RE-DERIVED UNDER THE CORRECTED GUARD (only the two cool chips needed it):
     data (year)  #06E7E7 0.997 -> #4EE2CA  L* 82  C/Cmax 0.859  dark ink 8.98:1
     ok   (level) #03EBBB 0.998 -> #45AE6F  L* 64  C/Cmax 0.793  dark ink 5.19:1
   Both oranges are byte-identical to what the owner approved. Separations re-measured: data vs ok
   dE 29.5, ok vs .btn--create dE 24.0 (a level chip still never reads as a create button). */
/* ---------------------------------------------------------------------------
   §12 VIBRANT CHIPS — bright fill, dark ink (owner verdict; replaces the soft pass)
   ---------------------------------------------------------------------------
   THE PASTEL VERSION WAS WRONG AND IS GONE. Its mistake was treating "light" as the goal. The
   recipe the owner actually wants is the one .btn--create already proves: a BRIGHT SATURATED FILL
   carrying DARK INK (#4ADE80 on #0A2E34 = 8.29:1). Dark ink is the unlock — white ink caps how
   chromatic a fill may be, dark ink removes that ceiling, which is why a bright chip can pop off
   the page from colour alone. Every chip below uses --color-on-accent, the SAME token the create
   button uses, so the vibrant family reads as one language rather than three inventions.
   MEASURED, on the ink and on all three grounds these chips sit on:
     .pill--data   #06E7E7  ink 9.35:1   L* 84 C 47   dE vs card 49 / cream 50 / ice 45
     .pill--ok     #03EBBB  ink 9.36:1   L* 84 C 59   dE vs card 61 / cream 60 / ice 58
     .pill--flag   #FFA700  ink 7.42:1   L* 76 C 83   dE vs card 86 / cream 79 / ice 88
   LIGHTNESS IS PINNED TO THE CREATE BUTTON'S BAND (L* 74-84), not left to the optimiser. Free, it
   drove every chip to L* 91-92 — maximum chroma is easiest up there because dark ink has room —
   and returned #08FFFE electric cyan and #81FEBF pale mint, both LIGHTER than the button they are
   imitating. That is the pastel mistake again in a brighter coat. Vibrancy is chroma at a mid-high
   lightness, which is exactly what .btn--create demonstrates.
   THE LEVEL CHIP IS NOT THE CREATE BUTTON, and the owner asked for that gap to be measured:
   #03EBBB vs #4ADE80 is dE 25.2. Same-hue-different-lightness could not clear it while staying
   bright, so the hue rotates 150 -> 170, toward the brand's own teal-green. Family separations:
   data vs ok 26.9, data vs flag 114.6, ok vs flag 106.7.
   FLAG JOINS THE VIBRANT FAMILY (owner's call, and it holds up): "רשימת המתנה" in bright orange on
   dark ink pops harder than the burnt #BB5300 it replaces and still reads as attention, because
   orange is doing the work rather than darkness. It stays >= 62 dE from muted and danger, so the
   three warm states never blur.
   MEANING-CRITICAL PILLS DO NOT JOIN: muted (archived / absent), danger, role (the brand Midnight
   anchor) and none keep their solid looks. Those carry state, not personality, and a bright fill
   would make "archived" as loud as "level 2".
   THE GLYPHS NEED NO CHANGE: they are stroke-on-currentColor, so they simply became dark ink.
   The padding bump stays — see the note below on why a chip needs the air. */
body[data-portal="solo"] .pill--data {
  background: #4EE2CA;
  color: var(--color-on-accent);
  padding-block: 3px;
}
body[data-portal="solo"] .pill--ok,
body[data-portal="solo"] .pill--level {
  background: #45AE6F;
  color: var(--color-on-accent);
  padding-block: 3px;
}
/* THE חד-פעמי CHIP — an IDENTITY, not a warning (owner ruling).
   It rendered .pill--muted, which has been RED since v1.1.18 — so "this lesson happens once" was
   wearing the app's archived/absent colour and reading as a problem. One-off is now an orange
   identity that the calendar block carries too, and the chip is its sibling: hue 62 against the
   block's 67, dE 28 apart, so they are obviously the same family at two lightnesses.
   VIBRANT RECIPE, like every other chip in this section: bright fill + dark ink, 6.29:1.
   MEASURED SEPARATIONS: dE 18 from רשימת המתנה's #FFA700 — the two can share a roster card, so
   that gap is load-bearing rather than theoretical; dE 45 from muted red and 53 from danger, which
   keeps ACTUAL red reserved for בוטל / אין רמה / delete, exactly as ruled. */
body[data-portal="solo"] .pill--oneoff {
  background: #FF8E25;
  color: var(--color-on-accent);
  padding-block: 3px;
}
body[data-portal="solo"] .pill--flag {
  background: #FFA700;
  color: var(--color-on-accent);
  padding-block: 3px;
}
body[data-portal="solo"] .pill__icon {
  flex: none;
  inline-size: .95em;
  block-size: .95em;
  stroke: currentColor;
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* ---- the group-colour swatch row (item A) ---- */
.swatch-row { display: flex; flex-wrap: wrap; gap: var(--space-s); }
.swatch { position: relative; display: inline-flex; cursor: pointer; }
/* The input is visually hidden but still focusable and still the thing that owns the checked
   state — the same pattern .day-toggle uses, so the radiogroup's keyboard behaviour is the
   platform's rather than something re-implemented here. */
.swatch input { position: absolute; opacity: 0; inset: 0; inline-size: 100%; block-size: 100%; margin: 0; cursor: pointer; }
/* The DEFAULT swatch takes the kind colour the editor sets inline, from the same helper the
   card edge uses. RETIRED CLAIM (v1.51.0): "so the circle in the row is literally what the
   calendar paints" — true only for the one-off kind (#A85F00 matches its block). The private and
   group defaults in KIND_DEFAULTS are the HOME CARD EDGE hexes (#335E65 / #4F4AAC), while the
   calendar paints its blocks from shell.css's --cal-lesson-single/--cal-lesson/--cal-team
   (#247184 / #005A70 / #123081). Repointing the two hexes is an owner-visible change to every
   card edge and needs its own contrast pass — deferred, recorded here so nobody "verifies" the
   dot against the old sentence. */
.swatch--default .swatch__dot { background: var(--sw-color); }
.swatch__dot {
  inline-size: 30px; block-size: 30px; border-radius: 50%;
  background: var(--sw-color);
  /* A hairline in the app's border colour, so a dark swatch still has an edge on a white dialog
     and the row reads as seven objects rather than seven holes. */
  border: 1px solid var(--color-border);
  transition: transform var(--motion-fast) var(--ease-quiet), box-shadow var(--motion-fast) var(--ease-quiet);
}
/* SELECTED: a white inner gap then a dark ring, which is the one treatment that works on all seven
   — an outline in any single colour would vanish on the swatch nearest it. Measured: the ring is
   --color-heading, which is >= 4.5:1 against every swatch AND against the white dialog behind it,
   so the selection is visible whichever swatch holds it. */
.swatch input:checked + .swatch__dot {
  box-shadow: 0 0 0 2px var(--color-surface), 0 0 0 4px var(--color-heading);
  transform: scale(1.06);
}
/* FOCUS IS NOT THE SAME AS SELECTED, and both must be visible at once: a keyboard user arrowing
   through the row moves focus without necessarily changing the checked swatch on every engine.
   The focus ring uses --color-ring at 3px, the app's own focus token, offset outside the
   selection ring so the two never overlap into one thick smudge. */
.swatch input:focus-visible + .swatch__dot {
  outline: 3px solid var(--color-ring);
  outline-offset: 3px;
}

/* ============================================================================
   THE ROSTER CHIP ROW — state/slot + level + year, ONE line on a phone (owner ruling)
   ============================================================================
   A LATER-WINS OVERRIDE, like the two blocks above it: .cluster (:215) and .pill (:845) are shared
   by every screen in every portal and neither is edited. Scoped by ID to #roster-list and, inside
   it, to the ONE `.cluster.mt-m` a roster card owns — page-registry.js emits exactly three clusters
   per card and only the chip row carries mt-m (the name row is a bare .cluster, the action cluster
   is a SIBLING of .list-item__main, and prefsLine()'s row is .list-item__meta.cluster). The
   `> .pill` child combinator keeps it off anything nested. `#roster-list` cannot reach lesson.html's
   list either: that one is `#team-roster-list`, a different id.

   THE COMPLAINT, MEASURED (headless Chrome, Heebo, the shipped card markup). At 390px the row has
   220.0px and the three chips need:
     רשימת המתנה + רמה 2/3 + 2020 ............. 255.2   (106.3 + 84.9 + 47.9, two 8px gaps)
     פרטי: א | 13:00 + רמה 2/3 + 2020 ......... 275.4
     בקבוצה: א | 13:00 + רמה 2/3 + 2020 ....... 292.6
     בקבוצה: א,ד | 13:00 + רמה 2/3 + 2020 ..... 303.2   (a lesson may meet on several days)
   So EVERY roster card is two lines on that phone, not only the placed ones — the owner sees the
   year drop because it is last, but the row was 35-83px over on all four shapes.

   WHY TIGHTENING ALONE CANNOT PAY FOR IT. Of the card's 356px padding box at 390, 120 are the
   freeze-chip gutter (`var(--space-m) + var(--space-s) + 6rem`, see the REGISTRY block at :2460)
   and 16 the ordinary inline-start padding. The absolute limit of squeezing this row — 4px chip
   padding plus a 4px gap — is 32px, and the SMALLEST case is 35.2px over. Chips are already
   var(--text-sm)/14px, which is the legibility floor; there is no arithmetic that fits this row
   inside 220px.

   SO THE ROW BORROWS THE GUTTER BACK, and it may, because that gutter is a VERTICAL reservation
   wearing horizontal clothes. .kid-freeze is 36px tall and pinned at `inset-block-start:
   var(--space-s)` — it occupies the card's FIRST band only, and this row is the SECOND row of
   .list-item__main, under the name. Measured clearance, chip bottom edge to this row's top edge:
     one-line name .... 12.8px      name that wraps to two lines .... 37.6px
   and it can only ever GROW, since everything stacked above this row can only get taller. That is
   what makes the reclaim safe by construction rather than by luck. `6rem + var(--space-s)` (104px)
   is taken and `var(--space-m)` deliberately is NOT, so the row's end edge lands 17px from the
   card's border — the same 17px the card's own inline-start edge sits at, not a ragged new one.

   NOTHING ELSE MOVES ON A PHONE — AND ON A WIDE CARD IT MOVED THE WRONG THING, WHICH IS WHY BOTH
   RULES ARE GATED. The row has no fill and no border, so the widened box is invisible until a chip
   actually needs the space; the name and contact rows are untouched and still stop at the gutter.
   THE CLAIM THAT USED TO STAND HERE WAS FALSE and is worth keeping as the reason for the gate. It
   read: "the negative margin lowers main's max-content contribution and the buttons move AWAY from
   the chips — measured at 600px, the narrowest width where they share a line, 21.0 -> 41.0px. It
   did not shrink at any width from 320 to 1440." Both halves are wrong. 600px was not a constant to
   measure at: lowering main's max-content by 104px is exactly what makes .list-item stop wrapping
   .list-item__actions ~104px EARLIER, so the width at which the two share a line MOVED — the change
   created the band it was then measured in. And in that newly-unwrapped band the reclaimed overflow
   lands UNDER the buttons rather than beside them, because the row is `justify-content:
   space-between` and the overflow is not part of main's box. Reviewer's measured overlaps, ungated:
   -39.5px at 515 (פרטי), -55.8 at 525 (two-day), -14.5/-30.0/-40.8 at 540, -10.0/-20.8 at 560,
   clean from 585 up — with the year chip rendering clipped ("020", "0") or swallowed entirely. That
   is a REGRESSION against the baseline, which wrapped the actions onto their own line at those
   widths, and at 200% zoom on a 1080px window it is a real 1.4.4/1.4.10 failure rather than a
   desktop curiosity.
   SO THE GATE IS THE FIX, AND IT IS HONEST RATHER THAN DEFENSIVE: the reclaim answers a phone
   problem (220px of usable row) and has nothing to offer a width where the chips already fit. Above
   31rem/496px both rules are simply absent and the card is byte-for-byte the baseline — which is
   also why no wide-width claim needs making here at all. 31rem sits below the 510px where the first
   overlap appears and far above the 430px of the widest phone, so neither edge is close.

   THE TWO TIGHTENINGS ARE SMALL ON PURPOSE. `column-gap` only — row-gap stays var(--space-s), so
   the one case that still wraps keeps its breathing room. And 6px of chip padding, NOT
   var(--space-xs): rendered at 4x and compared, at 4px the "2020" chip's digits reach into its own
   round ends and it stops reading as a lozenge. No font-size change, and no ellipsis: the year and
   the time stay whole, which was the point.

   MEASURED AFTER — row width : chips needed (slack), Heebo:
     390px   324.0 : 235.2 המתנה (+88.8)  255.4 פרטי (+68.6)  272.6 בקבוצה (+51.4)  283.2 two-day (+40.8)
     375px   309.0 : 235.2 (+73.8)  255.4 (+53.6)  272.6 (+36.4)  283.2 (+25.8)
     360px   294.0 : 235.2 (+58.8)  255.4 (+38.6)  272.6 (+21.4)  283.2 (+10.8)
     320px   254.0 : 235.2 (+18.8)  255.4 (-1.4)   272.6 (-18.6)  283.2 (-29.2)
   ONE ROW AT EVERY WIDTH DOWN TO 360, on all four shapes and on the frozen card (203.7, +50.3 at
   320). The three cases that still wrap do so ONLY at 320px — an iPhone SE 1/5 — and only the year
   chip drops, in the same order, which is why `flex-wrap` stays `wrap` rather than becoming
   `nowrap`: at 320 a nowrap row would overflow the card instead, and an overflowing row is worse
   than a wrapped one. HEEBO IS THE BINDING FACE, re-measured after this change rather than assumed:
   the three fallbacks font-display:swap paints first are all narrower on the פרטי case (Segoe UI
   252.6, Noto Sans Hebrew 250.2, Arial Hebrew 250.2 against Heebo's 255.4 — narrow enough that all
   three FIT at 320 where Heebo does not), so a card that fits in Heebo fits during the swap too.
   `white-space: nowrap` on the chips makes "a chip never becomes two lines" structural rather than
   incidental. Headroom before it could overflow: the widest single chip is 139.8px against the
   254.0px row at 320 — 114px, i.e. a slot label would have to roughly double before this bites.
   IF ANY CHIP'S TEXT CHANGES, RE-MEASURE. These are measurements, not round numbers. */
@media (max-width: 31rem) {
  /* THE ACTIONS TAKE THEIR OWN LINE, WHICH IS WHAT MAKES THE RECLAIM SAFE RATHER THAN LUCKY, and
     the gate alone was not enough: measured, the ungated rules overlapped from 488px, i.e. INSIDE a
     31rem/496px gate, and the threshold is not a constant to gate under. It is the width at which
     `main's max-content + 16 + the buttons' max-content` first fits the card, and main's max-content
     is whichever of the title, the chip row (need - 104) and the CONTACT LINE is longest. A student
     with a short name and no stored phone shortens that contact line, which drops the threshold —
     arithmetic puts the frozen card's own crossing near 406px on the shortest realistic meta, i.e.
     on a phone. A breakpoint cannot chase content, and the arithmetic is not theoretical: rendered
     with a short name and a two-character phone, the FROZEN card's buttons come up beside the chips
     at 402px — a phone, well inside the gate — and the other four shapes at 447px.
     So the invariant is stated instead of gated for: while the reclaim is on, the buttons are never
     on the chip row's line, so the overflow has nothing to land under.
     IT CHANGES NOTHING THAT WAS ALREADY CORRECT, AND THAT IS MEASURED RATHER THAN ASSERTED. Compare
     what RENDERS — every button's rect plus the card's height — with the rule and with
     `flex-basis: auto` forced back on, 320-496 in 1px steps: identical at every width below 473 on a
     normal contact line and below 402 on the minimal one, i.e. it fires only from the width where
     that content would otherwise have started overlapping, and never before. Where the actions
     already wrapped, the box stretches but the BUTTONS do not move: the stretched box shares its
     inline-start edge with the content-sized one, and .list-item__actions lays its children out
     flex-start, so the painted result is the same to the pixel. Do not re-check this by comparing
     the actions' own rect — that legitimately differs at every width, and it is not what is on
     screen.
     SWEEP OF RECORD: 320-1440 in 2px steps (561 widths) x both contact lengths, ZERO overlaps.
     MEASURE IT ON A PAGE THAT HAS A VIEWPORT META, under mobile device metrics. Two rigs lie here
     and both lied to me: a desktop emulation reserves a ~15px classic scrollbar, so every layout
     width is 15px narrower than the number requested; and mobile emulation on a fixture MISSING
     `<meta name="viewport">` silently lays out at Chrome's 980px default and ignores the emulated
     width entirely — every row then reads "fine" because it is being measured on a desktop-width
     page. The tell is one line: assert `innerWidth` is the width you asked for. */
  body[data-portal="solo"] #roster-list .list-item > .list-item__actions { flex-basis: 100%; }
  body[data-portal="solo"] #roster-list .list-item__main > .cluster.mt-m {
    margin-inline-end: calc(-1 * (6rem + var(--space-s)));
    column-gap: var(--space-xs);
  }
  body[data-portal="solo"] #roster-list .list-item__main > .cluster.mt-m > .pill {
    padding-inline: 6px;
    white-space: nowrap;
  }
}

/* ============================================================================
   THE "רישום תלמיד/ה חדש/ה" ROW in the lesson-creation pickers (owner ruling)
   ============================================================================
   Owner, on the שיעור חדש dialog's student picker: "the first option in the search bar should be to
   register a new student, shouldn't be full green big style button, give it the person with the +
   and רישום תלמיד/ה חדש/ה." So this is a LIST ROW that happens to act, not a call to action: it
   reuses .kid-option (:569) verbatim for its box, its gap, its 44px tap height and its radius, and
   it is given NO fill of its own. .btn--create is deliberately not in play — the green is this
   app's creation accent for a page's one primary act, and spending it inside a scrolling list of
   students would make one option shout over the others.
   THE TWO DECLARATIONS ARE THERE BECAUSE THE ELEMENT CHANGED, NOT BECAUSE THE LOOK DID. Every other
   .kid-option is a <label>; this one is a <button>, and a shared component class re-opens every UA
   declaration it never had to supply. Chrome's UA sheet gives a button `buttonface` and
   `text-align: center` — measured elsewhere in this codebase as rgb(240,240,240) and `center` — so
   without these two lines the row would render grey and centred among white, start-aligned
   siblings, i.e. "the broken one". `inherit`/`start` restate what a <label> got for free. Scoped
   with the element selector so no <label> consumer on any other screen is reached.
   `width: 100%` for the same reason: a button shrink-wraps its text where a label in a grid track
   fills it, so without it the row would be a short tab in a full-width list.
   THE GLYPH IS SIZED IN em, so it tracks the row's own text rather than being pinned to a pixel
   size, and `flex: none` keeps it from being squeezed by a long label at 320px. It inherits
   currentColor, which is what lets one inline SVG serve this row in both the light list and any
   future hover/active state without a second colour declaration. */
button.kid-option--new {
  background: none;
  text-align: start;
  inline-size: 100%;
  border: 0;
  color: inherit;
  font: inherit;
  cursor: pointer;
  /* PINNED AGAINST THE GLOBAL FOCUS RULE, the same trap the calendar's floating + hit. :189 is
     `:focus-visible { outline: 3px …; border-radius: 4px }` — it sets the ELEMENT's radius, not the
     outline's — so a focused row would snap from --radius-sm to 4px and visibly change shape under
     the keyboard. This selector is (0,1,1) — one class plus one ELEMENT — against that (0,1,0),
     so what wins it is the tag qualifier, not a second class. (An earlier draft wrote (0,2,1)
     here, the same arithmetic slip the .cal-fab note made: the conclusion held both times,
     which is exactly why the sum has to be counted rather than inferred from the outcome.)
     Restating .kid-option's own radius here holds it still. Nothing sets `outline: none` on this
     row, deliberately: the app's global focus ring is the ring, exactly as for every other control. */
  border-radius: var(--radius-sm);
}
.kid-option__glyph { inline-size: 1.25em; block-size: 1.25em; flex: none; }
/* THE DISABLED STATE MUST BE VISIBLE, not only spoken. applyKidCap() disables this row at the
   two-student cap, but the row is .kid-option--new, not .btn, so the app's own disabled language
   (.btn:disabled { opacity:.5; cursor:not-allowed }, :457 — restated deliberately at two other
   sites in this file) never reached it: measured disabled, it kept opacity 1 and cursor:pointer
   and read as tappable while doing nothing. Same pair, same meaning, scoped to this one row. */
button.kid-option--new:disabled { opacity: .5; cursor: not-allowed; }

/* THE PICKER LIST'S PADDING CLIPPED THE NEW ROW'S FOCUS RING. .kid-picker-list (:568) is
   `overflow-y: auto` with 4px of padding, and an OUTLINE does not contribute to scrollable overflow
   — so a 3px ring at 2px offset needs 5px of room and lost its outer pixel on the first and last
   rows. Pre-existing and previously unreachable: every other row in that box is a <label> whose
   focusable INPUT sits inset from the edge, so nothing was ever ringed at the boundary until this
   round put a focusable row there. Block axis only — the inline edges are not clipped, and widening
   them would move every row. :568 shipped at v1.49.0 and test/cssdiff.py holds it byte-identical,
   so this is the usual later-wins addition rather than an edit. */
.kid-picker-list { padding-block: 5px; }

[hidden] { display: none !important; }
