/*
 * Wallaroo EMS — central design system (single source of truth).
 *
 * This is the ONLY place brand colours, fonts, spacing and component styles are
 * defined. Every page and every module uses these tokens/classes — no module
 * hardcodes a hex colour or font. To change the look of the whole system, change
 * it here. Values come from the Wallaroo Style Guide (master brand palette p.9,
 * typography p.15–16).
 */

/*
 * Brand fonts — self-hosted (no external CDN, offline-safe, private).
 * Files live in static/fonts/. Both are Open Font License (see the .txt licences
 * alongside them). DM Sans is a variable font covering all weights in one file.
 * If a font ever fails to load, the Arial fallback (style-guide sanctioned, p.16)
 * is used automatically.
 */
@font-face {
    font-family: "Archivo Black";
    src: url("../fonts/ArchivoBlack-Regular.070d925f3e4f.ttf") format("truetype");
    font-weight: 400;
    font-display: swap;
}
@font-face {
    font-family: "DM Sans";
    src: url("../fonts/DMSans-Variable.09d1eaa1672d.ttf") format("truetype-variations");
    font-weight: 100 1000;
    font-display: swap;
}

:root {
    /* --- Brand palette (exact style-guide values) --- */
    --brand-orange: #faa11c; /* PMS 1375C */
    --brand-black: #1e1e1e; /* PMS Pro. Black C */
    --brand-grey: #545454; /* PMS 7540C — use sparingly */
    --brand-white: #ffffff;

    /* --- Semantic colours (use these in components, not the raw brand names) --- */
    --color-accent: var(--brand-orange);
    --color-accent-ink: var(--brand-black); /* text/icons that sit on orange */
    --color-accent-hover: #e08e12; /* darker orange for hover/active */
    --color-accent-tint: color-mix(
        in srgb,
        var(--color-accent) 14%,
        var(--color-surface)
    ); /* faint orange wash — e.g. an unread conversation */
    --color-text: var(--brand-black);
    --color-muted: var(--brand-grey);
    --color-surface: var(--brand-white); /* cards/panels */
    --color-page: #f4f4f5; /* app background */
    --color-border: #e2e2e4;
    /* A step DARKER than the page, for a surface that has to read as a distinct
       object sitting on it. Added 3 Aug 2026 for incoming chat bubbles, which
       used --color-page and were therefore the *same colour as the background
       behind them* — measured 1.00:1, i.e. genuinely invisible.
       Chosen by measuring, not by eye: nothing already in the palette works,
       because they are all so light. White on the page is 1.10:1 and
       --color-border is 1.18:1 — differences you infer from the shape rather
       than see. Measured in the browser: this lands at 1.41:1 against the page,
       with body text on it at 10.8:1 — and sits sensibly below the own-message
       accent bubble's 1.88:1, so "mine" still reads as the louder of the two.
       Not a status colour and not from the style guide — a neutral surface. */
    --color-surface-sunken: #cfcfd6;
    --color-error-bg: #fde8e8;
    --color-error-text: #9b1c1c;
    --color-danger: #d92d20; /* overdue (red) */
    --color-danger-hover: #b42318; /* the hover partner, as --color-success has */
    --color-warning: #f4c000; /* due soon (yellow) */
    /* The pale/ink pair for a warning panel, mirroring the error pair above. The
       raw --color-warning is a chip colour: as a background it takes any text
       below the contrast floor with it. */
    --color-warning-bg: #fdf3d0;
    --color-warning-text: #6b4e00;
    /* Status coding is red / yellow / green. Green is functional (like the red
       and yellow above, it's not in the brand palette): the accent orange was
       doubling as "good", which is hard to tell from the yellow warning at a
       glance. Orange stays brand/interaction-only (buttons, active tabs). */
    /* 5.43:1 on white. It was #1e8e3e, which measured 4.21:1 — below the 4.5 floor
       for normal text, and this green carries WHITE text in two places: .btn--success
       (16px bold, just under the 18.66px "large text" allowance) and .badge--published
       (11.5px, the smallest text in the system on any colour). Its other uses — the
       switch, the filled period button, a dot, a border — carry no text and only need
       3:1, which the old value already cleared. The oddity that gave it away: the
       HOVER colour passed at 5.87:1, so the button became compliant only while the
       pointer was on it. (20 Aug 2026.) */
    --color-success: #177a33; /* healthy / approved / done (green) */
    /* 7.60:1, and kept a real step darker than the base rather than a nominal one.
       The old pair was 9.2 L* apart; leaving the previous #187430 here would have
       left 2.1 L*, which is not a hover anyone would see — this is 9.1, so the
       press still reads exactly as it always has. */
    --color-success-hover: #116128;
    /* The pale/ink pair for a success panel, mirroring the error and warning
       pairs above — the raw --color-success is a chip colour and drags text
       below the contrast floor when used as a background. */
    --color-success-bg: #e3f4e8;
    --color-success-text: #14622a;
    --color-header: var(--brand-black);
    --color-header-ink: var(--brand-white);
    --color-dark: var(--brand-black);
    --color-dark-hover: #333333;

    /* --- Typography --- */
    --font-heading: "Archivo Black", Arial, "Helvetica Neue", sans-serif;
    --font-body: "DM Sans", Arial, system-ui, sans-serif;

    /* --- Spacing / shape --- */
    --radius: 10px;
    --radius-sm: 6px;
    --shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
    --space-1: 0.25rem;
    --space-2: 0.5rem;
    --space-3: 0.75rem;
    --space-4: 1rem;
    --space-6: 1.5rem;
    --space-8: 2rem;
    --content-width: 960px;
    --content-width-wide: 1280px; /* data-dense screens — see .page--wide */
}

/* --- Base elements --- */
* {
    box-sizing: border-box;
}

body {
    margin: 0;
    font-family: var(--font-body);
    color: var(--color-text);
    background: var(--color-page);
    line-height: 1.5;
    overflow-wrap: break-word; /* long words / emails never force horizontal scroll */
}

html {
    -webkit-text-size-adjust: 100%; /* stop iOS inflating text on orientation change */
}

img {
    max-width: 100%;
    height: auto;
}

h1,
h2,
h3 {
    font-family: var(--font-heading);
    color: var(--color-text);
    font-weight: normal; /* Archivo Black already carries the weight */
    line-height: 1.2;
    letter-spacing: 0.01em;
}

h1 {
    font-size: 1.6rem;
}
h2 {
    font-size: 1.25rem;
}
h3 {
    font-size: 1.05rem;
}

a {
    color: var(--color-accent-hover);
    text-decoration: none;
}
a:hover {
    text-decoration: underline;
}

/* --- Layout --- */
.app-header {
    background: var(--color-header);
    color: var(--color-header-ink);
    padding: var(--space-3) var(--space-6);
    display: flex;
    align-items: center;
    gap: var(--space-3);
    border-bottom: 3px solid var(--color-accent);
}

/* The client portal's download buttons. On a phone they go ONE PER ROW at equal
   width (owner's request, 15 Aug 2026): wrapped inline they came out ragged —
   "Compliance" beside "Images & Copy", "Site Management Guide" alone on the next
   line — so the list read as groups rather than as five equal choices. Full width
   also gives each one the whole thumb, which a 44px-tall button squeezed to its
   label does not. */
@media (max-width: 640px) {
    .portal-downloads {
        display: flex;
        flex-direction: column;
        align-items: stretch;
    }
    .portal-downloads .btn {
        width: 100%;
        /* Centred text would drift as labels differ in length; left-aligned, the
           list reads as a column of options. */
        justify-content: flex-start;
    }
}

/* A button sitting ON the dark header takes the header's ink, not the page's.
   `.btn--secondary` is transparent with `color: var(--color-text)` — near-black,
   which on a near-black header measures a contrast ratio of exactly 1.00. The
   client portal's "My events" and "Sign out" were invisible: only their borders
   showed, so a client saw two empty outlined boxes (reported 15 Aug 2026, and the
   same failure as the chat bubble that used --color-page on --color-page).

   Scoped to `.app-header` rather than fixed on the two buttons, so anything added
   to a header later inherits the correction instead of repeating the bug. The
   staff header never hit it because its controls (`.app-back`, `.app-nav__toggle`)
   set `--color-header-ink` explicitly. */
.app-header .btn--secondary {
    color: var(--color-header-ink);
    border-color: var(--color-header-ink);
}
.app-header .btn--secondary:hover {
    background: color-mix(in srgb, var(--color-header-ink) 14%, transparent);
    color: var(--color-header-ink);
}

/* A header's actions read as one pair of controls, so they get one width and one
   right edge. Left to themselves each button is sized by its own label, and where
   they stack on a phone the shorter one ended 39px short of the longer, which
   reads as a broken layout rather than two differently-worded buttons.

   A grid, not flex, because flex has no way to say "equal widths": `flex: 1`
   distributes the *spare* space evenly, so two buttons starting 17px apart stay
   17px apart. `grid-auto-columns: 1fr` sizes every track to the widest item, which
   is the actual requirement. (The first attempt used flex plus `width: 100%` on the
   buttons — that makes each item fill the row, so they stacked at EVERY width,
   desktop included. Equal widths, and wrong.)

   `margin-left: auto` pins the pair to the header's right padding, mirroring the
   brand's gutter on the left. Measured: 961px → both 118px, right edge 937 = the
   content edge; 375px → both 140px, right edge 359 against a brand starting at 16. */
.app-header .btn-row {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: 1fr;
    justify-content: end;
    gap: var(--space-2);
    margin: 0 0 0 auto;
    /* When the header runs out of room the BRAND gives way, not the actions: the
       brand wraps to two lines and still reads, whereas a squeezed button breaks
       its label mid-phrase ("My / events") and loses tap area. Without this the
       row shrank to 96px on a phone and both labels wrapped. */
    flex: 0 0 auto;
}
/* The Sign out button is wrapped in its own <form> (it POSTs), so the track width
   has to pass through the form or only one of the pair would stretch. */
.app-header .btn-row > form {
    display: grid;
    margin: 0;
}

/* Global back button — the burger's left-hand twin: same square, same border,
   one per system (rendered by base.html on every page except Home). */
.app-back {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px;
    min-height: 44px;
    color: var(--color-header-ink);
    background: transparent;
    border: 1px solid var(--color-header-ink);
    border-radius: var(--radius-sm);
    cursor: pointer;
}
/* A drawn arrow rather than a glyph — see base.html. `display: block` keeps it
   out of the inline formatting context, so the flex centring above places it
   exactly rather than around a font's baseline and descender space. */
.app-back__icon {
    display: block;
    width: 1.25rem;
    height: 1.25rem;
}
.app-back:hover,
.app-back:focus-visible {
    color: var(--color-accent-ink);
    background: var(--color-accent);
    border-color: var(--color-accent);
}

/* Global burger menu — lives in the shared header so every page of every module
   gets it automatically. Uses a native <details>/<summary> disclosure (no JS
   needed to toggle); a tiny script closes it on outside-click. */
.app-nav {
    margin-left: auto;
    position: relative;
}
.app-nav__toggle {
    list-style: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px; /* comfortable tap target */
    min-height: 44px;
    font-size: 1.5rem;
    line-height: 1;
    color: var(--color-header-ink);
    background: transparent;
    border: 1px solid var(--color-header-ink);
    border-radius: var(--radius-sm);
    cursor: pointer;
}
.app-nav__toggle::-webkit-details-marker {
    display: none; /* hide the default disclosure triangle (Safari/Chrome) */
}
.app-nav[open] .app-nav__toggle {
    color: var(--color-accent-ink);
    background: var(--color-accent);
    border-color: var(--color-accent);
}
.app-nav__menu {
    position: absolute;
    right: 0;
    top: calc(100% + 0.4rem);
    z-index: 50;
    min-width: 210px;
    display: flex;
    flex-direction: column;
    padding: var(--space-2);
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow);
}
.app-nav__menu a,
.app-nav__signout {
    display: block;
    width: 100%;
    text-align: left;
    padding: 0.6rem 0.8rem;
    min-height: 44px;
    color: var(--color-text);
    font: inherit;
    background: transparent;
    border: 0;
    border-radius: var(--radius-sm);
    cursor: pointer;
}
.app-nav__menu a:hover,
.app-nav__signout:hover {
    background: var(--color-page);
    text-decoration: none;
}
/* Divider above the account group (Settings + Sign out) at the foot of the menu.
   Scoped under .app-nav__menu to outweigh the `border: 0` reset on menu links. */
.app-nav__menu a.app-nav__account-top {
    margin-top: var(--space-1);
    border-top: 1px solid var(--color-border);
}
.app-nav__signout {
    color: var(--color-muted);
}

.brand {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    text-decoration: none;
}
.brand:hover {
    text-decoration: none;
}
.brand-logo {
    height: 36px;
    width: auto;
    display: block;
}

.brand-wordmark {
    font-family: var(--font-heading);
    font-size: 1.05rem;
    letter-spacing: 0.03em;
    color: var(--color-accent);
    text-transform: uppercase;
}
.brand-wordmark .accent {
    color: var(--color-accent);
}

.page {
    max-width: var(--content-width);
    margin: 0 auto;
    padding: var(--space-8) var(--space-6);
}

