/*
  The whole app's stylesheet. `templates/base.html` is the only thing that
  loads it and every page extends that template, so every rule here is on
  every page. A rule needed by one page goes in its own file loaded through
  `base.html`'s `{% block extra_css %}` instead (issue #137) --
  `account_list.css` first among them; `static/css/` is the current list. A page whose only
  unique styling is a rule or two keeps it here as a named component class
  instead; a stylesheet request per page is not worth three lines.

  It lived inline in base.html until issue #134. Out here WhiteNoise serves it
  precompressed and under a content-hashed filename, so it is fetched once and
  read from cache after that; inline, the identical bytes were re-sent with
  every single page load. They were uncompressed too until issue #141 added
  `GZipMiddleware`, which is no longer a difference between the two -- the
  caching is.

  Which is why the comments below are ordinary CSS comments again. While this
  was inline, Django's template engine had no idea what one was and shipped it
  straight through into the HTML -- so the house rule was `{% comment %}`,
  enforced by a test, and writing down *why* a rule exists cost bytes on every
  request. Out here it costs nothing on any request. Comment freely.

  One hazard the old inline form did not have, found by reading computed styles
  in the browser rather than by any test: a comment that quotes CSS's own
  closing delimiter ends there, and every rule after it is parsed as garbage
  until the next brace. This header did exactly that on its first draft, which
  silently emptied `:root` below and left the app bar transparent while the
  file still returned 200 and the whole suite still passed. Do not write the
  delimiter inside a comment -- say "a CSS comment" instead.
*/
/*
  The "Refined Clinical" palette at Balanced intensity -- issue #257 is the
  spec these values come from, decided against three rounds of mockups.

  Naming, because the restyle added three kinds of token:

  * plain colour (--accent, --mine, --excluded): the thing's own hue -- fills,
    borders, rings.
  * `-ink` (--accent-ink, --mine-ink, --excluded-ink): the text variant,
    wherever hue-coloured text sits -- tint, surface, or page bg. The plain
    hue is never a text colour: a hue tuned to carry --on-accent on its
    fills cannot also clear text contrast, most sharply on the dark
    palettes (issue #281). Non-text marks keep the plain hue -- the subtab
    underline, the app bar's aria-hidden alert glyph -- since a mark needs
    only 3:1 and its meaning lives in adjacent text. A dark theme's -ink
    is *lighter* than its plain hue.
  * --on-accent: text sitting on a solid accent/mine/excluded fill. One token
    rather than #fff at each site so the dark palette can retune it in one
    place. The pairing is a two-sided constraint (issues #281/#285): a
    fill must be dark enough that --on-accent clears 4.5:1 on it --
    enforced for every theme block by ComposedTextPairsStayReadable
    (shifts/tests/test_static_assets.py), with the app's other composed
    text pairs -- while staying light enough to mark at 3:1 against its
    own tint. Only Light's and Dark's mine/excluded are measured to that
    second half; Ember's fills and the dark accents sit below it, which
    is why the guard's mark side, MARK_PAIRS, does not hold these pairs
    -- add them there when they retune.
    On the dark side the window is narrow: retune by measuring, not by
    eye.

  --radius / --radius-sm / --radius-ctl is the 12/10/8 scale: cards / grid
  cells and small panels / controls (buttons, inputs, selects).

  --space-1..4 is the 4/8/12/16 rhythm -- issue #368, and the second
  dimensional scale in this file after --radius. Four rungs on a 4px grid,
  and four rather than six deliberately: a rung nothing wears is a rung
  that drifts, and these four covered every gap on the surface they were
  measured against.

  What they mean, so a new site picks the same rung as an old one:
    --space-1  the gap inside a thing -- a label and its control, an
               element and the one it is captioning.
    --space-2  the gap between siblings in a row -- pills, chips, the
               items of one group.
    --space-3  the gap between one band of the page and the next, and the
               page's own padding. The workhorse.
    --space-4  a deliberate separation, where a band needs to read as
               belonging to what follows rather than what precedes.

  **On-grid spacing is a token; off-grid spacing is a literal.** That is the
  whole rule, and both halves are load-bearing.

  #368 converted all 109 spacing declarations across `static/css/` that held
  exactly 4, 8, 12 or 16px -- every stylesheet, not just this one. Provably a
  no-op: computed margin, padding and gap were captured for all 6,418 elements
  on five pages before and after, and came back byte-identical. Zero on-grid
  literals remain in a `margin`, `padding` or `gap`, so `grep -n "gap: 8px"`
  finding one means somebody wrote it after this and it should be a token.

  The other half is why the conversion stops there. #368 first put
  `.admin-tabs.tabs-tight` (10px) and `.tabs-mid` (14px) on a rung, which
  collapsed two modifiers whose whole purpose was two values onto one, and
  moved admin_months and archived_months by 2px in a change that had not
  looked at them. Both were put back. **Converting an off-grid value is a
  pixel move, not a refactor** -- and because nothing on-grid is left as a
  literal, the remaining literals are now visibly the deliberate exceptions
  rather than indistinguishable from unconverted ones.

  --shadow is the resting elevation every raised surface wears; --shadow-hi
  is the raised state a *clickable* element lifts to on hover, plus the one
  at-rest exception: a panel that genuinely floats over the page body
  (the row menu's dropdown). Labels (status pills) stay flat --
  elevation marks controls, not labels.

  --bar-*: the app bar's own colours. In this palette the bar is the accent,
  but the dark palette will split them (its bar is deeper than its accent),
  so nothing but these tokens may colour the bar. Two literals outside CSS
  restate them and cannot read tokens -- base.html's theme-color meta and
  static/manifest.json's theme_color/background_color -- so a palette change
  edits those two files as well.
*/
:root {
  --bg: #f2f5f6;
  --surface: #ffffff;
  --surface-2: #fbfcfc;
  --text: #16262b;
  --muted: #617277;
  --accent: #14505f;
  --accent-ink: #0f4250;
  --accent-light: #e6f0f2;
  --on-accent: #ffffff;
  --mine: #1e8650;
  --mine-light: #e8f6ee;
  --mine-ink: #12703f;
  --excluded: #bd5346;
  --excluded-bg: #fbebe8;
  --excluded-ink: #a8422f;
  --warning: #d9a02c;
  --warning-bg: #fdf4e5;
  --border: #e3e9ea;
  /* The edge of a form field (issue #530). Its own token rather than
     --border because a field is a control and --border is a card's hairline:
     WCAG 1.4.11 holds whatever marks a field as one to 3:1 against what it
     sits on, and --border is about 1.2:1 in every palette. Each theme mixes
     its own --muted toward its --surface until it clears 3:1 against both
     --surface and --bg, so it stays in the theme's hue; the mark pairs in
     ComposedTextPairsStayReadable measure it. */
  --field-edge: #829094;
  --shift-a: #3f86cf;
  --shift-b: #bd7f2f;
  /* The shift-type tags on the All shifts tab (issue #548): a soft tint
     with its own ink, one pair per category. Hues stay off this palette's
     --mine and --excluded, which mark the viewer's own shift and a gap on
     the same page; a palette that moves those retunes these. */
  --cat-daytime-bg: #e3eefa;
  --cat-daytime-ink: #1f4f86;
  --cat-evening-bg: #efe6f7;
  --cat-evening-ink: #5b3a86;
  --cat-night-bg: #e2e6f3;
  --cat-night-ink: #2c3a6b;
  --cat-call2-bg: #dff1ef;
  --cat-call2-ink: #1d625c;
  /* A neighbouring month's day on the All shifts tab. Its own token rather
     than --surface-2 because here it has to read as "not this month" at a
     glance, and this palette's --surface-2 sits too close to white for that
     (the review, 2026-09-30). Sandstone, Ember and Harbor alias their
     --surface-2, which already reads. */
  --outside-month-bg: #f6f8f9;
  /* The working-week band behind the signup grid's cards (issue #477): each
     theme's own bar colour, stored as the solid colour it makes over that
     theme's --bg rather than as a see-through one. The band only ever sits
     on --bg, so the two look the same -- but neighbouring pieces overlap by a
     pixel to close the seam between them, and a see-through colour would
     show that overlap as a darker line. So a retune picks the tint, then
     composites it over the block's own --bg. This one is rgba(20,80,95,.20). */
  --week-band: #c6d4d8;
  /* The category left bar, spelled once: every surface that draws it in a
     box-shadow list says var(--cat-bar-a), never the inset literal, so a
     bar retune is one edit (per category) instead of ten. */
  --cat-bar-a: inset 4px 0 0 0 var(--shift-a);
  --cat-bar-b: inset 4px 0 0 0 var(--shift-b);
  --radius: 12px;
  --radius-sm: 10px;
  --radius-ctl: 8px;
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --shadow: 0 1px 2px rgba(16,40,48,.05), 0 1px 1px rgba(16,40,48,.04);
  --shadow-hi: 0 4px 10px rgba(16,40,48,.09), 0 1px 2px rgba(16,40,48,.06);
  /* The confirm dialog's ::backdrop wash: the theme's text tone at .45,
     deepened to black in the dark palettes the way the shadows are. A
     token because ::backdrop is still a page colour a theme must retune,
     even though only the <dialog> ever wears it. */
  --backdrop: rgba(22,38,43,.45);
  /* Hover feedback strength, tokens so each theme sets how strong and in
     which direction: brightness() is multiplicative, so the darken that
     reads on these light fills barely moves an already-dark one, and a
     dark-side theme restates both tokens above 1. -lift is every
     brightness hover's tier (the link chips gave up their own deeper
     dial in #293's retune -- mint a -deep token at the first chip-only
     retune, not before). -solid is the exception tier: a fill that
     wears --on-accent ink is dark in every palette (the contrast suite
     guarantees the white ink reads on it at rest), so no theme's darken
     has headroom there -- those controls lighten in every theme. The
     lighten spends label contrast while the pointer rests: measured at
     ~3.6:1 on the light side and ~3.4:1 on the dark side against the
     4.5:1 rest floor, accepted in PR #294 -- motion and shadow carry
     the state alongside, and a lighten small enough to hold the floor
     would be invisible. Wearers are .btn-primary:hover, the
     assigned/excluded badge hovers and the pressed .toggle-chip's hover --
     that is the full roster; a new solid-fill control joins by adding a
     rule, nothing inherits in. */
  --hover-lift: brightness(0.93);
  --hover-solid: brightness(1.15);
  --bar-bg: var(--accent);
  --bar-line: rgba(255,255,255,0.14);
  --bar-shadow: 0 1px 0 rgba(0,0,0,0.06);
  /* The white-alpha family the bar's nav controls are drawn with -- part of
     the same "nothing but these tokens may colour the bar" boundary, since
     the dark palette's deeper bar will want them retuned together. */
  --bar-outline: rgba(255,255,255,0.32);
  --bar-wash: rgba(255,255,255,0.12);
  --bar-wash-press: rgba(255,255,255,0.18);
}
/* --warning has no -ink variant, deliberately, in any palette: its one
   text use is the flash-warning glyph, which sits on --warning-bg with
   enough contrast as-is in every palette block -- sandstone's pair
   measures no worse than light's own. Mint the token when a warning
   text-on-tint use first appears, not before. */