/* Default spacing between whatever a page stacks at its top level.

   `.card` and `.panel` carry NO margin of their own — deliberately, because
   `.stack` and `.card-grid` space their children with `gap` and a margin would
   add to it. The gap is that a page laying containers out in plain block flow,
   without either wrapper, got nothing: measured on `/accounts/users/<id>/`,
   four consecutive `panel -> panel` gaps of exactly **0px**, so the borders
   touched and three separate cards read as one long box. Same for a `.btn-row`
   sitting under the last panel.

   An adjacent-sibling rule rather than a margin on `.panel`/`.card` themselves:
   it only fires where two things are actually next to each other in this
   container's flow, so nothing inside a `.stack` or `.card-grid` is touched (a
   flex/grid child is not a `.page` child) and no gap is ever doubled.

   `--space-4` to match `.stack`, which is what the 50 templates that already
   wrap their panels use — a page that forgot the wrapper should not end up with
   a different rhythm from one that remembered.

   Wrapped in `:where()` so it carries **zero specificity** — it is a default, not
   an override. Anything that sets its own top margin, including a bare `h2 { }`
   element rule, wins outright. Without `:where()` the selector scores (0,1,0) and
   quietly *reduced* headings that had a deliberately larger margin: measured on
   Settings, an `<h2>` dropped from 17px to 16px. One pixel there, but the same
   mechanism would flatten a 32px heading gap somewhere else, and a rule that
   silently overrides considered spacing is a worse bug than the one being fixed.

   Margins still collapse in this block container, so an element with its own
   larger margin keeps it rather than gaining 16px on top of it. */
:where(.page > * + *) {
    margin-block-start: var(--space-4);
}

/* Opt-in wider frame for data-dense screens (e.g. Reports). The default 960px is
   a readable measure for prose and forms; a page that is mostly a grid of cards
   or a wide table has nothing to gain from being kept narrow on a big monitor.
   Still a max-width, so it simply fills a smaller laptop screen. */
.page--wide {
    max-width: var(--content-width-wide);
}

.page--centered {
    display: flex;
    justify-content: center;
    align-items: flex-start;
    padding-top: 8vh;
}

/* --- Components --- */
.card {
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
    padding: var(--space-8);
    width: 100%;
    max-width: 420px;
}
.card > :first-child {
    margin-top: 0;
}
/* The 420px cap above suits a standalone card that shouldn't sprawl (a login
   box). A card that IS the page's content — a description panel above a
   full-width list — opts out with this and fills its container instead. The
   contextual rules (.card-grid > .card, .split-view__pane > .card) do the same
   job automatically where the container already decides the width. */
.card--wide {
    max-width: none;
}

.field {
    margin-bottom: var(--space-4);
}
.field label {
    display: block;
    margin-bottom: var(--space-1);
    font-weight: 700;
    font-size: 0.9rem;
}
.field input,
.field select,
.field textarea,
.input {
    width: 100%;
    padding: 0.55rem;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    font-family: var(--font-body);
    font-size: 1rem; /* 16px keeps iOS from zooming the page on focus */
    min-height: 44px; /* comfortable touch target */
    box-sizing: border-box;
}
/* Safari (and iOS in particular) ignores most styling on a native <select> and
   draws its own control — different height, its own rounding, its own arrow — so
   a filter bar that lines up in Chrome does not in Safari. Reported 2 Aug 2026.
   `appearance: none` opts out of that so the rules above actually apply, and the
   chevron below replaces the one we just removed.
   The colour is a literal because a data: URI cannot read a custom property; it
   is the same value as --color-muted and lives in brand.css, which is where the
   design system says colours belong. Keep the two in step. */
.field select,
select.input {
    appearance: none;
    -webkit-appearance: none;
    background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='none' stroke='%23545454' stroke-width='2' d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 0.75rem center;
    /* Room for the chevron so a long option cannot run underneath it. */
    padding-right: 2.25rem;
}
/* A multi-select renders as a list box, not a closed control, so the chevron
   would float over its options. */
.field select[multiple] {
    background-image: none;
    padding-right: 0.55rem;
}
.field textarea {
    min-height: 5rem;
}
/* A slider keeps the 44px tap target but sheds the text-input chrome. */
/* Show/hide control on a password box (core/_form_ux.html wraps every
   input[type=password] in .pw-wrap and appends the button).

   Scoped to .pw-wrap rather than styling .pw-toggle on its own: `.btn` and other
   generic single-class rules sit further up this file and would win at equal
   specificity, which reads as a positioning bug rather than a cascade one.

   The input keeps its own full-width sizing — the button is laid over its
   trailing edge, with padding on the input so typed text never runs under it. */
.pw-wrap {
    position: relative;
    display: block;
}
.pw-wrap input[type="password"],
.pw-wrap input[type="text"] {
    padding-right: 4.25rem;
}
.pw-wrap .pw-toggle {
    position: absolute;
    top: 50%;
    right: 0.4rem;
    transform: translateY(-50%);
    border: none;
    background: none;
    padding: 0.4rem 0.5rem;
    /* Full height of the 44px field it sits in — the mobile rule's floor applies
       to this as much as to any other control, and it is the one people reach for
       one-handed while typing a password. */
    min-height: 44px;
    font-family: var(--font-body);
    font-size: 0.8rem;
    font-weight: 700;
    /* --color-accent-hover, not the accent itself: this sits on white, where the
       brand orange is too light to read as text. No invented fallback token —
       one that does not exist fails silently. */
    color: var(--color-accent-hover);
    cursor: pointer;
}
.pw-wrap .pw-toggle:hover,
.pw-wrap .pw-toggle:focus-visible {
    text-decoration: underline;
}

.field input[type="range"] {
    padding: 0;
    border: none;
    accent-color: var(--color-accent);
}
/* Two sliders overlaid as one min–max bar (the rating filter): only the THUMBS
   take the pointer, so each handle drags independently and the pair reads as a
   single control. The native tracks are hidden — each input would paint its own
   filled-to-the-thumb bar and the top one covers the bottom one, colouring the
   min side wrongly. Instead the container draws the track itself: grey overall
   (::before) with an accent segment between the two thumbs (::after, driven by
   the --range-lo/--range-hi custom properties the page script sets, offset by
   the 20px thumb so the segment's ends sit under the thumb centres). */
.range-pair {
    position: relative;
    min-height: 44px;
    --range-lo: 0;
    --range-hi: 100;
}
.range-pair::before,
.range-pair::after {
    content: "";
    position: absolute;
    top: 50%;
    height: 5px;
    transform: translateY(-50%);
    border-radius: 999px;
    z-index: 0;
}
.range-pair::before {
    left: 0;
    right: 0;
    background: var(--color-border);
}
.range-pair::after {
    background: var(--color-accent);
    left: calc(10px + (100% - 20px) * var(--range-lo) / 100);
    right: calc(10px + (100% - 20px) * (100 - var(--range-hi)) / 100);
}
.range-pair input[type="range"] {
    position: absolute;
    inset: 0;
    min-height: 44px;
    margin: 0;
    pointer-events: none;
    background: transparent;
    -webkit-appearance: none;
    appearance: none;
    z-index: 1;
}
.range-pair input[type="range"]::-webkit-slider-runnable-track {
    background: transparent;
    height: 20px; /* thumb-sized, so the thumb centres on the input's midline */
}
.range-pair input[type="range"]::-moz-range-track {
    background: transparent;
}
.range-pair input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    pointer-events: auto;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: var(--color-accent);
    border: 2px solid var(--color-surface);
    box-shadow: var(--shadow);
    cursor: pointer;
}
.range-pair input[type="range"]::-moz-range-thumb {
    pointer-events: auto;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: var(--color-accent);
    border: 2px solid var(--color-surface);
    box-shadow: var(--shadow);
    cursor: pointer;
}
.field input:focus,
.field select:focus,
.field textarea:focus,
.input:focus {
    outline: 2px solid var(--color-accent);
    outline-offset: 1px;
    border-color: var(--color-accent);
}

/* A <summary> wearing .btn — a native no-JS disclosure whose toggle looks like
   any other button (e.g. "Request leave" / "Past requests" on My leave). */
summary.btn {
    list-style: none;
    display: inline-flex;
    width: auto;
}
summary.btn::-webkit-details-marker {
    display: none;
}

/* Inline confirm — a details disclosure replacing native confirm() dialogs,
   which some browsers suppress so the form silently never submits (a dead
   button). The summary is the guarded action ("Cancel leave…"); opening it
   SWAPS it for the real POST button, so only one button shows at a time.
   Clicking anywhere else closes it again (script in base.html). */
.confirm {
    display: inline-block;
}
.confirm[open] > summary {
    display: none;
}
.confirm .confirm__body {
    display: flex;
    align-items: center;
    gap: var(--space-2);
}

/* Field-level validation errors (Django renders them as <ul class="errorlist">)
   and help lists (e.g. the password rules): both sit just under their input. */
.errorlist {
    list-style: none;
    margin: var(--space-1) 0 0;
    padding: 0;
    color: var(--color-error-text);
    font-size: 0.9rem;
}
.field .text-muted ul {
    margin: var(--space-1) 0 0;
    padding-left: 1.1rem;
    font-size: 0.9rem;
}

/* Inline checkbox: label wraps the box + its text on one comfortable row. */
.field--check label {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    min-height: 44px; /* the whole row is the tap target */
    font-weight: 400;
    cursor: pointer;
}
.field--check input[type="checkbox"] {
    width: 20px;
    height: 20px;
    min-height: 0;
    flex: 0 0 auto;
    accent-color: var(--color-accent);
}

/* Multi-checkbox list (Django CheckboxSelectMultiple): small boxes, each inline
   with its label, wrapping neatly across the row. */
.check-list > div {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem 1.25rem;
}
.check-list label {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    min-height: 44px; /* comfortable tap target */
    margin: 0;
    font-weight: 400;
    cursor: pointer;
}
.check-list input[type="checkbox"] {
    width: 18px;
    height: 18px;
    min-height: 0;
    flex: 0 0 auto;
    accent-color: var(--color-accent);
}

/* One choice per row, without the scroll box. The default wraps choices into as
   many columns as fit, which is right for short labels and wrong when they are
   long enough to wrap mid-phrase: the eight state names broke across lines on a
   phone and read as more options than there were. Use for a handful of choices
   whose labels are sentences rather than words. */
.check-list--stacked > div {
    display: block;
}
.check-list--stacked label {
    display: flex;
}

/* A LONG check-list (tens–hundreds of choices): capped height, scrollable, one
   choice per row for scanning, with a type-to-filter input above it (the
   data-checklist-filter listener in base.html). Ticked choices never filter out. */
.check-list--scroll {
    margin-top: var(--space-1);
    max-height: 14rem;
    overflow-y: auto;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    padding: 0 var(--space-3);
}
.check-list--scroll > div {
    display: block;
}
.check-list--scroll label {
    display: flex;
}

/* Data grid / table. Wrap in .table-scroll so wide grids scroll sideways
   inside their container on small screens (the page itself never does). */
.table-scroll {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
}
.table {
    border-collapse: collapse;
    width: 100%;
}
.table th,
.table td {
    padding: var(--space-2) var(--space-3);
    border-bottom: 1px solid var(--color-border);
    text-align: left;
    vertical-align: middle;
}
/* NOT --font-heading. Archivo Black is a display face: at this size its counters
   close up and the mid-height strokes merge, so a heading reads as though it has
   been struck through — worst on words like "When", "Who", "Record", "Change".
   Reported 2 Aug 2026 as "the audit log headings are difficult to read", and it
   applied to every table in the system. Body font at 700 keeps a heading looking
   like a heading and stays legible at 13.6px. */
.table th {
    font-family: var(--font-body);
    font-weight: 700;
    font-size: 0.85rem;
    letter-spacing: 0.01em;
    white-space: nowrap;
}
/* --- Event summary: the customer-service quick reference -------------------
   A definition list read aloud on a phone call, so the label and its value must
   never be mistaken for one another. Two columns where there is room; stacked
   below 640px, where a narrow value column would wrap every date onto three
   lines. Scoped to the container per the specificity rule — `dt`/`dd` are
   generic enough that a bare rule would reach into any other list later. */
.summary-list {
    display: grid;
    grid-template-columns: minmax(9rem, 14rem) 1fr;
    gap: var(--space-2) var(--space-4);
    margin: 0;
}
.summary-list dt {
    font-weight: 700;
    color: var(--color-muted);
}
.summary-list dd {
    margin: 0;
}
/* Free-text blocks in a summary: line breaks the author typed are kept, and
   anything too long for the container wraps rather than pushing the card wide.
   `anywhere` not `break-word`: break-word will not split a single unbroken run
   (a long URL, an address with no spaces), which is exactly the case that
   overflows. Applies to the definition list's values for the same reason. */
.summary-text {
    margin: 0;
    white-space: pre-line;
    overflow-wrap: anywhere;
}
.summary-list dd,
.summary-list dt {
    overflow-wrap: anywhere;
}
.summary-services {
    margin: 0;
    padding-left: 1.2rem;
}
.summary-services li {
    margin-bottom: var(--space-1);
}
@media (max-width: 640px) {
    .summary-list {
        grid-template-columns: 1fr;
        gap: var(--space-1);
    }
    /* Stacked, the label needs air above it or the pairs run together. */
    .summary-list dt:not(:first-of-type) {
        margin-top: var(--space-3);
    }
}

/* `td.td--check`, not `td--check`: written without the dot this parses as a type
   selector for an element named "td--check", which matches nothing, so the
   checkbox column in the training assignment grid was never centred. A selector
   typo fails as silently as a class that was never defined. */
.table td.td--check {
    text-align: center;
}

/* Reporting hierarchy tree (nested lists). */
.tree,
.tree ul {
    list-style: none;
    margin: 0;
}
.tree {
    padding: 0;
}
.tree ul {
    padding-left: var(--space-6);
    border-left: 1px solid var(--color-border);
    margin-left: 0.35rem;
}
.tree li {
    padding: 0.25rem 0;
}
.table input[type="checkbox"] {
    width: 22px;
    height: 22px;
    accent-color: var(--color-accent);
}

.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 44px; /* comfortable touch target */
    padding: 0.5rem 1.1rem;
    border: 0;
    border-radius: var(--radius-sm);
    font-family: var(--font-body);
    font-weight: 700;
    font-size: 1rem;
    cursor: pointer;
}
.btn--primary {
    background: var(--color-accent);
    color: var(--color-accent-ink);
}
.btn--primary:hover {
    background: var(--color-accent-hover);
    text-decoration: none;
}

.alert {
    padding: 0.6rem 0.8rem;
    border-radius: var(--radius-sm);
    font-size: 0.9rem;
    margin-bottom: var(--space-4);
}
.alert--error {
    background: var(--color-error-bg);
    color: var(--color-error-text);
}
/* `.alert alert--warning` was already in use (the analysis box's "choose a
   condition first" notice) with no rule behind it, so it rendered as an unstyled
   box — a warning that doesn't look like one. Same silent-no-op family as a bare
   `.bubble`. */
.alert--warning {
    background: var(--color-warning-bg);
    color: var(--color-warning-text);
}
/* `.alert--danger` is the SAME thing as `.alert--error` under the name the status
   tokens use (--color-danger). Both spellings were already in templates and only
   one had a rule, so four messages — both two-factor errors and a messaging
   attachment error — rendered as bare indented text: the `.alert` padding with no
   box around it, which reads as a stray indent rather than an error. Third time
   this family of bug has appeared (see `.alert--warning` above and the bare
   `.bubble`), so both names are answered here rather than one being "correct".
   BEFORE REUSING A CLASS YOU HAVE SEEN IN A TEMPLATE, CHECK THE RULE EXISTS. */
.alert--danger {
    background: var(--color-error-bg);
    color: var(--color-error-text);
}
/* Was undefined while in use on the two-factor page ("On. You'll be asked for a
   code…"), so a confirmation rendered as an indented orphan line — reported
   2 Aug 2026 as "the first line of text after the heading is indented". */
.alert--success {
    background: var(--color-success-bg);
    color: var(--color-success-text);
}
/* Neutral / informational — "there is nothing here", "this thread takes no
   replies". Bare `.alert` sets padding and a radius and NO background, exactly
   like bare `.bubble`, so three templates were rendering an unstyled box that
   read as a paragraph with odd spacing. Deliberately NOT one of the status
   colours: nothing is wrong, and red/amber/green have to keep meaning something.
   (Added 9 Aug 2026 — the third appearance of this family of bug.) */
.alert--info {
    background: var(--color-surface-sunken);
    color: var(--color-text);
}

.text-muted {
    color: var(--color-muted);
}

/* Inline expandable detail — a native <details>/<summary> disclosure for extra
   information inside a row (e.g. an audit entry's before/after). The toggle is a
   tap target, so it carries the same 44px minimum as .btn does. */
.disclosure > summary {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    cursor: pointer;
    color: var(--color-muted);
    font-size: 0.9rem;
}
.disclosure > summary:hover {
    color: var(--color-text);
}
.disclosure__body {
    margin: 0 0 var(--space-2);
}
.disclosure__body dt {
    font-size: 0.85rem;
    color: var(--color-muted);
}
.disclosure__body dd {
    margin: 0 0 var(--space-2);
}
.disclosure__body dd:last-child {
    margin-bottom: 0;
}

/* --- Scheduling: the day-of-week label on the roster board ---
 * A roster is reasoned about in weekdays, and a date picker alone answers a
 * different question. Sits beside the picker rather than replacing it. */
.board-controls__weekday {
    align-self: center;
    margin-left: var(--space-2);
    font-weight: 600;
    white-space: nowrap;
}

/* --- The day-timeline calendar (`.daycal`) ---
 * A something-per-row timeline, built as ONE grid: a sticky label column plus one
 * column per day. Everything is placed explicitly by row and column, which is what
 * lets the weekend and today washes run `grid-row: 1 / -1` — the full height of the
 * calendar rather than a single row's band.
 *
 * Was `.leave-cal`, built for scheduling's leave calendar (13 Aug 2026) and renamed
 * on its second caller — the events calendar (14 Aug 2026) — rather than copied.
 * The layout is the shared part; what a row MEANS is the module's (a person for
 * leave, an event for events), as is what `--pending` stands for. A second copy is
 * a second thing to keep in step, and the one that drifts is always the one nobody
 * looks at (CLAUDE.md's corollary to rule 3).
 *
 * Everything is scoped to `.daycal`: these are generic-sounding names
 * (`__day`, `__bar`, `__name`) and an unscoped rule would be one `.bar` away from
 * colliding with something else in this file.
 *
 * The day columns are `minmax(CH, 1fr)`: they grow to fill a wide screen and stop
 * at a floor rather than collapsing, and the container scrolls instead. A month is
 * 31 columns — at 375px that is 11px each if you let them shrink, which is a
 * timeline nobody can read. */
.daycal {
    overflow-x: auto;
    border-radius: var(--radius);
}
.daycal__grid {
    display: grid;
    grid-template-columns: max-content repeat(var(--daycal-days), minmax(2rem, 1fr));
}
/* A month of TEXT chips, not bars. 2rem suits the leave calendar, where a bar
   spans days and carries its label along the span; a chip reading "21:00–5:00"
   in 32px truncates to "2…", which is a control that says nothing (measured
   23 Aug 2026 on My roster's month view). The component already scrolls
   sideways and says so in its aria-label, so the honest fix is columns wide
   enough to read rather than a chip that fits by saying less.

   6rem is measured, not guessed: the widest label a shift produces is a
   two-digit-to-two-digit range like "21:08–23:08", which needed 85px of content
   in a 70px box at 4.75rem. Re-measured after the change — zero chips clipped. */
.daycal--roomy .daycal__grid {
    grid-template-columns: max-content repeat(var(--daycal-days), minmax(6rem, 1fr));
    /* EXPLICIT rows, not `grid-auto-rows`. The washes span `grid-row: 1 / -1`,
       and a negative grid line resolves against the explicit grid only — with
       implicit rows `-1` silently means line 1, so a full-height wash renders
       one row tall and reads as a deliberate header tint. */
    grid-template-rows: repeat(var(--daycal-rows), minmax(2.5rem, auto));
    align-items: center;
    min-width: min-content;
}
.daycal__name {
    position: sticky;
    left: 0;
    /* Above the washes AND the bars: both scroll under it. A transparent or
       lower-stacked name column turns into unreadable overlapping text the
       moment you scroll sideways. */
    z-index: 3;
    grid-column: 1;
    display: flex;
    align-items: center;
    align-self: stretch;
    background: var(--color-surface);
    border-bottom: 1px solid var(--color-border);
    border-right: 1px solid var(--color-border);
    padding: var(--space-2) var(--space-3);
    font-weight: 600;
    font-size: 0.9rem;
    white-space: nowrap;
    min-height: 44px;
}
.daycal__name--head {
    z-index: 4;
    background: var(--color-surface-sunken);
}
.daycal__day {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    align-self: stretch;
    z-index: 2;
    min-height: 44px;
    background: var(--color-surface-sunken);
    font-size: 0.72rem;
    line-height: 1.2;
    color: var(--color-muted);
}
/* The two halves of a day heading: the weekday initial above the date. `__dom` is
   the one that has to stand out, so it overrides the muted parent; `__dow` is
   named here rather than left bare because it had NO rule at all from the leave
   calendar shipping (13 Aug) until the events calendar reused it (14 Aug). It
   looked correct the whole time — `.daycal__day` supplies the small muted text —
   which is exactly why nobody noticed, and exactly the trap of reusing a class
   seen in a template without checking the rule exists. Stating the inherited
   values changes nothing visually and stops the next person assuming a hook that
   isn't there. */
.daycal__dow {
    font-size: inherit;
    color: inherit;
}
.daycal__dom {
    font-weight: 700;
    color: var(--color-text);
}
.daycal__day--today .daycal__dom {
    color: var(--color-accent);
}
/* The full-height columns. Drawn at z-index 0 so every bar paints over them, and
   faded with `opacity` rather than a second set of colour tokens — the wash is a
   hint about the shape of the month, not information. */
.daycal__wash {
    z-index: 0;
    align-self: stretch;
}
.daycal__wash--weekend {
    background: var(--color-surface-sunken);
    opacity: 0.4;
}
/* Today is mixed at its own strength rather than faded from the shared tint.
   `--color-accent-tint` is 14% orange for a faint row wash (an unread thread);
   behind 0.55 opacity that landed near 8%, which read as "possibly nothing".
   26% is about double, still nowhere near the solid accent the bars use, so the
   column reads as today at a glance without competing with the leave on it.
   No opacity here — the mix IS the colour, so there is one number to change. */
.daycal__wash--today {
    background: color-mix(in srgb, var(--color-accent) 26%, var(--color-surface));
}
/* Named above the grid, so the holiday is readable at any width — a 32px column
   on a phone cannot carry it, and the tint alone says "something" without saying
   what. Redundant with the in-column label on a wide screen, which is the right
   trade: one of the two is always the one being read. */
.daycal__holidays {
    margin: 0 0 var(--space-2);
    font-family: var(--font-body);
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--color-warning-text);
}

/* A public holiday. Amber like the board's banner, so the two read as the same
   fact on two screens. The NAME is rendered next to the tint, not only a colour:
   a coloured column does not say which holiday, and the `title` tooltip that
   would have does not exist on a phone. */
.daycal__day--holiday {
    /* Mixed at its own strength, NOT `--color-warning-bg`. That token is a
       background for text to sit ON; as a column tint it measured 1.11:1
       against the surface behind it — fainter than the weekend wash (1.18) and
       today (1.20), i.e. the reader would not have seen it. 32% lands at 1.19:1,
       level with the two washes it sits beside, and text on it is still 6.48:1.
       Measured, because "it looks fine" is exactly what 1.11 looks like when you
       already know what it says. */
    background: color-mix(in srgb, var(--color-warning) 32%, var(--color-surface));
    border-radius: var(--radius-sm);
}
.daycal__holiday {
    display: block;
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    /* Its own family and line-height alongside the size, or this renders in
       whatever the surrounding heading supplies (CLAUDE.md). */
    font-family: var(--font-body);
    font-size: 0.6rem;
    font-weight: 600;
    line-height: 1.5;
    color: var(--color-warning-text);
}
/* One horizontal rule per person, spanning the day columns. It sits under the
   bars and over the washes, so a row reads as a band without hiding either. */
.daycal__rule {
    z-index: 1;
    align-self: stretch;
    border-bottom: 1px solid var(--color-border);
}
.daycal__bar {
    z-index: 2;
    display: flex;
    align-items: center;
    min-height: 2rem;
    margin: 2px 1px;
    padding: 0 var(--space-2);
    border-radius: var(--radius-sm);
    background: var(--color-accent);
    color: var(--brand-black);
    font-size: 0.75rem;
    font-family: var(--font-body);
    line-height: 1.5;
    font-weight: 600;
    overflow: hidden;
    white-space: nowrap;
    text-decoration: none;
}
/* Pending is NOT just a lighter shade: colour alone would be the only difference
   between "book around this" and "this might not happen", and roughly one man in
   twelve cannot rely on it. Stripes plus the word "Pending" carry it too. */
.daycal__bar--pending {
    background: repeating-linear-gradient(
        45deg,
        var(--color-surface-sunken),
        var(--color-surface-sunken) 6px,
        var(--color-border) 6px,
        var(--color-border) 12px
    );
    color: var(--color-text);
    border: 1px dashed var(--color-muted);
}
.daycal__bar--part {
    /* A part day is not a day off. Narrower bar, same column. */
    min-height: 1.4rem;
}
/* Only leave THIS viewer can act on is a link, so the affordance is honest. */
.daycal__bar--link {
    cursor: pointer;
}
.daycal__bar--link:hover,
.daycal__bar--link:focus-visible {
    filter: brightness(0.94);
    text-decoration: underline;
}
.daycal__bar-label {
    overflow: hidden;
    text-overflow: ellipsis;
}
.daycal__key {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-2);
    margin-top: var(--space-3);
    font-size: 0.85rem;
}
.daycal__swatch {
    display: inline-block;
    width: 1.5rem;
    height: 0.9rem;
    border-radius: var(--radius-sm);
    background: var(--color-accent);
}
.daycal__swatch--pending {
    background: repeating-linear-gradient(
        45deg,
        var(--color-surface-sunken),
        var(--color-surface-sunken) 4px,
        var(--color-border) 4px,
        var(--color-border) 8px
    );
    border: 1px dashed var(--color-muted);
}