/*
  Theme plumbing (issue #257). The :root block above is the standard light
  palette -- the default, the forced Light theme, and the light side of
  System. base.html stamps User.theme's name on <html> as data-theme.
  Themes are a flat list: a future palette is one more
  :root[data-theme="..."] block of these same custom properties (with its
  color-scheme line), plus a choice in users/models.py -- the picker
  derives from the choices, and CONVENTIONS.md ("Themes are a flat list")
  carries the full recipe, including the theme-color meta branch a forced
  theme adds in base.html. No crossing with dark.

  color-scheme is which side the browser draws its own widgets on
  (scrollbars, date pickers, a select's chevron and its open list -- a
  field's own fill and ink are tokens, through the field base rule further
  down): System declares both and the
  device picks; the forced themes pin one side. Every future light-side
  theme declares `light`.

  The dark token block appears twice below -- once as the forced Dark theme,
  once as System on a device set to dark -- because a selector and a media
  query cannot share one rule, and generating one from the other needs a
  build step this app does not have. The one-block alternative, light-dark()
  at every token, was rejected: an engine that predates that function drops
  every colour in the app at computed-value time -- the same silent failure
  class as the color-mix note further down, at total instead of local scale.
  DarkTokenTwinsStayIdentical (shifts/tests/test_static_assets.py) pins the
  twins identical, so an edit to one fails the suite rather than quietly
  forking the palette. The twin is Dark's alone: System resolves only to
  :root's light values or Dark's block, so a second dark-side theme
  (Ember) has no twin and needs none.
*/
:root { color-scheme: light dark; }
:root[data-theme="light"] { color-scheme: light; }
/*
  The forced-dark rules are @media screen so printing falls back to the
  light palette. Print drops backgrounds by default but honours text
  colour, so an unscoped dark block would put near-white ink on white
  paper. System needs no such scoping: print evaluates
  prefers-color-scheme as light, which skips the twin below the same way.
*/
@media screen {
  :root[data-theme="dark"] { color-scheme: dark; }
}
/*
  The dark palette (issue #257, tuned at Balanced). Colours, shadows and
  the hover filters: the radius scale is theme-neutral, and --cat-bar-* needs no
  restatement because the bars reference var(--shift-a/b) at their point of
  use and pick the dark hues up from here. Dark shadows are tuned, not
  scaled -- dark needs disproportionate opacity for a shadow to register at
  all. Three families the issue-257 spec left open are derived here: the
  warning pair, --on-accent (white clears 4.5:1 on the dark accent; the
  dark text tone does not), and the bar's white-alpha family, dialled down
  a step because white reads louder on the deeper bar.
*/
@media screen {
  :root[data-theme="dark"] {
    --bg: #0e1416;
    --surface: #161f22;
    --surface-2: #1a2427;
    --text: #e8eff0;
    --muted: #93a6ab;
    --accent: #2b7d8f;
    --accent-ink: #8ed3e2;
    --accent-light: #1b353d;
    --on-accent: #ffffff;
    --mine: #298553;
    --mine-light: #14301f;
    --mine-ink: #79dfa4;
    --excluded: #b35c50;
    --excluded-bg: #361d19;
    --excluded-ink: #f0a294;
    --warning: #e2ae4e;
    --warning-bg: #322511;
    --border: #222f33;
    --field-edge: #606f73;
    --shift-a: #5b9ede;
    --shift-b: #cf9a4e;
    --cat-daytime-bg: #1b2c40;
    --cat-daytime-ink: #9cc4ee;
    --cat-evening-bg: #2a2140;
    --cat-evening-ink: #c9b1ec;
    --cat-night-bg: #1f2540;
    --cat-night-ink: #aab6e6;
    --cat-call2-bg: #15302d;
    --cat-call2-ink: #86d2c8;
    --outside-month-bg: #1e2a2e;
    --week-band: #1b434c;
    --shadow: 0 1px 2px rgba(0,0,0,.4);
    --shadow-hi: 0 6px 18px rgba(0,0,0,.55);
    --backdrop: rgba(0,0,0,.55);
    --hover-lift: brightness(1.25);
    --hover-solid: brightness(1.15);
    --bar-bg: #112a31;
    --bar-line: rgba(255,255,255,0.10);
    --bar-shadow: 0 1px 0 rgba(0,0,0,0.35);
    --bar-outline: rgba(255,255,255,0.30);
    --bar-wash: rgba(255,255,255,0.10);
    --bar-wash-press: rgba(255,255,255,0.16);
  }
}
@media (prefers-color-scheme: dark) {
  :root[data-theme="system"] {
    --bg: #0e1416;
    --surface: #161f22;
    --surface-2: #1a2427;
    --text: #e8eff0;
    --muted: #93a6ab;
    --accent: #2b7d8f;
    --accent-ink: #8ed3e2;
    --accent-light: #1b353d;
    --on-accent: #ffffff;
    --mine: #298553;
    --mine-light: #14301f;
    --mine-ink: #79dfa4;
    --excluded: #b35c50;
    --excluded-bg: #361d19;
    --excluded-ink: #f0a294;
    --warning: #e2ae4e;
    --warning-bg: #322511;
    --border: #222f33;
    --field-edge: #606f73;
    --shift-a: #5b9ede;
    --shift-b: #cf9a4e;
    --cat-daytime-bg: #1b2c40;
    --cat-daytime-ink: #9cc4ee;
    --cat-evening-bg: #2a2140;
    --cat-evening-ink: #c9b1ec;
    --cat-night-bg: #1f2540;
    --cat-night-ink: #aab6e6;
    --cat-call2-bg: #15302d;
    --cat-call2-ink: #86d2c8;
    --outside-month-bg: #1e2a2e;
    --week-band: #1b434c;
    --shadow: 0 1px 2px rgba(0,0,0,.4);
    --shadow-hi: 0 6px 18px rgba(0,0,0,.55);
    --backdrop: rgba(0,0,0,.55);
    --hover-lift: brightness(1.25);
    --hover-solid: brightness(1.15);
    --bar-bg: #112a31;
    --bar-line: rgba(255,255,255,0.10);
    --bar-shadow: 0 1px 0 rgba(0,0,0,0.35);
    --bar-outline: rgba(255,255,255,0.30);
    --bar-wash: rgba(255,255,255,0.10);
    --bar-wash-press: rgba(255,255,255,0.16);
  }
}
/*
  Sandstone (issue #273): the warm light theme, tuned in the round-two
  mockups. Light-side, so it retunes rather than restates. The bar's
  white-alpha family inherits :root's values because this bar (#5d3f26)
  sits at the same depth as the teal bar they were tuned on -- checked,
  not assumed; a paler bar would need them retuned, not inherited.
  --bar-bg is a literal, not the :root alias, because the bar sits a step
  darker than the accent here; base.html's sandstone theme-color meta
  restates it, pinned by PaletteMirrorsStayInSync.
*/
:root[data-theme="sandstone"] {
  color-scheme: light;
  --bg: #efe7da;
  --surface: #faf5ec;
  --surface-2: #f4eddf;
  --text: #2b2318;
  --muted: #726658;
  --accent: #6f4d31;
  --accent-ink: #573b24;
  --accent-light: #eeddc6;
  --mine: #55803a;
  --mine-light: #ebf1de;
  --mine-ink: #3f6127;
  --excluded: #bf4b26;
  --excluded-bg: #f9e3d8;
  --excluded-ink: #96351a;
  --warning: #c79433;
  --warning-bg: #f7edd6;
  --border: #e0d4bf;
  --field-edge: #8d8376;
  --shift-a: #4a6f8e;
  --shift-b: #9a5b68;
  --outside-month-bg: var(--surface-2);
  --week-band: #d8cbbc;
  --shadow: 0 1px 2px rgba(70,50,20,.07), 0 1px 1px rgba(70,50,20,.05);
  --shadow-hi: 0 4px 10px rgba(70,50,20,.12), 0 1px 2px rgba(70,50,20,.08);
  --backdrop: rgba(43,35,24,.45);
  --bar-bg: #5d3f26;
}
/*
  Ember (issue #273): the warm dark theme -- charcoal-brown where Dark is
  cool blue-grey. Dark-side, so it restates the whole palette, inside
  @media screen like the forced-dark block above. The bar's white-alpha
  family repeats Dark's dialled-down values: this bar (#2e1f14) sits at
  the same depth as Dark's -- checked, not assumed. --accent runs a step
  deeper than the mockup's amber on purpose -- --on-accent has to read on
  both the accent fills and the bar, and warm white clears 4.5:1 on this
  accent where it could not on the brighter one.
*/
@media screen {
  :root[data-theme="ember"] {
    color-scheme: dark;
    --bg: #171210;
    --surface: #221a15;
    --surface-2: #2a201a;
    --text: #f0e6da;
    --muted: #a8988a;
    --accent: #8a5f2a;
    --accent-ink: #e8bd81;
    --accent-light: #3a2c1c;
    --on-accent: #f5ead9;
    --mine: #3c7546;
    --mine-light: #22321f;
    --mine-ink: #a4d8a4;
    --excluded: #b4482c;
    --excluded-bg: #3a1e14;
    --excluded-ink: #f0a488;
    --warning: #d9a850;
    --warning-bg: #33260f;
    --border: #35291f;
    --field-edge: #76695f;
    --shift-a: #6f93b8;
    --shift-b: #b06d89;
    --cat-daytime-bg: #1e2a36;
    --cat-daytime-ink: #a9c6e4;
    --cat-evening-bg: #2c2230;
    --cat-evening-ink: #d4b6dc;
    --cat-night-bg: #23253a;
    --cat-night-ink: #b3b9e0;
    --cat-call2-bg: #1a2c2a;
    --cat-call2-ink: #94cfc6;
    --outside-month-bg: var(--surface-2);
    --week-band: #3a2918;
    --shadow: 0 1px 2px rgba(0,0,0,.5);
    --shadow-hi: 0 6px 18px rgba(0,0,0,.65);
    --backdrop: rgba(0,0,0,.55);
    --hover-lift: brightness(1.25);
    --hover-solid: brightness(1.15);
    --bar-bg: #2e1f14;
    --bar-line: rgba(255,255,255,0.10);
    --bar-shadow: 0 1px 0 rgba(0,0,0,0.35);
    --bar-outline: rgba(255,255,255,0.30);
    --bar-wash: rgba(255,255,255,0.10);
    --bar-wash-press: rgba(255,255,255,0.16);
  }
}
/*
  Harbor (issue #273): the reassigned light theme -- the sanctioned
  families exception (CONVENTIONS.md, "Themes are a flat list"), keyed by
  .legend-state in the signup legend. Light-side, so it retunes rather
  than restates; --surface stays :root's white on purpose. --excluded runs
  a step deeper than the mockup's amber -- --on-accent's white clears
  4.5:1 on it where the amber held it to 3.5:1 -- and because that lands
  needs-coverage in the warning family, the warning pair is retuned
  yellower here rather than inherited, or error and warning flashes
  would share one cream. The bar-alpha family is retuned too, not
  inherited: this bar sits half a stop paler than the teal bar :root's
  values were tuned on, so the white alphas step up to keep the nav
  outline and hairline reading. --bar-bg restates the accent's value as
  a literal so the chrome pin has a value to hold.
*/
:root[data-theme="harbor"] {
  color-scheme: light;
  --bg: #f3f5f8;
  --surface-2: #fafbfd;
  --text: #1b2430;
  --muted: #606b7b;
  --accent: #7c4a70;
  --accent-ink: #63385a;
  --accent-light: #f0e2ee;
  --mine: #2e66b8;
  --mine-light: #e8f0fb;
  --mine-ink: #224f92;
  --excluded: #a85a0a;
  --excluded-bg: #fbf1de;
  --excluded-ink: #8f5a06;
  --warning: #b99a1d;
  --warning-bg: #faf5cf;
  --border: #dee4ec;
  --field-edge: #858d99;
  --shift-a: #2a8f8a;
  --shift-b: #a85275;
  /* Retuned rather than inherited: :root's Daytime blue and Night indigo
     would sit on this theme's blue --mine. */
  --cat-daytime-bg: #e9efdc;
  --cat-daytime-ink: #4a5d1f;
  --cat-evening-bg: #f6e4ea;
  --cat-evening-ink: #8a2f4f;
  --cat-night-bg: #e6e8ee;
  --cat-night-ink: #3a4458;
  --cat-call2-bg: #dcf0ee;
  --cat-call2-ink: #1d6560;
  --outside-month-bg: var(--surface-2);
  --week-band: #ded6e0;
  --shadow: 0 1px 2px rgba(20,30,48,.06), 0 1px 1px rgba(20,30,48,.05);
  --shadow-hi: 0 4px 10px rgba(20,30,48,.10), 0 1px 2px rgba(20,30,48,.07);
  --backdrop: rgba(27,36,48,.45);
  --bar-bg: #7c4a70;
  --bar-line: rgba(255,255,255,0.18);
  --bar-outline: rgba(255,255,255,0.42);
  --bar-wash: rgba(255,255,255,0.16);
  --bar-wash-press: rgba(255,255,255,0.22);
}
/*
  IBM Plex Sans, vendored -- issue #368. Served from this origin like every
  other asset: `default-src 'self'` covers fonts through the `font-src`
  fallback, so a face from anywhere else is blocked, silently, the way
  everything else under that policy is.

  These two files are the *only* url() in this stylesheet, which CONVENTIONS.md
  flags as the thing to check. They are checked twice over: the policy above,
  and `collectstatic`, where the manifest storage rewrites the reference below
  to the content-hashed filename. CI runs that step.

  A variable face rather than three static weights, and that is not a
  preference: `font-weight: 650` is already in this file (the app bar's h1),
  and static cuts would round it to 600 or 700 without saying so.

  The split is upstream's. Google serves latin and latin-ext as separate
  subsets and this vendors both unmodified -- the second is what keeps a name
  carrying a Central or Eastern European diacritic from dropping to the
  fallback face for that one glyph, which reads as a rendering fault rather
  than as a different font. Their unicode-ranges are copied verbatim; edit
  neither, and do not merge the files, which would mean re-subsetting bytes
  that are upstream's.
*/
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-latin.b2c9031d9fd6.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-latin-ext.f0e2925b3fa6.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
* { box-sizing: border-box; }
html {
  scrollbar-gutter: stable;
}
body {
  margin: 0;
  /*
    The system stack stays behind IBM Plex Sans as the fallback, which is what
    renders while the font is in flight under `font-display: swap` and if it
    never arrives at all.
  */
  font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  /*
    `100%`, and emphatically not `16px`. This shipped as `16px` first, on the
    reasoning that "every size in this file is a rem off it, so saying it out
    loud changes nothing" -- which is wrong twice over. `rem` resolves against
    `html`, not `body`, and `html` above sets only `scrollbar-gutter`, so a
    body font-size pins no `rem` at all. What it does pin is every element that
    inherits from `body`, against the reader's own browser font-size setting.

    Measured at a 20px root, which is what Chrome's Appearance > Font size
    "Large" produces: body copy stuck at 16px while `.signup-hint` (0.85rem)
    rendered 17px -- secondary muted text larger than the text it qualifies,
    and a preference half-honoured. `100%` says the same thing to a default
    browser and keeps the whole page scaling together (WCAG 1.4.4).

    `line-height` is the one that was genuinely missing. `body` set none, so
    every page took the UA's `normal` -- which is derived from the face's own
    metrics, and so was about to change underneath us anyway when the face did.

    A rule that sets its own is not disturbed by this -- among them
    `.confirm-dialog-message` 1.5, the grid cell's name list 1.3,
    `.limits-row` 1.2, and 1 on the icon-sized controls (`.nav-toggle`,
    `.month-nav-arrow`, `.chip-x`, `.row-menu summary`, the phone cards'
    arrows). Not a full list: grep for `line-height` rather than trusting a
    count here. It inherits into every page stylesheet too, so that grep
    reads those as well as this file.

    One more *inherits* one rather than setting it -- `.admin-picker-control
    select` says `line-height: inherit` -- so it now takes 1.5 where it used
    to take the UA's `normal`. Measured rather than assumed: it renders no
    differently, because it is a `<select>` and the browser forces a select's
    line box whatever the computed value says. Worth knowing before it becomes
    something other than a select. (`.ctl-select` did the same through the
    `font` shorthand until issue #564 dropped it, and measured no different
    either way.)
  */
  font-size: 100%;
  line-height: 1.5;
  background: var(--bg);
  color: var(--text);
  padding-bottom: env(safe-area-inset-bottom);
}
/*
  Form controls do not inherit `font-family`: every browser sets its own `font`
  shorthand on them, and that beats inheritance. So the rule above reaches the
  app's prose and stops at its buttons.

  It cost nothing while `body` named the system stack, because the UA's control
  font was one of the same faces. With a vendored typeface it is visible, and
  the sharpest case was a pair of siblings: `<a class="btn-secondary-sm">` in
  IBM Plex beside `<button class="btn-secondary-sm">` in Arial -- one class,
  two faces, two box heights, in the same flex row.

  `font-family` alone, not `font: inherit` and not `line-height`. The shorthand
  would also hand controls the body's 1.5, which changes the height of every
  button, input and select in the app -- a retune with its own browser pass,
  not a side effect of shipping a face. Controls keep the UA's `normal` here.

  Four rules already did this one at a time. The two that did nothing else --
  `.logout-btn` and account_list.css's row menu -- are gone with this rule.
  `.admin-picker-control select` stays because it carries more than
  `font-family`: it also says `line-height: inherit` (its own comment owns
  why). It shadows this rule rather than duplicating it, so a later edit here
  will not reach it -- a thing to check when editing this stack. The fourth,
  `.ctl-select`'s `font: inherit`, went in issue #564: it also reset the
  size, and the filter panel's selects take the field base rule's 16px.
*/
button, input, select, textarea, optgroup { font-family: inherit; }
/*
  Every field takes the theme by default (issue #530). Without this a field
  is whatever the browser draws: white on the light side and a dark grey on
  the dark side that belongs to no palette, unless its own rule remembers to
  colour it -- two did, the rest did not, and a field added tomorrow would
  not either.

  `:where()`, so the rule weighs nothing and every class still wins: the
  picker pill's transparent fill, a disabled limit's sunk --bg, a refused
  field's red ring. Text-entry fields only; checkboxes and radios keep the
  browser's own drawing, and the button-typed inputs are buttons.

  `color-scheme` still decides what no property here reaches -- a date
  picker, and a select's chevron and open list wherever the rule below
  does not apply.

  The text is `1rem`, the body size, rather than `inherit`, which takes the
  label's 0.85rem: iPhone Safari zooms the page into any field under 16px
  when it is tapped, and the Preferences limits are filled in on phones.
  `1rem` still follows the reader's own browser text size, which is what
  `inherit` was for. A control that sets its own size keeps it.
*/
:where(input:not([type="checkbox"], [type="radio"], [type="submit"], [type="button"], [type="reset"], [type="image"], [type="file"], [type="range"], [type="color"]), select, textarea) {
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--field-edge);
  font-size: 1rem;
}
/*
  The open dropdown list takes the theme too (issue #555), where the
  browser can draw it: `appearance: base-select`, customizable select.
  Without it the list is the platform's own, and its highlighted row is the
  operating system's colour -- blue in Chrome on Windows, grey in Edge --
  which `accent-color` does not move (a red one on the select, Chromium
  152, left the row blue).

  **Two gates, and both are the rule.** `@supports`, so a browser without
  base-select -- Safari and Firefox today -- gets none of this, including
  the option colours Firefox would otherwise paint into its own list. The
  media query, so only a mouse or trackpad gets it: Chrome on Android has
  base-select too, and there it would trade the phone's own picker sheet
  for an in-page list a thumb does worse with.

  **The rows are the Accounts row menu's** (account_list.css): `--surface`
  panel, `--border` edge, the raised shadow, and an `--accent-light` tint
  on the row under the pointer or the keyboard, which also gets the focus
  ring. The chosen row is a check
  and a semibold label with no fill, so hover and chosen never look alike
  and the check carries the state without colour.

  **The closed control changes too, and three rules hold it to what it
  was.** Padding, size and corners are each class's own already, and the
  height comes out within half a pixel of the native box's (a full-width
  `.form-field` select is 2px taller). What base-select does differently:

  - It inherits the type around it, where the native control reset it,
    so a select inside a bold `<label>` (Create Account's Role, the role
    filter) went bold. The native resets come back -- weight, style,
    letter-spacing, case -- and a class that sets one still wins. Line
    height is left to inherit: the body's 1.5 is what matches the native
    box's height.
  - It is a flex box, and `.form-field`'s `display: block` stacks the
    arrow under the text, so that one class is put back to `flex`.
  - A long chosen option runs over the arrow or wraps. Every select's
    first child is `_select_shown_option.html`, which keeps it to one line
    and cuts it short; the template carries why.

  The width is the one change kept: a base-select box is as wide as the
  option it shows, not its longest, since it no longer lays out the ones
  it is not showing. A select whose width a class sets is unaffected.

  **The section picker opts in by name**, beside its own rules further
  down: its `appearance: none` outranks the zero-weight rule here.
*/
@supports (appearance: base-select) {
  @media (hover: hover) and (pointer: fine) {
    :where(select), ::picker(select) { appearance: base-select; }
    :where(select) {
      font-weight: 400;
      font-style: normal;
      letter-spacing: normal;
      text-transform: none;
    }
    select.form-field { display: flex; }
    select > button { flex: 1 1 auto; min-width: 0; }
    selectedcontent { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    ::picker(select) {
      background: var(--surface);
      color: var(--text);
      border: 1px solid var(--border);
      border-radius: var(--radius-ctl);
      box-shadow: var(--shadow-hi);
      padding: 6px;
      /* Both edges: the list opens above the box when there is no room
         below, and the gap belongs on whichever side meets it. */
      margin-block: 4px;
    }
    /* Rows are regular weight whatever the closed box wears -- the section
       picker's is the active tab's semibold -- so the chosen one stands out. */
    option {
      font-weight: 400;
      gap: 6px;
      padding: 6px 10px 6px 6px;
      border-radius: 6px;
    }
    /* `select option`, one element heavier than `option`, so the section
       picker's own option colours (further down) cannot cover the tint.
       The keyboard's row also wears the app's focus ring, inset so the
       rows do not overlap: the tint alone is about 1.2:1 against the panel,
       and a focus mark needs 3:1. Measured as the change focus makes, the
       panel the row sat on to the ring that replaces its edge, the ring is
       --accent on --surface: 3.06:1 at its lowest (Ember). Against the
       tint inside it, the dark side falls under 3:1 (Dark 2.73, Ember
       2.41); the light side clears it. It is the app's one focus ring
       rather than a second colour for dropdowns. It also tells the
       keyboard's row from the pointer's, which Chrome does not move
       together. */
    select option:is(:hover, :focus-visible) { background: var(--accent-light); }
    select option:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: -2px;
    }
    option:checked { font-weight: 600; }
    option::checkmark { color: var(--accent-ink); }
    select::picker-icon { color: var(--muted); transition: rotate 0.15s ease; }
    select:open::picker-icon { rotate: 180deg; }
  }
  @media (prefers-reduced-motion: reduce) {
    select::picker-icon { transition: none; }
  }
}
/*
  Every link takes the theme by default (issue #356). Without this a link
  whose own class sets no colour is the browser's blue -- the pager on four
  pages and My Assignments' empty-result notes were. Underlined at rest,
  because colour alone cannot carry it: --accent-ink against --text is under
  1.7:1 in every palette, so a link in a sentence would read as the
  sentence. The underline thickens on hover; a visited link keeps the same
  colour, since nothing here is a list somebody works through.

  `:where()` for the field rule's reason: every class that colours its own
  link, or takes the underline off, still wins.
*/
:where(a[href]) {
  color: var(--accent-ink);
  text-decoration-line: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.18em;
}
@media (hover: hover) {
  :where(a[href]:hover) { text-decoration-thickness: 2px; }
}
/* A link to a spot on the page -- an FAQ card, the audit log's pages -- and
   a scrollIntoView() stop 16px below the sticky bar rather than under it.
   `--app-bar-height` is set by app.js, which measures the bar because its
   height changes with the width and the nav; the fallback is the bar's
   one-line height on a wide screen, its padding-top max() plus 51px: a 36px
   link, 14px below it and the 1px rule. */
html {
  scroll-padding-top: calc(var(--app-bar-height, calc(max(14px, env(safe-area-inset-top)) + 51px)) + 16px);
}
header.app-bar {
  background: var(--bar-bg);
  color: var(--on-accent);
  padding: 14px var(--space-4);
  padding-top: max(14px, env(safe-area-inset-top));
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* The hairline is drawn inside the bar's own dark field, so it reads as a
     bevel rather than a border; the 1px drop below is what separates the bar
     from the page once both are this close in value. */
  border-bottom: 1px solid var(--bar-line);
  box-shadow: var(--bar-shadow);
}
header.app-bar h1 {
  font-size: 1.1rem;
  margin: 0;
  font-weight: 650;
  letter-spacing: -0.01em;
}
/* 16px since issue #564, the size of every other control and of the body
   text. The bar collapses to the menu below 1100px (the media block by
   `#nav-links`) partly to pay for it: at 16px the admin's row needs
   1,071px. Log Out is the one button among the links, and takes their text
   from here too. */
header.app-bar a, header.app-bar .logout-btn {
  color: var(--on-accent);
  text-decoration: none;
  font-size: 1rem;
  opacity: 0.88;
}
/*
  The outlined pill every nav destination wears (issue #136). Eight anchors in
  `base.html` carried this as a `style=` attribute -- seven of them 164 bytes
  and the twice-nested My Signups one 166 -- re-sent with every render. Same
  cost issue #134 moved this whole file out of `base.html` to avoid, still being
  paid in the file #134 had just emptied.

  The byte figures below predate issue #141, which now compresses every
  template response: on the wire the duplication cost roughly a twenty-seventh
  of what is quoted here. The de-duplication is still worth having for the
  reason it always was -- one rule to change instead of eight.

  **Eight in the template, but at most six on any page**, so don't recompute the
  saving as 8 x 164. Two separate pairs are mutually exclusive, and only the
  first is visible from this template:

    * the two Dashboard links, an `{% if is_admin_user %}` /
      `{% elif is_readonly_viewer %}` pair, and
    * My Signups and Schedule -- `shifts/context_processors.py:36,49` gives the
      first to a physician and the second to a non-physician, so no account can
      hold both.

  Brute-forcing every role x flag combination puts the ceiling at six.

  What that saves depends on who is looking, which is worth stating because the
  obvious measurement is the unrepresentative one: a *superuser* sees six and
  saves 925 bytes per render, but the Overtime Admins on production are group
  members rather than superusers, get five, and save ~778. A plain physician
  sees three.

  Only three declarations, because the four in the rule above are not repeated:
  it already gives every app-bar anchor its colour, font-size, opacity and
  `text-decoration: none`, and half of each inline copy was restating them.
  Neither rule sets `font-weight` -- these render at the UA default, and there
  is nothing in the app bar to inherit one from.

  Scoped to `header.app-bar a` rather than being a free-floating `.nav-link`
  because the class is not self-sufficient and should not look as though it is.

  **Log Out wears it too** (issue #564), here and in the two feedback rules
  below. In this bar the outline is the sign that something can be pressed,
  and Log Out was the one control without it -- bare text, 20px tall. Not an
  underline: in this app that marks a link inside a sentence (#356), and a
  navigation bar is the standard exception. It is a button, since signing out
  is a POST; the anchor rule above names it too, and `.logout-btn`, by
  `#nav-links`, resets what a button brings of its own.

  `nowrap`, so a row too wide for the bar breaks between whole links rather
  than inside one (issue #564; `#nav-links` wraps).
*/
header.app-bar a.nav-link, header.app-bar .logout-btn {
  border: 1px solid var(--bar-outline);
  border-radius: var(--radius-ctl);
  padding: 5px 11px;
  white-space: nowrap;
  transition: opacity 0.1s ease, background 0.1s ease, transform 0.1s ease;
}
/*
  The nav half of issue #188: every button had feedback, no nav element did.
  The nav pills are transparent fills on the dark bar -- a brightness
  filter would move only text and border, which is why this family can
  never join the hover tokens' tiers (--on-accent ink notwithstanding);
  the lift is a white wash plus full opacity instead, the
  failed-email-alert's press scale for touch. Hover gated like every hover in this file -- see
  the lift group's comment; :active stays ungated and later, so press wins.
*/
@media (hover: hover) {
  header.app-bar a.nav-link:hover, header.app-bar .logout-btn:hover {
    opacity: 1;
    background: var(--bar-wash);
  }
}
header.app-bar a.nav-link:active, header.app-bar .logout-btn:active {
  transform: scale(0.96);
  background: var(--bar-wash-press);
}

/*
  The undelivered-email indicator (issue #185). A solid light pill among the
  outlined transparent ones, which is what makes it read as an alert rather than
  a sixth destination -- and it is the reason the fill is `--surface` and the
  text `--text` rather than a red fill with white on it: dark text on white is
  about 15:1, and the red is spent on the glyph, where it reinforces a signal
  the glyph and the number already carry. That ordering is
  the flash rules' convention applied here: colour may support the meaning, never
  be it, because red and green are the pair colourblind viewers cannot separate.

  `margin-left: auto` rather than a `gap` on `header.app-bar`. The bar is
  `justify-content: space-between` with the title at one end, so a third in-flow
  child would otherwise be spread into the middle at every width. An auto margin
  consumes the free space before `justify-content` sees it, pinning this to the
  right beside whatever follows -- the nav row on a wide screen, the hamburger
  below 1100px, where `#nav-links` leaves the flow entirely. Adding `gap` to the bar
  would have restyled the existing title/nav spacing on the way past, which is
  the issue #136 mistake.
*/
header.app-bar a.failed-email-alert {
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
  margin-left: auto;
  margin-right: 14px;
  padding: var(--space-1) 10px;
  /*
    Control radius, and bordered, because in this bar that pair *is* the
    "I am a link" marker -- every nav destination wears it. The border is
    `--excluded` rather than the nav's `rgba(255,255,255,0.32)` for the plain
    reason that a white outline on a white fill cannot be seen; spending the
    red here also keeps it off the text, which stays dark-on-white at roughly
    15:1.

    Not to be confused with the status pills in `_status_pills.html`
    ("Signups open", "Not published"), which are genuinely non-clickable and are
    `border-radius: 999px`. Fully round reads as a label in this app; the 8px
    rounded rectangle reads as a control. Keep that distinction if either
    changes.
  */
  border: 1px solid var(--excluded);
  border-radius: var(--radius-ctl);
  background: var(--surface);
  color: var(--text);
  opacity: 1;
  font-weight: 600;
  white-space: nowrap;
  transition: transform 0.1s ease, background 0.1s ease;
}
/* Plain hue, sanctioned: the glyph is a mark, not text (token charter) --
   aria-hidden, with the count and label beside it carrying the meaning. */
header.app-bar a.failed-email-alert span[aria-hidden] {
  color: var(--excluded);
}
@media (hover: hover) {
  header.app-bar a.failed-email-alert:hover {
    background: var(--excluded-bg);
  }
}
/*
  Press feedback, matching `.btn-secondary-sm`. Hover does not exist on a
  phone, and the mobile bar is exactly where this renders as a bare glyph and
  number -- so the tap response is the only confirmation a touch user gets that
  they hit a control rather than a label.
*/
header.app-bar a.failed-email-alert:active {
  transform: scale(0.96);
  background: var(--excluded-bg);
}
/* Its wording hides where the bar collapses: that rule is in the menu's
   media block, by `#nav-links`, so the breakpoint is spelled once.

   Its right margin stays 14px there. A first draft also dropped it to 12px,
   which `/simplify` asked the awkward question about: 14px is principled,
   matching `#nav-links`' own `gap` so the pill sits on the same rhythm as the
   links beside it, and the 12px was a value nobody had evaluated. That is the
   shape of the issue #136 mistake -- a number that had never rendered quietly
   restyling a surface -- so the no-change option wins until someone
   deliberately decides otherwise. */
main {
  max-width: 480px;
  margin: 0 auto;
  padding: var(--space-3);
}
/*
  The auth pages' column (#447). The column this caps -- 480px here, 900px past
  the breakpoint -- is sized for a table, a grid or a card list; the pages under
  templates/registration/ hold a two-field form and the sentence or two around
  it, and nothing on them wants that width.

  The cap is on the column rather than on `.auth-form` because the prose above
  the form and the links below it are the form's siblings, not its children --
  and password_reset_sent.html has no form at all -- so capping the form would
  centre it under a full-width paragraph and leave that page untouched.

  Everything is border-box (grep box-sizing), so the column carries main's padding
  on top of the width that was picked by eye: 400px is what the inputs measure.
  Outside the `min-width: 768px` block deliberately -- a ceiling needs no
  breakpoint. It binds above a 424px viewport, which reaches the larger phones
  too: a 430px screen loses about 6px. Below that `main` is already the narrower
  of the two and the rule does nothing. Grep main-narrow for the wearers, and
  main.main-wide for the modifier this one pairs with.
*/
main.main-narrow { max-width: calc(400px + 2 * var(--space-3)); }