/* A field laid out as ONE line: label, control, then whatever else belongs with
   it (`Web go-live: [date] [x] Estimated`). The default `.field` stacks, which is
   right for a form of long inputs and wrong for a column of short dates — stacked,
   eight dates take eight times the height and the eye has nothing to run down.

   The label gets a min-width so every control starts at the same x — that shared
   left edge IS the "in line" the layout promises; without it each row starts
   wherever its label happens to end and the column reads as ragged.

   `flex-wrap` plus the mobile rule below means it degrades to the stacked form on
   a phone rather than squeezing a date input to nothing. */
.field-grid {
    /* ONE grid across all the rows, so the label column sizes itself to the
       longest label and every control starts at the same x. The first attempt gave
       each row its own flex line with a `min-width: 13rem` label — which aligned
       seven of eight and put "Final numbers & payment due" (215px) 7px out. A
       hardcoded width is only ever right until somebody edits a label, and it
       fails silently when it stops being right. `max-content` cannot. */
    display: grid;
    grid-template-columns: max-content max-content max-content;
    gap: var(--space-2) var(--space-3);
    align-items: center;
}
.field-grid > .field--inline {
    /* The row's own box disappears so its three children become grid items of the
       parent — that is what puts them in shared columns rather than per-row ones. */
    display: contents;
}
.field-grid > .field--inline > label:first-of-type {
    /* Overrides `.field label`'s block + bottom margin, which would break the row. */
    display: inline-block;
    margin-bottom: 0;
}
.field-grid input[type="date"] {
    width: auto;
}
/* Errors take the whole row, under the line they belong to. */
.field-grid .errorlist {
    grid-column: 1 / -1;
    margin: 0;
}

/* The "Estimated" tick beside a date input. Inside a `.field`, whose `label` rule
   is a bold block heading — this one is a control's own label sitting UNDER the
   input, so it has to opt out of that or it reads as a second field's heading.
   44px for the tap target, as everywhere else. */
.estimated-tick {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    min-height: 44px;
    margin: 0;
    font-weight: 400;
    font-size: 0.9rem;
    color: var(--color-muted);
    cursor: pointer;
}
.estimated-tick input[type="checkbox"] {
    width: 18px;
    height: 18px;
    min-height: 0;
    flex: 0 0 auto;
    accent-color: var(--color-accent);
}

/* One person's shifts on one day, inside the shared grid. The leave and events
   calendars place positioned BARS here; a roster places the TIMES, because what a
   colleague reads off it is "9:00–17:00", not a proportional shape.

   z-index 2 is load-bearing, not decoration: `.daycal__rule` is z-index 1, so a
   cell left at `auto` is painted OVER by the row rule and the day's shifts simply
   vanish — measured on the today column, where the wash made it look like an empty
   day rather than a bug.

   Stacks when somebody has two shifts in a day, rather than overlapping them the
   way a single positioned bar per cell would. */
.daycal__cell {
    z-index: 2;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 2px;
    align-self: stretch;
    padding: 2px;
    min-width: 0;
}
.daycal__chip {
    /* A button now, so the browser's own font/colour/alignment have to be undone
       — see the note below about pinning the family. `cursor` because it is a
       control; `text-align` because a button centres its text and every other
       chip is left-aligned. */
    color: inherit;
    text-align: left;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 0.25rem;
    padding: 2px 4px;
    border-radius: var(--radius-sm);
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    /* Pinned, not inherited: this sits in a grid whose cells set their own
       font-size, and a component that sets its own size must set its own family
       and line-height too, or it renders differently per context. */
    font-family: var(--font-body);
    font-size: 0.7rem;
    line-height: 1.5;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.daycal__chip-dot {
    flex: 0 0 auto;
    width: 0.55rem;
    height: 0.55rem;
    border-radius: 2px;
}

/* On a phone the row stacks: a 13rem label plus a date input plus a tick does not
   fit in 375px, and squeezing them produces a date field too narrow to read. */
@media (max-width: 640px) {
    /* A day column is ~32px on a phone, where "Labour Day" ellipsises to
       something nobody can read — and `title` does not exist on touch, so there
       is no second chance. The tint still says WHICH day; `.daycal__holidays`
       above the grid says which holiday. When a control cannot work at a scale,
       change the control rather than shrink it (CLAUDE.md). Measured: 32px. */
    .daycal__holiday {
        display: none;
    }

    /* Narrow screens keep the label WITH its date and drop only the tick to the
       next line (owner's call, 15 Aug 2026). Two columns fit where three do not,
       and the pair that must be read together — "which date is this, and what is
       it set to" — stays together; "Estimated" reads fine underneath.

       Two columns rather than `display: block`, so the dates still share a left
       edge; the tick spans both and sits under them. */
    .field-grid {
        /* The DATE gets the fixed column here and the label takes what is left,
           the opposite way round from the desktop rule. At `max-content` the
           longest label (215px) left the date 82px — too narrow to show
           "dd/mm/yyyy" and its calendar button. A label may wrap onto two lines;
           an unreadable date input has no second line to fall back on. */
        grid-template-columns: minmax(0, 1fr) 10rem;
    }
    .field-grid > .field--inline > label:first-of-type {
        display: inline-block;
    }
    .field-grid .estimated-tick {
        grid-column: 1 / -1;
        /* Indented so it reads as belonging to the row above, not starting a new one. */
        margin-left: var(--space-3);
    }
    .field-grid input[type="date"] {
        width: 100%;
    }
}

/* The wider window behind a bar — for events, when we are on site (bump-in to
   bump-out) around the event's own dates. z-index 1 puts it above the row rule and
   below the bar (z-index 2), so the solid bar always reads as the primary fact and
   the band as context around it. A tint rather than a second accent: it is the
   shape of a commitment, not a second thing competing to be read. */
.daycal__band {
    z-index: 1;
    align-self: center;
    min-height: 2rem;
    margin: 2px 1px;
    border-radius: var(--radius-sm);
    background: var(--color-accent-tint);
    border: 1px solid var(--color-accent);
}
.daycal__swatch--band {
    background: var(--color-accent-tint);
    border: 1px solid var(--color-accent);
}
/* Over and done with — completed or cancelled. Muted so a month of history does
   not shout as loudly as the thing happening next week. Not a status COLOUR: red
   and green mean overdue and done elsewhere, and an event being finished is
   neither good nor bad. */
.daycal__bar--over {
    background: var(--color-surface-sunken);
    color: var(--color-muted);
    border: 1px solid var(--color-border);
}

/* --- Training: chapter navigation ---
 * Scoped to .chapter-nav rather than styling .disclosure's list, because
 * .disclosure is shared (the audit log's before/after uses it) and a later
 * single-class rule would win over it anyway — see the specificity note at the
 * top of this file. */
.chapter-nav__list {
    margin: 0;
    padding-left: var(--space-6);
}
.chapter-nav__item {
    /* 44px so a chapter is a comfortable tap target on a phone; the whole point
       is jumping back to re-read one, and a 20px line is not a target. */
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-2);
    min-height: 44px;
}
.chapter-nav__item--current {
    font-weight: 600;
}
.chapter-nav__count {
    font-size: 0.9rem;
}

/* Modal pop-up (native <dialog>): openers carry data-dialog-open="<id>", a
   data-dialog-close button or a backdrop click closes it — see base.html. The
   dialog itself has no padding so a click that lands on it IS the backdrop;
   the content sits in .dialog__body. */
dialog.dialog {
    width: min(56rem, calc(100vw - 2rem));
    padding: 0;
    border: none;
    border-radius: var(--radius);
    box-shadow: var(--shadow);
}
dialog.dialog::backdrop {
    background: rgba(30, 30, 30, 0.55); /* --color-ink at 55% — backdrop dim */
}
.dialog__body {
    padding: var(--space-4);
    /* `dvh` for the same reason as `.msg-thread` above — a modal holding a form
       has the identical problem when a keyboard opens over it. Not reported, but
       it is the same bug waiting, and it is one line. */
    max-height: calc(100vh - 4rem);
    max-height: calc(100dvh - 4rem);
    overflow-y: auto;
}
.dialog__bar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-3);
    margin-bottom: var(--space-3);
}

/* One action in a period's analysis: its tick sits ON the row, beside the thing
   being ticked off, not above it under a redundant "Done" label. */
.action-row {
    display: flex;
    align-items: flex-start;
    gap: var(--space-3);
    padding: var(--space-3);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    background: var(--color-surface);
}
.action-row + .action-row {
    margin-top: var(--space-2);
}
.action-row > input[type="checkbox"] {
    width: 20px;
    height: 20px;
    min-height: 0;
    flex: 0 0 auto;
    margin-top: 0.7rem; /* sits on the textarea's first line */
    accent-color: var(--color-accent);
}
.action-row__heading {
    display: block;
    font-weight: 700;
    font-size: 0.9rem;
    margin-bottom: var(--space-1);
}
/* Remove this action: an × rather than the word, kept to a full 44px tap target
   so it still meets the mobile rule despite being small on the page. */
.action-row__remove {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px;
    min-height: 44px;
    margin-top: 0.15rem;
    font-size: 1.4rem;
    line-height: 1;
    color: var(--color-muted);
    background: transparent;
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    cursor: pointer;
}
.action-row__remove:hover,
.action-row__remove:focus-visible {
    color: var(--color-error-text);
    border-color: var(--color-danger);
}

/* --- A row that is kept but set aside ---
 * An archived statistic, a past staff member's profile. It still lists (the history
 * is kept, not deleted), but reads as set aside rather than live. Opacity on the
 * whole row rather than a text colour, so its badges and bubbles fade WITH it
 * instead of staying bright against a grey row — "slightly", so the name stays
 * comfortably readable. Full strength on hover and on keyboard focus: it is still a
 * link you can open. Two classes on purpose — `.panel` is defined further down this
 * file and would win on order. */
.panel.panel--archived {
    opacity: 0.6;
}
.panel.panel--archived:hover,
.panel.panel--archived:focus-visible {
    opacity: 1;
}

/* --- Statistics: a condition's identity colour ---
 * An Advanced condition carries a colour chosen in Admin. It shows as a small chip
 * beside the name, never as the badge's background: the colours are free-form (the
 * demo set includes the brand orange), and a filled badge would both wreck contrast
 * and let a colour stand for "good" — which the status-colour rule reserves for
 * red / amber / green alone. Extracted here on its second caller: the analysis
 * header and the statistics list. */
.swatch {
    display: inline-block;
    width: 0.7rem;
    height: 0.7rem;
    border-radius: 3px;
    vertical-align: -1px;
    margin-right: 0.15rem;
}

/* --- Statistics: the "where does everything stand" summary above the list --- */
.card.state-summary {
    margin-bottom: var(--space-4);
}

/* --- Statistics: the analysis box's header ---
 * The heading, the period's badges and Close. On a phone all three wrap onto their
 * own lines — they can't fit across 273px — and a row whose gap suits items sitting
 * side by side is too mean once a 44px button is stacked under a 22px badge. It
 * also needs to end somewhere before the form starts.
 * Both selectors carry a second class on purpose: `.btn-row` is defined FURTHER
 * DOWN this file, so a plain `.analysis-head` would lose to it on order. */
.btn-row.analysis-head {
    justify-content: space-between;
    margin-bottom: var(--space-3);
}
@media (max-width: 640px) {
    .btn-row.analysis-head,
    .analysis-head .btn-row {
        gap: var(--space-3);
    }
}

/* --- Statistics: the graph's range picker ---
 * Every range is offered at every width. "All time" was briefly hidden on a phone
 * (60-odd periods on a 343px strip is ~10px each) until the axis started grouping
 * a long range into 3-month buckets — four or five comfortable targets at any
 * width, which removed the reason for the cap.
 * The selector carries a second class on purpose: `.btn-row` is defined FURTHER
 * DOWN this file and would win on order otherwise. */
.btn-row.range-picker {
    gap: 0.35rem;
}

/* --- Statistics: the 3-month buckets a long range shows instead of periods ---
 * Each one spans exactly the stretch of plot its periods occupy (the width comes
 * from the chart's slot geometry, like the positions), so the axis still reads
 * against the line above it — but there are four to ten of them rather than sixty,
 * which is the difference between a 13px target and a comfortable one. Clicking
 * opens that span, where the ordinary one-button-per-period strip returns.
 * Scoped to the strip for the usual reason: `.dropdown`/`.btn` are defined further
 * down this file and would win on order. */
.period-strip--buckets {
    height: 44px;
}
.period-strip .period-bucket {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    padding: 0 var(--space-2);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    background: var(--color-surface);
    color: var(--color-text);
    font-family: var(--font-body);
    font-size: 0.85rem;
    white-space: nowrap;
    overflow: hidden;
    text-decoration: none;
    cursor: pointer;
}
.period-strip .period-bucket:hover {
    border-color: var(--color-accent);
    background: var(--color-accent-tint);
}
/* The label in two lengths, because no CSS shortens text: "Nov 25–Feb 26" needs
   half again the ~68px a phone leaves a bucket, so the short form says where the
   span STARTS ("Nov 25") and the full range stays in the title. Two ways to end up
   there — a narrow screen, and too MANY buckets (30 months of a monthly statistic
   is eleven, which clip at any width; the view counts them). */
.period-bucket__short {
    display: none;
}
.period-strip--buckets-tight .period-bucket__long {
    display: none;
}
.period-strip--buckets-tight .period-bucket__short {
    display: inline;
}
@media (max-width: 640px) {
    .period-bucket__long {
        display: none;
    }
    .period-bucket__short {
        display: inline;
    }
    /* The oldest bucket is the partial one, so it can be half the width of its
       neighbours: at 46px, padding alone clipped "Jun 25" off it. Padding goes,
       and a couple of pixels of spill is allowed to show rather than be cut —
       a truncated date reads as a bug, a fractionally wide one doesn't. */
    .period-strip .period-bucket {
        padding: 0;
        overflow: visible;
    }
}
/* A bucket carries the urgency of what's inside it, so a quarter with an overdue
   period in it still says so before you've opened it. */
.period-strip .period-bucket--current {
    border-color: var(--color-accent);
    font-weight: 700;
}
.period-strip .period-bucket--due {
    border-color: var(--color-warning);
    font-weight: 700;
}
.period-strip .period-bucket--overdue {
    border-color: var(--color-danger);
    color: var(--color-error-text);
    font-weight: 700;
}

/* Zoomed into one bucket: what you're looking at, and the way back out. */
.zoom-bar {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-3);
    margin: var(--space-2) 0 0;
    color: var(--color-muted);
}

/* --- Statistics: the period strip under the graph ---
 * Each period's label IS the button that records it, positioned at that period's
 * own x on the chart so the strip reads as the graph's x axis. The chart hands
 * the percentage over (charts.slot_percents) rather than the CSS guessing at the
 * plot geometry a second time.
 *
 * Past ~14 periods there isn't room for a label each, so the strip thins itself
 * (see the THINNED block below). On a phone the surviving labels turn on their
 * side once there are more than five of them. */
.period-strip {
    position: relative;
    height: 44px;
    margin: var(--space-2) 0 var(--space-6);
}
/* Scoped to the strip so this outweighs `.dropdown { position: relative }`,
   which is defined further down the file and would otherwise win on order and
   leave every slot in normal flow. */
.period-strip .period-slot {
    position: absolute;
    top: 0;
    transform: translateX(-50%);
}
.period-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    padding: 0 var(--space-3);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    background: var(--color-surface);
    color: var(--color-text);
    font-family: var(--font-body);
    font-size: 0.85rem;
    white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
    list-style: none; /* it's a <summary> — hide the disclosure triangle */
}
.period-btn::-webkit-details-marker {
    display: none;
}
.period-btn:hover {
    border-color: var(--color-accent);
}
.period-btn--filled {
    border-color: var(--color-success);
}
.period-btn--current {
    border-color: var(--color-accent);
    font-weight: 700;
}
.period-btn--due {
    border-color: var(--color-warning);
    font-weight: 700;
}
.period-btn--overdue {
    border-color: var(--color-danger);
    color: var(--color-error-text);
    font-weight: 700;
}
/* The one period ahead of today: it's there so its target can be set, so it reads
   as pencilled in rather than as anything to act on — no status colour, because
   nothing about it is good, bad or late yet. */
.period-btn--future {
    border-style: dashed;
    color: var(--color-muted);
}
/* The one you're working on: filled orange, so it's unmistakable which period
   the pop-out (or the analysis box below) belongs to. */
.period-strip .dropdown[open] > .period-btn,
.period-btn--selected {
    background: var(--color-accent);
    border-color: var(--color-accent);
    color: var(--color-accent-ink);
    font-weight: 700;
}
/* …and a caret joining that button to its pop-out, so the two read as one
   control even though the panel is aligned to fit the page. */
.period-strip .dropdown[open] > .period-btn::after {
    content: "";
    position: absolute;
    left: 50%;
    top: calc(100% + 0.35rem);
    transform: translateX(-50%);
    border-left: 8px solid transparent;
    border-right: 8px solid transparent;
    border-bottom: 8px solid var(--color-accent);
}

/* --- A THINNED strip (a long window) ---
 * Past ~14 periods there is no room for a label each — 52 of them on the 12-month
 * weekly range overlap by a third of their width, and because here the label IS
 * the button, that makes the strip unreadable AND unclickable. So the strip thins
 * itself the way the chart thins its value labels (charts.label_step, one rule for
 * both): every k-th period keeps its text, the rest keep only a mark.
 *
 * The catch that drove the shape of this: a label is four or five pitches wide, so
 * leaving it at that size means it covers its neighbours and swallows their clicks
 * — "every period stays reachable" quietly stops being true. So in a thinned strip
 * EVERY button is exactly one pitch wide (--slot-pitch, handed over by the chart
 * like the positions themselves) and its text simply overflows. Hit areas end up
 * one per period and contiguous, and the strip becomes what it now is at this
 * density: an axis of ticks, some of them annotated. A window short enough not to
 * thin keeps the ordinary bordered buttons.
 *
 * Stacking settles clicks as well as paint, so an overflowing label can't be
 * hit-tested away by the tick it spills over. */
.period-strip .period-slot {
    z-index: 2;
}
.period-strip .period-slot--tick {
    z-index: 1;
}
/* The width goes on the SLOT, not the button. The slot is the absolutely
   positioned element, so a percentage resolves against the strip; a <summary>'s
   containing block is its shrink-to-fit <details>, where a percentage width is
   circular and silently collapses to the min-width. (Measured: every button came
   out 8px — the min — with gaps between 49 of the 60 pairs.) */
.period-strip--thinned .period-slot {
    width: var(--slot-pitch, 1rem);
    min-width: 8px;
}
.period-strip--thinned .period-slot > .period-btn {
    width: 100%;
}
.period-strip--thinned .period-btn {
    /* Positioned so the mark hangs off the BUTTON. A <summary> is otherwise
       unpositioned and would measure itself against the <details> around it. */
    position: relative;
    padding: 0;
    overflow: visible;
    background: transparent;
    border-color: transparent;
}
/* The label overflows its button by several pitches, so leaving it clickable lets
   it hit-test away the ticks underneath — a click on the axis would open a period
   four along from the one under the cursor. The text is an annotation; the pitch
   is the target. */
.period-strip--thinned .period-btn__text {
    pointer-events: none;
}
/* The mark, and the mark — not a border — is what carries the status colour now.
   Every one of them sits on the same foot line BELOW the text band: a taller tick
   looks better on its own, but a neighbouring label overflows straight across it
   and gets a line struck through it ("2|2 Aug"). Keeping them low keeps the two
   out of each other's way. */
.period-strip--thinned .period-btn::before {
    content: "";
    position: absolute;
    left: 50%;
    bottom: 0;
    width: 2px;
    height: 0.5rem;
    transform: translateX(-50%);
    border-radius: 1px;
    background: var(--color-border);
}
.period-strip--thinned .period-btn:hover::before {
    background: var(--color-accent);
}
.period-strip--thinned .period-btn--future::before {
    background: var(--color-muted);
}
.period-strip--thinned .period-btn--filled::before {
    background: var(--color-success);
}
.period-strip--thinned .period-btn--due::before {
    background: var(--color-warning);
}
.period-strip--thinned .period-btn--overdue::before {
    background: var(--color-danger);
}
/* Open or selected: filling a 14px box orange while its label spills out of it
   reads as a bug, so here the mark and the weight carry it instead. Must come
   after the generic [open] rule further up — same specificity, later wins. */
.period-strip--thinned .period-btn--selected,
.period-strip--thinned .dropdown[open] > .period-btn {
    background: transparent;
    color: var(--color-text);
    font-weight: 700;
}
.period-strip--thinned .period-btn--selected::before,
.period-strip--thinned .dropdown[open] > .period-btn::before {
    height: 1rem;
    width: 3px;
    background: var(--color-accent);
}
/* A tick's label is still there for a screen reader (and in the title attribute) —
   it just isn't painted. */
.period-btn--tick .period-btn__text {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

/* Side-on labels when they'd otherwise collide. Only on a phone: the thinning
   above keeps a wide screen down to a dozen-odd labels, which fit lying flat. */
@media (max-width: 640px) {
    .period-strip--dense-sm {
        height: 5.5rem;
    }
    .period-strip--dense-sm .period-btn {
        writing-mode: vertical-rl;
        min-height: 5.5rem;
        padding: var(--space-2) 0;
    }
    /* …but a tick has no text to turn, so it stays its pitch wide. */
    .period-strip--dense-sm .period-btn--tick {
        padding: 0;
    }
    /* Turned side-on, a button is only ~22px wide on a 44px pitch — so half the
       strip was dead space that swallowed a thumb-tap (measured 31 Jul 2026:
       22.4px targets, 44.1px pitch, 21.7px gaps). Widen the HIT AREA to the pitch
       without touching a pixel of the mark: the visible button keeps its size and
       an invisible ::after reaches out to either side.
       `inset` reaches out by that much on EACH side, so the figure is half the
       gap, not all of it: 10px gives a 42.4px target inside a 44.1px pitch. Written
       as -20px first, which made every neighbour overlap by 18px — the stolen-tap
       bug this strip already had once, reintroduced by arithmetic. Measured, not
       eyeballed: 0 overlaps, 8/8 centres on their own control. */
    .period-strip--dense-sm .period-btn {
        position: relative;
    }
    .period-strip--dense-sm .period-btn::after {
        content: "";
        position: absolute;
        inset: 0 -10px;
    }
}

/* The pop-out sits under its own button, aligning inwards near either end so it
   can't hang off the side of the page. Its accent top edge continues the caret.
   Scoped to the strip so these outweigh the generic `.dropdown__panel` rules
   (defined later in the file, which would otherwise win on order and pin every
   panel to left: 0). */
.period-strip .period-panel {
    top: calc(100% + 0.75rem);
    width: 16rem;
    min-width: 0;
    max-width: min(16rem, 88vw);
    border-top: 3px solid var(--color-accent);
}
.period-strip .period-panel--center {
    left: 50%;
    right: auto;
    transform: translateX(-50%);
}
.period-strip .period-panel--start {
    left: 0;
    right: auto;
}
.period-strip .period-panel--end {
    left: auto;
    right: 0;
}
/* The strip's own left/right edges are the reference for an edge-aligned panel,
   not the (zero-width) slot it hangs from. */
.period-strip .dropdown[open] {
    z-index: 40;
}

/* An event's photo/banner, shown under the page heading on its pages. */
.event-photo {
    display: block;
    width: 100%;
    max-height: 20rem;
    object-fit: cover;
    border-radius: var(--radius);
    margin: 0 0 var(--space-4);
}
/* The card-sized version on the events grid: a fixed height keeps the cards
   aligned whatever shape the uploaded image is. */
.event-photo--thumb {
    height: 7rem;
    margin-bottom: var(--space-3);
}

/* A responsive grid of cards — fits as many columns as the width allows and
   collapses to one on a phone, with no breakpoint maths. Use it instead of
   `.stack` whenever cards are browsable items rather than a reading sequence:
   a long single column wastes a desktop's width and buries the last item. */
.card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
    gap: var(--space-4);
}
/* For cards with a row of controls that shouldn't wrap (e.g. two download buttons). */
.card-grid--wide {
    grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
}
/* The column already sets the width; `.card`'s own 420px cap is there for
   standalone cards (a login box), and would leave a gap inside a grid cell. */
.card-grid > .card {
    max-width: none;
}

/* At-a-glance analytics panels (reporting). Deliberately CSS-drawn rather than a
   charting library: bars are element widths and the donut is a small inline SVG,
   so both inherit the tokens above and there is no JavaScript to keep patched. */
.panel-stat {
    font-family: var(--font-heading);
    font-size: 2rem;
    line-height: 1.1;
    color: var(--color-text);
}
.chart-bar {
    margin-top: var(--space-2);
}
.chart-bar__label {
    display: flex;
    justify-content: space-between;
    gap: var(--space-2);
    font-size: 0.85rem;
    color: var(--color-muted);
}
.chart-bar__track {
    height: 0.5rem;
    margin-top: 0.2rem;
    border-radius: 999px;
    background: var(--color-border);
    overflow: hidden;
}
.chart-bar__fill {
    height: 100%;
    border-radius: 999px;
    background: var(--color-accent);
}
.chart-bar__fill--danger {
    background: var(--color-danger);
}
.chart-bar__fill--warning {
    background: var(--color-warning);
}
.chart-bar__fill--muted {
    background: var(--color-muted);
}
.chart-donut {
    display: block;
    width: 7rem;
    height: 7rem;
    margin: var(--space-2) auto 0;
}
.chart-donut__track {
    fill: none;
    stroke: var(--color-border);
    stroke-width: 3.5;
}
.chart-donut__value {
    fill: none;
    stroke: var(--color-accent);
    stroke-width: 3.5;
    stroke-linecap: round;
}