.month-heading {
  font-size: 1.6rem;
  font-weight: 700;
  letter-spacing: -0.015em;
  margin: var(--space-1) 0 var(--space-3);
  color: var(--text);
}

/*
  One text size and line height for a button and for a link wearing the
  same class (issue #560). A <button> otherwise takes the browser's 13.3px
  and `normal` line height while an <a> inherits the body's 16px and 1.5,
  so a pair in one row showed two sizes and stood 4px apart in height.
  `rem`, so the reader's own browser text size still scales them.
*/
.btn-primary, .btn-secondary, .btn-outline, .btn-danger {
  font-size: 1rem;
  line-height: 1.5;
  padding: 10px 18px;
  border-radius: var(--radius-ctl);
  font-weight: 600;
  cursor: pointer;
  box-shadow: var(--shadow);
  /* No background channel: since the hover amendment nothing swaps these
     buttons' background in place -- publish-ready/-not-ready arrive by node
     replacement, which never transitions. */
  transition: transform 0.1s ease, filter 0.1s ease, box-shadow 0.1s ease;
}
/* A transparent border, not none: the other three wear 1px, so a filled
   button beside them would stand 2px shorter. Forced colours paints it in,
   which gives the filled button an edge where its repainted fill was. */
.btn-primary {
  border: 1px solid transparent;
  background: var(--accent);
  color: var(--on-accent);
}
.btn-secondary {
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
}
.btn-outline {
  border: 1px solid var(--accent);
  background: var(--surface);
  color: var(--accent-ink);
}
.btn-danger {
  border: 1px solid var(--excluded);
  background: var(--surface);
  color: var(--excluded-ink);
}
/*
  THE lift -- issue #257's hover amendment, one rule for every surface that
  carries the identical body: rise 1px, deepen the shadow, shift the
  element's brightness in its own colour -- var(--hover-lift), so the
  theme sets the strength and direction. brightness() replaces the old
  per-variant background swaps -- white darkens to warm grey, a
  red-tinted button to deeper red -- so the feedback is uniform without
  a hover colour per variant. One grouped rule rather than a copy per
  component, so the next tune is one edit; each selector keeps its own
  specificity, and every member's :active sits later in source, so
  holding a control still wins the transform tie.

  Gated on (hover: hover) -- every :hover in this file is -- because touch
  browsers emulate hover on tap and make it *stick*: a badge tap whose
  confirm is cancelled sends no request, swaps nothing, and would
  otherwise sit lifted until the next touch. Press feedback (:active) is
  the touch channel and is deliberately outside the gate.

  One selector is here despite living "elsewhere": the grid one is safe
  outside the width media block (below 768px the grid is display:none, so
  it is inert exactly where the block would exclude it). The shift compounds restate the category
  bar into the raised shadow -- see the bar comment at "Shift-type accent".
*/
@media (hover: hover) {
  .btn-primary:hover, .btn-secondary:hover, .btn-outline:hover, .btn-danger:hover,
  .btn-secondary-sm:hover,
  .day-row.tappable:hover,
  .calendar-grid .grid-cell.tappable:hover,
  /* Not while the pointer is on the Archive control: that form sits inside the
     card now (issue #390), so an unqualified `:hover` would lift the whole card
     from a pointer aimed at one button in its corner.

     **A `:has()` inside a shared selector list is fused with `:is()`; a
     standalone one is not.** That is the whole rule, and the difference is
     blast radius rather than doubt about support. One invalid selector
     invalidates the entire list it sits in, and this list is eight other
     controls -- every small button, the day rows, the grid cells, the month
     arrows -- so a renderer that cannot parse `:has()` would drop the lift from
     all of them to isolate one card. `:is()` takes a forgiving list, so an
     argument it cannot parse is discarded on its own and the rest survive.
     `.toggle-chip:has(input:checked)` needs none of this: it is a rule of its
     own, so its failure reaches only itself. Specificity is unchanged, since
     `:is()` and `:not()` both take their argument's. */
  .month-card:is(:not(:has(.month-card-action:hover))):hover,
  /* The Accounts phone card (issue #540), by the month card's rule: not from
     a pointer on its Unreleased shifts link, and not on a protected
     superuser's card, which opens nothing. */
  .person-card:is(:not(.is-protected, :has(.stranded-link:hover))):hover,
  /* Only the live arrows: the disabled one is a <span>, so it cannot match
     this and needs no :not() to keep it out. */
  a.month-nav-arrow:hover,
  .row-menu summary:hover {
    transform: translateY(-1px);
    box-shadow: var(--shadow-hi);
    filter: var(--hover-lift);
  }
  .day-card.tappable.shift-tue-thu:hover {
    box-shadow: var(--cat-bar-a), var(--shadow-hi);
  }
  .day-card.tappable.shift-fri-mon:hover {
    box-shadow: var(--cat-bar-b), var(--shadow-hi);
  }
  .calendar-grid .grid-cell.tappable.shift-tue-thu:hover {
    box-shadow: var(--cat-bar-a), var(--shadow-hi);
  }
  .calendar-grid .grid-cell.tappable.shift-fri-mon:hover {
    box-shadow: var(--cat-bar-b), var(--shadow-hi);
  }
  /* Solid tier (the -solid token's comment in :root carries the why and
     the roster). Same lift motion and shadow -- only the filter tier
     differs. This rule beats the group's identical selector by SOURCE
     ORDER alone: keep it after the group inside this media block, and
     never merge it into the group -- merged, the primary silently drops
     to the lift tier and no test notices. */
  .btn-primary:hover { filter: var(--hover-solid); }
}
.btn-primary:active, .btn-secondary:active, .btn-outline:active, .btn-danger:active {
  transform: scale(0.97);
  filter: brightness(0.91);
}
/* Keyboard focus in the theme's own accent, for the whole button family --
   the browser default ring ignores the palette. Grouped like the hover rule
   above and for the same reason: one edit tunes every wearer. Born scoped to
   the confirm dialog's buttons; widened app-wide because the justification
   never was dialog-shaped (/code-review on PR #307). :focus-visible, not
   :focus, so a mouse click does not paint a ring.

   The two day-cell tap surfaces join the family (issue #258): they carry
   role="button" and tabindex, so keyboard focus lands on them and has to
   show. The 2px *outward* offset is load-bearing there, not inherited
   styling: the state ring (further down) is an outline at -2px, and outline
   is one property -- an inset focus ring would be invisible exactly on the
   mine/needs-coverage days. While a state day holds focus the accent ring
   outside replaces its state ring; the state's fill and border stay, so the
   day still reads. The grid selector is safe outside the width media block:
   below 768px the grid subtree is display:none. `.tappable` scopes it and
   outranks the state rules; only tappable cells are focusable at all.

   The admin dashboard's own controls join it too (issue #565): the name
   chips, the date and the row's ›, the panel's ‹ › and ×, the rail's links
   and fold buttons, and the Max stepper -- and the phone's Month controls
   row (issue #574). */
.btn-primary:focus-visible, .btn-secondary:focus-visible, .btn-outline:focus-visible,
.btn-danger:focus-visible, .btn-secondary-sm:focus-visible, .btn-ghost-sm:focus-visible,
.day-row.tappable:focus-visible,
.calendar-grid .grid-cell.tappable:focus-visible,
.name-chip:focus-visible, .day-open:focus-visible, .row-open:focus-visible,
.panel-step:focus-visible, .panel-close:focus-visible, .rail-link:focus-visible,
.rail-fold:focus-visible, .rail-edge-btn:focus-visible, .max-step:focus-visible,
.controls-toggle:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* `line-height` for the full-size buttons' reason, up beside them (issue
   #560, carried here in #565). */
.btn-secondary-sm, .btn-ghost-sm {
  /* background stays a live channel here, unlike the full-size buttons:
     ghost hover and both :active states still swap it. */
  transition: transform 0.1s ease, filter 0.1s ease, background 0.1s ease,
              box-shadow 0.1s ease;
  font-size: 0.8rem;
  line-height: 1.5;
  padding: var(--space-1) 10px;
  border-radius: 6px;
  cursor: pointer;
}
/* Elevation on the solid small button but not the ghost one -- the ghost
   variant is the deliberately quiet member of the pair. */
.btn-secondary-sm {
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  box-shadow: var(--shadow);
}
/* Hover: the shared lift group above. */
.btn-secondary-sm:active {
  transform: scale(0.96);
  background: var(--accent-light);
}
.btn-ghost-sm {
  border: 1px solid var(--border);
  background: none;
  color: var(--muted);
}
/* The ghost stays shadowless on hover too -- it rises and shifts
   brightness like every control, but never gains the elevation it
   deliberately lacks at rest. Its own rule, not a lift-group member:
   different body. Known exception on the dark themes (#293, accepted):
   the fill feedback here is the background swap, brightness on a
   background of none moves only text and border, and --bg sits darker
   than the surface-2 panel this button lives on -- so this one fill
   dims where its siblings lighten. A redesign needs a
   "lighter-than-panel" token that does not exist; file it if the dim
   bothers anyone in use. */
@media (hover: hover) {
  .btn-ghost-sm:hover {
    transform: translateY(-1px);
    filter: var(--hover-lift);
    background: var(--bg);
  }
}
.btn-ghost-sm:active {
  transform: scale(0.96);
  background: var(--accent-light);
}
.day-row.tappable {
  cursor: pointer;
  transition: transform 0.1s ease, filter 0.1s ease, box-shadow 0.1s ease;
}
/* Hover: the shared lift group above (with the bar-restating compounds).
   Press tiers, since the values look scattered: full-size cards press at
   0.98, buttons at 0.97, small controls (badges, small buttons, nav, grid
   cells, row menu) at 0.96 -- the bigger the surface, the shallower the
   press. Brightness rides alongside at 0.91 for solid fills, 0.93 for
   tinted cards and the link chips: the tinted press matches the hover
   darken and the solid press digs past it, the same relation the
   pre-#293 values held, moved
   with the hover retune -- a press may never sit BRIGHTER than the hover
   it interrupts, or the click reads as a release. Literals, not the
   hover tokens, on purpose (#293): a press darken barely shows on a
   charter-dark fill in any theme (the solid tier's wearers included),
   so scale motion is what carries press there, judged sufficient. Two
   exceptions to the isolation: .btn-secondary-sm:active and
   .btn-ghost-sm:active swap background and set no filter, so their held
   fill keeps whatever the hover token painted. Revisit if presses feel
   dead in real use. */
.day-row.tappable:active,
/* The press half of the hover exclusion above, and it is the half that shows on
   a phone: `:active` propagates to ancestors, so a tap on Archive would scale
   and darken the entire card in the instant before the confirm dialog opens.

   Fused for that rule's reason: `.day-row.tappable` shares this list, and it is
   the signup surface. */
.month-card:is(:not(:has(.month-card-action:active))):active,
.person-card:is(:not(.is-protected, :has(.stranded-link:active))):active,
/* The dashboard's phone list (issue #565): no control inside it, so nothing
   to exclude. Press only -- the list shows below 768px, where nothing hovers,
   and the lift would replace its category edge. The Month controls row
   above it (issue #574) is a card-sized press for the same reason. */
.day-summary:active,
.controls-toggle:active {
  transform: scale(0.98);
  filter: brightness(0.93);
}
.form-field {
  display: block;
  width: 100%;
  margin-top: var(--space-1);
}
/* One box for a field and for the dropdowns that sit once on a page -- the
   Assignments filter panel's, the month switcher and the Accounts role filter
   -- so 40px tall, the account page's Person box (issue #564). Each keeps its
   own width. The text is the field base rule's 16px: none of them sets a size
   of its own, which is what let five dropdown styles drift under it before.

   The dropdowns repeated on every row -- `.add-select` -- keep their tighter
   box: at 40px each day card on the dashboard would grow by about 11px. */
.form-field, .ctl-select, .month-switcher, .filter-select {
  padding: var(--space-2) 10px;
  border-radius: var(--radius-ctl);
}

/*
  Flash messages, colour-coded by level. The same banner carries both
  "Schedule published" and "Not cleared: ... reload and try again", so a
  single success-green style made every refusal read as a confirmation.
  Colour alone is not enough to carry that: success/error is exactly the
  green/red pair red-green colourblind viewers cannot separate, and the
  tinted backgrounds are pale enough to be near-identical to them. Each
  level therefore also gets a distinct leading glyph and a thicker left
  border. An unrecognised level keeps .flash's neutral surface styling
  rather than silently inheriting some other level's colour.
*/
.flash-list { margin-bottom: 14px; }
.flash {
  display: flex;
  gap: var(--space-2);
  align-items: baseline;
  background: var(--surface);
  border: 1px solid var(--border);
  border-left-width: 4px;
  border-radius: var(--radius);
  color: var(--text);
  padding: 10px 14px;
  margin-bottom: var(--space-2);
  font-size: 0.9rem;
  box-shadow: var(--shadow);
}
.flash::before { font-weight: 700; }
/* Each glyph is text on its level's tint, so it wears the -ink form
   (issue #281). Warning is the sanctioned exception: it has no -ink,
   and the token block's --warning note carries why. */
.flash-success { background: var(--mine-light); border-color: var(--mine); }
.flash-success::before { content: "\2713"; color: var(--mine-ink); }
.flash-error { background: var(--excluded-bg); border-color: var(--excluded); }
.flash-error::before { content: "\2715"; color: var(--excluded-ink); }
.flash-warning { background: var(--warning-bg); border-color: var(--warning); }
.flash-warning::before { content: "!"; color: var(--warning); }
.flash-info { background: var(--accent-light); border-color: var(--accent); }
.flash-info::before { content: "i"; color: var(--accent-ink); }
/* A flash beside the control it answers rather than at the top of the page:
   an Accounts action's note, under its row on the list and inside its card
   on the account's own page (issue #561). Compact, since it sits among
   controls rather than above everything. */
.acct-note {
  margin: 0; padding: 6px var(--space-3); font-size: 0.85rem;
  border-radius: var(--radius-sm); box-shadow: none;
}
.acct-note + .acct-note { margin-top: var(--space-1); }

/*
  The shared confirm dialog (issue #306) -- base.html's one <dialog>, filled
  per call site by app.js. The buttons wear the app's own .btn-* classes, so
  they get the grouped hover lift, the grouped focus ring below, and the
  theme retunes for free; nothing here restyles them.

  max-width is min()'d against the viewport because a bare 420px would
  *replace* the UA stylesheet's own viewport clamp on <dialog> rather than
  add to it -- on a 375px phone a long confirm rendered exactly edge to
  edge (/code-review on PR #307, reproduced at 375px).

  The dim behind the dialog is the second box-shadow, not ::backdrop:
  ::backdrop only learned to read custom properties from the originating
  element in 2023-24 engines, and on the older ones inside this app's own
  baseline (Safari 15.4-17.3 has showModal but not the inheritance) a
  var() there computes to *transparent* while still suppressing the UA's
  default dim. The dialog element inherits tokens everywhere it exists at
  all, so a viewport-sized shadow spread carries the same token to every
  engine; the UA's faint backdrop dim underneath it is unstyled and adds
  imperceptibly.

  white-space: pre-line because four confirm messages carry a real blank line
  that a normal <p> would collapse -- the native confirm() honoured those
  newlines and the message text is unchanged in this move. Three are
  conditional warnings (Run Fill-In's and Run Master's provisional clauses,
  Run Master's and Clear's at-risk clauses); Close Signups Now's is
  unconditional, and app.js
  composes it from `data-confirm-alt-question` rather than the template
  writing the blank line in.
*/
dialog.confirm-dialog {
  max-width: min(420px, calc(100vw - 32px));
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-hi), 0 0 0 100vmax var(--backdrop);
  padding: 20px;
}
.confirm-dialog-message {
  margin: 0 0 18px;
  font-size: 0.95rem;
  line-height: 1.5;
  white-space: pre-line;
}
.confirm-dialog-buttons {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}
/*
  Three buttons on a phone (issue #395). Only the two-action confirm wears
  the stackable class -- app.js adds it from `opts.altLabel` and removes it
  on every other ask -- so the two-button confirms every other destructive
  control uses keep their single right-aligned row at every width.

  The breakpoint is measured rather than borrowed from the 767px the rest of
  this file uses. The dialog is `min(420px, 100vw - 32px)` wide with 20px of
  padding each side, so the button row is `min(380, 100vw - 72)`. The three
  labels plus their gaps need 354px, which the row has once the viewport
  reaches 426px. 460px is that figure with a margin: the labels are text, so
  a wording change or a fallback font moves the requirement, and the cost of
  stacking early is a taller dialog on a large phone, while the cost of
  stacking late is what the unfixed version does -- flex squashes the buttons
  and breaks every label onto two lines rather than overflowing, which is
  quieter and worse.

  column-reverse, so source order still reads Cancel, alt, action: that keeps
  the action button rightmost on the wide row *and* first on the stack, which
  is where a thumb lands. Cancel sits at the bottom and keeps the focus it is
  given on a destructive ask.
*/
/*
  Tab order runs bottom-to-top here, and it is a trade rather than an
  oversight (#395 review measured it: visual Email Everyone / Don't Email /
  Cancel, tab Cancel -> Don't Email -> Email Everyone).

  A flex container paints in `flex-direction` order and Tab follows the DOM,
  so the two agree only when source order *is* visual order. The wide row
  wants Cancel leftmost and the action rightmost; the stack wants the action
  on top and Cancel at the bottom, which every platform's stacked-dialog
  guidance asks for and which is the shape this dialog was signed off in.
  Those are opposite orders, and one DOM cannot be both -- so whichever is
  not reversed, the other is.

  It sits on the stack because that is where keyboards are rarest, and
  because the alternative is inverting the row for *every* confirm in the
  app to fix one dialog on phones. Focus order still runs least to most
  consequential (Cancel, then the quiet close, then the mail), so the
  sequence keeps its meaning even where it disagrees with the paint.

  `align-items: stretch` and `button { width: 100% }` were here and are not:
  measured at 299px, the buttons come out 299px wide with both, with either,
  and with neither -- a flex column already stretches its items.
*/
@media (max-width: 460px) {
  .confirm-dialog-buttons-stackable { flex-direction: column-reverse; }
}

/*
  Forced colors replaces author backgrounds, borders and ink with system
  colors. `.btn-outline` and `.btn-secondary` differ only in hue, so they
  collapse into one bordered button -- on the two-action dialog, "close
  signups quietly" and "do nothing" -- and the action button's fill, the
  only thing marking it as the yes, is repainted away.

  What the mode leaves to the author is shape, not colour: text decoration
  and border width and style survive. So the alt takes the underline,
  already how `app.css` marks the accent chip in this mode, and the action
  button takes a heavy border, on every confirm, since every confirm lost
  its fill.
  Cancel keeps the plain border, so the three are distinct from each other.
*/
@media (forced-colors: active) {
  #confirm-dialog-action { border: 3px solid; }
  #confirm-dialog-alt { text-decoration: underline; }
}

/*
  Equal-width tabs (issue #257): grid columns rather than content-hugging
  inline-flex, so every tab in a bar is the same size -- and so the columns
  can compress equally on a phone via minmax(0, 1fr) plus max-width instead
  of overflowing the viewport (the four-tab config nav was ~11px wider than
  a 375px screen with content-width tabs). The narrow-viewport padding rule
  below keeps the longest label inside its compressed column down to ~350px
  viewports; narrower than that, the ellipsis on the link is the bound, not
  the padding. The rule's 480px trigger is main's own mobile max-width cap
  -- the point below which the container starts tracking the viewport -- so
  if that cap moves, this breakpoint moves with it.

  A sliding active pill was tried here and deliberately dropped -- the rule
  is CONVENTIONS.md "The tab pill does not slide"; the decision and the
  sanctioned same-document route are #264.
*/
/* The pill the row sits in, shared with the picker that replaces the config
   nav below 768px (.admin-picker, further down). One rule naming both rather
   than a second copy of six declarations: they are peers wearing one chrome,
   the arrangement .month-nav-arrow already uses for its live and inert
   spellings. Only the grid below is the bar's own. */
.admin-tabs, .admin-picker {
  max-width: 100%;
  border: 1px solid var(--accent);
  border-radius: 999px;
  padding: 3px;
  margin-bottom: 20px;
  background: var(--surface);
  box-shadow: var(--shadow);
}
.admin-tabs {
  display: inline-grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
}
/* The weight and pill shape a tab wears, shared with the picker's control so
   the collapsed form cannot drift from the row it stands in for -- they are
   required to look like the same object, and below 768px the picker is the
   only one of the two anybody sees, so a drift would show on a phone and
   never on a desktop.

   `padding` is deliberately NOT in this group. The 480px override below
   narrows it to compress a four-column grid, which is a bar problem; the
   picker is one inline-block control and wants its own asymmetric padding
   for the divider and arrow.

   **Nor is the size.** Both are 1rem, from two rules: the picker is a
   select, and iPhone Safari zooms the page into a select under 16px when it
   is tapped, so it takes the field base rule's 1rem (issue #564); the tabs
   set their own 1rem below (issue #565). A tab is a link, so naming it here
   would restate the field rule for the picker rather than share anything. */
.admin-tabs a, .admin-tabs .tab-dead, .admin-picker-control select {
  font-weight: 600;
  border-radius: 999px;
}
/* 16px text on a 40px tab (issue #565): the body's 1.5 line height plus
   8px above and below. The same size as the full-size buttons and the
   fields beside the bars, so no bar is the small control in its row. */
.admin-tabs a, .admin-tabs .tab-dead {
  font-size: 1rem;
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  text-decoration: none;
  padding: var(--space-2) 18px;
  background: transparent;
  color: var(--accent-ink);
  transition: background 0.1s ease;
}
/* The active tab's fill, worn by the collapsed picker for the same reason as
   the group above: it *is* the active tab, drawn on its own. The lit tab is
   selected by `aria-current="page"` rather than a class, here and in the three
   rules below: the attribute a screen reader announces is the one the eye
   sees, so the two cannot be marked apart (#541 review). */