/* Statistics over-time line graph (inline SVG, brand tokens only). Scales to its
   container via the SVG viewBox. */
/* Fills the page: the period buttons under it are positioned by percentage of
   the SAME width (charts.slot_percents), so a cap here would slide the axis out
   from under its own points. */
.stats-graph {
    display: block;
    width: 100%;
    height: auto;
}
.stats-graph__grid {
    stroke: var(--color-border);
    stroke-width: 1;
}
.stats-graph__line {
    fill: none;
    stroke: var(--color-accent);
    stroke-width: 2.5;
    stroke-linejoin: round;
    stroke-linecap: round;
}
.stats-graph__dot {
    fill: var(--color-accent);
}
.stats-graph__value {
    fill: var(--color-text);
    font-family: var(--font-heading);
    font-size: 11px;
    text-anchor: middle;
}
.stats-graph__tick {
    fill: var(--color-muted);
    font-size: 11px;
}

/* Vertical list with consistent spacing. */
.stack {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
}

/* A lighter bordered container for list items. */
.panel {
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    padding: var(--space-4);
}

/* Status pill. */
/* Status pill. NOTE the deliberately asymmetric vertical padding — do not
   "tidy" it back to a single value.

   The label is `text-transform: uppercase`, so it has cap-height ink and
   essentially NO descender ink, while the line box still reserves descender
   space below the baseline. Centring the BOX therefore leaves the LETTERS
   sitting high: measured at 6.10px above the ink and 7.52px below it, i.e.
   1.42px high in a 22px pill. That is small, and on a solid pill with crisp
   edges it is visible — it was reported from a screenshot after an earlier pass
   measured the box (symmetric, 2.4px/2.4px) and wrongly concluded there was
   nothing to fix. Box symmetry and ink symmetry are different things.

   0.06em moved from the bottom to the top shifts the text down half of 1.42px
   and leaves the pill exactly as tall. To re-verify: take a Range over the text
   node for the line box, add half-leading and the font ascent for the baseline,
   then compare `baseline − actualBoundingBoxAscent` against
   `baseline + actualBoundingBoxDescent` at the pill's edges.

   Safe HERE because the content is guaranteed descender-free. It would be wrong
   on .btn, whose mixed-case labels measure 1.94px the OTHER way. */
.badge {
    display: inline-block;
    padding: calc(0.15rem + 0.06em) 0.5rem calc(0.15rem - 0.06em);
    border-radius: 999px;
    /* Own font and line-height, not the context's. A pill that sets its own SIZE
       but inherits its FAMILY renders differently depending on where it is put:
       inside an <h2> this became Archivo Black at 18.59px tall instead of DM Sans
       at 22.05px, which is a different component by eye — and it also broke the
       optical centring below, which is measured against DM Sans metrics.
       (Caught 3 Aug 2026: events/manage.html puts a count in a heading.) */
    font-family: var(--font-body);
    /* Sit on the CAP-HEIGHT centre of the text beside it, not on the baseline.
       An inline-block aligns by its own text baseline, so where the pill sits in
       a sentence depended on its internal padding — and the optical padding
       above therefore nudged the whole pill up. Pinning it here decouples the
       two: measured 1.74px below the cap centre at `baseline`, 0.13px at this
       value, with no growth in the line box (the paragraph stays 24px).

       The cap centre rather than the x-height centre because the label is
       uppercase: two cap-height bands are what the eye actually compares.
       Honest limit — `vertical-align` in `em` resolves against the PILL's font
       size, not the parent's, so inside a bigger heading it improves the gap
       (3.20px -> 1.59px) without closing it. No CSS length tracks a parent's
       cap height; body text is the common case and is exact. */
    vertical-align: 0.14em;
    /* 1.5 is the body line-height this pill has always inherited in body text,
       so pinning it changes nothing in the common case and only stops the
       outliers drifting. Do not "improve" it to a tighter number without
       re-deriving the optical padding below — the two are calibrated together,
       and 1.25 shrank the pill from 22.05px to 19.17px and put the text 1.08px
       out in EVERY context. */
    line-height: 1.5;
    font-size: 0.72rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    background: var(--color-border);
    color: var(--color-muted);
}
.badge--published {
    background: var(--color-success);
    color: var(--brand-white);
}
.badge--rejected {
    background: var(--color-error-bg);
    color: var(--color-error-text);
}
.badge--danger {
    background: var(--color-danger);
    color: var(--brand-white);
}
.badge--warning {
    background: var(--color-warning);
    color: var(--brand-black);
}

/* Notification count bubbles on nav tabs. */
.tab-wrap {
    position: relative;
    display: inline-flex;
}
.tab-wrap .bubbles {
    position: absolute;
    top: -8px;
    right: -8px;
    display: flex;
    gap: 2px;
}
/* Notification count. Same optical correction as .badge, and the same warning:
   the `padding-top` is deliberate. A count is digits, which — like caps — carry
   no descender ink, so `align-items: center` centres the line box and leaves the
   number sitting high: measured 5.17px above the ink and 6.77px below, 1.60px
   high in a 20px circle.

   The mechanism differs because this is a fixed-height flex box. With
   `box-sizing: border-box`, padding-top of N shrinks the content box from the
   top, so its centre — and the digit with it — moves down by N/2. 0.14em is
   1.61px here, which lands the shift on the 0.80px needed. Height is unchanged,
   set by the `height` above. */
.bubble {
    min-width: 20px;
    height: 20px;
    padding: 0.14em 5px 0;
    border-radius: 999px;
    /* Own font, for the same reason as .badge — a count inside a heading was
       rendering in Archivo Black. */
    font-family: var(--font-body);
    /* Same cap-centre alignment as .badge; see the note there. Ignored where a
       bubble is absolutely positioned (nav tabs) or is a flex item (inside a
       .btn), which is correct — those are already centred by their container. */
    vertical-align: 0.14em;
    font-size: 0.72rem;
    font-weight: 700;
    line-height: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}
.bubble--danger {
    background: var(--color-danger);
    color: var(--brand-white);
}
.bubble--warning {
    background: var(--color-warning);
    color: var(--brand-black);
}
.bubble--neutral {
    background: var(--brand-black);
    color: var(--brand-white);
}

.btn--secondary {
    background: transparent;
    border: 1px solid var(--color-border);
    color: var(--color-text);
}
.btn--secondary:hover {
    background: var(--color-page);
    text-decoration: none;
}

/* Solid green "go" button — confirms a positive action (finalise, approve) so
   it reads differently from the orange trigger that revealed it. */
.btn--success {
    background: var(--color-success);
    color: var(--brand-white);
}
.btn--success:hover {
    background: var(--color-success-hover);
    text-decoration: none;
}

/* The destructive twin of `.btn--success`. It was used on the two-factor "Yes,
   turn it off" confirmation and had NO rule at all, so the one button in that flow
   that should look consequential rendered as a plain default button — inside a
   `.confirm` disclosure, where the whole point is that the trigger becomes a button
   that says what it will do. FIFTH instance of "a class used in a template that was
   never defined" (see `.alert--danger` above for the previous four). */
.btn--danger {
    background: var(--color-danger);
    color: var(--brand-white);
}
.btn--danger:hover {
    background: var(--color-danger-hover);
    text-decoration: none;
}

/* Solid dark action button — distinct from the orange "current tab" state. */
.btn--dark {
    background: var(--color-dark);
    color: var(--color-header-ink);
}
.btn--dark:hover {
    background: var(--color-dark-hover);
    text-decoration: none;
}

/* The hidden attribute must beat a component's own display rule — otherwise an
   element like .doc-preview (display: block) stays on screen when JS hides it. */
[hidden] {
    display: none !important;
}

/* Embedded document preview (PDF/image/video) on a document page. Tall enough to
   read without leaving the page, shorter on phones where the viewport is the
   scarce thing; "Open full screen" is always there for a proper read. */
.doc-preview {
    display: block;
    width: 100%;
    height: 32rem;
    margin: var(--space-3) 0;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    /* White, and pinned to the light scheme: a browser forcing dark mode would
       otherwise invert an image/video preview. */
    background: var(--brand-white);
    color-scheme: light;
}

/* The pdf.js viewer: a scrolling stack of page canvases, so height is content —
   not a fixed frame — and every browser shows the pages the same way. */
.doc-preview--pdf {
    height: auto;
    max-height: 40rem;
    overflow-y: auto;
    padding: var(--space-3);
    background: var(--color-page);
}
.doc-preview__status {
    margin: 0;
    padding: var(--space-4);
    text-align: center;
    color: var(--color-muted);
}
.pdf-page {
    display: block;
    width: 100%;
    height: auto;
    margin: 0 auto var(--space-3);
    background: var(--brand-white);
    border: 1px solid var(--color-border);
    box-shadow: var(--shadow);
}
.pdf-page:last-child {
    margin-bottom: 0;
}
@media (max-width: 640px) {
    .doc-preview--pdf {
        max-height: 24rem;
        padding: var(--space-2);
    }
}
.doc-preview--image {
    height: auto;
    max-height: 32rem;
    object-fit: contain;
}
@media (max-width: 640px) {
    .doc-preview {
        height: 18rem;
    }
    .doc-preview--image {
        height: auto;
        max-height: 18rem;
    }
}

/* Checkbox dropdown — a details disclosure whose panel holds a .check-list, for
   picking several people/things without a wall of checkboxes on the page. The
   panel floats over the content (like the header menu) and scrolls when long;
   the outside-click script in base.html closes it. Ticks survive closing — the
   inputs stay in the DOM, so the form's Save button is still the commit. */
.dropdown {
    position: relative;
    display: inline-block;
    max-width: 100%;
}
.dropdown > summary {
    list-style: none;
}
.dropdown > summary::-webkit-details-marker {
    display: none;
}
.dropdown__panel {
    position: absolute;
    z-index: 40;
    left: 0;
    top: calc(100% + 0.35rem);
    min-width: 16rem;
    max-width: min(28rem, 90vw);
    max-height: 18rem;
    overflow-y: auto;
    padding: var(--space-3);
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
}
/* A panel whose trigger sits at the RIGHT of its row opens leftwards, or it
   would hang off the edge of the card. */
.dropdown__panel--right {
    left: auto;
    right: 0;
}
/* …but only while the trigger IS on the right. Below the breakpoint the toolbar
   rows wrap and the trigger drops to the LEFT margin, where right-anchoring throws
   a 256px panel 99px off the other edge — measured, not guessed, on a 375px
   viewport (14 Aug 2026). Same overflow the modifier exists to prevent, mirrored.
   Anchored left and width-capped here, so it fits whichever edge it starts from. */
@media (max-width: 640px) {
    /* `--right` STAYS right-anchored on a phone. It was flipped to the left here
       on 14 Aug, in the same edit that dropped `min-width` to 0 — and the flip was
       the half that was wrong. With no min-width the panel shrinks to its content,
       so anchoring to the trigger's right edge fits from either side of the screen;
       left-anchoring only fits when the trigger is near the left, and the leave
       calendar's is not (measured 15 Aug: 11px off the right edge, so the whole
       page scrolled sideways). */
    .dropdown__panel {
        /* Zero floor, so a panel shrinks to its content and cannot push the page
           wider than the viewport. A panel needing a comfortable INPUT should not
           be a dropdown at all — neither anchor edge is safe on a phone, where the
           trigger may wrap to either side (measured 15 Aug: left-anchored overflows
           by 119px when the trigger sits right; right-anchored by 99px when it sits
           left). Use the inline `.confirm` disclosure instead, as the Client Copy
           rename does. */
        min-width: 0;
        max-width: min(28rem, calc(100vw - 2 * var(--space-4)));
    }
}
/* A short panel shows in full — no inner scroll, so the button at its foot is
   always in view rather than hidden below a fold the user has to find. */
.dropdown__panel--tall {
    max-height: none;
    overflow-y: visible;
}
/* Inside the panel the checkboxes stack one per row (the shared .check-list
   wraps them side by side, which is wrong in a narrow floating panel). */
.dropdown__panel .check-list > div {
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-1);
}

/* A text input paired with its own submit button on ONE line — e.g. a "Reason"
   box beside "Send back". Without this the button wraps below the input and
   knocks the neighbouring action out of alignment. On phones it stacks
   deliberately: the mobile rule below makes a form's buttons full-width, so the
   input takes its own line and the button sits under it. */
.inline-form {
    display: flex;
    flex-wrap: nowrap;
    align-items: center;
    gap: var(--space-2);
    flex: 1 1 22rem;
    min-width: 0;
    margin: 0;
}
.inline-form .input {
    flex: 1 1 8rem;
    min-width: 0;
}
.inline-form .btn {
    flex: 0 0 auto;
}
@media (max-width: 640px) {
    .inline-form {
        flex-wrap: wrap;
    }
    .inline-form .input {
        flex: 1 1 100%;
    }
}

/* A row of actions that wraps on small screens. */
.btn-row {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    align-items: center;
}

/* A whole shift row in the staff diary, which is a link to that day's team roster.
   It reuses `.btn-row` for the wrapping layout, so it needs to undo the link styling
   only. The class existed on the element with these declarations written inline and
   NO rule behind it — invisible to the "does this class exist" sweep's original
   reason for being, and unstyleable from the stylesheet where everything else lives. */