.admin-tabs a[aria-current="page"], .admin-picker-control {
  background: var(--accent);
  color: var(--on-accent);
}
/* A tab for a view this month does not have yet -- signup.html's "Assigned"
   before publish (#267). Same idea as .month-nav-arrow-dead: it keeps its
   grid slot so the bar does not change width when the state changes, and it
   is a <span>, so none of the interactive rules above or below can reach it
   without a selector naming it.

   Deliberately NOT the arrows' 0.38. That value dims a chevron, which only
   has to read as "absent"; this is a word somebody has to read. Effective
   contrast against the bar, measured in both palettes: 0.38 gives 2.08:1
   light / 2.60:1 dark, and 0.55 gives 3.10:1 / 3.98:1 -- light is the weaker
   side, which is the opposite of the guess. Disabled controls are exempt
   from the WCAG minimum, so this is a legibility choice, not a compliance
   one; if the two dim treatments are ever unified, unify upward. */
.admin-tabs .tab-dead { opacity: 0.55; }
/* How many things wait on a tab's page (`shift_display.admin_tabs`), hidden
   at zero: the QGenda sync page's pending count (#551). Accent fill rather
   than an alert colour -- it is work to do, not something broken -- and
   inverted on the lit tab, whose own fill is the accent. */
.admin-tabs .tab-count {
  display: inline-block; min-width: 1.5em; padding: 0 6px; margin-left: 2px;
  border-radius: 999px; font-size: 0.72rem; font-weight: 700; line-height: 1.5;
  text-align: center; font-variant-numeric: tabular-nums;
  background: var(--accent); color: var(--on-accent);
}
.admin-tabs a[aria-current="page"] .tab-count { background: var(--on-accent); color: var(--accent); }
/* The phone squeeze, in two steps (issue #565). 8px a side at 16px text,
   because at 4px the longest labels touched their pill's edge: the four
   shift tabs then measure 319px, which fits a 360px screen's 336px column
   -- the floor the second-level strip below is measured against too.
   Below 360px it is 4px again, where the four measure 287px against a
   320px screen's 296px. 320 is also a 1280px desktop at 400% zoom, the
   width WCAG's reflow criterion is tested at.

   The second-level strip's phone squeeze sits below its own rules rather
   than in this block: same 480px, but a block up here loses to the strip's
   base padding rule, which comes later in the file at equal specificity. */
@media (max-width: 480px) {
  .admin-tabs a, .admin-tabs .tab-dead { padding-left: var(--space-2); padding-right: var(--space-2); }
}
@media (max-width: 359px) {
  .admin-tabs a, .admin-tabs .tab-dead { padding-left: var(--space-1); padding-right: var(--space-1); }
}

/*
  ---- The config nav's picker (issue #361) ----

  Below 768px the config nav's four equal-width tabs no longer fit the column,
  and a fifth cannot fit at all: `main` caps at 480px until that same
  breakpoint, so the bar gets 456px and five tabs need 551. The row is
  therefore replaced by a select naming the current section -- not truncated,
  not wrapped, not scrolled. `_admin_tabs.html` renders both and owns why.

  **Keyed on `admin-tabs-nav`, never on `.admin-tabs`.** That base class is a
  generic pill-tab row worn by five other bars -- the shift-category rows on
  the dashboard and the signup page, the Signups/Assigned toggle, and the two
  month lists. All five are needed on a phone, and a rule written against
  `.admin-tabs` here would collapse every one of them.

  768px rather than the 465px where the four tabs still technically fit: it is
  the breakpoint that already exists, and it keeps this independent of #362.
*/
.admin-picker { display: none; }
/* Above 768px the nav's tabs are as wide as their labels rather than equal
   (issue #565). At 16px, seven equal columns need 940px against the 868px
   the bar can have, which cuts the longest labels to an ellipsis at every
   width; sized to their labels the seven need 807px, and 667px with the
   tighter padding below 900px, against the 721px a 768px screen leaves.
   Measured with a three-digit count on the QGenda tab and at the default
   text size; a larger browser text size still overflows, as the equal
   columns did. The other bars keep equal columns: two to four short
   labels, which fit. */
.admin-tabs.admin-tabs-nav { grid-auto-columns: auto; }
@media (max-width: 899px) {
  .admin-tabs.admin-tabs-nav a { padding-left: var(--space-2); padding-right: var(--space-2); }
}
@media (max-width: 767px) {
  .admin-tabs.admin-tabs-nav { display: none; }
  /* inline-block, so the pill is as wide as the section name rather than the
     column -- the bar it replaces is `inline-grid` for the same reason. */
  .admin-picker { display: inline-block; }
}
/*
  The select wears the active tab's fill, so the collapsed control reads as the
  bar showing only where you are. `appearance: none` is what lets it: without
  it the platform draws its own chrome over the pill.
*/
.admin-picker-control {
  position: relative;
  display: block;
  border-radius: 999px;
}
.admin-picker-control select {
  appearance: none;
  /* The one vendor prefix in this file, and it is for Safari before 15.4 --
     without it the platform draws its own chrome over the pill, which is the
     whole illusion. Delete it when those devices are gone. */
  -webkit-appearance: none;
  width: 100%;
  border: 0;
  background: transparent;
  color: inherit;
  /* `font-family`/`line-height` rather than `font: inherit`, which is what
     this was: the shorthand also resets weight, and it sits after the shared
     group above, so it would have quietly undone the weight that keeps the
     picker matching the active tab. A form control's own default font is the
     only thing that needs overriding here. */
  font-family: inherit;
  line-height: inherit;
  /* Right padding clears the divider and the triangle below; the left matches
     `.admin-tabs a` above 480px so the label sits where the active tab's
     did. Weight and radius come from the shared group up beside
     `.admin-tabs a`. */
  padding: var(--space-2) 44px var(--space-2) 18px;
  cursor: pointer;
}
.admin-picker-control select:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}
/* A narrow desktop window shows the picker too, and there its open list
   takes the theme like every other select's (issue #555, under the field
   base rule, which carries the two gates). After the rule above because
   both weigh the same. The pill keeps its own drawn arrow below, so the
   browser's goes. */
@supports (appearance: base-select) {
  @media (hover: hover) and (pointer: fine) {
    .admin-picker-control select { appearance: base-select; }
    .admin-picker-control select::picker-icon { display: none; }
  }
}
/*
  **The options do not inherit the closed control's ink**, and both
  declarations are load-bearing rather than tidy. Still, beside the
  base-select rule (#555): phones, Safari and Firefox draw the native list,
  and this is what keeps it legible there.

  The select above is white-on-accent so the collapsed pill reads as the
  active tab. The dropdown the browser opens from it inherits that `color`
  and none of that `background`, so without this the list is white text on
  the popup's own pale surface -- every row invisible but the highlighted
  one, which is legible only because the selection band supplies the
  contrast behind it. It shipped that way and was caught by eye, not here:
  the popup is drawn by the platform, so it is outside the page, outside a
  screenshot, and outside every computed-style read of the control itself.

  `background` alone is not the fix and neither is `color`. In dark mode the
  popup also stayed the browser's near-white while the page around it was
  dark, so the list was wrong in both palettes for two different reasons and
  one declaration would have fixed one theme's symptom.

  `--text` on `--surface` is a registered pair in
  `ComposedTextPairsStayReadable`, so naming tokens here is also what makes
  the list's contrast measured in every theme rather than assumed.

  **If Firefox turns out to be in use, check the closed control here first.**
  It is reported to paint a closed `<select>` with the selected option's
  colour rather than the select's own, which would put `--text` on the
  `--accent` fill -- 1.74:1 in the light palette, and unreadable. Untested:
  only Chromium has been looked at, and nobody is known to use Firefox. Noted
  rather than fixed because the obvious remedy is not one: scoping this to
  `option:checked` names the very option a closed control would be painted
  from, so it would change nothing.
*/
.admin-picker-control option {
  color: var(--text);
  background: var(--surface);
}
/*
  Divider and arrow, both `currentColor` off the fill above, so a new theme
  retunes them with everything else.

  **The arrow is drawn with borders rather than set as a glyph, and that is
  not a style choice.** An SVG would have to arrive as a `data:` URI, which
  `default-src 'self'` blocks -- `img-src` inherits from it and `data:` does
  not satisfy `'self'`. Measured, not assumed: an injected `data:` image
  raises a violation naming `img-src` while a same-origin control loads.
  A text glyph was the other candidate and is worse here: U+25BE is drawn
  small inside its em box, so it stays small however the font-size is raised,
  and how small depends on the font the device happens to have. Borders
  depend on nothing and size exactly as asked.
*/
.admin-picker-control::before,
.admin-picker-control::after {
  content: "";
  position: absolute;
  pointer-events: none;
}
.admin-picker-control::before {
  right: 32px;
  top: 6px;
  bottom: 6px;
  width: 1px;
  background: currentColor;
  opacity: 0.35;
}
.admin-picker-control::after {
  right: 10px;
  top: 50%;
  width: 0;
  height: 0;
  transform: translateY(-40%);
  border-left: 6px solid transparent;
  border-right: 6px solid transparent;
  border-top: 7px solid currentColor;
}
/* Inactive tabs answer the cursor (issue #188); the active tab is where you
   already are, so it stays put. */
@media (hover: hover) {
  .admin-tabs a:not([aria-current="page"]):hover {
    background: var(--accent-light);
  }
}
.admin-subtabs {
  display: flex;
  gap: var(--space-1);
  border-bottom: 1px solid var(--border);
  margin-bottom: 20px;
}
.admin-subtabs a {
  text-decoration: none;
  font-size: 0.8rem;
  font-weight: 600;
  padding: 6px var(--space-3) var(--space-2);
  color: var(--muted);
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
}
.admin-subtabs a[aria-current="page"] {
  /* -ink (issue #281): as plain accent the selected tab was dimmer on the
     dark themes than the inactive tabs' --muted -- it read as the disabled
     one. The underline keeps the plain hue; a mark, not text. */
  color: var(--accent-ink);
  border-bottom-color: var(--accent);
}
/* Same issue #188 gap as .admin-tabs, quieter shape to match the underline
   style: the text firms up and the underline sketches itself in. */
@media (hover: hover) {
  .admin-subtabs a:not([aria-current="page"]):hover {
    color: var(--text);
    border-bottom-color: var(--border);
  }
}
/* The unsaved marker (issue #486): a dot after a tab's label while the form
   on its panel holds edits that differ from what was saved. preferences.js
   toggles the class from the form's data-unsaved attribute, so this rule
   only draws. Amber, the app's "look at this" hue, on a strip whose teal
   already means "selected". A glyph rather than a drawn circle so forced
   colours keep it, and a zero line-height so the larger glyph does not
   grow the strip's line box; the vertical-align settles it on the label's
   x-height. */
.admin-subtabs a.unsaved::after {
  content: "\2022";
  color: var(--warning);
  margin-left: 5px;
  font-size: 1.5em;
  line-height: 0;
  vertical-align: -0.08em;
}
/* On a phone the strip packs tighter (issue #486), at the 480px the
   first-level .admin-tabs already use rather than a width of its own. Its
   own block, because the base padding rule above would override a line in
   that earlier block at equal specificity. The physician Preferences
   page's four tabs measure 365px at the 12px padding against a 351px
   strip on a 375px screen, and the strip does not wrap -- the losing tab
   wraps its own label onto two lines instead, "Working / Week" under one
   underline. At 8px the four measure 333px, which fits a 360px screen's
   336px strip too. The labels still wrap rather than overflow below what
   fits, so a narrower screen degrades the way it did. */
@media (max-width: 480px) {
  .admin-subtabs a { padding-left: var(--space-2); padding-right: var(--space-2); }
}

/*
  Shift-type accent: the category left bar, worn by grid cells, signup day
  cards and admin day rows. `box-shadow` is one property, so the moment a
  surface also carries `--shadow` the two spellings fight and one silently
  vanishes -- which is why every bar-wearing surface *restates* the bar as a
  compound (`.day-card.shift-tue-thu { box-shadow: inset ..., var(--shadow) }`)
  next to its own elevation rule, and again for hover in the lift group.
  There is no bare `.shift-tue-thu` rule: all three wearers are elevated, so
  a bare rule could never win and would only misdirect the next reader. A
  new bar-wearing surface adds its own compound pair here or goes without.
*/

.grid-legend {
  display: flex;
  /* Wraps since the Harbor key (issue #273) grew the row to four entries,
     which overflows a 375px phone unwrapped. */
  flex-wrap: wrap;
  /* Row gap stays 6px rather than taking a rung: the comment above says
     this row wraps on a 375px phone, so it is the gap between two visible
     lines of legend, and --space-1 (4px) tightened exactly the case that
     comment exists to handle. Column gap is --space-4 on the nose. */
  gap: 6px var(--space-4);
  font-size: 0.8rem;
  color: var(--muted);
  margin-bottom: 10px;
}
/* The row's shape, once. The rota entry is an <a> in its offer form, so
   this names both rather than letting the link restate the three
   declarations -- see .legend-link below, which now carries only weight. */
.grid-legend span, .grid-legend a { display: inline-flex; align-items: center; gap: 6px; }

/*
  Per-physician Tue-Thu/Fri-Mon breakdown: hidden by default (opt-in
  detail, not everyone wants it); toggled via a class on the stable
  wrapper rather than the table itself, since the table is replaced
  wholesale by htmx oob swaps on badge clicks and would otherwise
  lose the toggle state on every click.
*/
.breakdown-detail {
  display: none;
  /* Typography arrived with the inline-style extraction (issue #175). It
     lives here rather than with the other stats-table rules so the whole
     component is one block -- two `.breakdown-detail` rules 500 lines apart
     is one an editor finds and one they miss. */
  color: var(--muted);
  font-size: 0.8rem;
}
/*
  `block`, not `inline`, since #368 right-aligned these columns. Inline, the
  breakdown trails the figure inside a right-aligned cell, so the parenthetical
  takes the right edge and the primary count floats at an offset that varies
  with the breakdown's own digit counts -- `5 (3 2)` and `12 (10 2)` put 5 and
  12 in different places. That is ragged in exactly the way the right-align was
  added to fix, and it fires for any admin who left the breakdown on, because
  the state is remembered.

  Scoping the right-align to `:not(.show-breakdown)` was the other candidate and
  is worse: app.js applies the class from localStorage after first paint, so the
  column would right-align and then jump left on every load.

  On its own line both the figure and its breakdown right-align, and the column
  of counts holds in both states. It costs row height while the breakdown is
  shown, which is an opt-in detail view.
*/
#physician-stats-section.show-breakdown .breakdown-detail { display: block; }

/*
  Column widths come from here, never from the data (issue #123). The
  table is re-sent whole by an oob swap on every badge click, and under
  auto layout the browser re-measures each time -- so a count crossing
  9 -> 10, or a physician's first assignment giving them a row, shifted
  every column downstream of it. `fixed` reads widths off the header row
  only and never measures a cell, so nothing moves.

  The max-width is the point of the change, not a detail: `main` caps at
  900px while this table's content needs about 470, and auto layout hands
  the ~400px of slack out *in proportion* to intrinsic width. That is what
  made "Assignments" fatter than "Signups" (longer word, bigger share) and
  what pushed each number far from the name it belongs to.

  It sits on the *section* rather than on the table because the breakdown
  toggle is a sibling of the table inside that wrapper, in a flex row that
  right-justifies it. Capping only the table left the button tracking the
  900px edge of `main` while the table stopped at 640 -- so one cap, on
  the element that owns both, rather than the same number written twice
  and free to drift apart. The table therefore depends on rendering inside
  that wrapper; `_physician_stats_table.html` records that, since all three
  of its include sites are what make it true.

  The numeric columns are percentages rather than px so they shrink with
  the viewport instead of overflowing a phone, and they are sized for the
  breakdown-*on* state. Sizing them per toggle state would cost two lines
  and reintroduce the reshuffle: the widths are identical breakdown on or
  off, and on the three-column Daytime tab, where the first column simply
  takes the larger remainder.
*/
#physician-stats-section { max-width: 640px; }
#physician-stats-table { table-layout: fixed; width: 100%; }
#physician-stats-table th:not(:first-child) { width: 20%; }

/*
  The counts right-align, and this is a consequence of the fixed layout
  above rather than a taste for right-aligned numbers -- issue #368.

  A 20% column is far wider than the one or two digits it usually holds, so
  left-aligned the digit sits at the column's left edge while the eye is
  scanning for a column of figures. Over the roster's rows there is nothing
  to track down. Right-aligned they form the edge the header sits on, and
  the ones, tens and hundreds line up by place value -- with tabular-nums
  so a 1 occupies the same width as a 7 and the alignment holds row to row.

  The headers align with them: a right-aligned column under a left-aligned
  header reads as a mistake, and on the Gap fills header it is the lightning
  glyph that would otherwise drift away from the figures it labels.
*/
#physician-stats-table th:not(:first-child),
#physician-stats-table td:not(:first-child) {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/*
  The name column is the one that can be handed a token with nowhere to
  break. A physician with no first or last name renders their email
  instead, and an email has no space in it -- so where auto layout used to
  widen the column to fit one, a fixed column lets it run straight over
  the numbers beside it. Measured before this line existed: a 45-character
  address rendered 337px of content into a 256px cell, and at 375px, where
  the column is 140px, even a short address overflowed.

  `break-word` rather than `anywhere`: the two are indistinguishable under
  a fixed layout, which ignores intrinsic widths, and `break-word` is
  understood by every browser this app is likely to meet.
*/
#physician-stats-table td:first-child { overflow-wrap: break-word; }

/*
  Two spellings for two of those headers. At 375px each numeric column is
  about 70px, which "Assignments" overflows into the column beside it, and
  that is what made the phone view read as crowded. Desktop has the room
  and keeps the full word rather than being abbreviated to match.

  Mobile-first like `.calendar-grid` below: the short spelling is the
  default and the full one is restored in the `min-width: 768px` block, so
  every responsive rule in this file reads the same direction.

  Named for the table rather than scoped to it by id. `.hdr-abbr` would
  have been a general name whose generality was a lie -- the rule hiding
  it would still have been id-scoped, so the same class used on any other
  header would silently render both spellings at every width.
*/
.stats-hdr-full { display: none; }

/*
  The key half only where the mark is. The card list says the same thing in
  words at each week heading, so a swatch there would name a mark no card
  wears -- while the *link* half stays on both surfaces, since a physician
  who has not set this has no mark to explain and is exactly who it is for.
  Restored to the row's own inline-flex, not `inline`, or the swatch and
  its label stop sharing a centre line.
*/
.grid-legend .legend-desktop { display: none; }

.grid-legend i {
  display: inline-block;
  width: 12px; height: 12px;
  border-radius: 4px;
  /* The swatch is the entry's whole point; the label flexes, not it. */
  flex-shrink: 0;
}

/*
  Doubles/gap-claim name badges (inline variant): mobile card list and
  the admin schedule view both render at every width, unlike the block
  variant below which only exists inside the desktop calendar grid.
*/
.name-badge-inline {
  font-size: 0.65rem;
  vertical-align: super;
  opacity: 0.8;
  margin-left: var(--space-1);
}

/* Desktop calendar grid: hidden by default (mobile-first), shown on wide viewports. */
.calendar-grid { display: none; }

@media (min-width: 768px) {
  main { max-width: 900px; }
  main.main-wide { max-width: 1250px; }
  /*
    The full stats headers, restored where there is room for them --
    see `.stats-hdr-full` above for what the two spellings are.
  */
  .stats-hdr-full { display: inline; }
  .stats-hdr-abbr { display: none; }
  /* The rota week key, restored where the mark it names is drawn. Only
     `display`, because `.grid-legend span` above already carries the row's
     alignment and gap. */
  .grid-legend .legend-desktop { display: inline-flex; }
  /* --grid-gap is named because the working-week band below reaches half of
     it past each card. 10px is off the spacing scale on purpose (issue #477):
     at 6px the band had too little room to read as a band. */
  .calendar-grid {
    --grid-gap: 10px;
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: var(--grid-gap);
    margin-bottom: 20px;
    isolation: isolate;
  }
  /*
    The working-week band (issue #477): the physician's declared week as one
    tinted region behind its cards, because the question it answers is
    "where does my week run" -- a stretch of days -- rather than "is this
    one day mine". Only the working week is marked, as in #357.

    It is drawn by `.grid-slot`, the wrapper `_calendar_grid.html` puts
    round every dated cell, and never by the cell itself. A tappable cell
    lifts, brightens and shrinks under the pointer, and a band drawn by the
    cell would do all three and come apart from its neighbours. The slot
    also sits outside the element the out-of-band swaps replace, so a tap
    never has to redraw it.

    Each working slot's pseudo-element reaches half a gap past its cell,
    so neighbours meet in the middle of the gap and read as one piece; a
    joined side reaches one pixel further and squares its corners, closing
    the seam that sub-pixel column widths would otherwise leave. The
    overlap is invisible only because --week-band is opaque -- its comment
    in :root. Unjoined ends stay rounded, including where a Tuesday-to-
    Monday week breaks across two rows: the lone Monday is its own rounded
    tile, and colour plus reading order tie it to the row above. No two
    rows' bands ever touch: the cells above and below a day are seven days
    away, in the other rota week.

    z-index -1 puts the band behind every cell; `isolation` on the grid
    keeps it from falling behind the page instead.
  */
  .calendar-grid .grid-slot {
    position: relative;
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    min-width: 0;
  }
  .calendar-grid .grid-slot.wk-band::before {
    content: "";
    position: absolute;
    z-index: -1;
    inset: calc(var(--grid-gap) / -2);
    background: var(--week-band);
    border-radius: calc(var(--radius-sm) + var(--grid-gap) / 2);
  }
  .calendar-grid .grid-slot.wk-join-left::before {
    left: calc(var(--grid-gap) / -2 - 1px);
    border-top-left-radius: 0;
    border-bottom-left-radius: 0;
  }
  .calendar-grid .grid-slot.wk-join-right::before {
    right: calc(var(--grid-gap) / -2 - 1px);
    border-top-right-radius: 0;
    border-bottom-right-radius: 0;
  }
  .calendar-grid .grid-weekday {
    font-size: 0.72rem;
    font-weight: 700;
    color: var(--muted);
    text-align: center;
    padding-bottom: var(--space-1);
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }
  .calendar-grid .grid-cell {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    min-height: 91px;
    min-width: 0;
    padding: var(--space-2);
    position: relative;
    box-shadow: var(--shadow);
  }
  /* The category bar restated with the elevation -- see the bar comment at
     "Shift-type accent". The compound (0,3,0) outranks the base cell rule
     (0,2,0), so the bar survives wherever these rules sit. */
  .calendar-grid .grid-cell.shift-tue-thu {
    box-shadow: var(--cat-bar-a), var(--shadow);
  }
  .calendar-grid .grid-cell.shift-fri-mon {
    box-shadow: var(--cat-bar-b), var(--shadow);
  }
  .calendar-grid .grid-cell .grid-names {
    margin-top: var(--space-1);
    font-size: 0.7rem;
    line-height: 1.3;
    color: var(--text);
    display: flex;
    flex-direction: column;
    gap: 2px;
  }
  .calendar-grid .grid-cell .grid-name-row {
    display: flex;
    align-items: baseline;
    gap: var(--space-1);
    min-width: 0;
  }
  .calendar-grid .grid-cell .grid-name-text {
    flex: 0 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .calendar-grid .grid-cell .grid-name-badge {
    flex-shrink: 0;
    font-weight: 700;
  }
  /* The Daytime posted order in the grid (issue #312): the same .rank-pill
     both card surfaces wear, at this surface's own scale -- the surface
     adopts the class and keeps only its own layout in its own rule (#291).

     The worked ✓ is mine-*family* on every surface, which is the invariant --
     not one token. `--mine-ink` here and on `.p-worked-note`, because both
     are text on a tint or surface; the admin badge is `--mine` as a fill
     under `--on-accent` ink, and stays that way: the plain hue is never a
     text colour (the token charter's -ink rule, #281). How this surface
     says the word rather than showing it is `_grid_cell.html`'s business,
     and its own comment carries the reason. */
  .calendar-grid .grid-cell .rank-pill {
    font-size: 0.55rem;
    padding: 0 5px;
    min-width: auto;
  }
  .calendar-grid .grid-cell .grid-name-badge-worked {
    color: var(--mine-ink);
  }
  .calendar-grid .grid-cell .grid-name-badge-gap-claim {
    margin-left: -5px;
  }
  .calendar-grid .grid-cell .grid-undo-note {
    margin-top: var(--space-1);
    font-size: 0.65rem;
    font-weight: 600;
    color: var(--mine-ink);
  }
  .calendar-grid .grid-cell.empty {
    background: transparent; border-color: transparent; box-shadow: none;
  }
  /* State rings: grouped with the card rules outside this block -- search
     "state ring" -- so each state has one spelling for both surfaces. */
  .calendar-grid .grid-cell .grid-cell-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 6px;
  }
  .calendar-grid .grid-cell .grid-date {
    font-size: 0.85rem;
    font-weight: 650;
    font-variant-numeric: tabular-nums;
  }
  .calendar-grid .grid-cell .grid-count {
    background: var(--accent-light);
    border: 1px solid var(--border);
    color: var(--accent-ink);
    font-size: 0.7rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    border-radius: 999px;
    padding: 2px 7px;
  }
  .calendar-grid .grid-cell.tappable {
    cursor: pointer;
    transition: transform 0.1s ease, filter 0.1s ease, box-shadow 0.1s ease;
  }
  /* Hover: the shared lift group near the buttons (its comment covers why
     the grid selectors live outside this width block). The old accent
     border-swap hover is gone, so state cells no longer need a defensive
     hover rule for their border colour. */
  .calendar-grid .grid-cell.tappable:active { transform: scale(0.96); filter: brightness(0.93); }

  /*
    On desktop the grid is the input surface; the list below becomes
    read-only detail (matches v2's original grid-above/list-below pattern,
    avoids two surfaces both claiming to be clickable).
  */
  .day-row {
    pointer-events: none;
    cursor: default;
  }

  /*
    Desktop: the grid is the single surface for browsing and marking
    availability, showing names directly in its cells -- the day-row
    list below is mobile-only, where there's no grid and the list is
    the only surface.
  */
  .day-row-list { display: none; }
}

/*
  Nav: inline row on a wide screen (default). Below 1100px it collapses into
  a hamburger-revealed dropdown panel instead -- wrapping the row was
  considered but rejected since it degrades every time another nav
  item gets added; a hamburger's footprint stays constant.

  **1100px, not the 768px it was, and measured rather than borrowed** (issue
  #564). At 768px the row only switched off where it no longer fitted a
  phone; between 768 and about 1,070px it fitted nowhere, and every label
  wrapped inside its own box. An Overtime Admin's row -- five links, the name
  and Log Out, all at 16px -- needs 1,071px of the page, and a window 1100px
  wide gives 1,085 once a 15px scrollbar has taken its share. A plain physician's is
  narrower and gets the menu sooner than it strictly needs; one number for
  everybody is the price of not measuring the bar in script. A superuser's
  extra Django Admin link, and the undelivered-email alert's wording, can
  still overfill the row above 1100px; `#nav-links` then breaks between
  whole links rather than inside them.
*/
/* The glyph is about 22px square, under WCAG 2.2's 24px target and half
   Apple's 44pt; it was only on phones until issue #564 put it on tablets and
   phones held sideways too. 11px of padding makes the tap area about 44px,
   and the matching negative margin keeps the glyph and the bar exactly where
   they were. */
.nav-toggle {
  display: none;
  background: none;
  border: none;
  color: var(--on-accent);
  font-size: 1.4rem;
  line-height: 1;
  cursor: pointer;
  padding: 11px;
  margin: -11px;
}

/*
  `align-items` and `gap` were a `style=` attribute on the div until issue #136,
  and moving them here is what let the `!important` below go. That flag was not
  guarding anything: it existed only because an inline declaration outranks any
  stylesheet rule however specific, so the mobile panel could not switch the row
  to `stretch` by ordinary means. With both declarations in this file the media
  query wins on source order at equal specificity, which is the mechanism doing
  the work rather than a flag suppressing one.

  **The `gap` beside it had no such flag, and that asymmetry is why the media
  query below now says 14px.** `align-items: stretch !important` won on main, so
  the panel always stretched; `gap: 10px` sat directly under it with no flag,
  lost to the inline `gap: 14px`, and so had never once applied since the day it
  was written. Hoisting the attribute would have switched it on and taken the
  sub-768px panel from 14px to 10px between links, 328px to 300px tall.

  It is 14px because that is what shipped and what anyone has ever seen. The
  10px was not a design decision that this change restores -- it was dead the
  moment it was written, so nobody ever evaluated it, and adopting it here would
  have meant a CSS de-duplication quietly restyling a surface on its way past.
  Change it deliberately or not at all.

  Measured in the browser against a *faithful* reconstruction of main, meaning
  both halves -- the inline attribute and the `!important` together. Reinstating
  only the attribute makes it look as though `align-items` changed too, and it
  did not: alignment and the 343px link width are identical either way.

  The general lesson, since the next person hoisting an inline declaration will
  hit it: enumerate every stylesheet declaration the attribute was outranking,
  not only the one wearing the `!important`. The flag marks where somebody
  noticed the conflict. The silent losers are the ones that move.
*/
#nav-links {
  display: flex;
  align-items: center;
  gap: 14px;
  /* A row too wide for the bar breaks between whole links, right-aligned,
     rather than squeezing every label onto two lines inside its own box
     (issue #564). Above 1100px that is only the two rows the breakpoint
     cannot cover: a superuser's extra link, or the alert's wording. */
  flex-wrap: wrap;
  justify-content: flex-end;
}
/* Name over role, two short lines rather than one long one (issue #564): 102px
   rather than 185px of the row, and at 1.25 the pair is 32px, inside a 36px
   link's height, so the bar does not grow for it. `_nav_user.html` is the
   markup, rendered twice by base.html; the blocks below say which copy shows
   where. */
.nav-user {
  display: inline-flex; flex-direction: column;
  color: var(--on-accent); opacity: 0.7; font-size: 0.8rem;
  line-height: 1.25; white-space: nowrap;
}
.logout-form { margin: 0; }
/* What a button brings that the links' rules up by the app bar do not undo:
   its own fill, and the browser's `normal` line height (the control-font
   rule's comment) where the links take the body's 1.5. */
.logout-btn { background: none; line-height: 1.5; cursor: pointer; }

@media (max-width: 1099px) {
  .nav-toggle { display: block; }
  #nav-links {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    background: var(--bar-bg);
    flex-direction: column;
    align-items: stretch;
    /* 14px, not the 10px written here originally -- see above. That value was
       outranked by an inline attribute from the day it was written and never
       once rendered, so matching what shipped is the no-change option. */
    gap: 14px;
    padding: var(--space-3) var(--space-4);
    z-index: 20;
    /* The row's wrap and right alignment, undone for a column. In a column
       with a height cap, `wrap` sends the overflow into a second column and
       `flex-end` pushes it off the top, where no scroll reaches it. */
    flex-wrap: nowrap;
    justify-content: flex-start;
    /* No taller than the screen below the bar, scrolling inside itself
       (issue #564). A phone held sideways is about 390px tall, and the menu
       ran off the bottom with Log Out under the edge -- the bar is sticky, so
       scrolling the page moved nothing. `vh` first for a browser without
       `dvh`, which follows the browser's own bars as they show and hide. */
    max-height: calc(100vh - var(--app-bar-height, 61px));
    max-height: calc(100dvh - var(--app-bar-height, 61px));
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  #nav-links.open { display: flex; }
  /* Log Out is a row of the menu like the links above it (issue #564): its
     form is the flex item, so the button fills the form. */
  #nav-links .logout-btn { width: 100%; text-align: left; }
  /*
    The undelivered-email alert keeps its count and glyph and drops its
    wording wherever the bar has collapsed, so it lives in this block rather
    than restating the breakpoint beside the alert's own rules (issue #564).
    The label is what makes it self-explanatory on a bar that has room for
    it; a collapsed bar holds the page title, on a tablet the name beside the
    menu, and the hamburger, and a five-word label there pushes the title
    into a third line on a phone. `title` on the anchor keeps the full
    wording reachable either way. Only the label changes; the right margin
    stays 14px (the note by the alert's own rules says why).
  */
  header.app-bar a.failed-email-alert .failed-email-alert-label {
    display: none;
  }
}

/*
  Who the bar says is signed in, in two copies of `_nav_user.html` (issue
  #564): one in the row with the links, one standing beside the menu button.
  A tablet's collapsed bar has room for the name beside ☰, so it stays in
  sight there; a phone's does not -- a long title and a long name will not
  both fit 375px -- so there it stays in the menu, as before. One copy shows
  at any width, so it is never read out twice.
*/
.nav-user-bar { display: none; }
/* The tablet band: the bar has collapsed, but there is room to spare. Its
   upper bound is the block above's, and the two change together. */
@media (min-width: 768px) and (max-width: 1099px) {
  /* The open menu is a panel under the button rather than the bar's full
     width, which at 1024px made each link a 1,000px bar. A phone keeps the
     full width, the width of a thumb's reach anyway. */
  #nav-links { left: auto; width: 18rem; }
  .nav-user-bar { display: inline-flex; margin-left: auto; margin-right: 14px; }
  /* The alert's own auto margin already pins the pair right. */
  .failed-email-alert + .nav-user-bar { margin-left: 0; }
  #nav-links .nav-user { display: none; }
}

/*
  ---- Extracted inline styles (issue #175) ----
  Everything below this line came out of template style="" attributes,
  verbatim -- same declarations, same values.

  **One class per component, not per declaration set.** Several components
  below carry the same body today (`font-size: 0.8rem; color: var(--muted)`
  is six of them). They keep their own names on purpose: a physician's name
  list and a Max-slots hint are different things that happen to agree right
  now, and the restyle will move some of them apart. What is not allowed is
  one component spelled twice, or a body duplicated across files -- those
  are unified here.

  Where two components differ by a single value they stay two classes, since
  merging them would be a visible change and this pass must render
  identically to what it replaced.
*/

/* base.html -- the name and Log Out rules moved up beside `#nav-links` in
   issue #564, when they stopped being the attributes they were extracted
   from. */
/* Rendered by base.html and re-rendered out-of-band by
   _assignment_day_badges.html; one rule keeps the two spellings identical. */
#live-counts-data { display: none; }

/* _month_switcher.html -- the list and the prev/next arrows either side of
   it (issue #257) read as one row of controls, so the box they share is one
   rule rather than two that a comment promises are identical. That includes
   the edge: the arrows wear the field's --field-edge rather than a card's
   --border, or the list would be ringed and the arrows beside it not. */
.month-switcher, .month-nav-arrow {
  background: var(--surface);
  border: 1px solid var(--field-edge);
  border-radius: var(--radius-ctl);
  box-shadow: var(--shadow);
}
/* `stretch`, so the arrows take the list's height whatever it is (issue
   #564): with the list at the once-a-page dropdowns' 40px, centred arrows at
   their own 34px stood short of it. */
.month-switcher-nav { display: inline-flex; align-items: stretch; gap: 6px; }
.month-nav-arrow {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 34px; min-height: 34px; padding: 0 9px;
  color: var(--accent-ink);
  font-size: 1rem; font-weight: 700; line-height: 1; text-decoration: none;
  transition: transform 0.1s ease, filter 0.1s ease, box-shadow 0.1s ease;
}
/* An end-of-list arrow: flat, because elevation in this app means "operable
   control". Why it is rendered at all rather than omitted is the template's
   business, and its comment holds that. */
.month-nav-arrow-dead {
  opacity: 0.38;
  box-shadow: none;
}
/* `a.` on both halves, not just hover: the anchor qualifier is what keeps
   the inert span out, and dropping it from one of the pair also breaks the
   equal-specificity tie the lift group's comment relies on -- hover would
   out-rank press and holding a live arrow would never scale. */
a.month-nav-arrow:active { transform: scale(0.97); filter: brightness(0.91); }

/* _signup_window.html */
.signup-window-dates {
  color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums;
}
/* One bound, kept whole. Since issue #345 each bound carries its time, so
   "Jul 1, 7:00 AM – Jul 10, 5:00 PM" offers eight places to break and only the
   pair either side of the dash reads. This leaves that pair and closes the
   other six, the worst of which splits "5:00" from "PM". */
.signup-window-bound { white-space: nowrap; }

/* _status_pills.html + _publish_pill.html: one base, one class per colourway.
   pill-neutral is shared by "Not published", "Signups not yet open" and
   "Archived" because their inline declarations were already byte-identical. */
.status-pill {
  border-radius: 999px; padding: 3px 10px; font-size: 0.8rem; font-weight: 600;
}
/* Ink text on the tinted fills, and the border softened to a 45% mix of the
   state colour -- a solid state-colour border read heavier than the text it
   framed. Pills stay flat: labels get no elevation.

   The mix lives in an @supports block, not inline: color-mix() wrapping a
   var() is accepted at parse time everywhere and only fails at
   computed-value time, where it resets border-style to its initial `none`
   rather than falling back to an earlier declaration -- so a plain
   fallback line above it would be discarded too, and a pre-2023 engine
   would show no border at all. Gated, old engines keep the solid border
   they always had. */
.status-pill.pill-accent {
  border: 1px solid var(--accent);
  background: var(--accent-light); color: var(--accent-ink);
}
.status-pill.pill-neutral {
  border: 1px solid var(--border); background: var(--surface-2); color: var(--muted);
}
.status-pill.pill-open {
  border: 1px solid var(--mine);
  background: var(--mine-light); color: var(--mine-ink);
}
.status-pill.pill-closed {
  border: 1px solid var(--excluded);
  background: var(--excluded-bg); color: var(--excluded-ink);
}
@supports (color: color-mix(in srgb, red, red)) {
  .status-pill.pill-accent { border-color: color-mix(in srgb, var(--accent) 45%, transparent); }
  .status-pill.pill-open { border-color: color-mix(in srgb, var(--mine) 45%, transparent); }
  .status-pill.pill-closed { border-color: color-mix(in srgb, var(--excluded) 45%, transparent); }
}
/* An account's status, on its Accounts row and its own page (#559,
   `_account_status.html`). Borderless colourways -- the account pills never
   had the border the month pills carry. Compounds, matching the
   .status-pill.pill-* grammar above, so they cannot be silently outranked if
   the base ever gains colour. */
.status-pill.pill-active { background: var(--mine-light); color: var(--mine-ink); }
.status-pill.pill-deactivated { background: var(--excluded-bg); color: var(--excluded-ink); }
.stranded-link {
  display: inline-block; margin-top: var(--space-1); border-radius: 999px;
  padding: 3px 10px; font-size: 0.8rem; background: var(--excluded-bg);
  color: var(--excluded-ink); text-decoration: none;
}

/* _readiness_dot.html */
.readiness-dot {
  width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; display: inline-block;
}
.readiness-dot.dot-green { background: var(--mine); }
.readiness-dot.dot-yellow { background: var(--warning); }
.readiness-dot.dot-red { background: var(--excluded); }

/* The small muted note, on every surface that has one: "No signups for this
   day.", "Everyone available is already signed up.", "All days fully
   staffed.", the ranked-day empty state, the failed-email queue count, the
   release-schedule per-month note. `.flush` is for the <p> uses, which need
   the paragraph margin off; the <div> uses have none to begin with. */
.muted-note { font-size: 0.85rem; color: var(--muted); }
.muted-note.flush { margin: 0; }

/* Cross-page primitives (issue #291): looks that had grown a copy per
   stylesheet -- and page stylesheets cannot see each other, so a retune
   landed in one copy while the rest kept the old look. A surface adopts
   the class in its template and keeps only its own layout, spacing and
   type in its own rule. */
/* The raised card. Worn in templates -- grep class="card" for the
   roster rather than trusting a list here to stay complete. The
   near-quartets that stay out differ on purpose: .prepublish-checklist
   and the grid cells are the --radius-sm tier, the dashboard's panel is
   the deliberately quieter --surface-2 box, and the flash wears the
   quartet too but widens one border edge into its state bar and
   retints per level -- one render site in base.html, left standalone. */
.card {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); box-shadow: var(--shadow);
}
/* The muted uppercase section head; .spaced is the mid-page variant.
   .pref-group-head below is not a wearer deliberately: it differs in
   size, tracking and margin, so the base would be all overrides there. */
.section-head {
  font-size: 0.85rem; font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.04em; color: var(--muted); margin: 0 0 var(--space-3);
}
.section-head.spaced { margin: 24px 0 var(--space-3); }
/* The muted empty-state line ("No months...", "No upcoming assignments...").
   Sibling of .muted-note above, kept apart by size and role: a note
   annotates a surface that exists, this stands in for one that is empty.
   The intro lines (.faq-intro, .release-intro) share this body but not
   the role and deliberately stay their own spellings. */
.empty-note { color: var(--muted); font-size: 0.9rem; }
/* The accent chip: --accent-ink on --accent-light in the 5px corner
   family. Chips that are links shift brightness on hover and keyboard
   focus -- var(--hover-lift), so the light themes darken and the dark
   themes lighten (the chips' own deeper dial was retired in #293; the
   :root token comment marks the re-mint point). The underline lives
   only under forced colors -- and not because filters stop running
   there (they do not): that mode repaints the fill with system colours,
   so a brightness shift has nothing left to act on, and the underline,
   drawn in the forced ink, is the feedback that survives. Nowhere
   else -- an underline is foreign to this app's hover language (#293).
   The hover is gated like every hover in this file, so a tap cannot
   stick a chip shifted; the ungated :focus-visible is the keyboard
   channel. A page rule that must restyle a link chip's text-decoration
   or transition spells its selector a.<class> -- this a. rule outranks
   a bare class. */
.accent-chip {
  color: var(--accent-ink); background: var(--accent-light);
  border-radius: 5px;
}
/* The All shifts tab's "Needs coverage" line (a.as-gap, #548) is the same
   kind of control-shaped link and wears the same feedback. */
a.accent-chip, a.as-gap { text-decoration: none; transition: filter 0.1s ease, transform 0.1s ease; }
@media (hover: hover) {
  a.accent-chip:hover, a.as-gap:hover { filter: var(--hover-lift); }
}
a.accent-chip:focus-visible, a.as-gap:focus-visible { filter: var(--hover-lift); }
/* The touch channel, ungated like every press and later in source than
   hover and focus so holding wins the ties: small-control scale with
   the tinted-fill press dip. Chips were the one control-shaped link
   with no tap response (PR #294 review follow-up). */
a.accent-chip:active, a.as-gap:active { transform: scale(0.96); filter: brightness(0.93); }
@media (forced-colors: active) and (hover: hover) {
  a.accent-chip:hover, a.as-gap:hover { text-decoration: underline; }
}
@media (forced-colors: active) {
  a.accent-chip:focus-visible, a.as-gap:focus-visible { text-decoration: underline; }
}

/* no_months.html -- .empty-note plus the page margin. */
.no-months-note { margin: 20px 0; }

/* An <a> wearing a .btn-* class needs these two back; the button rules
   assume a <button>. Grep btn-link-reset for the wearers -- a hand-kept
   roster here went stale once already. */
.btn-link-reset { display: inline-block; text-decoration: none; }

/* The way back, first and top left where people look for it, on an
   account's page and a dashboard day's (issue #565). Its colour and
   underline are the base link rule. 16px, and 44px tall to tap: it is the
   page's main way out, and at 13.6px its target was about 20px, under
   WCAG's 24px minimum. */
.back-link {
  display: inline-flex; align-items: center; min-height: 44px;
  font-size: 1rem; margin-bottom: var(--space-2);
}
/* At the head of a page laid out as a gapped column, which spaces it and
   would otherwise stretch it to the column's width. */