/* A whole shift row, and a <button> since 23 Aug 2026 (tapping it opens the
   shift's details). Everything here except the first two lines is undoing button
   chrome: a button supplies its own font, its own centring and a border, none of
   which a row of text should have. */
.diary-shift {
    text-decoration: none;
    color: inherit;
    font: inherit;
    text-align: left;
    background: none;
    cursor: pointer;
    width: 100%;
}
/* `.panel.diary-shift` on the team roster keeps the panel's own border and
   background; only the button's are dropped. */
.diary-shift.panel {
    background: var(--color-surface);
}

/* The package-bookings editor. The count is at most a few digits, so it takes a
   fixed narrow column and the package NAME gets everything else — at 375px the
   three columns split evenly and the name input was ~80px, too narrow to read
   back what you had just typed. The table still scrolls inside `.table-scroll`
   rather than pushing the page sideways. */
.package-table__name input[type="text"] {
    width: 100%;
    min-width: 9rem;
}
.package-table__count {
    width: 6.5rem;
}
.package-table__count input {
    width: 100%;
}
.package-table__remove {
    width: 4.5rem;
}

/* "Or pick a single file" under each portal folder. A bare <summary> is the
   height of its text — measured 24px, well under the 44px this project requires
   of anything you tap. It is a real control on a phone, so it gets a real target. */
.folder-files > summary {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    cursor: pointer;
}

/* The little colour square that stands for a role, beside a shift. The same
   five declarations were written inline in four places (the diary row, the team
   roster's day list, the leave calendar, the shift dialog) — one class so a
   change to the shape happens once. */
.shift-swatch {
    display: inline-block;
    width: 1rem;
    height: 1rem;
    border-radius: 4px;
    vertical-align: -0.15em;
}

/* --- Chat: giving the messages back their screen (28 Aug 2026) ------------
 * Measured on a 375x812 phone with a conversation open in the overlay: 502px of
 * chrome around 230px of conversation, and with the keyboard up the feed fell to
 * 64px — about two lines — while the composer held 220px. These rules are the
 * three blocks that were spending it. */

/* The conversation's own header: back, title, member count, ONE row. In the
   overlay the whole row is lifted into the panel bar, which is why it carries no
   margin of its own — the bar supplies its own padding. */
.msg-convo-head {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-width: 0; /* or a long title refuses to shrink and pushes the count off */
}
.msg-convo-head__title {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.msg-convo-head__back,
.msg-convo-head__count {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px;
    min-height: 44px;
    flex: 0 0 auto;
    border: none;
    background: none;
    color: inherit;
    font: inherit;
    text-decoration: none;
    cursor: pointer;
}
.msg-convo-head__back {
    font-size: 1.5rem;
    line-height: 1;
}
/* Inside the panel bar the row sits on the near-black surface, so its controls
   take the header ink like everything else there (the 1.00:1 lesson). */
.msg-panel__bar .msg-convo-head__back,
.msg-panel__bar .msg-convo-head__count,
.msg-panel__bar .msg-convo-head__title {
    color: var(--color-header-ink);
}
/* The member dropdown sits at the RIGHT of the header row, so it is anchored
   right — left-anchored it hangs off the side of a 375px panel. The modifier
   goes on the PANEL (`dropdown__panel--right`), not the wrapper. */
.msg-members .dropdown__panel {
    /* An explicit width, because the shared panel's `min-width: 16rem` is set to 0
       on phones and this one then shrank to 133px — every name wrapping. */
    width: min(20rem, 80vw);
}
.msg-members > summary {
    list-style: none;
}
.msg-members > summary::-webkit-details-marker {
    display: none;
}

/* The composer: 📎 and Send flank the box instead of stacking above and below
   it. `align-items: flex-end` so both stay level with the last line as the
   textarea grows. */
.msg-composer {
    position: relative; /* the tray positions against the whole row, see below */
    display: flex;
    gap: var(--space-2);
    /* `stretch`, not `flex-end`: the button column is the tallest item and the
       text box grows to match it, which is what makes the three line up. */
    align-items: stretch;
}
.msg-composer__box {
    flex: 1 1 auto;
    margin: 0;
    min-width: 0;
    display: flex; /* so the textarea can fill the stretched wrapper */
}
.msg-composer__box textarea {
    height: 100%;
    /* The drag handle would let the box be pulled out of line with the column,
       and the height is now decided for it. */
    resize: none;
}
/* 📎 above Send. The column's own height — two 44px targets and the gap — is what
   the row is measured by, so neither button can be squeezed under the minimum. */
.msg-composer__controls {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    flex: 0 0 auto;
}
.msg-composer__send,
.msg-composer__attach > summary {
    flex: 0 0 auto;
}
/* The tray hangs above the composer rather than pushing it down — the composer
   is anchored to the bottom of the panel, and a tray that displaced it would
   move the Send button out from under the thumb that just opened the tray. */
/* Deliberately NOT positioned: the tray anchors to `.msg-composer` so it clears
   the whole row. Anchored to this 44px summary instead, it came out overlapping
   the text box — measured 39px into it. */
.msg-composer__attach {
    position: static;
}
.msg-composer__attach > summary {
    list-style: none;
}
.msg-composer__attach > summary::-webkit-details-marker {
    display: none;
}
.msg-composer__tray {
    position: absolute;
    bottom: calc(100% + 4px);
    left: 0;
    z-index: 20;
    width: max(14rem, 60vw);
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    padding: var(--space-3);
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
}
.msg-composer__file {
    font-size: 0.85rem;
    color: var(--color-muted);
}

/* Mute / Action / Storage on one line. They wrapped onto two and held 96px. */
.msg-convo-actions {
    margin-top: var(--space-3);
    flex-wrap: nowrap;
    gap: var(--space-2);
}
.msg-convo-actions form {
    margin: 0;
    min-width: 0;
}
.msg-convo-actions .btn {
    white-space: nowrap;
}

/* Progress bar (e.g. training wizard step progress). */
.progress {
    height: 8px;
    background: var(--color-border);
    border-radius: 999px;
    overflow: hidden;
    margin: 0.35rem 0 1.5rem;
}
.progress__bar {
    height: 100%;
    background: var(--color-accent);
    border-radius: 999px;
    transition: width 0.2s ease;
}

/* A field that only a bot can see (the careers form's honeypot).
 *
 * NOT `display: none` and NOT `hidden`: the better bots skip anything obviously
 * hidden, and a hidden input is the first thing they ignore. This is present and
 * laid out, just moved out of sight — which also keeps it out of a person's way
 * without pretending it doesn't exist. Paired with tabindex=-1 and aria-hidden on
 * the input itself, so keyboard and screen-reader users never land on it. */
.honeypot {
    position: absolute;
    left: -9999px;
    width: 1px;
    height: 1px;
    overflow: hidden;
}

/* --- Split view: a list down one side, the selected record's detail beside it.
 * (Recruitment's applicant screen is the first user.)
 *
 * On a phone there is no room for both, so the list fills the screen and opening
 * a record replaces it, with a back link to return. That swap is **CSS only** —
 * the server renders whichever columns the URL calls for, and the viewport
 * decides what's on screen. So deep links work, the browser's own Back button
 * does the right thing, and none of it needs JavaScript. */
.split-view {
    display: grid;
    grid-template-columns: minmax(240px, 340px) 1fr;
    /* --space-6, and not a token that doesn't exist: this said --space-5 (a size
       the scale has never had), the declaration was invalid, and the two columns
       sat flush against each other with no gap at all. */
    gap: var(--space-6);
    align-items: start;
}
.split-view__pane {
    position: sticky;
    top: var(--space-4);
}
/* The filter bar runs across the whole page, over both columns — lift the
   standalone-card width cap (420px, sized for a login box). */
.card.split-view__filters {
    max-width: none;
}
/* Same for the viewing pane: its card fills the pane's grid column, so the
   detail grows with the width of the page instead of stopping at 420px. */
.split-view__pane > .card {
    max-width: none;
}
/* The list scrolls within itself so a long list never pushes the detail pane
   off the bottom of the page. */
.split-view__list {
    max-height: 78vh;
    max-height: 78dvh;
    overflow-y: auto;
}
/* Only ever shown on a phone (where the pane replaces the list). */
.split-view__back {
    display: none;
}
.split-view__row {
    display: block;
    text-decoration: none;
    color: inherit;
}
.split-view__row--selected {
    border-color: var(--color-accent);
    box-shadow: inset 3px 0 0 var(--color-accent);
}

/* --- Small screens (phones / narrow viewports) ---
 * The layout is fluid by default; these tune spacing and tap-targets for phones.
 * Every module inherits this — see the mobile-first rule in CLAUDE.md. */
@media (max-width: 640px) {
    /* One column, and only one of the two halves on screen at a time. */
    .split-view {
        grid-template-columns: 1fr;
    }
    .split-view--detail .split-view__list,
    .split-view--detail .split-view__filters {
        display: none;
    }
    /* The filter bar now sits ABOVE .split-view, so the descendant rule above
       can't reach it — the template adds this modifier when a record is open. */
    .split-view__filters--behind {
        display: none;
    }
    .split-view:not(.split-view--detail) .split-view__pane {
        display: none;
    }
    .split-view__pane {
        position: static;
    }
    .split-view__list {
        max-height: none;
        overflow-y: visible;
    }
    .split-view__back {
        display: inline-flex;
    }
    /* Beside the brand there is only ~140px left at 375px, which would give each
       column ~66px and wrap both labels mid-word. Stacked, each fills that width —
       still equal, still flush right. */
    .app-header .btn-row {
        grid-auto-flow: row;
    }

    .page {
        padding: var(--space-6) var(--space-4);
    }
    .page--centered {
        padding-top: var(--space-6);
    }
    .card {
        padding: var(--space-6);
    }
    h1 {
        font-size: 1.4rem;
    }
    .app-header {
        padding: var(--space-3) var(--space-4);
    }
    /* Full-width primary buttons inside forms are easier to tap one-handed. */
    form .btn {
        width: 100%;
    }
    /* ...except a form that IS a row. The rule above stacks a form's buttons full
       width on a phone, which is right for an ordinary form and wrong for the chat
       composer: 📎 and Send sit either side of the text box, and full width made
       Send take all 342px and squeezed the box to 20px — a text field you could
       not read a word in (measured 28 Aug 2026). Two classes beats `form .btn`,
       so this wins wherever it sits in the file. */
    .msg-composer .btn {
        width: auto;
    }
}

/* --- Messaging overlay (M-c): floating chat button + right-hand side panel.
 * The one system-wide UI element — rendered once in base.html for signed-in
 * staff, so every page gets it. Isolated (fixed position, high z-index) so it
 * never disturbs page layout; becomes a full-width sheet on phones. */
.msg-fab {
    position: fixed;
    right: var(--space-4);
    bottom: var(--space-4);
    z-index: 900;
    width: 56px;
    height: 56px;
    border-radius: 999px;
    border: none;
    background: var(--color-accent);
    color: var(--color-accent-ink);
    font-size: 1.5rem;
    cursor: pointer;
    box-shadow: var(--shadow);
}
.msg-fab__badge {
    position: absolute;
    top: -2px;
    right: -2px;
    min-width: 20px;
    height: 20px;
    padding: 0 5px;
    border-radius: 999px;
    background: var(--color-danger);
    color: var(--brand-white);
    font-size: 0.72rem;
    font-weight: 700;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}
/* The panel is sized to the VISUAL viewport, not the layout one.
 *
 * iOS Safari does not shrink the layout viewport when the software keyboard
 * opens — `innerHeight` stays at the full screen height and the keyboard simply
 * draws over the bottom ~336px. A `position: fixed` panel of `height: 100vh`
 * therefore keeps its bottom underneath the keyboard, and because it is fixed
 * there is nothing for Safari to scroll to reveal the field: the composer
 * measured 258px inside the covered region on a 375x812 phone, which is the
 * "I can't see what I'm typing" report (22 Aug 2026).
 *
 * `--msg-panel-h` is set from `visualViewport.height` by the script in
 * `_overlay.html`, which DOES shrink for the keyboard. The fallbacks matter and
 * are ordered deliberately: `100dvh` for browsers with no visualViewport (it
 * tracks browser chrome, which is better than nothing), then `100vh` for those
 * without dynamic units. NOTE `dvh` alone does NOT fix the keyboard — it is a
 * fallback here, never the fix. */
.msg-panel {
    position: fixed;
    top: var(--msg-panel-top, 0);
    right: 0;
    z-index: 1000;
    height: 100vh;
    height: 100dvh;
    height: var(--msg-panel-h, 100dvh);
    width: 380px;
    max-width: 100%;
    display: flex;
    flex-direction: column;
    background: var(--color-surface);
    border-left: 1px solid var(--color-border);
    box-shadow: var(--shadow);
    transform: translateX(100%);
    transition: transform 0.2s ease;
}
.msg-panel.is-open {
    transform: translateX(0);
}
.msg-panel__bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-3);
    background: var(--color-header);
    color: var(--color-header-ink);
}
/* The bar's title slot: it holds the word "Messages" on the inbox and the whole
   conversation header once one is open, so it has to be allowed to shrink. */