.acct-page .back-link, .day-page .back-link { margin-bottom: 0; align-self: flex-start; }

/* The disclosure triangle (issue #574): Messages' recipient picker and the
   dashboard's Month controls row on a phone. Drawn from borders, so there
   is no image for the policy's `default-src 'self'` to block, and turned a
   quarter when what it marks is open -- an open <details> around its
   summary, or the button that says so with aria-expanded. */
.disclosure-tri {
  flex-shrink: 0;
  width: 0; height: 0;
  border-top: 5px solid transparent;
  border-bottom: 5px solid transparent;
  border-left: 6px solid var(--accent-ink);
  transition: transform 0.1s ease;
}
details[open] > summary > .disclosure-tri,
[aria-expanded="true"] > .disclosure-tri { transform: rotate(90deg); }

/* The per-row actions menu: the Accounts table's, and each person's row on
 * a dashboard day's own page (issue #565).
 *
 * A <details>/<summary> disclosure rather than a JS-built dropdown, so the
 * menu opens with no script at all and app.js only handles the niceties
 * (click-outside to close, one-open-at-a-time).
 */
.row-menu { position: relative; }

.row-menu summary {
  list-style: none;
  cursor: pointer;
  display: inline-block;
  padding: var(--space-1) 10px;
  border-radius: var(--radius-ctl);
  border: 1px solid var(--border);
  background: var(--surface);
  font-size: 1rem;
  line-height: 1;
  box-shadow: var(--shadow);
  transition: transform 0.1s ease, filter 0.1s ease, box-shadow 0.1s ease;
}
/* Hover: the shared lift group carries `.row-menu summary:hover`, one home
   for the lift. Press stays local: its 0.96/0.91 tier differs from the group's cards. */
.row-menu summary:active {
  transform: scale(0.96);
  filter: brightness(0.91);
}

/* Chrome/Safari draw their own triangle on summary; list-style above only
 * covers Firefox. Both are needed to get a bare button. */
.row-menu summary::-webkit-details-marker { display: none; }

.row-menu[open] summary { border-color: var(--accent); }

.row-menu-panel {
  position: absolute;
  right: 0;
  top: calc(100% + 4px);
  z-index: 5;
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-ctl);
  padding: 6px;
  min-width: 120px;
  /* The floating panel wears the raised shadow -- the one element in the
     page body that floats over other content (the sticky app bar and the
     mobile nav dropdown are chrome, not page). */
  box-shadow: var(--shadow-hi);
}

/* Links and buttons sit side by side in the panel and must be
 * indistinguishable: one navigates, the others POST a form. */
.row-menu-panel a,
.row-menu-panel button {
  display: block;
  width: 100%;
  text-align: left;
  padding: 6px 10px;
  border-radius: 6px;
  border: none;
  background: none;
  color: var(--text);
  font-size: 0.8rem;
  text-decoration: none;
  cursor: pointer;
}

/* Gated like every hover in app.css -- its lift-group comment carries
   why; here the stuck highlight would sit inside the open menu until
   it closes (PR #294 review). */
@media (hover: hover) {
  .row-menu-panel a:hover,
  .row-menu-panel button:hover { background: var(--accent-light); }
}

/* The auth pages' button back to sign-in. Grep auth-return-link for the
   wearers rather than listing them here. */
.auth-return-link { margin-top: var(--space-3); }

/* The full-width data table, shared by the coverage-gap table
   (_missing_assignments_table.html), the accounts table, the failed-emails
   table and the audit log. Each of those still names its own cells, which
   differ; only this base was identical in all four. */
.data-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }

/* _missing_assignments_table.html. Cell-level classes rather than element
   selectors on purpose: the empty-state td has no border-bottom, and a
   `.data-table td` rule would have added one. */
.missing-th { text-align: left; padding: 6px var(--space-2); border-bottom: 1px solid var(--border); }
.missing-td { padding: 6px var(--space-2); border-bottom: 1px solid var(--border); }
.missing-td-empty { padding: 6px var(--space-2); color: var(--muted); }

/* _day_row.html and _daytime_physician_day.html -- the signup-surface day
   card. Their inline attributes were spelled differently (whitespace, and
   the daytime one has no gap_open branch) but resolved to the same
   declarations per state, so they share one family.
   `.day-row` stays what it was: the behaviour hook (.tappable rules, the
   desktop pointer-events gate); `.day-card` carries the extracted look,
   with the raised-card base worn as `.card` in the templates. */
.day-card {
  display: flex; flex-direction: column; gap: var(--space-1);
  padding: 14px var(--space-4); margin-bottom: var(--space-2);
}
/* The category bar restated with the elevation -- see the bar comment at
   "Shift-type accent". */
.day-card.shift-tue-thu { box-shadow: var(--cat-bar-a), var(--shadow); }
.day-card.shift-fri-mon { box-shadow: var(--cat-bar-b), var(--shadow); }
/* The state ring: 2px, drawn with outline at -2px offset so it thickens the
   edge with zero layout shift and follows the corner radius. One rule per
   state, one class vocabulary, for BOTH surfaces -- phone day card and
   desktop grid cell -- which is the is_tappable consistency rule holding by
   construction. The grid selectors are safe outside the width media block:
   below 768px the grid subtree is display:none. No shift-* on a state day
   (CONVENTIONS.md, is_tappable section).

   These stay `outline`, not box-shadow like the category bar above: the
   keyboard focus ring (the :focus-visible group near the buttons) relies on
   outline being one property to *replace* the state ring while a state day
   holds focus (issue #258). Migrated to box-shadow, a focused state day
   would paint both rings, and no test would notice -- ring rendering is
   browser-only. */
.day-card.state-mine,
.calendar-grid .grid-cell.state-mine {
  background: var(--mine-light); border-color: var(--mine);
  outline: 2px solid var(--mine); outline-offset: -2px;
}
.day-card.state-gap,
.calendar-grid .grid-cell.state-gap {
  background: var(--excluded-bg); border-color: var(--excluded);
  outline: 2px solid var(--excluded); outline-offset: -2px;
}
.day-card-top {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
}
.day-card-date { font-weight: 600; font-size: 1rem; }
.day-card-right { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }
.day-note { font-size: 0.8rem; font-weight: 650; }
.day-note.day-note-mine { color: var(--mine-ink); }
.day-note.day-note-gap { color: var(--excluded-ink); }
.day-count {
  background: var(--accent-light); border: 1px solid var(--border);
  color: var(--accent-ink); font-size: 0.8rem; font-weight: 700;
  font-variant-numeric: tabular-nums;
  border-radius: 999px; padding: var(--space-1) 10px;
}
/* The daytime count pill, one rule for both daytime surfaces (physician
   list and admin ranked list) so the pair cannot drift. Kept separate
   from .day-count above rather than unified, since adding its border
   would be a visible change. */
.day-count-plain, .ranked-count {
  background: var(--accent-light); color: var(--accent-ink);
  font-size: 0.8rem; font-weight: 700; font-variant-numeric: tabular-nums;
  border-radius: 999px; padding: var(--space-1) 10px;
}
.day-people { font-size: 0.8rem; color: var(--muted); }

/*
  The rota week headings in the card list (issue #357). The card list's own
  `display: none` above 768px is what keeps these off the desktop grid, so
  they need no width gate of their own.

  A heading rather than the grid's per-day mark, and not both: the list is
  linear, so the heading *is* the grouping and every card under it is
  already unambiguous. Repeating the mark on each card would restate what
  the heading just said.

  Uppercase micro-label with a rule running off the end -- the same shape
  `.grid-weekday` uses, so the list gains a heading level without gaining a
  new voice. The working week takes --accent-ink and the off week stays
  --muted: both are legible, and the one the physician declared is the one
  that steps forward.
*/
.wk-divider {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 18px 0 var(--space-2);
  font-size: 0.74rem;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--muted);
}
.wk-divider::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--border);
}
.day-row-list .wk-divider:first-child,
.all-shifts-list .wk-divider:first-child { margin-top: 0; }
.wk-divider.wk-divider-on { color: var(--accent-ink); }

/* The Daytime posted order (issue #312), on both of its surfaces -- the
   physician Assigned view's day cards (.p-ranked-*, the prefix that keeps
   these names off other pages' classes) and the admin ranked list, which
   puts .rank-pill inside its existing .ranked-row.

   The pill's 1px ink border is load-bearing, not decoration: the accent
   tint alone dissolves into --mine-light on a signed-up day's mine-tinted
   card, and the -ink tone is the one already tuned to read on tints in
   every theme (the mine family's hue is the theme's own business -- Harbor's
   is blue). The "new" variant is the unplaced late signup, dashed to say
   "not yet part of the order". "not placed" is the same row once its day's
   order has closed (#385): a record rather than a pending act, so it drops
   the dash and the warning hue for the neutral status pill's --muted on
   --surface-2, keeping a --muted border for the mine-tinted card's reason
   above. */
.rank-pill {
  flex-shrink: 0; font-size: 0.65rem; font-weight: 700;
  background: var(--accent-light); color: var(--accent-ink);
  border: 1px solid var(--accent-ink); border-radius: 999px;
  padding: 2px var(--space-2); min-width: 2rem; text-align: center;
}
.rank-pill-new {
  background: var(--warning-bg); color: var(--text);
  border-style: dashed; border-color: var(--warning);
}
.rank-pill-not-placed {
  background: var(--surface-2); color: var(--muted); border-color: var(--muted);
}
.p-ranked-rows { display: flex; flex-direction: column; gap: 5px; margin-top: var(--space-2); }
.p-ranked-row { display: flex; align-items: center; gap: var(--space-2); font-size: 0.85rem; }
.p-name-muted { color: var(--muted); }
/* The viewer's own name, on every calendar surface: the phone cards
   (_daytime_physician_day.html, _day_row.html) and the desktop grid
   (_grid_cell.html). Bold means "you" and nothing else (issue #537); the
   ✓ says "worked" on its own. In the mine ink, like the "You signed up"
   note on the same card -- the name only renders on a day that wears the
   mine tint, and --mine-ink on --mine-light is a measured pair. One rule
   and no surface prefix, so the surfaces cannot drift apart; none of
   their own rules sets a weight or colour on the name itself. */
.name-mine { font-weight: 700; color: var(--mine-ink); }
/* .p-, not bare .worked-note: release_schedule.css owns that name for its
   own page, and base.html loads page CSS after this file -- a bare twin
   here leaks any declaration the page rule doesn't restate (found as a
   font-weight leak by /code-review on PR #313). */
.p-worked-note { color: var(--mine-ink); font-weight: 700; font-size: 0.75rem; }
/* The admin list's reorder arrows, pushed to the row's far edge so the
   tap targets sit clear of the mark-as-worked badges. */
.rank-move { display: flex; gap: var(--space-1); margin-left: auto; }
.rank-arrow { padding: 2px 9px; font-size: 0.8rem; }

/* A dashboard day's add row, on its own page and in the dashboard's
   panel (_add_signup_form.html, _gap_assign_form.html). `.chip-row` is My
   Assignments' type-chip row, and `.add-select` is also each row's Role
   dropdown on account_list.html.

   The add row's box has a width (#458): a select with none is as wide as its
   longest option, so every day's list sized its own box and the column of
   them went ragged. The box shows a name of about 20 characters whole; a
   longer one is cut short in the closed box -- with an ellipsis where the
   browser draws one on a select, Chrome does -- and the open list shows it in
   full. It stayed 12.5rem when its text went to 16px (#564): any wider and
   the button drops below it on a 375px phone. In rem, like the text inside
   it, so a reader's larger browser font
   size (see `body`) widens the box with the name rather than cutting it. On
   the Accounts page the same width would double every Role box, which is
   why it is scoped here rather than on `.add-select`; that column holds
   one width of its own, in account_list.css.

   `flex-wrap` drops the button below the box on a card too narrow for both,
   instead of pushing it out of the card; on a desktop card they always fit.
   `min-width: 0` lets the box shrink when even its own line is narrower than
   it -- a phone at a large font size, where the box outgrows the card -- as
   a flex item otherwise keeps its width and overflows. */
.inline-form { display: inline; }
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
.add-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
/* The row's own tight box, without the once-a-page dropdowns' 40px height on
   every row (`.form-field`'s group says why). It sets no text size, so the
   field base rule's 16px applies (issue #564). */
.add-select {
  padding: var(--space-1) 6px; border-radius: 6px;
}
.add-row .add-select { width: 12.5rem; min-width: 0; text-overflow: ellipsis; }
.signups-label { font-size: 0.75rem; font-weight: 600; color: var(--muted); }

/* Filter controls (issue #340, My Assignments' panel). Here rather than in
   the page stylesheet because every other control in this app is here, and a
   page-local control would be the first one anybody had to go looking for.

   `.toggle-chip` is a multi-select toggle and deliberately NOT a widening of
   `.accent-chip`: every interactive rule on that one is `a.accent-chip`, a
   link with a single appearance, and a control that stays pressed is a
   different thing wearing a similar shape. The rounded rectangle is shared on purpose though
   -- app.css's tab pill means "switches view in place, one at a time", which
   is the one promise a multi-select must not make.

   The checkbox is the control; the label is its skin. Visually hidden rather
   than `display: none`, which would take it out of the tab order and leave a
   toggle no keyboard could reach. Focus is drawn on the label, since the
   input itself is a 1px speck. */
/* Present for screen readers, copy and CSS-off; absent from the rendered
   page. One recipe for every such element -- the chip's checkbox, and the
   "&" a My Assignments row reads between two chips. admin_dashboard.css
   restates it for the audit log's headings on a phone, where a class cannot
   follow the width; a change here belongs there too. */
.visually-hidden, .toggle-chip input {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}
/* The panel's other controls are 16px and 40px tall (issue #564), so the
   chips are too: 16px text, and 7px above and below makes 40px with the
   body's 1.5 line height. 8px at the sides, not the 11px they had: with 11px
   the fourth chip, Daytime, wraps onto a line of its own at 375px. With 8px
   all four fit one line on a phone from about 346px wide. Each hugs its own
   label rather than stretching to the dropdowns' width, which would read as
   a pick-one control. */
.toggle-chip {
  display: inline-flex; align-items: center; cursor: pointer;
  padding: 7px 8px; border-radius: var(--radius-ctl);
  border: 1px solid var(--border); background: var(--surface);
  color: var(--muted); font-size: 1rem; font-weight: 600;
  transition: background 0.1s ease, color 0.1s ease, border-color 0.1s ease;
}
.toggle-chip:has(input:checked) {
  background: var(--accent); border-color: var(--accent); color: var(--on-accent);
}
.toggle-chip:has(input:focus-visible) {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
@media (hover: hover) {
  .toggle-chip:hover { border-color: var(--accent); }
  /* Solid tier (the -solid token's comment in :root carries the why and the
     roster): a pressed chip wears --on-accent ink on the accent fill. */
  .toggle-chip:has(input:checked):hover { filter: var(--hover-solid); }
}
.toggle-chip:active { transform: scale(0.96); }

/* The filter panel's selects: their box is `.form-field`'s, in the group
   beside it, and they have no rule of their own. */

.max-note { font-size: 0.8rem; color: var(--muted); }
.max-form {
  display: flex; align-items: center; gap: 6px;
  font-size: 0.8rem; color: var(--muted);
}
.max-input { width: 48px; padding: 3px 6px; border-radius: 6px; }
/* _signup_remove_form.html, inside a day's row menu. */
.chip-form { display: inline; margin: 0; }

/* The Daytime card's header, the phone list's (_daytime_day_summary.html).
   Three explicitly-placed columns rather than the flex space-between this
   was, so the handover chip sits in the card's true centre. Under
   space-between it only *looked* centred while the date and the count pill
   happened to be near-equal width -- a day carrying marks reads "3 signed
   up · 1 marked" and slid the chip visibly left. Each child names its own
   column, so a header missing the chip, the count, or both still lays out
   the same: an absent child leaves its column at zero width and the 1fr
   pair still splits the slack. */
.ranked-day-top {
  display: grid; grid-template-columns: 1fr auto 1fr;
  align-items: center; gap: 10px; margin-bottom: 0;
}
.ranked-day-top .day-card-date { grid-column: 1; justify-self: start; }
.ranked-day-top .handover-flag { grid-column: 2; justify-self: center; }
/* nowrap so the count's column is sized by what it says. Without it the
   equal 1fr columns hand the count half the slack whatever it needs, and
   "3 signed up · 1 marked" broke across two lines inside its own pill from
   560px down. Sized this way the wider column takes what it wants from the
   date's, which costs the chip a few pixels of centring in that band and
   nothing at the widths anyone reads this at. */
.ranked-day-top .ranked-count {
  grid-column: 3; justify-self: end; white-space: nowrap;
}
/* On a phone the three do not fit one line: the date, the chip and a count
   reading "3 signed up · 1 marked" want ~420px of viewport, and the equal
   1fr columns squeezed the count into two lines at 375px well before that.
   The chip takes its own centred row instead, which keeps the centring the
   card is asking for rather than trading it for a cramped line.

   480px here against the rows' 767px, deliberately: measured, this header
   holds one line down to 500px, so stacking it any earlier would cost the
   centred chip its place beside the date for nothing. */
@media (max-width: 480px) {
  .ranked-day-top { grid-template-columns: 1fr auto; }
  .ranked-day-top .day-card-date { grid-row: 1; }
  .ranked-day-top .ranked-count { grid-column: 2; grid-row: 1; }
  .ranked-day-top .handover-flag { grid-column: 1 / -1; grid-row: 2; }
}
/* .ranked-count: grouped with .day-count-plain up in the day-card
   section -- the same pill on the sibling daytime surface. */

/* The handover flag (issue #312): the days where somebody was overtaken in
   the posted order, which is where the admin has a decision to make.

   Amber, not red and not a ring: both are spoken for on this surface --
   red is the needs-coverage family app-wide, and a ring meant the
   consecutive run this PR retires -- and reusing either would say the wrong
   thing to anyone who has learnt them elsewhere. The words carry the
   meaning; the colour only draws the eye, per the token charter's rule that
   colour may reinforce a signal but never be it.

   **Only the glyph wears the plain hue.** --warning has no -ink variant in
   any palette, deliberately, and the one sanctioned plain-hue text use is
   .flash-warning's "!" -- a glyph, bold, on --warning-bg. Lettering is a
   different case, and the token's own charter says to mint the -ink form
   when it appears rather than borrow the glyph's tolerance: measured,
   --warning on --warning-bg is 2.14:1 in light, 2.34:1 in sandstone and
   2.47:1 in harbor, against AA's 4.5:1 for normal text. So the words are
   --text on --warning-bg -- a registered pair in
   ComposedTextPairsStayReadable, 12.8:1 or better everywhere -- and amber
   is carried by the glyph, the chip's border, the row tint and the card's
   edge, every one of them a mark rather than a word.

   The card's edge restates --shadow because it is a box-shadow list: the
   .card rule it overrides carries the elevation, and dropping it would
   flatten exactly the cards asking to be noticed. Same mechanism as
   --cat-bar-a on the assignment day cards, and 3px rather than that bar's
   4px so the two never read as the same mark. The phone list's card wears
   both handover marks; the dashboard table's row (issue #565) wears the
   edge on its date, in admin_dashboard.css. */
.day-summary.handover {
  box-shadow: inset 3px 0 0 0 var(--warning), var(--shadow);
}
.handover-flag {
  display: inline-flex; align-items: center; gap: var(--space-1);
  background: var(--warning-bg); color: var(--text);
  border: 1px solid var(--warning); border-radius: 999px;
  padding: 2px 9px;
  font-size: 0.7rem; font-weight: 700; white-space: nowrap;
}
/* Generated content, the way every .flash-* glyph is: it is the mark, and
   the chip's words carry the meaning without it. U+2691. */
.handover-flag::before { content: "\2691"; color: var(--warning); }
/* Every displaced row is tinted, not just the first: with 0-3 signups a day
   a three-row card is near the worst case, and the tint is what makes
   "which rows moved" scannable without reading. The side margins pull the
   tint out towards the card's own padding so it reads as a band across the
   row rather than a box around the badge. */
.day-summary .p-ranked-row.handover-from,
.person-row.handover-from {
  background: var(--warning-bg); border-radius: var(--radius-sm);
  margin: 0 -6px; padding: 3px 6px;
}
.handover-note { color: var(--muted); font-style: italic; font-size: 0.8rem; }
/* The section's two state lines. Both sit above the day cards and describe
   the whole month: how many days change hands, and -- when the answer is
   "none because nothing was built" -- the call to action that fixes it. */
/* The muted lead-in is .section-hint's, worn as a second class -- it is
   the same thing in the same place, a section's lead-in on the admin
   dashboard, and a near-copy differing by 2px of margin is how that family
   grows a third member nobody retunes together. All that is local is the
   count, which lifts out of the muted body. */
.handover-summary strong { color: var(--text); }
.order-state-line {
  display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
  font-size: 0.85rem; color: var(--muted); margin-bottom: 10px;
}

/* _physician_stats_table.html */
.stats-table { border-collapse: collapse; margin-bottom: 28px; font-size: 0.9rem; }
.stats-head-row { text-align: left; border-bottom: 2px solid var(--border); }
.stats-row { border-bottom: 1px solid var(--border); }
.stats-cell { padding: var(--space-2); }
.stats-cell-strong { font-weight: 600; }
.stats-cell-muted { color: var(--muted); }
/* `.breakdown-detail`'s own rule is up with the show/hide toggle it belongs
   to -- search "Per-physician Tue-Thu/Fri-Mon breakdown". */
.phase-sq { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }
.phase-sq.phase-sq-a { background: var(--shift-a); }
/* The margin is the gap between the two squares, so it rides on the second
   one rather than on a third class -- which is also how the single inline
   attribute this came from spelled it. */
.phase-sq.phase-sq-b { background: var(--shift-b); margin-left: var(--space-1); }

/* _prepublish_controls.html */
#prepublish-controls { display: contents; }
/* The line that stands where the checklist goes while signups are still open
   (issue #376). `display: contents` on the region above makes this <p> a
   direct flex item of `.action-bar`, which sets no align-items, so without
   this it stretches to the button row's height and its text sits at the top.
   Alignment only -- the colour and size come from `.muted-note`. */
.prepublish-hint { align-self: center; }
.prepublish-checklist {
  display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
  flex: 1 1 100%; padding: var(--space-2) 14px; border-radius: var(--radius-sm);
  border: 1px solid var(--border); background: var(--surface); font-size: 0.85rem;
  box-shadow: var(--shadow);
}
.checklist-title { font-weight: 600; color: var(--text); white-space: nowrap; }
.checklist-item { display: flex; align-items: center; gap: 6px; cursor: pointer; white-space: nowrap; }
.checklist-sep { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }
/* Daytime's stand-in for a dot. A bar rather than a ring or a hollow circle:
   those read as "a dot in some other state", and this is not a fourth
   readiness state -- it is the absence of the question. --muted for the same
   reason, being the colour this stylesheet already uses for "present, and not
   the point". 8px wide to match .readiness-dot by eye; nothing enforces that,
   and the grid does not need it to (see the mobile block below).

   Fourth site-local swatch primitive, after .readiness-dot, .phase-sq and
   .grid-legend i -- and the one that is not a swatch: it stands for a mark that
   is deliberately absent, so it stays a bar and never takes a state colour. */
.readiness-dash {
  width: 8px; height: 2px; border-radius: 1px; display: inline-block;
  background: var(--muted); flex-shrink: 0;
}
/*
  The checklist as a 2x2 grid below 767px (issue #370).

  767px is where it measurably starts breaking, and it is not a phone width:
  `main` is capped at 480px below this breakpoint and widened to 900px above
  it, so the checklist has a 426px track across the whole 480-767 band and
  wraps throughout it. One line across is not reachable: measured on the
  shipped markup the four items are 91.02 / 75.70 / 77.42 / 93.63px, and
  337.77px of items plus three 8px gaps needs 361.77px against a 336px track
  on a 390px phone.

  Seven columns -- mark, word, box, divider, mark, word, box -- and each item
  is a `subgrid` spanning three of them, so the two rows share the columns and
  every dot, word and box lines up with the one below it. `repeat(7,
  max-content)` rather than a literal track list: the mark and divider tracks
  would otherwise restate .readiness-dot's 8px and .checklist-sep's 1px, two
  numbers nothing would keep in step with the rules that own them.

  **One rule does the placing: the word is pinned to column 2.** Everything
  else falls where auto-placement puts it, and that is enough -- measured, by
  dropping each rule in turn and comparing every box. Pinning the word is what
  makes a missing dot free: an item with only a word and a box would otherwise
  start in the mark column, and that is the common case rather than an edge
  one, since no category has a dot before the signup window opens. Explicit
  rules for the mark and the box were written first and measured as no-ops;
  they are not here, so do not read the two-column result as three rules
  working.

  The alternative, an always-emitted wrapper element, needs the markup to
  carry a layout concern and a rule to undo itself on the desktop row; it was
  tried and it was wrong, because a `:empty` guard cannot see the whitespace a
  Django `{% include %}` leaves behind. Same shape as .ranked-row's mobile
  block above.

  **This grid cannot reflow.** `max-content` tracks and .checklist-item's
  `white-space: nowrap` mean it has no wrap point, so at very large text zoom
  it overflows rather than breaking -- acceptable on an admin-only surface
  with four short labels, and the thing to reconsider before copying this
  pattern anywhere a physician reads.
*/
@media (max-width: 767px) {
  .prepublish-checklist {
    display: grid;
    grid-template-columns: repeat(7, max-content);
    gap: var(--space-2);
  }
  .checklist-title { grid-column: 1 / -1; }
  /* `gap` is stated here rather than inherited: .checklist-item's 6px belongs
     to the desktop flex row, and a subgrid's own gutter overrides the parent's
     within its span -- so leaving it out runs 6px gutters inside tracks the
     parent sized at 8px. `align-items` is not restated: the base rules already
     centre both, and it means the same thing to a grid as to a flex row. */
  .checklist-item {
    grid-column: span 3;
    display: grid;
    grid-template-columns: subgrid;
    gap: var(--space-2);
  }
  .checklist-item > .checklist-word { grid-column: 2; }
  /* Three dividers ship, for a run of four; a 2x2 wants one per row. Hiding
     the middle one takes it out of the grid entirely, which is what lands the
     third in the second row's divider column.

     Selected as "the divider after the second item" rather than
     `.checklist-sep:nth-of-type(2)`, because `.checklist-title` is a <span>
     too: nth-of-type counts it and hides the *first* divider instead.

     This and the seven-column track list are what assume exactly four items.
     A fifth category needs both revisited -- the form's `hx-trigger` comment
     above says a fifth checkbox needs to remember nothing, and that stays
     true of the *form*, not of this layout. */
  .checklist-item:nth-of-type(2) + .checklist-sep { display: none; }
}
/* Compound with each state's base button class, so the override holds
   wherever these sit in the file. Bare `.publish-ready` would tie with
   `.btn-primary` and win only on source order -- and a reorder would light
   the Publish button up as ready over a month the server will refuse. */
.btn-primary.publish-ready { background: var(--mine); }
/* Not-ready wears the .btn-secondary shape with --muted text (issue #284):
   inert but readable. The old --muted *fill* put --on-accent on a text
   tone -- under 2.6:1 on the dark themes -- and no theme's --text reads on
   that grey either. As text on --surface, --muted is the pairing it is
   tuned for: 4.5:1 or better in every palette. Inertness now comes from
   being the action bar's one all-grey control rather than from a fill.
   Compose's Clear is the same quiet secondary button (#560). */
.btn-secondary.publish-not-ready, .btn-secondary.msg-clear { color: var(--muted); }

/* Shared by signup.html and admin_dashboard.html -- the two month pages.
   (.tabs-mid also spaces signup's Assigned/Signups toggle, which is an
   .admin-tabs pill row wearing the same 14px.)

   Both stay literals rather than taking a --space-* rung, and #368 put them
   on one before putting them back. Neither 10 nor 14 is on the 4px grid, so
   converting them landed both on 12px -- which erased the only thing two
   modifier names exist to carry, and moved admin_months and archived_months
   (the .tabs-mid sites outside signup) by 2px in a change that had not
   looked at them. They join the scale when somebody retunes them on purpose. */
.admin-tabs.tabs-tight { margin-bottom: 10px; }
.meta-row {
  display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center;
  margin-bottom: var(--space-3); font-size: 0.85rem; color: var(--muted);
}
.meta-row.spaced { margin-bottom: var(--space-4); }
/* The status pills as one flex item, so the row breaks between the window line
   and the pills rather than between one pill and the next (issue #345). Keeps
   its own flex-wrap: three pills (an archived month) still break within the
   group rather than overflowing it.

   Not shared with .month-card-pills, which does the same job for this app's
   one other _status_pills.html caller. The rule is that each tracks the gap
   of the row it sits in, and since #368 put .meta-row on --space-2 the two
   happen to hold the same 8px -- they stay separate rules anyway, so that
   moving one parent's rhythm does not silently move the other's pills. */
.meta-row-pills { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.page-headrow {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-2); flex-wrap: wrap; margin: var(--space-1) 0 var(--space-3);
}
.month-heading.month-heading-tight { margin: 0; }
.signup-hint { color: var(--muted); font-size: 0.85rem; margin: var(--space-1) 0 var(--space-3); }
/* Scoped under .grid-legend so they outrank `.grid-legend i` (0,2,0 over
   0,1,1) -- bare .legend-a/b lost that contest and only looked fine because
   the i rule sets no colour. */
.grid-legend .legend-a { background: var(--shift-a); }
.grid-legend .legend-b { background: var(--shift-b); }
/*
  The key CONVENTIONS.md's families exception owes (#273), in
  signup.html's legend. The spans render for every theme -- the swatches
  draw from the state tokens, so they are correct anywhere -- and one
  CSS rule hides them where no key is owed; a future theme that earns a
  key joins the :not() list. Solid chips like .legend-a/b, not the
  state's tint-plus-ring: the tints are tuned against the card surface
  and go invisible on the page background the legend sits on.
*/
:root:not([data-theme="harbor"]) .grid-legend .legend-state { display: none; }
.grid-legend .legend-mine { background: var(--mine); }
.grid-legend .legend-gap { background: var(--excluded); }
/*
  The rota week entry (issue #357). A small patch of the band itself (issue
  #477) -- the grid draws this colour behind a working week's cards. Wider
  than the row's 12px chips so it reads as a stretch rather than as one
  more category square. Change the band's colour and this follows, since
  both read --week-band. Height and radius are the chip row's, inherited
  from `.grid-legend i`.

  Outranks `.grid-legend i` at (0,2,0) over (0,1,1), same as .legend-a/b.
*/
.grid-legend .legend-rota {
  width: 20px;
  background: var(--week-band);
}
/*
  The entry has two forms and they are not the same element: a key once the
  physician has set a rota week, and before that a link offering to. The
  link is the only pointer to a preference that lives on another page, so
  it is real link text -- a title tooltip reaches neither touch nor the
  keyboard, and this legend wraps to fit a 375px phone because that is
  where it gets read. The base link rule colours and underlines it; this
  only makes it a step heavier.
*/
.grid-legend .legend-link {
  font-weight: 600;
}

/*
  The posted-order key's two entries, grouped (issue #324). Below 768px the
  group takes a line of its own, so the colour swatches keep the first and
  the OT entry does not trail "Fri-Mon"; above it, the basis relaxes and the
  group sits inline like any other entry.

  **A group, not a spacer.** The first version was an empty full-width span
  before the entries, which cannot join a line that already has content, so
  it wrapped to an invisible line of its own and put the badges on the
  *third* -- two row gaps rather than one. It also had to be `display: none`d
  on desktop. A wrapper drops both, and says something true besides: these
  two entries are one key, gated together and rendered together.

  Print is only half fixed, knowingly. The query below is media-type `all`
  and a Letter page box is about 739px once default margins come off it, so
  the mobile branch still applies on paper. What the wrapper removes there
  is the *empty* printed line and a `display: none` that never fired; the
  group itself still takes a full-width line. The rest wants an
  `@media print` block this file does not have.

  `margin-top` restores the 12px that arrangement produced by accident.
  Justin looked at 12px and 6px side by side and kept 12px, so it is a
  decision now rather than a side effect -- and one number, if it ever
  changes. The legend's own row gap above is 6px, which is what every other
  wrap in this row costs; this entry deliberately pays more.

  The group's own `gap` mirrors the parent's so the two entries space like
  their neighbours whichever line they land on. It costs a line only when
  the rota entry is showing -- in its unset `.legend-link` form, which is
  the one visible on mobile, and the one every *physician* account has
  before anyone chooses a working week. With a week set that entry is
  `.legend-desktop` and hidden here, and the legend is two lines with the
  group or without it -- measured by unwrapping the group, not by hiding
  it, which removes the entries and answers a different question. A
  read-only viewer is offered neither form, so two lines either way.
  The two badge entries carry no `.legend-desktop` of their own: unlike the
  rota key, the marks they name are on both surfaces.
*/
.grid-legend .legend-order-key {
  flex-basis: 100%;
  display: flex; flex-wrap: wrap; gap: 6px var(--space-4);
  margin-top: 6px;
}
@media (min-width: 768px) {
  .grid-legend .legend-order-key { flex-basis: auto; margin-top: 0; }
}
/* The worked entry's swatch: the grid's bare ✓ (.grid-name-badge-worked),
   in the same --mine-ink, so the key looks like the mark it names. */
.grid-legend .legend-worked { color: var(--mine-ink); font-weight: 700; }

/* The section heading and its lead-in, on the admin pages that stack
   several sections: admin_schedule, admin_settings, account_list,
   queued_email_failures. admin_dashboard wears the lead-in alone. */
.section-h3 { font-size: 1.1rem; margin-bottom: 6px; }
.section-hint { color: var(--muted); font-size: 0.85rem; margin: 0 0 10px; }

/* Every labelled form field in the app -- seven templates today. */
.form-label { font-size: 0.85rem; font-weight: 600; }

/* The refusal a form shows in place of the browser's own bubble (issue #448).
   Written into the page by `app.js`, and drawn by `_account_fields.html` for
   a server refusal in the same markup (#559): the two are kept in step by
   hand.

   The shape is the GOV.UK Design System's: the message above the input it is
   about, with a red bar down the left tying the two into one block. The bar is
   doing real work rather than decoration -- on the Preferences grid a
   full-width line sits between two rows of boxes, and without something
   joining it to one of them it reads as belonging to neither.

   **Two marks, not one, and the gap between them is the point.** The message
   carries a bar and the field carries a bar, separated by a space: one mark
   for the problem, one for the thing to fix. An unbroken line reads as a
   single block and loses that.

   It also has to be this way. A stacked form's `<label>` wraps its own input,
   so a message placed inside it *would* run as one continuous line -- but a
   grid's label is a separate cell and nothing wraps a row, so the grid can
   never produce that line. Chasing it would have made the two layouts differ
   on a flourish only half the app could have. So the message sits outside the
   label in both, and both read the same way.

   The colours are the app's existing error family, so a new theme retunes
   these with everything else and nothing here is a fourth red to keep in
   step. */
/* The mark itself, spelled once and worn twice -- by the message and by the
   field it is about. Two copies of the same three values would have to be
   retuned together, with nothing failing if only one was. */
.field-error,
.field-invalid { border-left: 4px solid var(--excluded); padding-left: 10px; }
.field-error {
  display: block; margin: 0 0 8px;
  font-size: 0.8rem; font-weight: 700; line-height: 1.3;
  color: var(--excluded-ink);
}
/* The grid's message spans every column; the stacked one is a block already. */
.field-error-row { grid-column: 1 / -1; }
/* The ring on the box itself: the second signal, so the refusal does not rest
   on colour alone for anyone who cannot see it -- the message, the bar and
   this are three.

   The element names are load-bearing, not tidiness. A field class that sets
   its own border at equal specificity, further down this file, wins over a
   bare class here, and the box keeps its resting edge -- measured in the
   browser when `.auth-input` and `.pref-input` still set theirs, where a
   refused sign-in showed the message and the bar with no ring at all. They
   no longer do (the field base rule supplies the edge), but an element name
   settles it wherever the rules end up, which moving this block would not:
   that is a fix a later reorder or a new field class silently undoes.

   All three, because `app.js` puts the class on whatever field was refused.
   No `<select>` or `<textarea>` in these forms can be refused today -- the one
   select always has an option chosen -- but qualifying on `input` alone meant
   the first required select with a placeholder option would show the message
   and the bar and leave its own box unmarked, quietly turning three signals
   into two. */
input.field-invalid-input,
select.field-invalid-input,
textarea.field-invalid-input { border-color: var(--excluded); border-width: 2px; }

/*
  The admin config panel's heading, hint and card sizing. Named for Settings
  because that is the page they were written for, and they now dress the
  Messages tab too (issue #438) -- so they are here rather than in a page
  stylesheet only one of the two loads. (`admin_settings.css` was that
  stylesheet; once these moved out it held nothing and was deleted.)

  That is not a tidy-up: a page stylesheet another page cannot see is a rule
  that silently does nothing there, and it did. Messages shipped with
  `max-width: none` and `padding: 0` on a card meant to be 480px wide with
  20px of breathing room, which is invisible to the suite and obvious on the
  screen -- content against the card's edge and a subject field 806px long.

  `.settings-card` went with them and then went entirely: by the time the move
  settled, no template wore it -- Settings' one form is a plain `.card` and the
  Messages panels are `.msg-panel` at 520px. Its padding rule kept two live
  wearers, so the rule stayed and the name left it.

  Kept under their existing names, the way `.pref-checkbox-accent` is -- the
  comment naming the second wearer is the mechanism this file already uses for
  a rule that outgrew its page.
*/
.settings-h3 { font-size: 1.1rem; margin: 28px 0 6px; }
.hint-wide { max-width: 520px; }
/* The panel inset, worn by two surfaces and stated once. `.qef-card` had its
   own copy of these exact values in queued_email_failures.css, which is the
   drift this guards: two files agreeing by coincidence until the first retune
   reaches one of them. A `<details>` card cannot wear it -- padding on the
   element would shrink the summary's click target and stop the detail's rule
   spanning -- so `messages.css`'s `.sent-summary` / `.sent-detail` restate the
   two lengths on the children and say they are following this. */
.qef-card, .msg-panel { padding: var(--space-4) 20px; }
/* No width here: this file sets the inset the two surfaces share and nothing
   else, because width is each surface's own. `messages.css`'s `.msg-panel,
   .sent-card` rule carries the measured argument for why Sent is capped at
   520 and Delivery's six-column table is not -- it lives beside the rule it
   decides, which this paragraph stopped doing the moment `.settings-card`
   was deleted out from under it. */

/* Table and form odds and ends, each used by two or more pages. */
.cell-form { margin: 0; }
.pager {
  display: flex; justify-content: space-between; align-items: center;
  margin-top: 10px; font-size: 0.85rem;
}
.pager-note { color: var(--muted); }
.pref-intro { color: var(--muted); font-size: 0.85rem; margin: 0 0 14px; }
.pref-help { font-weight: 400; color: var(--muted); font-size: 0.8rem; margin: 2px 0 var(--space-1); }
.pref-input { width: 100%; padding: var(--space-2) 10px; border-radius: var(--radius-ctl); }
/* Grayed while the checkbox that gives it meaning is off (issue #430).

   The fill carries it, not the text colour, because the field is usually
   empty: with doubles off there is rarely a cap to show, so `--muted` text
   alone -- the way .publish-not-ready signals inertness -- would leave a
   disabled box pixel-identical to a live one. `--surface-2` was tried first
   and measured 4 points from white in the light theme, which reads as no
   difference at all. `--bg` sinks it to the page colour, and `--muted` on
   `--bg` is a pair the contrast registry already measures, so a stored value
   stays readable in every theme. The text needs saying too: the field base
   rule's `--text` reaches a disabled field as well, and would print a
   locked cap at full strength. */
.pref-input:disabled { background: var(--bg); color: var(--muted); cursor: not-allowed; }
/* ...and the whole block around it fades, with an "Off" tag after the label.

   Chosen from mockups after the shading-only version -- a grey label over the
   page-coloured box -- read clearly on one monitor and vanished on another.
   A small lightness step is what a washed-out panel flattens first, so this
   carries two signals that are not shading: opacity, which dims the dark text
   and border along with the fill, and a word.

   `:has()` follows the input's disabled state, so the block cannot drift from
   the box, and a browser without it leaves the section undimmed as before.

   **Below AA, deliberately.** As rendered at .55 the tag's text measures
   1.96:1 on its fill in the light theme and 2.65:1 in the dark one, well under
   4.5:1. WCAG 1.4.3 exempts an inactive control, and a readable "Off" on a
   field that cannot be used is not what this is for -- which is also why the
   pair is not in `ComposedTextPairsStayReadable`: it would fail by design.
   The tag's shape is pinned by `ALockedFieldSaysSo` in test_static_assets. */
.form-label:has(> .pref-input:disabled) { color: var(--muted); opacity: .55; }
.form-label:has(> .pref-input:disabled) > .pref-label-title::after {
  content: "Off"; margin-left: var(--space-2); padding: 1px var(--space-2);
  border: 1px solid var(--muted); border-radius: 999px;
  background: var(--border); color: var(--muted);
  font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em;
  text-transform: uppercase; vertical-align: 1px;
}
.pref-divider {
  border-top: 1px solid var(--border); padding-top: 14px;
  display: flex; flex-direction: column; gap: 14px;
}
.pref-group-head {
  font-size: 0.75rem; font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.05em; color: var(--muted);
}
.pref-check-label {
  font-size: 0.85rem; font-weight: 600;
  display: flex; align-items: center; gap: 10px; cursor: pointer;
}
.pref-checkbox { width: 16px; height: 16px; cursor: pointer; }
.pref-note { font-weight: 400; color: var(--muted); font-size: 0.8rem; }
.pref-h3 { font-size: 1rem; font-weight: 600; margin: 0 0 6px; }
/* The "Per shift type" grid on both Preferences pages (issue #330): a label
   column sized to its content, then Per week / Per month sharing the rest
   -- about 80px each on a 360px phone, measured. A row label is the
   category, with the pool beneath it on the two Evening rows beside the
   schedule's own Tue-Thu / Fri-Mon swatch (.phase-sq, the stats table's),
   so the split reads as the two halves of the week the grid and legend
   already colour that way. The column headers wear .pref-group-head a
   step smaller and the row labels wear .form-label (they are the inputs'
   labels, via aria-labelledby), so a retune of either reaches them. The
   swatch sheds .phase-sq-b's sibling margin: that gap is for the stats
   table, where the two squares sit side by side. */
/* .rule-grid is the Schedule tab's signup-window rule (issue #341): the same
   two-value-columns-per-row shape, so it shares the rules rather than
   restating them, the way .pref-form/.create-form/.settings-form share one
   body below. Its column heads wear .pref-group-head too, which is now the
   one muted uppercase group head across both pages -- the `pref-` prefix
   under-describes it, and renaming it would touch every Preferences
   template, so it stays. */
.limits-grid, .rule-grid {
  display: grid; grid-template-columns: max-content 1fr 1fr;
  gap: var(--space-2) 10px; align-items: center;
}
.limits-grid .pref-group-head, .rule-grid .pref-group-head { font-size: 0.72rem; }
/* .form-field's top margin is for a label stacked above it; in a grid the
   label is the row's first cell, so the input sits on the row's baseline. */
.rule-grid .form-field { margin-top: 0; }
.limits-row { line-height: 1.2; }
.limits-pool {
  display: flex; align-items: center; gap: 5px;
  font-size: 0.75rem; font-weight: 600; color: var(--muted); margin-top: 2px;
}
.limits-pool .phase-sq { margin-left: 0; }
.limits-grid .pref-input { font-variant-numeric: tabular-nums; }
/* The Save row of every form on the physician's Preferences page (issue
   #486): sticky at the bottom of the screen while its form runs below the
   fold, so the button is reachable without scrolling past the twelve
   fields it saves, and settled into its place as the form's last row once
   the end scrolls into view. One rule for all four forms rather than one
   for the long one: three fit on a screen today, where sticky has nothing
   to do, and the next long form gets it without anyone remembering. The
   negative margins take the row out to the card's edge, reading the
   form-card's own --form-pad-* (declared on the grouped form-card rule in
   the Promoted section below), so fields scroll under a surface rather
   than behind a bare button; the bottom radius is the card's less its
   border. Flex column, so the button stretches the way it did as the
   form's own flex item. Wearers: every form on both Preferences pages;
   the admin Scheduling form's row holds a .btn-row, so Save and Clear All
   sit side by side in it at their own widths rather than stretching. */
.pref-save {
  position: sticky; bottom: 0; z-index: 1;
  display: flex; flex-direction: column;
  background: var(--surface);
  margin: 0 calc(-1 * var(--form-pad-x)) calc(-1 * var(--form-pad-y));
  padding: var(--space-3) var(--form-pad-x) calc(var(--form-pad-y) + env(safe-area-inset-bottom));
  border-top: 1px solid var(--border);
  border-radius: 0 0 calc(var(--radius) - 1px) calc(var(--radius) - 1px);
}
/* A field focused from the keyboard scrolls into view above the pinned
   row rather than under it. Scoped to the page that has one, so no other
   page's scrollIntoView stops short of the bottom. The row measures 67px
   at desktop width with no safe-area inset, and grows by the inset on a
   phone with a home indicator, so the padding carries the inset too; 72
   leaves a little over. */
html:has(.pref-save) { scroll-padding-bottom: calc(72px + env(safe-area-inset-bottom)); }
.updated-note { color: var(--muted); font-size: 0.8rem; margin: 0; }
/* The date wraps as one piece (_last_changed_note.html). */
.updated-note time { white-space: nowrap; }

/* The month lists' Active / Archived toggle (month_list.html). */
.admin-tabs.tabs-mid { margin-bottom: 14px; }
/* Raised-card base worn as .card by the month lists (month_list.html).

   **The card is a <div> and the month name is the link** (issue #390). It was
   an <a> wrapping the whole card, which left the Archive control no valid home
   inside it -- interactive content cannot nest -- so the form sat outside as a
   sibling and took 82.9px of a phone-width row away from a stats grid that had
   no way to give it up. `.month-card-link::after` covers the card, so the whole
   surface still navigates.

   It repaired the link's name on the way past. With the card as the anchor,
   the accessible name was computed from its contents: all 170 characters, every
   pill and every number announced before the reader learns where the link goes.
   Now it is the month. */
.month-card {
  position: relative;
  margin-bottom: 10px;
  padding: 14px 18px;
  display: grid; grid-template-columns: minmax(0, 1fr) auto;
  align-items: start; gap: var(--space-2) 10px;
  transition: transform 0.1s ease, filter 0.1s ease, box-shadow 0.1s ease;
}
/* **Three cells, placed rather than flowed, and the placement is the whole
   difference between the two widths.** Wide, the numbers sit beside the name
   and the control tucks under them; narrow, the name and the control share the
   top line and the numbers take a full-width row of their own.

   `align-items: start` is what keeps the name level with the `SIGNUPS` header
   instead of floating against the middle of a block twice its height -- which
   is what the `center` this replaced did once the card grew a second row.

   **No cell spans a row it does not need.** The head was briefly `1 / span 2`,
   which conjures the second row on every card and leaves the row gap sitting
   under the ones with no control: 8px of nothing, on most of the list. */
.month-card-head { min-width: 0; grid-column: 1; grid-row: 1; }
.month-card-link {
  font-weight: 700; font-size: 1.05rem; color: inherit; text-decoration: none;
}
/* The stretched link: the name is the anchor and the card is its hit area.
   `.month-card` is the positioned ancestor this resolves against, which is what
   its `position: relative` is for.

   **This sheet lies over every cell of the card, so any interactive descendant
   added here needs `position: relative` and a `z-index` above it**, the way
   `.month-card-action` below does. A control that skips them renders, takes
   focus, and cannot be clicked at all -- the pointer meets the sheet and
   navigates instead, and nothing anywhere goes red. There is no blanket
   `.month-card > * { z-index: 1 }` to reach for: it would lift the stats above
   the sheet too, and the card body would stop navigating. */
/* `.person-card-link` is the Accounts phone card's name (issue #540), and
   the warning above holds for it too: its Unreleased shifts link is the
   lifted control, in account_list.css. `.day-summary-link` is a dashboard
   day's date on the phone's list (issue #565), whose card holds no control.
   The two phone cards share their shell as well: rows down the left, and
   the arrow at the right edge beside all of them. */
.month-card-link::after, .person-card-link::after, .day-summary-link::after { content: ""; position: absolute; inset: 0; }
/* The whole card presses (the press tiers, above), so a phone's own tap
   highlight -- Android Chrome's box over the link's text alone -- is a
   second, smaller answer to the same tap, in a colour no theme chose. A
   keyboard still gets the focus ring. */
.month-card-link, .person-card-link, .day-summary-link { -webkit-tap-highlight-color: transparent; }
.person-card, .day-summary {
  position: relative;
  display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center;
}
.person-card > :not(.person-card-arrow), .day-summary > :not(.day-summary-arrow) { grid-column: 1; }
.person-card-arrow, .day-summary-arrow { grid-column: 2; grid-row: 1 / span 3; color: var(--muted); font-size: 1.4rem; line-height: 1; }
/* The Archive / Unarchive control, and the one wearer of the rule above.
   `display: flex` because it is a <form> whose only child is the button. */
.month-card-action {
  grid-column: 2; grid-row: 2; justify-self: end;
  display: flex; position: relative; z-index: 1;
}
.month-card-pills { display: flex; gap: var(--space-2); flex-wrap: wrap; margin-top: var(--space-1); }
/* **The words are column headers, not a suffix on every number** (issue #390),
   and a `<table>` rather than a grid of spans. The markup half is the
   table in `month_list.html`, and its comment carries why the semantics matter.

   `repeat(4, max-content)` was the bug this replaced. A `max-content` track
   cannot shrink, so the block held its natural width whatever the card had --
   and the card gives up 82.9px to an Archive button, leaving the stats to draw
   straight over it. Measured at 375px: 61.8px of overhang, 53.8px of it across
   the button itself.

   **What fixes it is headroom, not an inability to overflow.** Hoisting the
   three words into headers takes the block from 310.9px to 250.3px, and that
   is the whole of it. Measured, because the obvious guess is wrong: this table
   cannot give way at all -- its `min-content` and `max-content` are the same,
   since every cell is a single unbreakable token (`SIGNUPS`, `ASSIGNED`,
   `UNFILLED`, the row labels, and the numbers). There is nothing in it to
   wrap. Squeezed to a 60px card it keeps its width and simply hangs out of it.

   So the margin is finite and worth knowing. The Daytime row (issue #230)
   made the table 252.9px, since `Daytime` is the widest label, 2.6px wider
   than `Evening`. The card's interior is the viewport less 62px, so this holds
   down to a viewport of about 315px. Measured 2026-10-01 at the ends: 320px,
   the narrowest phone still in use, has 5.1px to spare; 375px at the
   browser's Large text setting, a 20px root, has 2.9px.
   **A fifth column, or a longer word in any of these cells, spends that margin
   and nothing will absorb it.** That is the check to run before adding one.

   **Do not put a width here.** The thing being sized is text. Issue #388's
   follow-up spent two measured constants learning that, and this block has the
   same property: it is as wide as the words in it, in whatever face is
   rendering, at whatever text size the reader has chosen.

   `justify-self: start` because a table is a block box in a grid area: left to
   stretch it would take the whole column and spread its cells. */
.month-card-stats {
  grid-column: 2; grid-row: 1; justify-self: start;
  border-collapse: collapse; font-size: 0.85rem; color: var(--text);
}
/* `padding-left` is the column gap and `padding-block` is half the row gap,
   since a collapsed table has no `gap` of its own. The first cell in each row
   drops the leading one so the block starts flush. */
.month-card-stats th,
.month-card-stats td { padding: 2px 0 2px var(--space-2); text-align: right; }
/* Both header kinds beat the UA's bold down to 600, so the figures below stay
   the heaviest thing in the block. */
.month-card-stats th { font-weight: 600; }
.month-card-stats tr > :first-child { padding-left: 0; }
/* The eyebrow over each number column. Its own size rather than `.ctl-label`'s:
   that one is a page-panel label and this sits inside a card at 0.85rem.

   **`--text` rather than the `--muted` an eyebrow usually wears.** At this size
   muted measures 5.02:1 on the card against the 15.6:1 of the numbers directly
   beneath it -- over the 4.5:1 floor, and still visibly faint next to what it
   labels, which is how Justin read it on a phone. The small caps and the
   letter-spacing are what mark this as a header; the colour was not carrying
   that job, only costing legibility. */
.month-card-stats th[scope="col"] {
  font-size: 0.72rem; letter-spacing: 0.05em; text-transform: uppercase;
}
/* **The figures stay the heaviest thing in the block**, which is what the
   `<strong>` they used to sit in was doing. A `<td>` carries no weight of its
   own, so dropping the element without this leaves the data lighter than both
   its row header and its column header. */
.month-card-stats td { font-weight: 700; }

@media (max-width: 767px) {
  /* **The name and the control share the top line; the numbers take their own
     row underneath.** One column of content instead of two, which is what gives
     the stats the full card width -- and the width is the whole point: the
     table is 250.3px against a card interior of 292px at 352px, where beside
     the name it had nowhere near that.

     The control moves corner rather than staying put, and that is deliberate.
     Down here the top-right is empty; up there it is where the `SIGNUPS` header
     starts, and a control placed over it overlaps the headers by 65 x 27px --
     measured, not assumed. Same reason the desktop card cannot simply adopt
     this arrangement: it would leave two thirds of an 780px card empty. */
  .month-card-action { grid-row: 1; }
  /* `1 / -1` so it takes both tracks whatever the second one is holding. It
     keeps its own width from the base rule's `justify-self: start`. */
  .month-card-stats { grid-column: 1 / -1; grid-row: 2; }
}

/* Promoted: shared by 2+ pages once the last templates were extracted
   (admin_preferences/release_schedule/account_detail; account_list/
   admin_schedule; admin_settings/admin_schedule/account_detail;
   preferences/admin_email_preferences). */
.btn-row { display: flex; gap: 10px; }
/* Two related buttons side by side at one width, the wider label's: a
   grid sized to its content gives both equal columns (#560). Its gap is
   `.btn-row`'s. Grep btn-pair for the wearers. */
.btn-pair {
  display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
  width: max-content; max-width: 100%;
}
.physician-pick {
  font-size: 0.85rem; font-weight: 600; display: block;
  max-width: 360px; margin-bottom: 20px;
}
/* The form card, one body for the five of them -- shared values in the
   group, each form's own deltas below it. All five wear .card in their
   templates; .acct-card is an account page's sections (#559). The padding is declared as two scoped properties so that
   .pref-save, which has to reach the card's edge from inside it, can read
   the same figures rather than retype them. */
.pref-form, .create-form, .settings-form, .pref-form-email, .acct-card {
  --form-pad-x: 20px;
  --form-pad-y: var(--space-4);
  padding: var(--form-pad-y) var(--form-pad-x);
  display: flex; flex-direction: column; gap: var(--space-3); max-width: 360px;
}
.pref-form { gap: 14px; }
/* The Preferences card's width, and with it the strip above the card
   (issue #486): the strip is the card's tabs, and above phone width its
   underline ran on across the column, a rule under nothing. One figure
   for all of them, so a retune moves them together; the intro paragraphs
   are prose and keep wrapping at the column. The strip is named by the
   id preferences.js already reads. An account's own page (#559) is the
   other admin page about one person, so its column and cards take the
   same figure. */
.pref-form, .pref-form-email, #pref-subtabs, .acct-page, .acct-card { max-width: 400px; }
.create-form { margin-bottom: 28px; }
/* .theme-radio / .theme-choice-label: the Appearance panel's rows are the
   email panel's row shape with a radio instead of a checkbox, so they share
   the rules rather than restating them.

   **`.pref-checkbox-accent` is worn outside this page**: the admin dashboard's
   four pre-publish checkboxes took it in issue #370, which is what stopped
   them being the app's only interactive boxes at the UA's size, cursor and
   blue. Retuning any of the four declarations below reaches that surface too,
   and it is a 2x2 grid there -- the box width is one of its column sizes. */
.pref-checkbox-accent, .theme-radio, .rota-radio { width: 16px; height: 16px; cursor: pointer; accent-color: var(--accent); }
.email-check-label, .theme-choice-label, .rota-choice-label {
  display: flex; align-items: center; gap: 10px; font-size: 0.9rem; cursor: pointer;
}
.email-divider { border-top: 1px solid var(--border); padding-top: var(--space-3); margin-top: var(--space-1); }
.snooze-label { font-size: 0.85rem; font-weight: 600; display: block; }
.snooze-active-note { color: var(--excluded-ink); font-size: 0.8rem; margin: 6px 0 0; }

/* admin_schedule.html */
.latest-note { color: var(--muted); font-size: 0.85rem; margin: 0 0 var(--space-1); }


/* The auth pages: login and the password pages under
   templates/registration/. Grep auth- for the wearers. */
.auth-form { display: flex; flex-direction: column; gap: var(--space-3); margin-top: 24px; }
.auth-error { color: var(--excluded-ink); }
.auth-note { color: var(--muted); }
.auth-input {
  width: 100%; padding: var(--space-3); border-radius: var(--radius-ctl);
  margin-top: var(--space-1);
}
/* Compound, so the padding override does not depend on sitting below
   `.btn-primary` in this file. */
.btn-primary.auth-submit { padding: 14px; }
/* The auth pages' text links, and the lockout page's (#417): the base link
   rule's colour and underline, a step heavier. */
.auth-link { font-weight: 500; }

/*
  Error pages (issues #263, #300, #302, #401): templates/_error_page.html is
  the only wearer -- the five Django-named error templates, the axes lockout
  page and the sign-in budget's busy page all render through it. Standalone
  documents with no app bar, so the page has to read as the app at rest on
  tokens alone -- the status code is oversized muted type rather than a
  heading, and the global `main` rule provides the column.
*/
.error-page { text-align: center; padding-top: 18vh; }
.error-code {
  font-size: 2.6rem;
  font-weight: 700;
  color: var(--muted);
  letter-spacing: 0.04em;
  margin: 0;
}
.error-page h1 { font-size: 1.4rem; margin: 6px 0 10px; }
.error-note { color: var(--muted); margin: 0 0 24px; }

/*
  Reduced motion: the restyle made micro-motion systemic (hover lifts, press
  scales, shadow transitions), so it gets the one systemic escape. Zeroing
  duration rather than removing the states: the lift and press still land,
  instantly -- the preference is about movement over time, not about state
  feedback. 0s and not a near-zero: transition-property's initial value is
  `all`, so any non-zero duration on * makes every element in the document
  transition-eligible and fire transition events on every style change --
  and nothing in this app (htmx included: it settles on timers) listens for
  them. !important is the point, not a smell: this must outrank every
  per-component transition above without naming them.

  **The `!important` is the protection, not the position.** This used to be
  the last rule in the file and said so; issue #138 appended the htmx
  indicator rules below it. Nothing broke -- an important declaration
  outranks a later normal one whatever the order, and that was always what
  was doing the work here. But a later rule that is *also* `!important`
  would win on specificity, `.htmx-indicator` (0,1,0) beating `*` (0,0,0),
  and motion would come back for the people who asked for none. So: nothing
  below may carry `!important` on a transition property.
*/
@media (prefers-reduced-motion: reduce) {
  * {
    transition-duration: 0s !important;
  }
}

/*
  htmx's own indicator rules, moved here from htmx's own injected <style>.

  htmx writes these three into a <style> element at init unless told not to.
  `default-src 'self'` blocks that element (issue #138), which does not error
  anywhere -- htmx neither checks nor reports it -- and simply leaves
  `.htmx-indicator` at its default `opacity: 1`. An indicator that is meant to
  appear only during a request would sit visible on the page forever.

  Nothing in this app uses `hx-indicator` today, so this restores no broken
  screen. It is here so that the next thing that does works the way htmx
  documents, rather than discovering this. `includeIndicatorStyles` is turned
  off in `base.html`'s htmx-config meta, and the two go together: turning the
  injection off without these leaves the same silent default behind.

  Copied from `htmx.min.js` at the vendored version -- the declarations are
  its, the whitespace is ours, so do not diff them literally. Keep them in
  step with an htmx upgrade the way CONVENTIONS' vendoring rule already asks.

  The two `.htmx-request` selectors are not a duplicate pair: one is a
  descendant, the other a compound on a single element, and htmx ships both.

  Not tokenised, because there is no colour here -- opacity and a duration --
  and `PageColoursComeFromTokens` is about colour literals.
*/
.htmx-indicator { opacity: 0; }
.htmx-request .htmx-indicator { opacity: 1; transition: opacity 200ms ease-in; }
.htmx-request.htmx-indicator { opacity: 1; transition: opacity 200ms ease-in; }

/*
  The All shifts tab (issue #548): every published shift, Tuesday to
  Monday. Mobile-first like `.calendar-grid`: a card list under week
  headings, and the seven-column grid where the list goes -- from 1025px,
  not the other calendar's 768px. A day here holds every shift's names
  rather than one category's, and below that width the cells are too
  narrow to read them: measured on dev September, 162 of 261 names cut
  off at 768px and 35 at 1024px, against 5 at 1300px (/verify, #549).
  Tablets and sideways phones get the cards, which show every name whole;
  in the grid a long name is cut with an ellipsis and its title carries
  it, as the mockup decided (wrapping was tried and rejected as ragged).

  The two surfaces share `_all_shifts_day.html` and differ in one thing:
  a grid cell is about 150px wide, so a shift's label sits above its names
  and each name gets the cell's width; a card has the room to put the label
  beside them. No rule between the shift blocks -- spacing groups them, and
  the tag heads each one (the mockup review, 2026-09-30).
*/
.all-shifts-grid { display: none; }
/* Both surfaces wear `.card`, the raised-card base, so the outside-month
   and unpublished rules below reach both; a grid cell narrows the radius
   to the --radius-sm tier the other calendar's cells use. */
.all-shifts-list .as-day {
  padding: var(--space-3);
  margin-bottom: var(--space-2);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.all-shifts-list .as-date { margin: 0; font-size: 0.95rem; font-weight: 650; }
.all-shifts-list .as-group {
  display: grid;
  grid-template-columns: 76px minmax(0, 1fr);
  gap: var(--space-2);
  align-items: start;
}
.all-shifts-list .as-tag { justify-self: start; margin-top: 2px; }
.as-day.as-day-outside { background: var(--outside-month-bg); }
.as-day.as-day-unpublished {
  background: transparent;
  border-style: dashed;
  box-shadow: none;
}
.as-unpublished-note { margin: 0; font-size: 0.8rem; color: var(--muted); }
.as-site { display: flex; flex-direction: column; gap: 10px; }
.as-site-name {
  margin: 0;
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}
.as-tag {
  display: inline-block;
  font-size: 0.72rem;
  font-weight: 600;
  border-radius: 999px;
  padding: 1px var(--space-2);
  white-space: nowrap;
}
.as-tag-daytime { background: var(--cat-daytime-bg); color: var(--cat-daytime-ink); }
.as-tag-evening { background: var(--cat-evening-bg); color: var(--cat-evening-ink); }
.as-tag-night { background: var(--cat-night-bg); color: var(--cat-night-ink); }
.as-tag-call2 { background: var(--cat-call2-bg); color: var(--cat-call2-ink); }
.as-names {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
.as-line {
  display: flex;
  align-items: baseline;
  gap: 5px;
  min-width: 0;
  font-size: 0.875rem;
  line-height: 1.45;
}
.as-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.as-ot { flex: none; font-weight: 600; color: var(--muted); }
.as-worked { flex: none; color: var(--mine-ink); font-weight: 700; }
/* Your own shift: the theme's mine family, so Harbor's is blue. The
   negative margin lets the tint reach past the name without moving it. */
.as-line.as-mine {
  background: var(--mine-light);
  color: var(--mine-ink);
  font-weight: 600;
  border-radius: 6px;
  padding: 0 6px;
  margin: 0 -6px;
}
.as-line.as-mine .as-ot { color: var(--mine-ink); }
.as-gap {
  display: flex;
  justify-content: space-between;
  gap: var(--space-1);
  background: var(--excluded-bg);
  color: var(--excluded-ink);
  font-size: 0.875rem;
  line-height: 1.45;
  font-weight: 600;
  text-decoration: none;
  border-radius: 6px;
  padding: 0 6px;
  margin: 0 -6px;
}

@media (min-width: 1025px) {
  .all-shifts-list { display: none; }
  .all-shifts-grid {
    display: grid;
    grid-template-columns: repeat(7, minmax(0, 1fr));
    gap: var(--space-2);
    margin-bottom: 20px;
  }
  .all-shifts-grid .as-weekday {
    font-size: 0.72rem;
    font-weight: 700;
    color: var(--muted);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    padding: 0 2px;
  }
  .all-shifts-grid .as-week-span {
    grid-column: 1 / -1;
    margin: var(--space-2) 0 0;
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--muted);
  }
  .all-shifts-grid .as-day {
    min-width: 0;
    border-radius: var(--radius-sm);
    padding: var(--space-2);
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  .all-shifts-grid .as-date {
    margin: 0;
    font-size: 0.78rem;
    font-weight: 650;
    color: var(--muted);
    font-variant-numeric: tabular-nums;
  }
  .all-shifts-grid .as-group { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
  .all-shifts-grid .as-tag { align-self: flex-start; margin-bottom: 1px; }
  .all-shifts-grid .as-line, .all-shifts-grid .as-gap { font-size: 0.8rem; }
}