.msg-panel__title {
    flex: 1 1 auto;
    min-width: 0;
}

/* 44px like every other control: this sits in the chat overlay's bar, which is
   injected into EVERY page by base.html, so a 26px target was the one undersized
   thing on all of them (measured 31 Jul 2026). The glyph stays 1.5rem — it is the
   hit area that grows, not the ×. */
.msg-panel__close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 44px;
    min-height: 44px;
    background: none;
    border: none;
    color: var(--color-header-ink);
    font-size: 1.5rem;
    line-height: 1;
    cursor: pointer;
}
/* A column, so the message thread inside can absorb the height the keyboard
   takes and the composer below it stays on screen. Still scrolls as a fallback:
   if the rest of the content genuinely cannot fit (a long member list expanded,
   say) the reader gets today's behaviour rather than a clipped panel. */
.msg-panel__body {
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
    overflow-y: auto;
    padding: var(--space-4);
}
/* Everything in the thread column keeps its size; only the thread gives way. */
.msg-panel__body > * {
    flex: 0 0 auto;
}

/* The scrolling list of messages. Was 200 characters of inline style on the
   element, including `max-height: 50vh` — half the LAYOUT viewport, so with the
   keyboard up it carried on reserving 406px of a screen that had 476px left,
   which is what pushed the composer under the keyboard in the first place. */
.msg-thread {
    max-height: 50vh;
    max-height: 50dvh;
    overflow-y: auto;
    padding: var(--space-2);
    border: 1px solid var(--color-border);
    border-radius: 0.5rem;
    margin: var(--space-4) 0;
}
/* Inside the panel the height is decided by the flex column, not by a fraction
   of the viewport: the thread takes what is left after the composer, and shrinks
   when the keyboard arrives. `min-height: 0` is what lets a flex item shrink
   below its content — without it this rule does nothing at all. */
.msg-panel .msg-thread {
    max-height: none;
    flex: 1 1 auto;
    min-height: 4rem;
}
@media (max-width: 640px) {
    .msg-panel {
        width: 100%;
    }
}

/* --- Messaging inbox: folder tabs + the "+" compose menu.
 * The inbox top bar shows the Inbox / To action / Storage folders as a tab strip
 * with a single "+" button on the right; the three compose flows (new message /
 * group / broadcast) live behind that "+" so the inbox stays clean. */
.msg-topbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: var(--space-2);
    flex-wrap: wrap;
    margin: 0.25rem 0 0.75rem;
}
.msg-tabs {
    display: flex;
    gap: var(--space-1);
    flex-wrap: wrap;
}
.msg-tab {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    padding: 0 0.9rem;
    border-radius: 999px;
    border: 1px solid transparent;
    background: var(--color-page);
    color: var(--color-muted);
    font-weight: 600;
    font-size: 0.9rem;
    text-decoration: none;
}
.msg-tab.is-active {
    background: var(--color-accent);
    color: var(--color-accent-ink);
}
.msg-compose {
    position: relative;
}
.msg-compose__btn {
    list-style: none;
    cursor: pointer;
    width: 44px;
    height: 44px;
    border-radius: 999px;
    background: var(--color-accent);
    color: var(--color-accent-ink);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 1.5rem;
    line-height: 1;
    font-weight: 700;
}
.msg-compose__btn::-webkit-details-marker {
    display: none;
}
.msg-compose[open] .msg-compose__btn {
    background: var(--color-accent-hover);
}
/* An unread conversation in the inbox list — a faint orange wash over the whole
 * card (plus the red dot) so it's easy to spot at a glance. */
.msg-convo--unread {
    background: var(--color-accent-tint);
    border-color: color-mix(in srgb, var(--color-accent) 45%, var(--color-border));
}
/* The three entries in this menu (New message / New group / Broadcast) are
   `<summary>`, which has no default height — they measured 24px, and they are the
   only way into starting a conversation on a phone. Scoped to the menu so it
   cannot reach the period-strip's summaries or any other disclosure (the
   later-rule-wins trap noted at the top of this file). */
.msg-compose__menu .panel > summary {
    display: flex;
    align-items: center;
    min-height: 44px;
}
.msg-compose__menu {
    position: absolute;
    right: 0;
    top: calc(100% + 4px);
    z-index: 30;
    width: min(320px, 88vw);
    max-height: 70vh;
    max-height: 70dvh;
    overflow-y: auto;
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
    padding: var(--space-2);
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
}

/* --- Messaging message rows: per-message actions dropdown + the hover / tap-hold
 * reaction bar. Each row is a positioning context so the ▾ actions menu and the
 * reaction bar can float against the bubble. */
.msg-row {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    margin: 0.35rem 0 0.1rem;
}
.msg-row--own {
    align-items: flex-end;
}
/* Incoming messages. Before 3 Aug 2026 this was --color-page — the exact colour
   of the background behind it, so an incoming bubble was invisible except where
   its text happened to fall. The fill alone carries it now; a border was tried
   first and measured no better than the fill it sat on (1.18:1 against the page),
   so it was drawing nothing and has been left off. */
.msg-bubble {
    position: relative;
    max-width: 80%;
    padding: 0.5rem 0.75rem;
    border-radius: 0.75rem;
    background: var(--color-surface-sunken);
    color: var(--color-text);
}
.msg-bubble--own {
    background: var(--color-accent);
    color: var(--color-accent-ink);
}
.msg-bubble--removed {
    opacity: 0.6;
}
.msg-bubble__meta {
    font-size: 0.72rem;
    color: var(--color-muted);
    padding-right: 1.1rem; /* clear the ▾ actions button */
}
.msg-bubble--own .msg-bubble__meta {
    color: var(--color-accent-ink);
    opacity: 0.75;
}
.msg-actions {
    position: absolute;
    top: 2px;
    right: 4px;
}
.msg-actions__btn {
    list-style: none;
    cursor: pointer;
    font-size: 0.9rem;
    line-height: 1;
    padding: 0 0.2rem;
    border-radius: 4px;
    opacity: 0.45;
    color: inherit;
}
.msg-actions__btn::-webkit-details-marker {
    display: none;
}
.msg-bubble:hover .msg-actions__btn,
.msg-actions[open] .msg-actions__btn,
.msg-actions__btn:focus-visible {
    opacity: 1;
}
.msg-actions__menu {
    position: absolute;
    top: 100%;
    right: 0;
    z-index: 25;
    min-width: 210px;
    /* Cap the width, or this menu has no width of its own: it shrink-to-fits,
       so it fills whatever space is offered up to its max-content. That made it
       WIDER THAN THE PANEL IT SITS IN (408px inside 380px), and it made the
       script that positions it circular - the width depended on the left it was
       given, and the left was computed from the width, so the two never agreed.
       A cap breaks that loop and keeps it inside a 320px-wide phone. */
    max-width: min(320px, calc(100vw - 16px));
    background: var(--color-surface);
    color: var(--color-text);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    box-shadow: var(--shadow);
    padding: var(--space-2);
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    text-align: left;
}
.msg-actions__menu .btn {
    width: 100%;
}
.msg-react-bar {
    position: absolute;
    top: -16px;
    z-index: 15;
    display: flex;
    gap: 0.1rem;
    padding: 0.1rem 0.25rem;
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: 999px;
    box-shadow: var(--shadow);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.12s ease;
}
.msg-row:not(.msg-row--own) .msg-react-bar {
    left: 8px;
}
.msg-row--own .msg-react-bar {
    right: 8px;
}
/* Hovering the BUBBLE, not the row. `.msg-row` is a full-width flex column and
   the bubble is `max-width: 80%`, so `.msg-row:hover` fired anywhere across the
   line — including the empty gutter beside a message, which is how reaction
   options appeared with the cursor nowhere near the text (reported 31 Aug 2026).

   `.msg-react-bar:hover` keeps it up once you have moved onto it: the bar sits
   16px ABOVE the bubble, so reaching for a reaction leaves the bubble and would
   otherwise dismiss the thing you were reaching for. */
.msg-bubble:hover ~ .msg-react-bar,
.msg-react-bar:hover,
.msg-react-bar:focus-within,
.msg-react-bar.is-open {
    opacity: 1;
    pointer-events: auto;
}
.msg-react-bar form {
    display: flex;
    gap: 0.1rem;
}
.msg-react-bar button {
    border: none;
    background: none;
    cursor: pointer;
    font-size: 1.05rem;
    line-height: 1.2;
    padding: 0.05rem 0.25rem;
    border-radius: 6px;
}
.msg-react-bar button:hover {
    background: var(--color-page);
}
.msg-reactions {
    margin-top: 0.3rem;
    display: flex;
    gap: 0.25rem;
    flex-wrap: wrap;
}
.msg-reaction {
    font-size: 0.72rem;
    padding: 0.05rem 0.4rem;
    border-radius: 999px;
    border: 1px solid var(--color-border);
    background: var(--color-surface);
    color: var(--color-text);
    cursor: pointer;
}
.msg-reaction.is-mine {
    border-color: var(--color-accent);
    background: var(--color-accent);
    color: var(--color-accent-ink);
}

/* --- Dashboard preferences (Settings) ------------------------------------ */
/* One row per card: reorder controls, the card's name, and a show/hide toggle.
   Stacks nothing on mobile — it stays a single tidy row because the label flexes
   and the controls keep their 44px tap targets. */
.pref-row {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-3) var(--space-4);
    border-bottom: 1px solid var(--color-border);
}
.pref-row:last-child {
    border-bottom: 0;
}
.pref-row--off .pref-row__label {
    opacity: 0.55;
}
.pref-row__move {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
}
.pref-row__label {
    flex: 1;
    min-width: 0;
}
.pref-row__toggle {
    margin: 0;
    flex: 0 0 auto;
}
/* On/off switch — a POST submit button styled as a toggle (the Settings
   notification rows). The visible track is 48×28; the invisible ::after pad
   extends the hit area to the 44px minimum without growing the row. */
.switch {
    position: relative;
    width: 48px;
    height: 28px;
    flex: 0 0 auto;
    border: 1px solid var(--color-border);
    border-radius: 14px;
    background: var(--color-page);
    padding: 0;
    cursor: pointer;
}
.switch::after {
    content: "";
    position: absolute;
    inset: -8px;
}
.switch:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 1px;
}
.switch__thumb {
    position: absolute;
    top: 3px;
    left: 3px;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: var(--color-surface);
    box-shadow: var(--shadow);
    transition: left 0.15s ease;
}
.switch--on {
    /* Green, not accent: on/off is a state, and state coding is red/yellow/green. */
    background: var(--color-success);
    border-color: var(--color-success);
}
.switch--on .switch__thumb {
    left: 23px;
}

/* The up/down arrows are compact but still meet the 44px minimum height via
   .btn; this just trims their width so two stack neatly beside the label. */
.pref-move-btn {
    padding-left: var(--space-3);
    padding-right: var(--space-3);
    line-height: 1;
}
.pref-move-btn:disabled {
    opacity: 0.35;
    cursor: default;
}

/* Authored dot points (core.templatetags.authored_text).
   A bullet typed into a textarea used to render as one long `<br>` line, so a
   wrapped line went back to the left margin and sat under the bullet instead of
   under the words. Real list items hang correctly by definition; this only
   supplies the spacing, and the indent comes free (reported 8 Aug 2026). */
.authored-list {
    margin: 0 0 var(--space-3);
    padding-left: var(--space-4);
}

.authored-list li {
    margin-bottom: var(--space-1);
}

.authored-list li:last-child {
    margin-bottom: 0;
}

/* --- Scheduling: who can't work today (the board's absence panel) ---
 * Sits under Compliance. Three kinds, and they are told apart by a WORD as well
 * as a colour — "on leave" and "rostered elsewhere" lead to different decisions,
 * and roughly one man in twelve cannot rely on the colour. */
.absence-list {
    margin: 0;
    padding: 0;
    list-style: none;
}
.absence-list__row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--space-2);
    min-height: 44px;
    padding: var(--space-2) 0;
    border-bottom: 1px solid var(--color-border);
}
.absence-list__row:last-child {
    border-bottom: 0;
}
.badge--absence-leave {
    background: var(--color-danger);
    color: var(--brand-white);
}
.badge--absence-leave_pending {
    background: var(--color-warning);
    color: var(--brand-black);
}
.badge--absence-elsewhere {
    background: var(--color-border);
    color: var(--color-text);
}

/* --- Scheduling: rostered hours against the ideal (Week / Individual) --- */
.ideal-hours {
    display: inline-flex;
    align-items: baseline;
    gap: var(--space-2);
    font-size: 0.8rem;
    font-family: var(--font-body);
    line-height: 1.5;
    color: var(--color-muted);
    white-space: nowrap;
}
.ideal-hours__pct {
    font-weight: 700;
    color: var(--color-text);
}
/* Over their ideal is worth noticing, not an error — amber, not red. */
.ideal-hours__pct--over {
    color: var(--color-warning-text, var(--color-text));
    background: var(--color-warning);
    border-radius: var(--radius-sm);
    padding: 0 0.25rem;
}
