/*
 * The phase-3 stylesheet. One file, hand-written, no framework and no CDN.
 *
 * Three reasons, in order. The VPS is standalone (decision #19), so a CDN is an
 * outside dependency this deployment does not otherwise have. The whole UI is
 * about eight screens, which is less CSS than a framework's own configuration.
 * And Caddy serves this file straight from a volume — one request, cached for an
 * hour — where a framework would be the largest thing the site sends.
 */

/* --------------------------------------------------------------------------
 * Utilities
 *
 * One class, and it earns its place: the calendar's faces are decorative
 * (`aria-hidden`), so the figures behind them have to be readable by something
 * that cannot see a ring colour. `display: none` would hide them from a screen
 * reader too, which is the mistake this pattern exists to avoid.
 * ------------------------------------------------------------------------ */

.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
}

/* --------------------------------------------------------------------------
 * Tokens
 *
 * Three ways in, and the order they are declared in is the order they win:
 *
 *   1. `:root` — the light theme, and the default for a device that has not
 *      asked for anything.
 *   2. `@media (prefers-color-scheme: dark)` — the device's answer.
 *   3. `:root[data-theme="…"]` — the reader's answer, stamped on <html> by
 *      `theme.js` before the first paint. Declared last so it beats the media
 *      query in both directions: somebody on a dark phone who chose light gets
 *      light, which a media query alone cannot express.
 *
 * The light theme is built on #fff4fb, a warm pink-white, and the accent is a
 * plum that sits on it at better than 4.5:1. The dark theme takes the same
 * accent family rather than the blue it used to have — two themes are one
 * product, and an application that changes its accent hue when the sun goes
 * down reads as two applications.
 * ------------------------------------------------------------------------ */

:root {
    /*
     * `color-scheme` tells the **browser** which way round this page is, which
     * no custom property can. Everything the platform draws for itself reads
     * it: the `select` dropdown list, the date and time picker popups, the
     * checkbox and radio defaults, scrollbars, and the yellow ground Chrome
     * paints behind an autofilled field. Without it, every one of those stayed
     * in light mode on the dark theme — a white popup out of a dark field.
     *
     * Declared in all four blocks rather than once as `light dark`, because
     * `light dark` follows the *device* and this application lets the reader
     * override it. It has to move with the tokens, not with the OS.
     */
    color-scheme: light;

    --field-bg: #fffdfe;
    --bg: #fff4fb;
    --surface: #fffafd;
    --border: #ecd6e4;
    --text: #2a1f27;
    --muted: #6c5a66;

    --accent: #a5246d;
    --accent-text: #ffffff;
    /* A shade darker than the accent, for text on the page background. The
     * accent itself is tuned for a white button; a link is small type on pink,
     * which needs the extra contrast. */
    --link: #8e1a5c;
    --link-visited: #6f3b8e;

    /* The status palette. Chosen so present/absent/leave stay distinguishable
     * without colour alone — every one of them is also labelled in text,
     * because a percentage somebody disputes must be readable by a person who
     * cannot tell the two greens apart. */
    --present: #1a7f4b;
    --present-bg: #e4f5ec;
    --half: #8a5a00;
    --half-bg: #fbf0d8;
    --leave: #6639ba;
    --leave-bg: #efe7fb;
    --absent: #c0392b;
    --absent-bg: #fceae9;
    --closed-bg: #f6e6ef;

    /* The ground a quoted reason sits on. Its own value rather than
     * `--surface`, which is a hair off `--bg` and would leave the block
     * invisible on a card, and rather than `--leave-bg`, which would say
     * "this is leave" about a sentence that can equally belong to a
     * holiday-work request. */
    --quote-bg: #fce7f2;

    /* The dragged selection. Deliberately not one of the status colours: a lit
     * square means "you picked this", which is a different kind of fact from
     * "you were present", and the two appear on the same square at once. */
    --picked: #d4368f;
    --picked-bg: #fbdcee;

    /* The guided tour. `--tour-scrim` dims everything the spotlight is not on,
     * and is deliberately weaker than the card scrim: this one has to leave the
     * page underneath *readable*, because the page is what each step is talking
     * about. `--tour-ring` outlines the hole. */
    --tour-scrim: rgba(42, 31, 39, 0.55);
    --tour-ring: #a5246d;

    /* ----------------------------------------------------------------
     * The scales.
     *
     * Colour was the only thing this file had tokens for, and it shows: 21
     * screens were spaced by eye, one decision at a time, out of eighteen
     * different small values. None of them is wrong on its own and together
     * they are why the application reads as assembled rather than designed.
     *
     * Declared once here rather than in all four theme blocks, because only
     * colour varies with the theme. Elevation is the exception and is
     * declared alongside the palette, where it belongs: a shadow tuned for a
     * pink-white ground is invisible on #16121a.
     * ---------------------------------------------------------------- */

    /* Spacing, on fours. Everything from a gap between two words to the space
     * between cards comes from these seven. */
    --space-1: 0.25rem;
    --space-2: 0.5rem;
    --space-3: 0.75rem;
    --space-4: 1rem;
    --space-5: 1.5rem;
    --space-6: 2rem;
    --space-7: 3rem;

    /* Type, on roughly 1.2. The stray 13px and 15px values in this file were
     * both reaching for one of these. */
    --text-xs: 0.75rem;
    --text-sm: 0.875rem;
    --text-base: 1rem;
    --text-lg: 1.125rem;
    --text-xl: 1.375rem;
    --text-2xl: 1.75rem;

    --leading-tight: 1.25;
    --leading-normal: 1.55;

    --weight-medium: 500;
    --weight-semibold: 600;

    /* Radius. The base moves 6px → 8px: it is the single value most
     * responsible for the old hand-cut look, and it is inherited by every
     * card, field, button and pill at once. */
    --radius-sm: 4px;
    --radius: 8px;
    --radius-lg: 14px;
    --radius-full: 999px;

    /* Motion. One easing, three durations, so a transition is never invented
     * per component again. Everything that uses them still sits inside this
     * file's `prefers-reduced-motion` block. */
    --ease: cubic-bezier(0.22, 0.9, 0.25, 1);
    --dur-fast: 120ms;
    --dur: 200ms;
    --dur-slow: 280ms;

    /* The day card's own gesture, longer than anything else in the application
     * because it is the only place where a thing becomes another thing and the
     * eye needs time to follow it. Its own token rather than a raised
     * `--dur-slow`, which everything else shares and which should stay brisk. */
    --dur-card: 450ms;

    /*
     * And its own curve, for the same reason.
     *
     * `--ease` is tuned for a control that should feel instant — it puts half
     * the motion in the first fifth of the time. Measured on the card at 450ms:
     * half done by 84ms, ninety per cent by 200ms, and then a quarter of a
     * second crawling the last five. Lengthening the duration under that curve
     * does not make the gesture readable, it only makes the tail longer.
     *
     * This one holds back at the start and decelerates into place, so the time
     * it is given is time you can actually see.
     */
    --ease-card: cubic-bezier(0.2, 0, 0, 1);

    /* Kept, because three rules already read it, and now it means something:
     * the row gap of the frame, off the scale like everything else. */
    --gap: var(--space-4);

    /* Poppins, with the old system stack behind it. The fallback is not
     * decoration: the font comes from fonts.googleapis.com, so a deployment that
     * cannot reach it — or a browser that blocks third-party fonts — renders in
     * the stack this project used before, rather than in the browser's serif
     * default.
     *
     * The font is self-hosted now (see the @font-face block below), so the
     * unreachable-CDN case is gone. The stack still matters for the moment
     * before the file arrives under `font-display: swap`, and for a browser with
     * web fonts switched off. */

    /* Elevation. Warm rather than neutral, so a raised surface on a pink-white
     * ground does not read as grey. */
    --shadow-1: 0 1px 2px rgba(74, 30, 56, 0.06), 0 1px 3px rgba(74, 30, 56, 0.05);
    --shadow-2: 0 2px 4px rgba(74, 30, 56, 0.06), 0 6px 16px rgba(74, 30, 56, 0.08);
    --shadow-3: 0 8px 16px rgba(74, 30, 56, 0.10), 0 24px 48px rgba(74, 30, 56, 0.14);
    --font-sans: "Poppins", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}

@media (prefers-color-scheme: dark) {
    :root {
        color-scheme: dark;

        /* A shade lighter than `--surface`, not darker: on a dark ground a
         * field reads as a place to type when it is *nearer* the light, which
         * is the opposite of the light theme and the reason this is a token per
         * theme rather than one `color-mix` for both. */
        --field-bg: #261f2c;
        --bg: #16121a;
        --surface: #1f1a24;
        --border: #372c3d;
        --text: #f0e6ee;
        --muted: #ac9dab;

        --accent: #e879b8;
        --accent-text: #2a1020;
        --link: #f2a2cd;
        --link-visited: #c9a6ea;

        --present: #57d18d;
        --present-bg: #14301f;
        --half: #e0b341;
        --half-bg: #322709;
        --leave: #b799f5;
        --leave-bg: #241a3a;
        --absent: #ff8071;
        --absent-bg: #351613;
        --closed-bg: #241d29;
        /* Lighter than `--surface` rather than darker: a quote has to
         * lift off the card it sits on, and on a dark page lifting
         * means brighter. */
        --quote-bg: #2a2032;

        --picked: #f58ec7;
        --picked-bg: #3d1c30;

        /* Darker than the light theme's scrim rather than lighter. Dimming a
         * dark page by lightening it would raise its brightness, which is the
         * opposite of what a spotlight does. */
        --tour-scrim: rgba(6, 3, 8, 0.68);
        --tour-ring: #e879b8;

        /* A drop shadow alone does almost nothing at this lightness, so each
         * one carries an inset hairline along the top edge — the way a raised
         * surface actually catches light. */
        --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.04);
        --shadow-2: 0 4px 12px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.05);
        --shadow-3: 0 12px 36px rgba(0, 0, 0, 0.62), inset 0 1px 0 rgba(255, 255, 255, 0.06);
    }
}

/* The reader's explicit choice. Repeated in full rather than layered on the
 * blocks above, because a selector that only fills in the differences would
 * depend on which of the two ran first — and `data-theme="light"` on a dark
 * device has to undo every value the media query set, not some of them. */
:root[data-theme="light"] {
    color-scheme: light;
    --field-bg: #fffdfe;
    --bg: #fff4fb;
    --surface: #fffafd;
    --border: #ecd6e4;
    --text: #2a1f27;
    --muted: #6c5a66;

    --accent: #a5246d;
    --accent-text: #ffffff;
    --link: #8e1a5c;
    --link-visited: #6f3b8e;

    --present: #1a7f4b;
    --present-bg: #e4f5ec;
    --half: #8a5a00;
    --half-bg: #fbf0d8;
    --leave: #6639ba;
    --leave-bg: #efe7fb;
    --absent: #c0392b;
    --absent-bg: #fceae9;
    --closed-bg: #f6e6ef;
    --quote-bg: #fce7f2;

    --picked: #d4368f;
    --picked-bg: #fbdcee;

    --tour-scrim: rgba(42, 31, 39, 0.55);
    --tour-ring: #a5246d;

    /* Elevation. Warm rather than neutral, so a raised surface on a
     * pink-white ground does not read as grey. */
    --shadow-1: 0 1px 2px rgba(74, 30, 56, 0.06), 0 1px 3px rgba(74, 30, 56, 0.05);
    --shadow-2: 0 2px 4px rgba(74, 30, 56, 0.06), 0 6px 16px rgba(74, 30, 56, 0.08);
    --shadow-3: 0 8px 16px rgba(74, 30, 56, 0.10), 0 24px 48px rgba(74, 30, 56, 0.14);
}

:root[data-theme="dark"] {
    color-scheme: dark;
    --field-bg: #261f2c;
    --bg: #16121a;
    --surface: #1f1a24;
    --border: #372c3d;
    --text: #f0e6ee;
    --muted: #ac9dab;

    --accent: #e879b8;
    --accent-text: #2a1020;
    --link: #f2a2cd;
    --link-visited: #c9a6ea;

    --present: #57d18d;
    --present-bg: #14301f;
    --half: #e0b341;
    --half-bg: #322709;
    --leave: #b799f5;
    --leave-bg: #241a3a;
    --absent: #ff8071;
    --absent-bg: #351613;
    --closed-bg: #241d29;
    --quote-bg: #2a2032;

    --picked: #f58ec7;
    --picked-bg: #3d1c30;

    --tour-scrim: rgba(6, 3, 8, 0.68);
    --tour-ring: #e879b8;

    /* A drop shadow alone does almost nothing at this lightness, so each
     * one carries an inset hairline along the top edge — the way a raised
     * surface actually catches light. */
    --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.04);
    --shadow-2: 0 4px 12px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.05);
    --shadow-3: 0 12px 36px rgba(0, 0, 0, 0.62), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}

/* --------------------------------------------------------------------------
 * Frame
 * ------------------------------------------------------------------------ */

* { box-sizing: border-box; }

/* `hidden` means hidden.
 *
 * The browser's own rule is `[hidden] { display: none }` — a single attribute
 * selector, which any class rule setting `display` silently beats. That has bitten
 * this stylesheet twice: a `display: flex` on the marking buttons left them
 * showing inside every square, and the same on the sheet left it laid out and
 * hittable while it looked closed. Both were invisible in the markup, which said
 * `hidden` and meant it.
 *
 * `!important` is deliberate and is the one place in this file that uses it. The
 * attribute is a statement that an element is not currently part of the page, and
 * nothing about how a component looks when it *is* showing should be able to
 * overrule that. */
[hidden] { display: none !important; }

body {
    margin: 0;
    background: var(--bg);
    color: var(--text);
    /* Poppins runs a little small and a little tight at the sizes this
     * interface uses, so the line height goes up with it. */
    font: 400 16px/1.55 var(--font-sans);
}

/* Every link in the page body. Its absence was the bug: only the header's own
 * links were ever coloured, so an ordinary `<a>` fell back to the browser's
 * default navy — which on a dark surface is very nearly the surface. Declared
 * once, here, rather than per screen, so a link added to any template is
 * legible in both themes without anybody remembering to style it. */
a {
    color: var(--link);
    text-decoration-thickness: 1px;
    text-underline-offset: 0.15em;
}

a:visited { color: var(--link-visited); }
a:hover { color: var(--accent); }

/* The exceptions, and they are all elements that are links only structurally.
 * Left unset above rather than given `!important` here, because a component
 * that wants its own colour should say so in its own rule. */
.brand,
.site-nav a,
.notif-link,
.button,
.calendar .day a { color: inherit; }

.brand:visited,
.site-nav a:visited,
.notif-link:visited,
.button:visited { color: inherit; }

/* Screen-reader-only text. Used by the theme toggle, whose buttons are glyphs. */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

.site-header {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3) var(--space-5);
    padding: var(--space-3) var(--space-5);
    background: var(--surface);
    border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
    box-shadow: var(--shadow-1);
}

/* Poppins, self-hosted (plan decision #19: no CDN).
 *
 * Copied from what fonts.googleapis.com served for weights 400 to 700, keeping
 * only the Latin and Latin Extended subsets: employee names are Latin-only
 * (#44), and the interface's own text will be translated rather than
 * transliterated, so the Devanagari subset Google also sent had nothing to draw.
 * `unicode-range` is kept from Google's CSS, so a page never downloads the
 * extended subset unless it actually has an accented character on it.
 *
 * Licensed under the SIL Open Font License, which travels with the files as
 * static/fonts/Poppins-OFL.txt. */
@font-face {
    font-family: 'Poppins';
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url('../fonts/poppins-400-latin-ext.woff2') format('woff2');
    unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
    font-family: 'Poppins';
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url('../fonts/poppins-400-latin.woff2') format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
    font-family: 'Poppins';
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: url('../fonts/poppins-500-latin-ext.woff2') format('woff2');
    unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
    font-family: 'Poppins';
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: url('../fonts/poppins-500-latin.woff2') format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
    font-family: 'Poppins';
    font-style: normal;
    font-weight: 600;
    font-display: swap;
    src: url('../fonts/poppins-600-latin-ext.woff2') format('woff2');
    unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
    font-family: 'Poppins';
    font-style: normal;
    font-weight: 600;
    font-display: swap;
    src: url('../fonts/poppins-600-latin.woff2') format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
    font-family: 'Poppins';
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url('../fonts/poppins-700-latin-ext.woff2') format('woff2');
    unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
    font-family: 'Poppins';
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url('../fonts/poppins-700-latin.woff2') format('woff2');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* @import url('https://smc.org.in/fonts/nupuram.css');
@import url('https://smc.org.in/fonts/nupuram-calligraphy.css'); */
@font-face {
    font-family: 'Nupuram-Calligraphy';

    /* Path to local webfont file relative to CSS location */
    /* src: url('../fonts/Nupuram-Bold.woff2') format('woff2'); */
    src: url('../fonts/nupuram-calligraphy-regular.ttf') format('truetype');

    /* Define standard weight (400 for Regular) */
    font-weight: 400;

    /* Define normal font style */
    font-style: normal;

    /* Render fallback text while font downloads to avoid invisible text */
    font-display: swap;
}

.brand {
    font-weight: 600;
    text-decoration: none;
    color: var(--text);
    font-size: xxx-large;
    font-family: "Nupuram-Calligraphy", sans-serif;
}

.site-nav {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1);
    flex: 1;
}

/*
 * Eleven destinations, and every one of them used to be an outlined pill — so
 * the header was thirty-odd feet of identical boxes wrapping onto three rows,
 * with the one you were actually on no easier to find than the rest.
 *
 * The pill was there for the target size, and the padding that gave it is still
 * here: what is gone is the border and the fill, which were doing nothing but
 * drawing every target at once. The pill comes back on hover and stays for the
 * current page, which is the only one that needs it.
 */
.site-nav a {
    color: var(--muted);
    text-decoration: none;
    padding: var(--space-2) var(--space-3);
    border: 1px solid transparent;
    border-radius: var(--radius-full);
    font-size: var(--text-sm);
    font-weight: var(--weight-medium);
    line-height: var(--leading-tight);
    white-space: nowrap;
    transition: background var(--dur-fast) ease-out,
                color var(--dur-fast) ease-out;
}

.site-nav a:hover {
    color: var(--text);
    background: color-mix(in srgb, var(--accent) 10%, transparent);
}

/* Where you are. The only thing on any page that says so, which is why it is a
 * filled pill rather than a shade of grey — and why `aria-current="page"` goes
 * on the same element, since colour alone tells a screen reader nothing. */
.site-nav a.is-here {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--accent-text);
    font-weight: var(--weight-semibold);
}

.site-nav a.is-here:hover { color: var(--accent-text); }

/* --------------------------------------------------------------------------
 * The account chip, and the menu under it
 *
 * These two used to be the only unstyled things in the header: the username as
 * bare muted text, and sign-out as an underlined link — beside a row of pills,
 * a theme toggle and an alerts button. Worse, the name was `display: none`
 * below the phone breakpoint, so on the device most of the office actually uses
 * there was nothing at all saying which account was signed in.
 *
 * Now it is one control: an avatar, the name, and a caret, opening a small menu.
 * On a phone the name and caret drop away and the avatar alone remains — which
 * is how the identity survives a 390px header instead of being hidden by it.
 * ------------------------------------------------------------------------ */

.user-menu { position: relative; }

.user-chip {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: 0.25rem 0.6rem 0.25rem 0.3rem;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--surface);
    color: inherit;
    font-size: 0.875rem;
    font-family: inherit;
    cursor: pointer;
}

.user-chip:hover { border-color: var(--accent); }

/* Open is a state worth seeing: without it the panel appears to belong to
 * nothing in particular. */
.user-chip[aria-expanded="true"] {
    border-color: var(--accent);
    background: var(--bg);
}

.user-chip-name {
    font-weight: 600;
    /* A long username must not push the theme toggle off the bar. */
    max-width: 10rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.user-chip-caret { color: var(--muted); font-size: 0.7rem; }

/* The avatar itself — a photo when there is one, initials when there is not.
 * Both are the same size and shape so the header does not move when somebody
 * uploads a picture for the first time. */
.avatar {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    border-radius: 50%;
    /* `cover`, so a photo that is not square is cropped rather than squashed —
     * a stretched face is worse than a cropped one. */
    object-fit: cover;
    flex: 0 0 auto;
}

.avatar-initials {
    background: var(--accent);
    color: var(--accent-text);
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.01em;
    /* The letters are decorative — the name is beside them in the chip, and on
     * the profile screen the heading says it. `aria-hidden` in the markup keeps
     * a screen reader from spelling them out. */
    text-transform: uppercase;
}

/* The bigger one on the profile screen. */
.avatar-lg {
    width: 6rem;
    height: 6rem;
    font-size: 1.75rem;
}

/* For a table cell and for an attender pill, where the default 1.75rem disc
 * makes the row taller than the text in it. Small enough to sit inside a pill
 * without the pill growing, large enough that two letters still read. */
.avatar-sm {
    width: 1.375rem;
    height: 1.375rem;
    font-size: 0.625rem;
}

/* A person in a table cell: the face, then the name, on one line and staying on
 * one line — a name that wraps under its own avatar looks like two rows. */
.cell-person {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
}

.user-menu-panel {
    position: absolute;
    top: calc(100% + 0.4rem);
    /* Anchored to the right edge: the chip is the last thing in the bar, so a
     * left-anchored panel would hang off the window on a narrow screen. */
    right: 0;
    min-width: 11rem;
    z-index: 50;

    display: flex;
    flex-direction: column;
    padding: 0.3rem;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
}

/* One rule for the link and the button, because they must be
 * indistinguishable: a menu where one item is a link and the other is a form
 * submit should not look like two different kinds of thing. */
.user-menu-item {
    display: block;
    width: 100%;
    padding: 0.5rem 0.65rem;
    border: none;
    border-radius: calc(var(--radius) - 2px);
    background: none;
    color: inherit;
    font: inherit;
    font-size: 0.875rem;
    text-align: left;
    text-decoration: none;
    cursor: pointer;
}

.user-menu-item:hover { background: var(--bg); }

/* Signing out is the one destructive thing in the menu, so it is the one thing
 * tinted — and it is separated from the rest by a rule, so it cannot be hit by
 * somebody aiming at the item above it. */
.user-menu-item.is-signout {
    color: var(--absent);
    border-top: 1px solid var(--border);
    border-radius: 0 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px);
    margin-top: 0.3rem;
}

/* --------------------------------------------------------------------------
 * Theme toggle
 *
 * Two buttons rather than a select: it is two states, and a select on a phone
 * opens a full-screen picker to choose between two glyphs. There used to be a
 * third for "follow the device", which is still what happens until one of these
 * is pressed — it was removed because on a device already set to light it did
 * nothing visible, and a control that appears inert is worse than no control.
 * ------------------------------------------------------------------------ */

.theme-toggle {
    display: inline-flex;
    border: 1px solid var(--border);
    border-radius: 999px;
    overflow: hidden;
}

.theme-toggle button {
    border: none;
    border-radius: 0;
    background: transparent;
    color: var(--muted);
    padding: 0.25rem 0.6rem;
    font-size: 0.9rem;
    line-height: 1.4;
}

.theme-toggle button + button { border-left: 1px solid var(--border); }
.theme-toggle button:hover { color: var(--text); }

/* Marked by `theme.js`, which also sets `aria-pressed` — colour alone would not
 * tell somebody using a screen reader which of the three is in force. */
.push-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    border-radius: 999px;
    padding: 0.3rem 0.75rem;
    font-size: 0.8125rem;
    background: var(--bg);
    color: var(--muted);
    white-space: nowrap;
}

.push-toggle:hover { color: var(--text); }

/* Subscribed. Filled like the current nav pill, because it is the same kind of
 * statement: this is on. */
.push-toggle[data-push-state="on"] {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--accent-text);
}

/* Blocked, unsupported, or no keys on the server. Nothing pressing it can do,
 * so it stops looking like something to press. */
.push-toggle:disabled {
    opacity: 0.55;
    cursor: default;
}

.theme-toggle button.is-active {
    background: var(--accent);
    color: var(--accent-text);
}

.page {
    max-width: 62rem;
    margin: 0 auto;
    padding: 1.5rem 1.25rem 4rem;
}

/* The calendar, and only the calendar. A grid of seven columns gets better the
 * wider it is; a form does not, and neither does a paragraph — which is why this
 * is a class one template opts into rather than a change to `.page`. */
.page-wide {
    max-width: none;
    padding-left: var(--space-4);
    padding-right: var(--space-4);
}

/* Two heading sizes were doing the work of four, which is why a page title and
 * the month nav beside it carried the same weight and the nav usually won. */
h1 {
    font-size: var(--text-2xl);
    line-height: var(--leading-tight);
    letter-spacing: -0.01em;
    margin: 0 0 var(--space-1);
}

h2 {
    font-size: var(--text-xl);
    line-height: var(--leading-tight);
    letter-spacing: -0.005em;
    margin: var(--space-6) 0 var(--space-3);
}

h3 {
    font-size: var(--text-lg);
    line-height: var(--leading-tight);
    margin: var(--space-5) 0 var(--space-2);
}

/* A card opens with its own heading, so the top margin that separates one
 * section from the last is wrong there. */
.card > h2:first-child,
.card > h3:first-child { margin-top: 0; }

.lede {
    color: var(--muted);
    margin: 0 0 var(--space-5);
    /* Prose stops being readable somewhere past seventy characters, and these
     * run the full 62rem of the page. */
    max-width: 62ch;
}

/* --------------------------------------------------------------------------
 * Surfaces
 * ------------------------------------------------------------------------ */

/* The container almost every screen is built out of.
 *
 * It was a flat 1px box on a ground a shade away from its own fill, which is
 * why a page of them read as a wireframe. Now it sits on the page rather than
 * being drawn on it: a hairline that is a tint of the border rather than the
 * whole of it, and the first step of elevation doing the separating. */
.card {
    background: var(--surface);
    border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-1);
    padding: var(--space-5);
    margin-bottom: var(--space-4);
}

/* Arrived here from a notification, which linked to one particular card by its
 * fragment. The browser has already scrolled to it; this is what says "this one"
 * once it has, because a list of identical cards gives no other clue which of
 * them the message was about.
 *
 * `:target` rather than a class, so nothing has to render differently and no
 * script is involved — and it clears itself the moment the reader navigates
 * anywhere else, which is exactly how long the highlight should last. */
.card:target {
    border-color: var(--accent);
    box-shadow: var(--shadow-2), 0 0 0 3px var(--picked-bg);
}

.messages { list-style: none; margin: 0 0 var(--space-5); padding: 0; }

/* A bar down the leading edge rather than a full coloured border: the colour is
 * the fastest thing to read and it should not have to outline the whole
 * sentence to be seen. */
.message {
    padding: var(--space-3) var(--space-4);
    border-radius: var(--radius);
    border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
    border-left: 3px solid var(--border);
    background: var(--surface);
    box-shadow: var(--shadow-1);
    margin-bottom: var(--space-2);
}

.message-success { border-left-color: var(--present); background: var(--present-bg); }
.message-error { border-left-color: var(--absent); background: var(--absent-bg); }
.message-warning { border-left-color: var(--half); background: var(--half-bg); }

/* Nothing to show is a state, not a gap. It gets the room to say so. */
.empty {
    color: var(--muted);
    padding: var(--space-7) var(--space-5);
    text-align: center;
    text-wrap: balance;
}

/* --------------------------------------------------------------------------
 * Controls
 * ------------------------------------------------------------------------ */

/*
 * One button, four intents.
 *
 * There was a single flat style plus `.primary`, so "Reject", "Clear this
 * declaration" and "Filter" all looked equally like the thing to press. The
 * shapes are shared and only the weight differs, which is what keeps a row of
 * them reading as one control group.
 */
button, .button {
    font: inherit;
    font-weight: var(--weight-medium);
    /* Inline-flex, so a button holding an icon and a word centres both — and so
     * every button on a row is the same height whatever is inside it. */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    min-height: 2.25rem;
    padding: var(--space-2) var(--space-4);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--surface);
    color: var(--text);
    cursor: pointer;
    text-decoration: none;
    box-shadow: var(--shadow-1);
    transition: background var(--dur-fast) ease-out,
                border-color var(--dur-fast) ease-out,
                box-shadow var(--dur-fast) ease-out,
                transform var(--dur-fast) ease-out;
}

button:hover, .button:hover {
    border-color: var(--accent);
    box-shadow: var(--shadow-2);
}

/* Pressed, and meant. A single pixel is enough to feel and small enough that a
 * row of buttons does not appear to wobble. */
button:active, .button:active {
    transform: translateY(1px);
    box-shadow: var(--shadow-1);
}

.button-primary, button.primary {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--accent-text);
}

.button-primary:hover, button.primary:hover {
    background: color-mix(in srgb, var(--accent) 88%, var(--text));
    border-color: color-mix(in srgb, var(--accent) 88%, var(--text));
}

/* Destructive, and it should not be mistaken for the ordinary one beside it.
 * Outlined rather than filled: the filled treatment belongs to the action a
 * screen wants you to take, and this is never that. */
.button-danger, button.danger {
    color: var(--absent);
    border-color: color-mix(in srgb, var(--absent) 45%, var(--border));
}

.button-danger:hover, button.danger:hover {
    border-color: var(--absent);
    background: var(--absent-bg);
}

/* For the third and fourth control in a row, where a full button each would be
 * four boxes competing. */
.button-ghost, button.ghost {
    background: transparent;
    border-color: transparent;
    box-shadow: none;
    color: var(--muted);
}

.button-ghost:hover, button.ghost:hover {
    background: color-mix(in srgb, var(--accent) 8%, transparent);
    border-color: transparent;
    box-shadow: none;
    color: var(--text);
}

button:disabled, .button[aria-disabled="true"] {
    opacity: 0.55;
    cursor: not-allowed;
    box-shadow: none;
}

button:disabled:hover { border-color: var(--border); box-shadow: none; }

/* --------------------------------------------------------------------------
 * The profile screen
 * ------------------------------------------------------------------------ */

.profile-photo-card {
    display: flex;
    align-items: flex-start;
    gap: var(--space-5);
    flex-wrap: wrap;
}

.profile-photo-controls { flex: 1 1 16rem; }
.profile-photo-controls h2 { margin-top: 0; }

/* Its own form, so it needs its own separation from the upload button above. */
.profile-photo-remove { margin-top: 0.75rem; }

/* Name/value pairs, as a grid rather than a table: this is a definition list
 * and it should stay one for a screen reader, but the default `dl` stacks
 * every term above its value and wastes the width. */
.detail-list {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 0.4rem 1.25rem;
    margin: 0 0 1rem;
}

.detail-list dt { color: var(--muted); font-size: 0.875rem; }
.detail-list dd { margin: 0; }

.link-button {
    border: none;
    background: none;
    color: var(--muted);
    padding: 0;
    text-decoration: underline;
}

form .field { margin-bottom: 1rem; }
form label { display: block; font-weight: 600; margin-bottom: 0.25rem; }

/* Every text-like input, listed by type because `input` on its own would also
 * catch checkboxes, radios and submit buttons.
 *
 * `email` was missing, which is the whole of bug 1: the one email field in the
 * application — the employee creation form — fell through to the browser's
 * default box, so it sat next to four styled inputs looking like a different
 * form. `url` and `tel` are here for the same reason before anybody adds one:
 * the failure is silent, and it looks like a mistake in the template rather
 * than a gap in a selector list.
 *
 * `time` is the same bug one type later, and it is the one the reminder form
 * was built on: its only field fell through to the browser's default box and
 * showed `--:--` beside a clock glyph the size of a full stop, sitting next to
 * a styled text box and looking like a different form. */
input[type="text"], input[type="date"], input[type="time"],
input[type="number"], input[type="password"], input[type="email"],
input[type="url"], input[type="tel"], input[type="search"], select, textarea {
    font: inherit;
    width: 100%;
    max-width: 24rem;
    padding: 0.55rem 0.7rem;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    /* Its own ground rather than `--surface`. A field on a card was previously
     * the same colour as the card, so the border was the only thing saying
     * "type here" — which on the employee form is nine identical outlines. */
    background: var(--field-bg);
    color: var(--text);
    /* The border and the ring move on focus; nothing else does, and neither
     * moves for somebody who asked for less of it. */
    transition: border-color 120ms ease-out, box-shadow 120ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
    input, select, textarea { transition: none; }
}

/* Placeholder text is a hint, not a value. Left to the browser it is a grey
 * that belongs to neither theme — the search box on the employee list was the
 * one place it showed. */
input::placeholder, textarea::placeholder {
    color: var(--muted);
    opacity: 1;      /* Firefox dims it a second time without this. */
}

/* Drag it taller, never wider: these sit in cards and in the calendar's day
 * panel, and a textarea pulled sideways pushes its way out of both. */
textarea {
    resize: vertical;
    min-height: 3.5rem;
}

/* A field that cannot be used says so, rather than looking like one that can.
 * `[readonly]` keeps its normal text colour — the value is still the point,
 * it simply cannot be edited — where `:disabled` dims both. */
input:disabled, select:disabled, textarea:disabled,
input[readonly], textarea[readonly] {
    background: color-mix(in srgb, var(--border) 35%, var(--field-bg));
    cursor: not-allowed;
}

input:disabled, select:disabled, textarea:disabled {
    color: var(--muted);
}

/* One visible focus ring for everything focusable, in the accent rather than
 * the browser's default blue — which is invisible against the dark surface for
 * the same reason the unstyled links were. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
.day:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
}

/*
 * A field focuses differently from a link or a button.
 *
 * An outline sits *outside* a box with a 24rem cap and a border of its own, so
 * on a form it reads as a second rectangle around the first. The field takes
 * the accent into its own border instead, with a soft ring behind it — one
 * shape, and the eye lands on the box rather than beside it.
 *
 * **The transparent outline is load-bearing.** In forced-colors mode the
 * `box-shadow` is thrown away, and a rule that simply removed the outline would
 * leave nothing at all; an outline that is there but transparent is one the
 * mode can force to a system colour. Declared after the block above so it wins
 * for fields, which leaves links, buttons and `.day` on the plain ring.
 */
input:focus-visible, select:focus-visible, textarea:focus-visible {
    outline: 2px solid transparent;
    outline-offset: 0;
    border-color: var(--accent);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}

/* A refused field stays refused while it is being corrected. Without this the
 * rule above would paint the accent over the one piece of colour that says
 * which box was wrong, exactly when somebody has gone to it to fix it. */
input[aria-invalid="true"]:focus-visible,
select[aria-invalid="true"]:focus-visible,
textarea[aria-invalid="true"]:focus-visible {
    border-color: var(--absent);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--absent) 22%, transparent);
}

.errorlist {
    list-style: none;
    margin: 0.25rem 0 0;
    padding: 0;
    color: var(--absent);
    font-size: 0.875rem;
}

.helptext { color: var(--muted); font-size: 0.875rem; }

/* --------------------------------------------------------------------------
 * Field states
 *
 * Rendered by `partials/form_fields.html`, which every form in the web UI goes
 * through — so everything here lands on all of them at once, including the four
 * screens that used to hand-roll a copy of that loop and each dropped something
 * different from it.
 * ------------------------------------------------------------------------ */

/* The anchor for a roller panel, which is positioned against the field rather
 * than against the page. Harmless on every field that never opens one. */
.field { position: relative; }

/* A filter bar, and the one-question action forms that share its class.
 *
 * `.inline-form` is on eight forms and styled nothing, so every one of them
 * stacked its fields vertically under a name that says otherwise — two boxes
 * and a button taking four rows of a card. A wrapping row is what the class
 * always claimed to be, and wrapping rather than scrolling is what keeps it
 * usable at phone width.
 *
 * `flex-end` so the button sits on the inputs' baseline rather than being
 * stretched to the height of the tallest field once one of them carries help
 * text underneath.
 */
.inline-form {
    display: flex;
    flex-wrap: wrap;
    /* Tops, not bottoms. Aligning the bottoms looked obvious and is wrong: one
     * field carrying help text pushes its own input up, and the row comes out
     * with its labels on three different lines. Aligned at the top, every label
     * and every input lands on the same line and the help text hangs below the
     * field it belongs to. */
    align-items: flex-start;
    gap: var(--space-3);
}

/* The row's own `gap` replaces the per-field margin; keeping both would space
 * the fields twice. */
.inline-form .field {
    margin-bottom: 0;
    /* Share the row, but break to the next line rather than squeeze below a
     * width a date or a name can still be read in. */
    flex: 1 1 12rem;
}

/* The button belongs on the inputs' line, and the only thing above an input in
 * a field is one line of label plus its margin — which is what this is. `1lh`
 * so it follows the label's own line height rather than a number that is right
 * at one font size; the `rem` before it is the answer for a browser without
 * `lh`, which is close enough that nothing looks broken there either. */
.inline-form > button {
    align-self: flex-start;
    margin-top: 1.75rem;
    margin-top: calc(1lh + 0.25rem);
}

/* A radio group's wrapper. A `<fieldset>` because that is the element a group
 * of inputs can be described by — see the partial — and stripped back to
 * nothing because the browser's default border and margin would draw a box
 * around one field in a column of fields that have none. */
.field-group {
    border: 0;
    margin: 0;
    padding: 0;
    min-width: 0;      /* Or a fieldset refuses to shrink inside a flex row. */
}

.field-group legend {
    padding: 0;
    font-weight: 600;
    margin-bottom: var(--space-1);
}

/*
 * The three controls that had no styling at all.
 *
 * Everything else on a form went through the input list above; these fell
 * through it, because `input` on its own would have swept up submit buttons
 * too. So a pink-accented application drew the browser's blue tick box, and the
 * one file field on the site kept a grey button from 1998.
 *
 * `accent-color` rather than rebuilding them out of pseudo-elements: the native
 * controls already handle indeterminate state, forced colors, high contrast and
 * every platform's own idea of a tick. One property borrows all of that and
 * only changes the colour.
 */
input[type="checkbox"], input[type="radio"] {
    accent-color: var(--accent);
    /* The platform default is small against 16px body text, and a tick box is
     * also a tap target. */
    width: 1.05rem;
    height: 1.05rem;
    margin: 0;
    flex: 0 0 auto;
    cursor: pointer;
}

input[type="checkbox"]:disabled, input[type="radio"]:disabled {
    cursor: not-allowed;
}

/*
 * The upload button inside a file input.
 *
 * `::file-selector-button` is the only part of the control that can be styled,
 * and it is worth doing: it sits on the profile screen beside two buttons this
 * stylesheet does own, and looked like neither of them.
 */
input[type="file"] {
    font: inherit;
    color: var(--muted);
    max-width: 24rem;
}

input[type="file"]::file-selector-button {
    font: inherit;
    margin-right: var(--space-3);
    padding: 0.4rem 0.8rem;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--surface);
    color: var(--text);
    cursor: pointer;
}

input[type="file"]::file-selector-button:hover {
    border-color: var(--accent);
}

/*
 * A radio group's options.
 *
 * Django renders each one as `<div><label><input> text</label></div>`, and
 * `form label { display: block; font-weight: 600 }` above is right for a field's
 * own label and wrong for these — it made every option a bold paragraph with
 * the button floating at the start of it. Inside a `.field-group` a label is an
 * option: normal weight, on one line with its button.
 */
.field-group label {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    font-weight: 400;
    margin-bottom: 0;
    /* The words are part of the target; the browser already routes the click
     * through the label, so this only says so. */
    cursor: pointer;
    padding: 0.15rem 0;
}

/* A checkbox belongs beside its label, not under it.
 *
 * `form label { display: block }` above is right for every other kind of field
 * and wrong for this one: it leaves the words on one line and a lone box on the
 * next, which reads as a broken layout rather than as a question. The hook is
 * `widget_type`, which Django supplies — no guessing from the input's own
 * attributes, and no template knows about this.
 */
[data-field-type="checkbox"] {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex-wrap: wrap;
}

[data-field-type="checkbox"] label {
    margin-bottom: 0;
    order: 2;          /* The words after the box, as every checkbox reads. */
}

[data-field-type="checkbox"] input { order: 1; }

/* Both belong under the pair rather than between the box and its words. */
[data-field-type="checkbox"] .helptext,
[data-field-type="checkbox"] .errorlist {
    order: 3;
    flex-basis: 100%;
}

/* A refused field is marked on the **input**, not only in the message under it.
 * A red sentence at the bottom of a long form does not say which box to look
 * at, which is the whole failure mode on the employee form's nine fields. */
.field input[aria-invalid="true"],
.field select[aria-invalid="true"],
.field textarea[aria-invalid="true"] {
    border-color: var(--absent);
}

/* Required, said once and quietly. `aria-hidden` in the markup, because the
 * input itself already carries `required` and a screen reader announces that —
 * this is the visual half of a fact the accessibility tree already has. */
.field-required {
    color: var(--absent);
    font-weight: 600;
    margin-left: 0.15rem;
}

/* --------------------------------------------------------------------------
 * The roller picker
 *
 * Built entirely by `static/js/rollers.js` and present in no template, so with
 * scripting off none of this matches anything and the field is the plain input
 * Django rendered — the same property the password reveal maintains, and for the
 * same reason: a control that cannot work without a script should not be in the
 * HTML claiming it can.
 *
 * **The geometry here is load-bearing.** The script computes which item is
 * chosen as `round(scrollTop / itemHeight)`, so the spacer above the first item
 * must be exactly half the difference between the column's height and one row —
 * otherwise every value is read off by one. Change one number below and the
 * other two follow it.
 * ------------------------------------------------------------------------ */

.roller {
    position: absolute;
    z-index: 15;
    margin-top: var(--space-1);
    padding: var(--space-2);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
}

.roller-cols {
    position: relative;
    display: flex;
    gap: var(--space-1);
}

/*
 * The rows nearest the top and bottom edges fade out.
 *
 * An overlay rather than `mask-image` on the columns themselves: a mask clips
 * the whole of an element's rendering, and that includes the `:focus-visible`
 * outline a column needs to be usable from the keyboard. Two gradients painted
 * over the top cost that nothing.
 *
 * Fading to `--surface` rather than to transparent because the panel's ground
 * is a known solid — so this is indistinguishable from a mask, and follows the
 * theme for free.
 *
 * `::before` and not `::after`: the centre band is `::after` and paints later,
 * which is what keeps it crisp on top of the fade rather than under it.
 */
.roller-cols::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    background:
        linear-gradient(to bottom, var(--surface) 0%, transparent 40%),
        linear-gradient(to top, var(--surface) 0%, transparent 40%);
}

/* The band the chosen row sits in, drawn once across all the columns rather
 * than per item: one rectangle that does not move is easier to follow with the
 * eye than three highlights that do. */
.roller-cols::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    /* (column height − row height) ÷ 2 — the same number as `.roller-pad`. */
    top: 4.5rem;
    height: 2.25rem;
    border-top: 1px solid var(--accent);
    border-bottom: 1px solid var(--accent);
    pointer-events: none;
    z-index: 2;
}

.roller-col {
    height: 11.25rem;          /* five rows */
    overflow-y: auto;
    scroll-snap-type: y mandatory;
    /* The scrollbar is noise on a control whose whole job is to be spun. The
     * column is still scrollable by wheel, drag, and arrow key. */
    scrollbar-width: none;
    /* Grabbable, because it is: `makeDraggable` in `rollers.js` spins the
     * column from a held mouse button, which is the gesture a wheel invites and
     * the one an overflow container does not answer on its own. */
    cursor: grab;
    /* A drag over text would otherwise select the numbers it passes. */
    user-select: none;
    /* What makes the wheel below a cylinder rather than a shear. The
     * `perspective-origin` defaults to the centre of this element's padding box
     * — which does not scroll — so a row's distortion grows with its distance
     * from the centre line, exactly as it would on a real drum. */
    perspective: 34rem;
}

/*
 * For the length of a drag only.
 *
 * A drag moves the column by assigning `scrollTop`, and when a snap container
 * re-snaps after such an assignment is left to the engine — Chromium waits for
 * the scroll to finish, others have not always. Taking snapping off for the
 * duration means the column tracks the pointer exactly rather than depending on
 * that, and nothing is lost: `glide` in `rollers.js` is what lands it on a row
 * when the button comes up, snap or no snap.
 */
.roller-col.is-dragging {
    scroll-snap-type: none;
    cursor: grabbing;
}

.roller-col::-webkit-scrollbar { display: none; }

.roller-col:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
    border-radius: var(--radius);
}

.roller-item {
    height: 2.25rem;
    display: flex;
    align-items: center;
    justify-content: center;
    min-width: 3rem;
    scroll-snap-align: center;
    font-variant-numeric: tabular-nums;
    color: var(--muted);
    /* Deliberately no `cursor` of its own: clicking a row does not choose it —
     * spinning the column does — so the grab cursor the column sets is the
     * honest one, and a pointing hand here would promise otherwise. */
    user-select: none;
}

/* The part that leans. See `buildColumn` in `rollers.js` for why the transform
 * may not go on `.roller-item`: the item's box is a scroll-snap area, and a
 * transformed one snaps somewhere else. */
.roller-face {
    display: block;
    /* Deliberately no `transform-style: preserve-3d`. The perspective that makes
     * this a wheel belongs to the column, not to the row — and declaring it here
     * only forces a 3D rendering context on a leaf that has nothing inside it to
     * keep in 3D, which leaves even the flat centre row carrying a `matrix3d`
     * instead of no transform at all. */
}

.roller-item.is-selected {
    color: var(--text);
    font-weight: 600;
}

/*
 * The wheel.
 *
 * Each row animates its own tilt and opacity against where it sits in the
 * column, through a scroll-driven timeline — so the browser runs it on the
 * compositor and **nothing is added to the scroll path**. That matters: the
 * minute column is sixty rows, and a handler that touched each of them on every
 * scroll event would be the one expensive thing in this file.
 *
 * `cover` spans the whole of a row's journey, from entering at one edge to
 * leaving at the other, which puts its midpoint exactly on the centre line.
 * Measured rather than assumed: with the geometry above, the row the script
 * calls chosen comes out at opacity 1 and an identity transform, with its
 * neighbours falling off symmetrically. No fudge factor, and no number here
 * that has to be kept in step with the arithmetic in the script.
 *
 * **The `@supports` guard is not decoration.** A browser that does not know
 * `animation-timeline` drops that declaration and is left with an animation of
 * zero duration and `both` fill — which paints every row at the *last*
 * keyframe: rotated away and invisible. Inside the guard such a browser simply
 * gets the flat column, which is what it had before. The fade above is outside
 * the guard on purpose, so the half of this somebody actually asked for —
 * numbers disappearing at the top and the bottom — happens everywhere.
 */
@supports (animation-timeline: view()) {
    .roller-face {
        animation: roller-wheel linear both;
        animation-timeline: view(block);
        animation-range: cover 0% cover 100%;
    }

    @keyframes roller-wheel {
        from {
            transform: rotateX(62deg) translateZ(-1.1rem);
            opacity: 0;
        }
        50% {
            transform: rotateX(0deg) translateZ(0);
            opacity: 1;
        }
        to {
            transform: rotateX(-62deg) translateZ(-1.1rem);
            opacity: 0;
        }
    }

    /*
     * The tilt is motion, the fade is not.
     *
     * Somebody who has asked for less movement still needs to see which rows are
     * the far ones, so the opacity ramp stays and only the rotation goes. Both
     * the keyframes and the perspective are undone, because a perspective with
     * nothing rotating in it does nothing but cost a layer.
     */
    @media (prefers-reduced-motion: reduce) {
        .roller-col { perspective: none; }

        @keyframes roller-wheel {
            from { opacity: 0.15; }
            50% { opacity: 1; }
            to { opacity: 0.15; }
        }
    }
}

/* The spacers that let the first and last real values reach the centre line.
 * Padding on the column itself would not do — an element's own padding does not
 * participate in scroll-snap. */
.roller-pad { height: 4.5rem; }

.roller-actions {
    display: flex;
    justify-content: space-between;
    gap: var(--space-2);
    margin-top: var(--space-2);
}

.roller-actions button {
    font: inherit;
    padding: 0.3rem 0.7rem;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--surface);
    color: var(--text);
    cursor: pointer;
}

.roller-done {
    background: var(--accent);
    color: var(--accent-text);
    border-color: var(--accent);
}

/* A field whose roller is open, so the box and the panel read as one control. */
.has-roller-open {
    border-color: var(--accent) !important;
}

/* Motion is the browser's here — `scroll-behavior: smooth` on a snap container
 * — so honouring the preference means turning that off rather than removing an
 * animation this file owns. */
@media (prefers-reduced-motion: reduce) {
    .roller-col { scroll-behavior: auto; }
}

/* --------------------------------------------------------------------------
 * The reveal control on a password box
 *
 * The wrapper and the button are both built by `static/js/password.js` and
 * exist in no template, so with scripting off none of this matches anything and
 * the box is the plain input Django rendered. That is deliberate: a control
 * that cannot work without a script should not be in the HTML claiming it can.
 *
 * **The wrapper is the field.** It carries the border, the radius and the
 * background that every other input carries itself, and the input inside gives
 * all three up. The first version instead left the input framed and hung the
 * button over its right-hand end with `position: absolute`, which put the icon
 * on top of the border rather than inside the box — it read as an ornament
 * stuck to the edge of the field instead of a control belonging to it.
 *
 * Flex rather than absolute positioning is what makes that true rather than
 * approximately true: the button is a sibling that occupies real width, so the
 * text can never run underneath it and no `padding-right` has to be kept in
 * agreement with the button's size.
 * ------------------------------------------------------------------------ */

.password-field {
    display: flex;
    align-items: stretch;
    width: 100%;
    /* The same ceiling every other input has, so a password field is exactly as
     * wide as the ones above and below it. */
    max-width: 24rem;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--surface);
    /* The button is square-cornered; without this its corner paints over the
     * wrapper's rounded one. */
    overflow: hidden;
}

/* The input, stripped of the frame the wrapper now carries. Both types are
 * listed because the script swaps between them, and a `text` input that kept
 * the default border would grow a second frame the moment the eye is pressed. */
.password-field input[type="password"],
.password-field input[type="text"] {
    flex: 1 1 auto;
    /* A flex item defaults to `min-width: auto`, which refuses to shrink below
     * its content. Without this a long password pushes the button out through
     * the right-hand edge — the exact failure this layout is meant to end. */
    min-width: 0;
    width: auto;
    max-width: none;
    border: 0;
    border-radius: 0;
    background: none;
}

/* The ring belongs to the whole field, not to the input inside it. Without
 * this pair, focusing the box draws an outline around the text area alone and
 * the button sits visibly outside the thing that is focused. */
.password-field input:focus-visible {
    outline: none;
}

.password-field:focus-within {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
}

.password-reveal {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    /* Narrower than the 44px pointer target guidance, and deliberately: it is
     * full-height, so the actual hit area is the width below by the height of
     * the field, which clears it comfortably. */
    width: 2.25rem;
    padding: 0;
    border: 0;
    background: none;
    color: var(--muted);
    cursor: pointer;
}

.password-reveal:hover { color: var(--accent); }

/* Inset, so the ring reads as belonging to the button rather than as a second
 * border drawn just inside the field's own. */
.password-reveal:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: -3px;
    border-radius: var(--radius);
}

/* Edge draws its own reveal control inside a password input, and Safari a
 * strong-password key. Either sitting beside this one is two eyes on one box,
 * and the platform ones vanish the moment the type is swapped to `text`, which
 * makes the pair flicker. */
.password-field input::-ms-reveal,
.password-field input::-webkit-credentials-auto-fill-button {
    display: none;
}

/* --------------------------------------------------------------------------
 * Tables
 * ------------------------------------------------------------------------ */

/* Nine screens are a table, and every one of them was a bare grid of hairlines
 * sitting directly on the page while the cards above it were boxes. The wrapper
 * is already in every one of those templates — it only ever did `overflow-x` —
 * so it becomes the surface the table sits on and no markup has to change. */
.table-scroll {
    overflow-x: auto;
    background: var(--surface);
    border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-1);
    margin-bottom: var(--space-4);
}

table {
    width: 100%;
    border-collapse: collapse;
    background: transparent;
}

th, td {
    text-align: left;
    /* Vertical on the scale for the row rhythm, horizontal one step down: the
     * monthly report is ten columns wide, so every extra 4px of side padding is
     * 80px of table, and at 1rem it overflowed its own container and made the
     * names wrap. Measured, not guessed — 1025px of table in 950px of card. */
    padding: var(--space-3);
    border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
    vertical-align: middle;
}

/*
 * `white-space: nowrap` used to be on every cell, which is what forced the
 * horizontal scrollbar on the employee list and the monthly report: one long
 * name and the whole table had to be dragged. It belongs on the columns whose
 * content genuinely must not break — a code, a date, a figure, and the actions
 * at the end of a row — and a name is none of those.
 */
th, td.numeric, td.nowrap, .table-scroll td:first-child { white-space: nowrap; }

thead th {
    position: sticky;
    top: 0;
    z-index: 1;
    background: var(--surface);
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--muted);
    /* A sticky header has no bottom border once it detaches — the row's border
     * scrolls away with the row. A shadow drawn under it does stay. */
    box-shadow: inset 0 -1px 0 var(--border);
    border-bottom: 0;
}

/* The last row should not draw a line against the container's own edge. */
tbody tr:last-child td { border-bottom: 0; }

/* Reading along a row of nine numbers is the whole job on the monthly report. */
tbody tr { transition: background var(--dur-fast) ease-out; }
tbody tr:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }

td.numeric, th.numeric { text-align: right; font-variant-numeric: tabular-nums; }

/* --------------------------------------------------------------------------
 * Month grid
 *
 * The screen a plain admin changelist cannot produce (plan §10): a calendar
 * whose ordinary days have no rows, because rules are computed and only
 * decisions are stored. It is now also the marking screen and the leave
 * picker, so a square is a target as well as a statement.
 * ------------------------------------------------------------------------ */

.month-nav {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--gap);
    margin-bottom: var(--space-4);
}

/* The month is the heading of this screen and used to be set in the body size,
 * which left the largest thing on the page as the word "Calendar" above it —
 * a title that says the same as the tab it is in. */
.month-nav strong {
    font-size: clamp(1.5rem, 3.2vw, 2.25rem);
    font-weight: 600;
    letter-spacing: -0.01em;
    line-height: 1.15;
}

.calendar {
    display: grid;
    grid-template-columns: repeat(7, minmax(0, 1fr));
    gap: 2px;
    background: var(--border);
    border: 1px solid var(--border);
    border-radius: var(--radius);

    /* Not `overflow: hidden`, and that is a deliberate trade. A pressed square
     * scales up and casts a shadow, and clipping the grid would shave that flat
     * for every cell on an edge — which is most of them. The cost is that the
     * four corner cells square off the container's rounding by a pixel or two;
     * the alternative is a lift that works everywhere except the outside row. */
    overflow: visible;

    /* Dragging across the grid must not scroll the page or start a text
     * selection — which is what a finger dragged across a page normally does,
     * and is the single thing that makes every gesture here work on a phone. */
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
}

.calendar .weekday {
    background: var(--surface);
    color: var(--muted);
    font-size: 0.75rem;
    text-transform: uppercase;
    padding: 0.4rem;
    text-align: center;
}

.calendar .day {
    background: var(--surface);
    /* Tied to the width rather than fixed, which is what stops the grid looking
     * squeezed now that it runs the full width of the window: a row that stays
     * 5.5rem tall while its columns grow to 200px wide reads as a spreadsheet,
     * not a calendar. The floor keeps a narrow window usable and the ceiling
     * stops a very wide monitor producing squares the size of postcards. */
    min-height: clamp(6rem, 9.5vw, 10rem);
    padding: 0.5rem 0.55rem;
    display: flex;
    flex-direction: column;
    gap: 0.2rem;
    font-size: 0.8125rem;
    position: relative;

    /*
     * The card's rest state, and the only place these are written down in CSS.
     *
     * `setFlip` in `calendar.js` overwrites them for the length of the open and
     * the close; at every other moment the surface is unscaled, which is what
     * these say. Declared here rather than left to the `var()` fallback so that
     * the stylesheet describes its own states — and so the guard in
     * `config/tests.py` that every `var()` resolves keeps meaning something,
     * which is how this omission was caught in the first place.
     */
    --flip-x: 0px;
    --flip-y: 0px;
    --flip-sx: 1;
    --flip-sy: 1;
    --flip-bg: var(--surface);
}

/* The date is the thing being scanned for, so it is set like one. */
.calendar .day .num {
    font-size: 1.0625rem;
    font-weight: 600;
    line-height: 1.2;
}

/* Leading and trailing blanks, so the 1st lands under the right weekday. */
.calendar .day.blank { background: var(--bg); min-height: 3rem; }

.calendar .day .num { font-weight: 600; }
.calendar .day .note { color: var(--muted); font-size: 0.75rem; }
.calendar .day.today { outline: 2px solid var(--accent); outline-offset: -2px; }

/* Status colouring, always paired with a text label in the cell itself. */
.day.is-present { background: var(--present-bg); }
.day.is-half_day { background: var(--half-bg); }
.day.is-leave { background: var(--leave-bg); }
.day.is-absent { background: var(--absent-bg); }
.day.is-not_owed { background: var(--closed-bg); }
/* Half worked, half on leave. Painted as the half day it also is, in the half
 * day's amber, the choice `.badge-half_leave` above makes for the same reason:
 * what somebody scans for is how much of the day was worked. Without this rule
 * such a square drew no colour at all. */
.day.is-half_leave { background: var(--half-bg); }

.badge {
    display: inline-block;
    font-size: 0.6875rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.02em;
    padding: 0.1rem 0.35rem;
    border-radius: 999px;
    border: 1px solid currentColor;
}

.badge-present { color: var(--present); }
.badge-half_day { color: var(--half); }
/* Half worked, half leave. Amber like the other half day rather than purple like
 * leave: what the reader is scanning a column of badges for is how much of the
 * day was worked, and half of it was. The label says the rest. */
.badge-half_leave { color: var(--half); }
.badge-leave { color: var(--leave); }
.badge-absent { color: var(--absent); }
.badge-not_owed { color: var(--muted); }
.badge-pending { color: var(--accent); }

/* --------------------------------------------------------------------------
 * Picking days, and pressing them
 *
 * The whole square is the target. It used to be a checkbox in the corner of the
 * cell with a "Pick" label beside it — a 13-pixel box on a phone, and two
 * separate things to look at on every one of thirty-one squares.
 * ------------------------------------------------------------------------ */

.calendar .day[data-date] { cursor: pointer; }

.calendar .day.unselectable:not([data-markable]) { opacity: 0.62; }

/* The lift. A square grows and casts a shadow while it is held, so a tap feels
 * like pressing something rather than like waiting to see whether it worked.
 *
 * This is why the grid above is `overflow: visible`: a raised square has to be
 * allowed outside its own cell, and `overflow: hidden` would shave the top row
 * flat while lifting the others. */
.calendar .day.is-pressed {
    transform: scale(1.06);
    z-index: 3;
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
}

.calendar .day {
    transition: transform 120ms ease-out, box-shadow 120ms ease-out;
}

/* A picked square. The whole cell lights, which is the point of the gesture: a
 * dragged run reads as one block rather than as five ticked boxes. Marked by an
 * inset ring as well as by fill, so a run stays legible where the fill competes
 * with a status colour underneath it. */
.calendar .day.is-picked {
    background: var(--picked-bg);
    box-shadow: inset 0 0 0 2px var(--picked);
    /* The trail. Only squares that were not already lit run this, because the
     * class is toggled rather than rewritten — so a drag leaves a sweep behind
     * the finger instead of the whole block pulsing at once. */
    animation: pick-glow 260ms ease-out;
}

@keyframes pick-glow {
    from {
        box-shadow: inset 0 0 0 2px var(--picked), 0 0 0 6px var(--picked-bg);
        transform: scale(1.04);
    }
    to {
        box-shadow: inset 0 0 0 2px var(--picked), 0 0 0 0 transparent;
        transform: scale(1);
    }
}

/* A markable square announces itself, or nobody discovers that opening it
 * offers anything. A dotted underline on the number is enough — the grid is
 * dense, and a second badge per square is not. */
.calendar .day[data-markable] .num {
    text-decoration: underline dotted var(--accent);
    text-underline-offset: 0.2em;
}

/* An admin's per-day count. Pushed to the bottom of the cell so the column of
 * figures lines up down the week regardless of what else each day holds. */
.calendar .day .note.attended {
    margin-top: auto;
    font-weight: 600;
    color: var(--accent);
}

/* The people who were in, as faces.
 *
 * This replaced "2 in", which answered how many and not who — so an admin
 * scanning a month for one person had to open every square. Faces answer it from
 * the grid, and the square still opens into the names.
 *
 * `margin-top: auto` for the same reason the count had it: the row sits on the
 * floor of the cell, so the faces line up across a week however much each day
 * holds above them. */
.calendar .day .day-faces {
    display: flex;
    flex-wrap: wrap;
    gap: 2px;
    margin-top: auto;
}

/* Ringed in the status colour, so a half day is visible without opening
 * anything — and never *only* by colour: the square's hidden summary and the
 * opened card both say it in words.
 *
 * The size is a variable the square sets, not a constant. A face exists to be
 * recognised, and on a day two people came in there is room to draw one properly;
 * the eight-person day is the crowded case, not the common one. `--face` below
 * shrinks a step per person so the row keeps fitting the cell. */
.face {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--face, 1.15rem);
    height: var(--face, 1.15rem);
    border-radius: 50%;
    object-fit: cover;
    flex: 0 0 auto;
    /* The letters follow the disc rather than being set per tier, so an initials
     * avatar stays readable at every size without eight more rules. */
    font-size: calc(var(--face, 1.15rem) * 0.42);
    font-weight: 700;
    line-height: 1;
    /* The ring is drawn inside the border box rather than as a border, so a photo
     * and an initials disc are exactly the same size and the row does not shift
     * when somebody uploads a picture. */
    box-shadow: 0 0 0 2px var(--surface), 0 0 0 3px var(--muted);
}

/* One step per person. Two large discs, then down to the dense row the busiest
 * day needs — and the step is gentle enough that a square does not appear to
 * jump when the third person marks.
 *
 * Declared on the container so a photo and an initials disc read the same
 * variable; `.face-more` reads it too, so the "+N" chip keeps pace. */
.calendar .day .day-faces[data-faces="1"] { --face: 2.4rem; }
.calendar .day .day-faces[data-faces="2"] { --face: 2rem; }
.calendar .day .day-faces[data-faces="3"] { --face: 1.7rem; }
.calendar .day .day-faces[data-faces="4"] { --face: 1.5rem; }
.calendar .day .day-faces[data-faces="5"] { --face: 1.35rem; }
.calendar .day .day-faces[data-faces="6"] { --face: 1.25rem; }
.calendar .day .day-faces[data-faces="7"] { --face: 1.2rem; }
.calendar .day .day-faces[data-faces="8"] { --face: 1.15rem; }

.face-initials {
    background: var(--accent);
    color: var(--accent-text);
    text-transform: uppercase;
}

.face.is-present { box-shadow: 0 0 0 2px var(--surface), 0 0 0 3px var(--present); }
.face.is-half_day,
.face.is-half_leave { box-shadow: 0 0 0 2px var(--surface), 0 0 0 3px var(--half); }

/* The ones that did not fit. A chip rather than a ninth face, because a face
 * that is not anybody's is worse than a number. */
.face-more {
    width: auto;
    min-width: var(--face, 1.15rem);
    padding: 0 0.25rem;
    border-radius: 999px;
    background: var(--surface);
    color: var(--muted);
    box-shadow: 0 0 0 1px var(--border);
    /* Slightly larger than the letters on a disc: this is a number to read, not
     * an initial to recognise. */
    font-size: calc(var(--face, 1.15rem) * 0.46);
}

/* Who was away. A figure and not faces: they were not here, and drawing them
 * beside the people who were would say the office was fuller than it was. */
.calendar .day .note.day-away {
    margin-top: 0.15rem;
    color: var(--leave);
}

/* The square is a button as far as the keyboard is concerned, so it needs a
 * focus ring that is not the browser's default blue. Handled by the shared
 * `:focus-visible` rule; this only lifts it above its neighbours. */
.calendar .day:focus-visible { z-index: 4; }

/* Rendered inside every square and never shown there — the card clones it. */
/* Nothing here hides the detail any more, and that is deliberate. It used to
 * carry `display: none`, from when a separate card cloned it out of the square;
 * now the square itself expands and shows its own detail in place, so the only
 * thing hiding it is the `hidden` attribute the script clears — which is exactly
 * what `[hidden]` at the top of this file already handles.
 *
 * A `display: none` here would beat that attribute being removed, and the square
 * would grow to a panel with nothing in it. */

/* --------------------------------------------------------------------------
 * Bigger squares on a phone
 *
 * The desktop grid is dense because a mouse can hit a 20-pixel target. A thumb
 * cannot, and this application is opened on a phone by most of the office —
 * so below this width the cells get taller, the type gets larger, and the
 * decorations that make a cell crowded are dropped rather than shrunk.
 * ------------------------------------------------------------------------ */

@media (max-width: 40rem) {
    /* The header first, because on a phone it was costing more screen than the
     * thing it sits above: eight nav links wrapping one per line pushed the
     * first row of the calendar below the fold. One scrollable row instead —
     * the links stay reachable, and the calendar starts where it should. */
    .site-header { padding: 0.5rem 0.75rem; gap: 0.5rem 0.75rem; }

    .site-nav {
        order: 5;
        flex-basis: 100%;
        flex-wrap: nowrap;
        overflow-x: auto;
        gap: var(--space-4);
        /* The bar is the navigation; a scrollbar under it is noise on a device
         * that scrolls by touch anyway. */
        scrollbar-width: none;
        -webkit-overflow-scrolling: touch;
    }

    .site-nav::-webkit-scrollbar { display: none; }

    /* Pills cost more height than the plain words they replaced, which on a
     * phone comes straight out of the calendar below. Tightened rather than
     * abandoned: the border is what makes each one a target, and that is the
     * point of them. */
    .site-nav a {
        white-space: nowrap;
        padding: 0.25rem 0.7rem;
        font-size: 0.875rem;
    }

    /* The month bar is the next thing between the header and the first week. */
    .month-nav { margin-bottom: 0.6rem; }

    /* Pinned, and only here.
     *
     * Above this breakpoint the calendar page is a non-scrolling `100dvh` flex
     * column, so the note is always on screen and sticky would buy nothing —
     * while a sticky item inside a `min-height: 0` flex column is a behaviour
     * worth not inviting. A phone scrolls, which is exactly where whose month
     * this is would otherwise scroll away under a thumb.
     *
     * `z-index: 5` sits above a focused square (4) and below `.card-scrim` (20)
     * and `.day.is-expanding` (30), so an opened day still covers it. The site
     * header is not sticky, so `top: 0` is the top of the window. */
    .viewing-note {
        position: sticky;
        top: 0;
        z-index: 5;
        margin-bottom: 0.6rem;
    }

    .viewing-name { font-size: 1rem; }
    .viewing-caution { font-size: 0.8125rem; }
    .viewing-note .avatar { width: 2.125rem; height: 2.125rem; font-size: 0.875rem; }

    /* One size down on a phone. Full size, it left the account avatar a few
     * pixels short of the row and alone on a line of its own — once the theme
     * and alerts pills moved into the account menu, it was the only thing
     * that did not fit. */
    .brand { flex: 1; font-size: xx-large; }

    /* The name goes, the avatar stays. This replaces a blunt
     * `.whoami { display: none }` that hid the identity altogether — on the
     * device most of the office actually uses, the header could not say which
     * account was signed in. The avatar costs about 28px and answers it. */
    .user-chip-name,
    .user-chip-caret { display: none; }

    .user-chip {
        padding: 0.2rem;
        /* Round, because with the name gone it is an avatar in a ring rather
         * than a pill with something missing from it. */
        border-radius: 50%;
    }

    /* Pushed to the right edge of whichever wrapped row it lands on.
     *
     * The header is one wrapping flex line, so without this the avatar sits
     * immediately after the notifications link, stranded mid-row with empty
     * space beside it. `margin-left: auto` eats that space, which puts the
     * account control in the corner people already reach for it in — and it
     * survives the row breaking differently as the nav gains or loses links,
     * where a fixed order or a spacer element would not. */
    .user-menu { margin-left: auto; }

    .calendar { gap: 3px; }

    .calendar .day {
        min-height: 4.6rem;
        padding: 0.5rem 0.35rem;
        font-size: 0.9375rem;
    }

    .calendar .day .num { font-size: 1.0625rem; }

    /* The holiday name and the "Owes 0.5" line are the first things to go: at
     * this width they wrap to three lines and push the number out of sight. The
     * card says both in full the moment the square is opened. */
    .calendar .day .note:not(.attended) { display: none; }

    /* The faces stay. They are the reason to look at a square on a phone at all,
     * and at this width there is room for two rows of them where there was never
     * room for a holiday name.
     *
     * A step larger than the desktop tiers rather than a flat size: a thumb-held
     * screen is held closer, but a busy day still has to fit, so the shrinking
     * behaviour is kept and only its floor is raised. */
    .calendar .day .day-faces { gap: 3px; }
    .calendar .day .day-faces[data-faces="1"] { --face: 2.6rem; }
    .calendar .day .day-faces[data-faces="2"] { --face: 2.2rem; }
    .calendar .day .day-faces[data-faces="3"] { --face: 1.9rem; }
    .calendar .day .day-faces[data-faces="4"] { --face: 1.7rem; }
    .calendar .day .day-faces[data-faces="5"] { --face: 1.5rem; }
    .calendar .day .day-faces[data-faces="6"] { --face: 1.4rem; }
    .calendar .day .day-faces[data-faces="7"] { --face: 1.3rem; }
    .calendar .day .day-faces[data-faces="8"] { --face: 1.25rem; }

    .calendar .day .badge {
        font-size: 0.5625rem;
        padding: 0.05rem 0.25rem;
    }

    .page { padding: 1rem 0.75rem 4rem; }
}

/* --------------------------------------------------------------------------
 * The month, edge to edge
 *
 * The whole window below the header is the calendar. Full width, full height,
 * nothing to scroll: seven columns across the glass and however many weeks the
 * month has dividing the height between them.
 *
 * Two separate things had to change to get there, and they are worth telling
 * apart because only one of them is about height.
 *
 * **Height.** The rows used to be `min-height: clamp(6rem, 9.5vw, 10rem)`, which
 * on a wide monitor is 10rem — six of them being 960px of grid before the header
 * and the month bar are counted, so the last week of every month sat below the
 * fold. Rows now get a *share* of what is left rather than a height of their own.
 * `grid-auto-rows` rather than six explicit rows, because a month is four, five
 * or six weeks and six fixed rows would leave February a sixth of the grid as an
 * empty band.
 *
 * **Width.** `.page` carries `margin: 0 auto` to centre the readable column on
 * every other screen. That margin is why this page is a flex column and not
 * simply `height: 100dvh` — auto margins on a flex item's cross axis *absorb the
 * free space* instead of letting the item stretch, so leaving them in place
 * collapses the grid to a fit-content column stranded in the middle of the
 * window. `margin-inline: 0` is not tidying; it is the line that makes this full
 * width at all.
 *
 * The gutters go too. `.page-wide` kept 1rem at each side and 4rem at the foot,
 * which is right for a page that scrolls and wrong for one that is exactly the
 * size of the window: those are pixels the squares should have. The month bar
 * keeps its own inset so the heading is not jammed against the glass.
 *
 * **Above the phone breakpoint only.** On a phone the squares are already barely
 * a thumb wide, and squeezing six rows into what is left of a portrait screen
 * after the header would make each one shorter than the finger aiming at it. The
 * phone rules above stay exactly as they were: taller squares, and a scroll.
 *
 * `:has()` scopes all of this to the one page that has a calendar on it, so no
 * other screen is turned into a fixed-height column.
 * ------------------------------------------------------------------------ */

@media (min-width: 40.0625rem) {
    body:has(.calendar[data-calendar]) {
        /* `dvh`, not `vh`: on a tablet with a retracting browser chrome, `vh` is
         * the *largest* the viewport gets, which would put the last week under
         * the address bar until somebody scrolled — the exact problem this is
         * here to remove. */
        height: 100dvh;
        display: flex;
        flex-direction: column;
        /* The page is exactly the window now, so anything that overflows is a
         * rounding error rather than content somebody needs to reach. Without
         * this a stray pixel raises a scrollbar on a page whose whole point is
         * not having one. */
        overflow: hidden;
    }

    body:has(.calendar[data-calendar]) .page {
        /* The width fix, and the reason this rule exists at all. See above:
         * `margin: 0 auto` on a flex item stops it stretching and shrinks it to
         * its content, which is what left the grid marooned mid-window. */
        margin-inline: 0;
        width: 100%;
        max-width: none;

        /* `min-height: 0` is the equivalent load-bearing line for height. A flex
         * item's floor is its content by default, so without this the grid would
         * refuse to shrink below its natural height and the column would overflow
         * instead — the same scroll, arrived at by a longer route. */
        flex: 1 1 auto;
        min-height: 0;
        display: flex;
        flex-direction: column;

        /* Edge to edge. The side gutters and the 4rem foot are what a scrolling
         * page needs; this one is the size of the window. */
        padding: 0;
    }

    /* Everything above the grid keeps its natural height. Without this they are
     * shrinkable flex items like any other, so a tall month would take its
     * missing pixels partly out of the month heading and the messages — and the
     * grid is the only thing here that should be giving any up. */
    body:has(.calendar[data-calendar]) .page > *:not(.calendar) {
        flex: 0 0 auto;
    }

    /* The grid is flush with the glass; the words above it are not. Restoring an
     * inset on just these keeps "March 2026" and any message off the very edge
     * without costing the calendar a pixel. */
    body:has(.calendar[data-calendar]) .month-nav,
    body:has(.calendar[data-calendar]) .messages,
    body:has(.calendar[data-calendar]) .viewing-note {
        margin-inline: var(--space-4);
    }

    /* Tightened, because every row of it comes straight out of the squares. */
    body:has(.calendar[data-calendar]) .month-nav {
        margin-top: var(--space-3);
        margin-bottom: var(--space-3);
    }

    body:has(.calendar[data-calendar]) .calendar {
        flex: 1 1 auto;
        /* For the same reason as on `.page`: the grid is itself a flex item. */
        min-height: 0;

        /* The weekday headings keep their own height; every week row splits what
         * is left. `minmax(0, 1fr)` rather than plain `1fr` because a grid row's
         * default floor is also its content, and one cell with a long holiday
         * name would otherwise push its whole row taller than its siblings. */
        grid-template-rows: auto;
        grid-auto-rows: minmax(0, 1fr);

        /* Rounded corners and side borders are for a card sitting on a page.
         * This is not sitting on anything — it runs into the edges of the
         * window, and a radius there reads as a rendering fault. */
        border-radius: 0;
        border-left: 0;
        border-right: 0;
        border-bottom: 0;
    }

    body:has(.calendar[data-calendar]) .calendar .day {
        /* Replaces the clamp. The row height is the grid's decision now. */
        min-height: 0;
        /* Insurance, for the shortest rows a six-week month on a small laptop
         * produces: a cell clips its own contents rather than growing and
         * dragging the rest of the week with it. The expanded panel is
         * `position: fixed` — set by `calendar.js` — so it is not affected by
         * this, and neither is the lift on press, which is a transform. */
        overflow: hidden;
    }

    /* The blanks are ordinary rows now, not the 3rem stubs they were when every
     * row was sized independently. Left at their own height they would compress
     * the first and last weeks of the month against the others. */
    body:has(.calendar[data-calendar]) .calendar .day.blank { min-height: 0; }
}

/* --------------------------------------------------------------------------
 * Reading somebody else's month
 * ------------------------------------------------------------------------ */

/* Every button in the grid below writes against their name, not yours, so this
 * is a state worth being unable to miss.
 *
 * That sentence has been here since the note was written, and for a long time
 * the rule under it did not deliver on it: muted 15px prose on the ordinary
 * surface, with the name buried mid-sentence. It read as a hint. So the person
 * is now the payload — a face, a name at heading weight, the code beside it —
 * and the caution is the smaller line underneath.
 *
 * Amber rather than the accent, from `--half` / `--half-bg`. Those are the pair
 * `.message-warning` already uses as the caution colours and they are defined in
 * both schemes, so this introduces no new colour to a deliberately small
 * palette. The accent is the brand and is everywhere; this needs to read as a
 * *different mode*, which a colour used for nothing else on the page does. */
.viewing-note {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    margin: 0 0 1rem;
    padding: 0.7rem 0.9rem;
    border: 1px solid var(--half);
    border-left-width: 4px;
    border-radius: var(--radius);
    background: var(--half-bg);
    /* Full strength. `--muted` on a tinted ground is the combination that made
     * the old one skimmable. */
    color: var(--text);
}

/* Bigger than the header's chip and much smaller than the profile page's.
 * Scoped here rather than added as a third global `.avatar-*` modifier, because
 * the number belongs to this banner and to nothing else. */
.viewing-note .avatar {
    width: 2.5rem;
    height: 2.5rem;
    font-size: 1rem;
}

.viewing-who { min-width: 0; }

.viewing-name {
    margin: 0;
    font-size: 1.0625rem;
    font-weight: 600;
    line-height: 1.3;
}

/* Beside the name rather than under it: the code is how every other screen and
 * every export identifies them, so it belongs on the same line as what it is an
 * identifier for. */
.viewing-code {
    font-weight: 400;
    color: var(--muted);
}

.viewing-caution {
    margin: 0.15rem 0 0;
    font-size: 0.875rem;
    line-height: 1.35;
}

/* The grid's own ground, turned amber.
 *
 * `.calendar` draws its gridlines *with its background* — `gap: 2px` over
 * `background: var(--border)` plus a 1px border — so this is the one place a
 * tint can go that reads across the whole month.
 *
 * Deliberately **not** on the squares. Their backgrounds are the status
 * colours, which carry the meaning of the screen, and an amber wash over
 * present, leave and absent alike would fight all three. */
.calendar.is-someone-else {
    /* A tint, not a repaint. `--half` at full strength would draw a lattice
     * *darker* than the ordinary gridlines in light mode and a bright gold one
     * in dark, which is a different grid rather than the same grid in a
     * different mode. Forty per cent mixed into the usual line colour reads as
     * "something is different here" without redrawing the month.
     *
     * `color-mix` is the one modern feature in this file with no precedent
     * above it, and it was chosen partly because of how it fails: a browser
     * that does not know it drops the declaration, `.calendar`'s own
     * `background: var(--border)` stands, and the amber outer border below
     * still says which mode this is. */
    background: color-mix(in srgb, var(--half) 40%, var(--border));
    /* The frame at full strength — it is 1px and it is what carries the mode at
     * a glance from across a desk. */
    border-color: var(--half);
}

/* Whose day the opened card is about, under the date it is about.
 *
 * Amber like the banner it repeats, because it is the same fact said in the one
 * place the banner cannot reach: the card covers the grid, and on a phone it
 * covers the page. */
.card-subject {
    margin: 0.15rem 0 0.6rem;
    font-size: 0.9375rem;
    font-weight: 600;
    color: var(--half);
}

/* --------------------------------------------------------------------------
 * The square that becomes the panel
 *
 * There is no separate popup. Tapping a day promotes that square: it lifts out
 * of the grid, grows over the calendar, and shows the detail already inside it.
 * `calendar.js` measures and pins; everything here is what that looks like.
 *
 * The geometry is set inline by the script, so these rules deliberately set no
 * width, height, left or top — only what the transition animates and what the
 * expanded state looks like.
 * ------------------------------------------------------------------------ */

.card-scrim {
    position: fixed;
    inset: 0;
    background: rgba(20, 10, 18, 0.45);
    opacity: 0;
    /* The same duration and curve as the square it dims for. These were 180ms
     * ease-out against the square's 260ms cubic-bezier, so the background
     * finished settling a third of a second before the thing in front of it
     * did — which is most of what made the gesture feel like two animations
     * rather than one. */
    transition: opacity var(--dur-card) var(--ease-card);
    z-index: 20;
}

.card-scrim.is-open { opacity: 1; }

/* The square while it is pinned, whether growing or shrinking. `position` and
 * the four offsets come from the script; this is the part that has to be true
 * for the whole of the animation. */
.calendar .day.is-expanding {
    z-index: 30;
    /*
     * Nothing here is a layout property any more. The card is pinned at its
     * final geometry by `calendar.js` and never resized; only this transform
     * moves, and the compositor handles it without a layout. Measured before
     * the change: twenty-two layouts to open and twenty-one to close, about one
     * per frame, each of them re-flowing the whole form inside the card.
     *
     * The `transition` is stated rather than inherited: `.calendar .day` above
     * carries a 120ms one for the hover lift, and being more specific is the
     * only thing that stops it winning here.
     */
    /*
     * A keyframed animation, not a transition, and that is not a style choice.
     *
     * A transition has to *capture* the value it starts from, and the card's
     * start value has to be written while the class carrying the transition is
     * already on the element — so writing it transitioned it: the card set off
     * towards the square it came from, was told a frame later to come home, and
     * the two motions cancelled into no visible animation at all. Suppressing
     * the transition to write the start state fixed the card and not its
     * surface, whose style is resolved once at the end of the task, by which
     * time the suppression has been taken off again: measured, the shape went
     * from 1.0 to 0.875 and back over fifty milliseconds while the card
     * travelled for two hundred and eighty.
     *
     * Keyframes carry their start value explicitly, so there is nothing to
     * capture and nothing to race. `calendar.js` writes the four custom
     * properties and adds the class; everything else follows from that.
     */
    animation: card-flip-in var(--dur-card) var(--ease-card) both;
    will-change: transform;

    /* The surface moved to `::before`, so the card itself paints nothing. */
    background: transparent;
    box-shadow: none;

    /*
     * Deliberately **not** `overflow: hidden`.
     *
     * It was there to clip the content while the box grew into it. The box no
     * longer grows — it is full size throughout — so there is nothing to clip,
     * and hiding the overflow would now clip the shadow off the surface below
     * instead. What keeps the content from showing outside the small shape at
     * the start of the gesture is that it is faded almost to nothing there.
     */
}

/*
 * The shape: background, border, radius and shadow, and the only thing that is
 * ever scaled.
 *
 * Separating it from the content is what lets the card zoom without the text
 * inside it being squashed and stretched — the alternative, scaling the card
 * and counter-scaling the contents by `1/s`, needs two eased curves to stay
 * exact reciprocals of each other frame for frame, and they do not: they drift
 * apart in the middle, which is precisely the distortion the counter-scale was
 * meant to remove.
 *
 * `transform-origin: 0 0` so the scale anchors at the card's top-left — which
 * the card's own translate has already placed over the square it came from, so
 * the two together land the shape exactly on that square.
 *
 * The scale arrives through custom properties because a pseudo-element cannot
 * be reached from script. `setFlip` in `calendar.js` writes them.
 */
.calendar .day.is-expanding::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background: var(--surface);
    border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-3);
    /*
     * About the centre, not the corner.
     *
     * The card is centred on the square it came from, so scaling the shape
     * about its own centre grows it outward from exactly where the square was —
     * and leaves the content, which is centred in the same place, sitting still.
     */
    transform-origin: 50% 50%;
    animation: card-surface-in var(--dur-card) var(--ease-card) both;
    will-change: transform;
}

/*
 * The four of them. `from` on the way in is where the square was; `to` on the
 * way out is where it is going — the same numbers, read the other way round.
 */
@keyframes card-flip-in {
    from { transform: translate(var(--flip-x), var(--flip-y)); }
    to { transform: none; }
}

@keyframes card-flip-out {
    from { transform: none; }
    to { transform: translate(var(--flip-x), var(--flip-y)); }
}

/* `--flip-bg` is the square's own status tint, read by `captureColour` in
 * `calendar.js` before the card is made transparent. The shape therefore begins
 * as the colour the day already was and settles to the card's neutral ground,
 * rather than turning white the moment it is touched. */
@keyframes card-surface-in {
    from {
        transform: scale(var(--flip-sx), var(--flip-sy));
        background-color: var(--flip-bg);
    }
    to {
        transform: none;
        background-color: var(--surface);
    }
}

@keyframes card-surface-out {
    from {
        transform: none;
        background-color: var(--surface);
    }
    to {
        transform: scale(var(--flip-sx), var(--flip-sy));
        background-color: var(--flip-bg);
    }
}

/* Once it has arrived. The square's own summary — the number, the badges, the
 * count — is what it showed as a grid cell, and the detail replaces it rather
 * than sitting under it. */
.calendar .day.is-expanded {
    background: var(--surface);
    padding: 0;
    cursor: default;
    /* A ceiling of its own, independent of the script.
     *
     * `calendar.js` already clamps the height it animates to, and that clamp is
     * a measurement taken once: it does not survive the window being resized,
     * the browser's toolbars collapsing on a phone, or a font scale that grows
     * the list after the fact. A card that outgrows the window is one whose
     * bottom half cannot be reached at all, because the body is locked while it
     * is open. */
    max-height: calc(100vh - 2rem);
}

/*
 * The square's own summary — the date, the badges, the "2 in" — while the panel
 * is open **and while it is closing**.
 *
 * `is-collapsing` is the whole of the closing fix. `is-expanded` used to come
 * off the moment the square started shrinking, which put the number and the
 * badges back instantly, at full size, inside a box that was still panel-sized
 * and still moving. The content snapped and then the box caught up: two
 * animations again, and the jarring one.
 *
 * Now the summary stays hidden for the whole shrink and comes back only once
 * the square is home and back in the grid — by which time it is the right size
 * for it.
 */
/*
 * `.day-faces` is in this list because the old implementation was hiding it by
 * accident. The card used to carry `overflow: hidden` to clip its contents
 * while the box grew into them, and the row of faces along the bottom of the
 * square was being clipped by it rather than hidden on purpose. The box no
 * longer grows — it is full size throughout — so there is nothing to clip and
 * nothing clipping, and the faces reappeared, floating outside the shape for
 * the length of the gesture. They are part of the square's summary like the
 * date and the badges, and belong here with them.
 */
.calendar .day.is-expanded > .num,
.calendar .day.is-expanded > .note,
.calendar .day.is-expanded > .badge,
.calendar .day.is-expanded > .day-faces,
.calendar .day.is-collapsing > .num,
.calendar .day.is-collapsing > .note,
.calendar .day.is-collapsing > .badge,
.calendar .day.is-collapsing > .day-faces { display: none; }

/* Closing is the opening in reverse, and a little quicker — leaving should not
 * take as long as arriving. */
.calendar .day.is-collapsing {
    background: transparent;
    padding: 0;
    /* Equal specificity with the `is-expanding` rule above — the element
     * carries both classes for the whole of the close — so this wins by being
     * further down the file, and must stay there. */
    animation: card-flip-out var(--dur-card) var(--ease-card) both;
}

.calendar .day.is-collapsing::before {
    animation: card-surface-out var(--dur-card) var(--ease-card) both;
}

.calendar .day.is-collapsing .day-detail {
    /* `--dur-slow`, not `--dur`: the box travels for `--dur-slow`, and a detail
     * that had finished fading 80ms earlier left an empty rectangle sliding the
     * rest of the way on its own. */
    animation: detail-out var(--dur-card) var(--ease-card) both;
    /* No delay on the way out. On the way in the detail waits for the square to
     * be worth filling; on the way out it is the first thing to go, so the box
     * is empty before it is small. */
    animation-delay: 0ms;
    overflow: hidden;
}

/* What holds the square's place in the grid while it is out of the flow.
 * Without it the remaining days reflow into the gap and the animation appears
 * to start from somewhere else entirely. */
.calendar .day-placeholder {
    background: var(--bg);
    pointer-events: none;
}

.day-detail {
    padding: var(--space-5);
    /* Fades in behind the growing square rather than appearing at full size in
     * a cell that is still the size of a calendar day. */
    animation: detail-in var(--dur-card) var(--ease-card) both;
    animation-delay: 60ms;
}

/* The detail is what scrolls, not the card around it.
 *
 * `.calendar .day` is a flex column and this is one of its items, so it arrives
 * with the flex default of `min-height: auto` — which means it refuses to
 * shrink below its own content, grows the card past the height the script gave
 * it, and leaves an `overflow-y` on the card with nothing to scroll. On a day
 * with eight people in and a short window the list was simply cut off at the
 * bottom of the screen. `min-height: 0` is the whole fix; the rest makes it
 * behave once it scrolls.
 *
 * `overscroll-behavior` so reaching the end of the list does not start
 * scrolling whatever is behind — the body is locked while a card is open
 * (`body.card-open`), and chaining into a locked body reads as a dead scroll. */
.calendar .day.is-expanded .day-detail {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
}

@keyframes detail-in {
    from { opacity: 0; transform: translateY(4px); }
    to { opacity: 1; transform: none; }
}

/*
 * Front-loaded on purpose.
 *
 * The shape shrinks towards the square while the content stays at its full size
 * — that is the whole point, since scaling it is what squashes the text — so for
 * the middle of the close the content is larger than the box it is in. With a
 * flat ramp it was still at 40% opacity when the shape was half its size, and
 * that ghost spilling past the edges is the last thing left looking wrong.
 *
 * Clipping it to the shape is the other answer, and a worse one: a clip on the
 * card takes the surface's shadow with it, and the shadow is what makes the
 * card look lifted. So the content simply leads the shape out — most of the way
 * gone by the time the box is small enough for the difference to show, without
 * a hard cut that would leave an empty rectangle still travelling.
 */
@keyframes detail-out {
    from { opacity: 1; transform: none; }
    40% { opacity: 0.07; transform: translateY(2px); }
    to { opacity: 0; transform: translateY(4px); }
}

.card-close {
    position: absolute;
    top: 0.35rem;
    right: 0.35rem;
    border: none;
    background: none;
    color: var(--muted);
    font-size: 1.5rem;
    line-height: 1;
    padding: var(--space-1) var(--space-2);
    cursor: pointer;
    z-index: 1;
}

.card-close:hover { color: var(--text); }

.card-title { font-size: 1.0625rem; font-weight: 600; margin: 0 1.5rem 0.5rem 0; }
.card-subtitle {
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    font-weight: 600;
    color: var(--muted);
    margin: 1.15rem 0 0.5rem;
}
.card-line { margin: 0 0 0.6rem; font-size: 0.9375rem; }

/* Why a day is leave, in the words it was asked for in.
 *
 * One class for every screen that shows a reason — the day card, the attender
 * pill, the requests list and the approval queue — because it is the same
 * sentence in all four and four treatments of it is how they drift.
 *
 * It reads as a *quotation* rather than as interface chrome. The ruled-and-
 * indented version this replaces was right in principle and too quiet in
 * practice: a 2px `--border` rule against the card's own background is a
 * whisper, and next to it the same sentence in the attender pill was `--muted`
 * at the weight of an employee code. This is the one piece of human writing on
 * the screen, and everything around it — codes, statuses, dates — is the part
 * that should recede.
 *
 * Ground *and* edge, not colour alone: the palette's rule is that no fact is
 * carried by hue by itself, and a tinted block is still a block for somebody
 * who cannot see the tint.
 *
 * `pre-line` so a reason typed across three lines reads as three lines. The
 * textarea it came from accepts newlines and Django escapes the value, so the
 * alternative is a paragraph that silently runs them together. */
.reason-quote {
    margin: 0 0 0.6rem;
    padding: 0.5rem 0.7rem;
    border-left: 3px solid var(--reason-accent, var(--accent));
    border-radius: 0 0.5rem 0.5rem 0;
    background: var(--quote-bg);
    color: var(--text);
    font-size: 0.9375rem;
}

/* The sentence itself, and the **only** thing that gets `pre-line`.
 *
 * It sits on the leaf rather than on the block around it because the block also
 * contains the decision note, and the template's own indentation between the two
 * is newlines — which `pre-line` preserves. On the block it would have rendered
 * a couple of blank lines inside every quote that had a note. */
.reason-text {
    white-space: pre-line;
}

/* What the admin wrote back when they decided it — inside the same block as the
 * reason rather than under it, because the pair is the whole story of the day
 * and one without the other tells half of it.
 *
 * Smaller but **not** muted. It is a second person's writing, not a caption on
 * the first person's, and greying it out is what put it below notice. The rule
 * above it is what separates the two voices. */
.reason-note {
    margin: 0.5rem 0 0;
    padding-top: var(--space-2);
    border-top: 1px solid var(--border);
    color: var(--text);
    font-size: 0.875rem;
}

/* Plan §6.1's three buttons, and the same three when an admin is correcting.
 * Side by side and full width, because this is what nineteen people out of
 * twenty open the application to press. */
.card-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
    margin: 0.6rem 0 0.25rem;
}

.card-actions .day-action {
    flex: 1 1 6rem;
    padding: 0.7rem 0.4rem;
    font-weight: 500;
    font-size: 0.9375rem;
}

/* The morning/afternoon choice, revealed by the Half day button. Indented and
 * ruled, so it reads as a question that button asked rather than as three more
 * buttons that were there all along. */
.card-halves {
    margin-top: 0.4rem;
    padding-left: 0.6rem;
    border-left: 2px solid var(--border);
}

.card-halves .helptext { margin: 0; }

.card-actions .day-action.is-current {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--accent-text);
}

/* The reason a press asked for, revealed by that press.
 *
 * Ruled and indented like `.card-halves` above, and for the same reason: it is
 * a question a button asked, not a field that was always sitting there. The
 * accent edge rather than the plain border because, unlike the half chooser,
 * nothing is recorded until the confirm inside it is pressed — the card is
 * waiting on the reader, and the colour is what says so. */
.card-prompt {
    margin-top: 0.6rem;
    padding-left: 0.6rem;
    border-left: 2px solid var(--accent);
}

.card-prompt .card-line { margin-bottom: 0.4rem; }
.card-prompt textarea { max-width: none; }

/* The button that actually writes the day. Filled, because the row it sits in
 * also holds a Cancel, and two identical buttons either side of a decision is
 * the shape that gets the wrong one pressed. */
.card-actions .day-action.is-confirm {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--accent-text);
    font-weight: 600;
}

/* An admin's correction, marked out from ordinary marking because it writes
 * against somebody else's name and plan §5.5 makes the reason mandatory. */
.card-correct {
    border-left: 3px solid var(--accent);
    padding-left: var(--space-3);
    margin-top: var(--space-3);
}

.card-correct textarea { max-width: none; }

/* --------------------------------------------------------------------------
 * Who was in
 *
 * Pills rather than raw links. Each one is also the way into that person's
 * month, which is how another calendar is reached now that the dropdown is
 * gone — so it has to look like something you can press.
 * ------------------------------------------------------------------------ */

/* The request heading on the approvals queue: a face and the name of whoever
 * is asking. `margin-top: 0` was an inline style on the element, which is where
 * it could not be seen and could not be overridden. */
.request-who {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    margin-top: 0;
}

.attender-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
}

.attender-pill {
    display: inline-flex;
    /* A row now: the face, and everything else. What used to be here — a column
     * so that a reason could sit inside the same pill as the name it belongs to
     * — moved into `.attender-body`, because a face stacked above one line of a
     * two-line pill is not beside the name it is the face of. */
    flex-direction: row;
    align-items: center;
    gap: var(--space-2);
    padding: 0.35rem 0.7rem;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--bg);
    color: var(--text);
    text-decoration: none;
    font-size: 0.875rem;
    transition: border-color 120ms ease-out, background 120ms ease-out;
}

/* Everything but the face. The column the pill used to be, so a reason still
 * sits directly under the name it belongs to. A pill with no reason is a column
 * with one child, which is the single line it has always been. */
.attender-body {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-1);
    /* Or a long name refuses to wrap and pushes the pill past the card. */
    min-width: 0;
}

/* The name, the code and the status: what used to be the pill's own children,
 * now one line of it. Wrapping rather than shrinking, because a long name and a
 * status that reads "Half day — Morning" do not fit a phone side by side. */
.attender-head {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 0.4rem;
}

/* A pill that has grown a second line stops being a lozenge. 999px on a
 * two-line box bows the sides out around the text; a fixed radius keeps the
 * corners and lets the shape say "this is one block" — which is the whole
 * point of the reason being in here. */
.attender-pill.has-reason {
    border-radius: 0.9rem;
}

.attender-pill:hover {
    border-color: var(--accent);
    background: var(--surface);
    color: var(--text);
}

/* The status tints the pill's edge, so a half day is visible without reading
 * every label — and the label is still there, which is what the palette's
 * colour-blind rule requires.
 *
 * Each also sets `--reason-accent`, which the reason inside the pill inherits
 * for its own edge. A custom property rather than four more selectors: the two
 * edges then cannot disagree about what status the pill is, because there is
 * one value and the child reads it. */
.attender-pill.is-present { border-left: 3px solid var(--present); --reason-accent: var(--present); }
.attender-pill.is-half_day { border-left: 3px solid var(--half); --reason-accent: var(--half); }
.attender-pill.is-half_leave { border-left: 3px solid var(--half); --reason-accent: var(--half); }
.attender-pill.is-leave { border-left: 3px solid var(--leave); --reason-accent: var(--leave); }

.attender-name { font-weight: 500; }
.attender-code { color: var(--muted); font-size: 0.75rem; font-variant-numeric: tabular-nums; }
.attender-status { color: var(--muted); font-size: 0.75rem; }

/* Why somebody is away, on the pill belonging to the person it is about.
 *
 * Inside rather than beneath, and that is the point of it: a sentence sitting
 * between two pills reads as belonging to either, and on a day with six people
 * away whose reason it is, is the one thing the list has to be unambiguous
 * about. The pill's own border is what says so.
 *
 * A chip within the pill rather than a muted second line. It was `--muted` at
 * 0.8125rem — the weight of the employee code beside it — which put the one
 * human sentence on the row at the same level as its filing metadata. Now it
 * has the ground and the edge every other reason on the site has, taking its
 * accent from the status the pill already carries, so a half day and a full
 * day of leave stay distinguishable without reading either label.
 *
 * `pre-line` so a reason typed across three lines reads as three lines — the
 * textarea it came from accepts newlines, and Django escapes the value, so the
 * alternative is a sentence that silently runs them together. */
.attender-reason {
    padding: 0.3rem 0.5rem;
    border-left: 3px solid var(--reason-accent, var(--accent));
    border-radius: 0 0.4rem 0.4rem 0;
    background: var(--quote-bg);
    color: var(--text);
    font-size: 0.8125rem;
    max-width: 22rem;
    white-space: pre-line;
}

/* The office-wide export link, under the month's lock controls. Its own line
 * rather than beside the button, because it is a read and the button is a write
 * — a download sitting next to "Close this month" invites the wrong press. */
.month-export { margin: 0.75rem 0 0; font-size: 0.9375rem; }

/* Previous / next under a paged list — the audit trail and the inbox.
 *
 * Spread rather than clustered, so the two directions sit at the edges a thumb
 * reaches on a phone and the position sits between them where it is read rather
 * than aimed at. Absent entirely on a single page, so a small office never sees
 * it (`partials/pager.html` renders nothing there). */
.pager {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    margin: 1.25rem 0 0.5rem;
    font-size: 0.9375rem;
}

/* The end of the list, said by greying the direction rather than removing it:
 * a control that disappears takes the layout with it, and the reader has to
 * work out whether they have reached the end or mis-tapped. */
.pager-disabled { color: var(--muted); }
.pager-position { color: var(--muted); font-size: 0.875rem; }

/* The forms host, which the script moves into whichever square is open. */
.card-forms { margin-top: 0.5rem; }

/* The page does not scroll behind an open square. Without this a scroll inside
 * the panel that reaches its end starts moving the calendar underneath — and
 * the square is pinned to viewport coordinates, so the two would come apart. */
body.card-open { overflow: hidden; }

/* --------------------------------------------------------------------------
 * Reduced motion
 *
 * Every animation above is decoration on something that already works. Somebody
 * who has asked their system for less movement gets the same calendar with the
 * lift, the glow and the slide removed rather than a degraded one.
 * ------------------------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
    .calendar .day,
    .card-scrim {
        transition: none;
    }

    /*
     * `animation`, not `transition`.
     *
     * The card moved to keyframes, and this block was left saying `transition:
     * none` — which is now true of the card and changes nothing about it, so
     * somebody who had asked for less movement was still given the full zoom.
     * The surface has to be named too: it animates on its own element.
     */
    .calendar .day.is-expanding,
    .calendar .day.is-expanding::before,
    .calendar .day.is-collapsing,
    .calendar .day.is-collapsing::before {
        animation: none;
    }

    .calendar .day.is-collapsing .day-detail { animation: none; }

    .day-detail { animation: none; }

    .calendar .day.is-picked { animation: none; }
    .calendar .day.is-pressed { transform: none; }

    /* It still appears, it just stops sliding. A warning that animates in is the
     * one piece of motion here somebody cannot avoid by not touching anything. */
    .connection-toast { animation: none; }

    /* The spotlight jumps between steps rather than gliding. The glide is the
     * one thing in the tour that moves without being asked to, and it moves
     * across the whole screen. */
    .tour-ring { transition: none; }
}

/* --------------------------------------------------------------------------
 * Dashboard figures
 * ------------------------------------------------------------------------ */

/*
 * The dashboard's figures.
 *
 * `auto-fit` packed as many as would go and left the last one alone on its own
 * row — six and an orphan, at the width this page actually is. And because each
 * tile was only as tall as its own label, one that wrapped to two lines pushed
 * its number down and out of line with every other number on the screen.
 *
 * So: a wider minimum, so the row breaks somewhere deliberate; and the tile is
 * a flex column with the label pinned to the top and the value pushed to the
 * bottom, which makes the numbers line up whatever the labels do.
 */
.stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(10.5rem, 1fr));
    gap: var(--space-3);
    margin-bottom: var(--space-4);
}

.stat {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    min-height: 5.25rem;
    background: var(--surface);
    border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-1);
    padding: var(--space-4);
}

.stat .label {
    display: block;
    font-size: var(--text-xs);
    font-weight: var(--weight-semibold);
    line-height: var(--leading-tight);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--muted);
}

.stat .value {
    display: block;
    /* `auto` top margin: whatever the label did, the numbers share a baseline. */
    margin-top: auto;
    font-size: var(--text-2xl);
    font-weight: var(--weight-semibold);
    line-height: var(--leading-tight);
    letter-spacing: -0.01em;
    font-variant-numeric: tabular-nums;
}

.stat .value.small { font-size: var(--text-lg); }

.locked-note {
    display: inline-block;
    font-size: 0.8125rem;
    color: var(--muted);
    border: 1px dashed var(--border);
    border-radius: var(--radius);
    padding: 0.2rem 0.5rem;
}

/* --------------------------------------------------------------------------
 * Sign-in
 * ------------------------------------------------------------------------ */

.signin {
    max-width: 22rem;
    margin: 4rem auto;
}

/* --------------------------------------------------------------------------
 * Notifications (#62)
 *
 * The badge is server-rendered with the count as it stood when the page was
 * built, then refreshed by polling. It is therefore correct with scripting off
 * and merely stops updating — which is why `is-empty` is a class the stylesheet
 * interprets rather than a style the script sets.
 * ------------------------------------------------------------------------ */

.notif-link {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    color: var(--muted);
    text-decoration: none;
    font-size: 0.875rem;
}

.notif-link:hover { color: var(--accent); }

.notif-badge {
    display: inline-block;
    min-width: 1.4rem;
    padding: 0.05rem 0.4rem;
    border-radius: 999px;
    background: var(--accent);
    color: var(--accent-text);
    font-size: 0.75rem;
    font-weight: 600;
    text-align: center;
    font-variant-numeric: tabular-nums;
}

/* Zero unread. Kept in the layout rather than hidden, so the header does not
 * reflow every time the count crosses zero — a row of links that shifts sideways
 * while somebody is aiming at one is worse than a grey nought. */
.notif-badge.is-empty {
    background: transparent;
    color: var(--muted);
    border: 1px solid var(--border);
}

/* Before the first poll answers, and forever where scripting is off. `display:
 * none` rather than `visibility: hidden`: there is no number yet, so reserving
 * space for one would leave a gap that never fills. */
.notif-badge.is-hidden { display: none; }

/* The poll is failing, so the number is stale.
 *
 * Drained of colour rather than hidden or blanked. What it shows was true when
 * it arrived and is only no longer known to be current — hiding it would claim
 * more than that, and emptying it would move every link in the header sideways
 * for a problem that may last one poll. Same size, same position, less
 * insistence; the `title` the script sets says the rest.
 *
 * After `.is-empty` so a stale zero reads as stale rather than as empty: both
 * are single-class selectors, so this only wins by coming later. */
.notif-badge.is-stale {
    background: transparent;
    color: var(--muted);
    border: 1px dashed var(--border);
}

/* --------------------------------------------------------------------------
 * The connection toast
 *
 * What the page says when the badge poll stops working — the wording lives in
 * `partials/connection_toast.html`, because a `.po` catalogue cannot reach a
 * string inside a script.
 *
 * `position: fixed`, and that is a requirement rather than a preference: the
 * calendar is a fixed-height flex column filling the viewport exactly, so
 * anything in normal flow would take its height out of the week rows and
 * reintroduce the scroll that layout exists to remove.
 * ------------------------------------------------------------------------ */

.connection-toast {
    position: fixed;
    /* Bottom rather than top: the header is already the busiest part of the
     * screen, and on the calendar the top edge holds the month navigation. */
    bottom: 1rem;
    right: 1rem;
    /* Clear of everything the calendar puts on screen — the scrim is 20, an
     * expanded day is 30, and the account menu is 50. Being told you are signed
     * out matters more than the panel you are being told it over. */
    z-index: 60;

    display: flex;
    align-items: center;
    gap: var(--space-3);
    max-width: min(28rem, calc(100vw - 2rem));
    padding: 0.75rem 0.9rem;

    /* The warning palette from `.message-warning`, so this reads as the same
     * class of thing as a server-rendered warning rather than a new vocabulary. */
    background: var(--half-bg);
    border: 1px solid var(--half);
    border-radius: var(--radius);
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22);
    font-size: 0.875rem;

    animation: toast-in 180ms ease-out both;
}

@keyframes toast-in {
    from { opacity: 0; transform: translateY(0.5rem); }
    to { opacity: 1; transform: none; }
}

.connection-toast-text { margin: 0; }

/* The one thing that fixes the signed-out case, so it looks pressable. */
.connection-toast-action {
    color: var(--accent);
    font-weight: 600;
    white-space: nowrap;
}

.connection-toast-close {
    border: none;
    background: none;
    color: var(--muted);
    font-size: 1.25rem;
    line-height: 1;
    padding: 0.1rem 0.25rem;
    margin-left: auto;
    cursor: pointer;
}

.connection-toast-close:hover { color: var(--text); }

.notification-list { list-style: none; margin: 0; padding: 0; }

.notification {
    background: var(--surface);
    border: 1px solid var(--border);
    border-left: 4px solid var(--border);
    border-radius: var(--radius);
    /* No padding here any more. It moved onto the link inside, so the padded
     * area is part of the target rather than a dead margin around it — a row
     * whose edges do not respond is a row people click twice. */
    margin-bottom: 0.6rem;
    /* The link's own focus ring and hover fill are drawn inside these corners. */
    overflow: hidden;
}

/* The whole row, as one target.
 *
 * `display: block` and `color: inherit` are what stop this looking like a link
 * and start it looking like the row it replaced: a list of blue underlined
 * titles would read as a page of links rather than as an inbox. What marks it
 * as clickable is that it responds — the fill on hover, the ring on focus. */
.notification-link {
    display: block;
    padding: 0.9rem 1rem;
    color: inherit;
    text-decoration: none;
}

.notification-link:hover { background: var(--bg); }

/* Inset, because the row clips its own corners: an outline drawn outside the
 * link would be shaved off by the `overflow: hidden` above. */
.notification-link:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: -2px;
}

/* The title is the thing being clicked, so it is what underlines — not the
 * whole row, and not the timestamp. */
.notification-link:hover .notification-title { text-decoration: underline; }

/* Unread is marked by a coloured edge as well as by weight. Colour alone would
 * fail the same test the status palette above is built against: somebody who
 * cannot distinguish the two must still be able to tell which are new. */
.notification.is-unread {
    border-left-color: var(--accent);
    background: var(--surface);
}

.notification.is-unread .notification-title { font-weight: 600; }

.notification-head {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: var(--space-3);
    color: var(--muted);
    font-size: 0.75rem;
    margin-bottom: 0.35rem;
}

.notification-title { margin: 0; }

.notification-body {
    color: var(--muted);
    font-size: 0.875rem;
}

.notification-body p { margin: 0.4rem 0 0; }

/* One badge colour per notification kind, matching the status palette where the
 * meanings line up — a record an admin changed is the same concern as an absent
 * day, and an overdue request is the one thing on this list that is overdue. */
.badge-attendance_reminder { color: var(--half); }
.badge-request_submitted { color: var(--accent); }
.badge-request_decided { color: var(--present); }
.badge-record_changed { color: var(--absent); }
.badge-calendar_changed { color: var(--muted); }
.badge-monthly_summary { color: var(--leave); }
.badge-request_overdue { color: var(--absent); }

/* --------------------------------------------------------------------------
 * Notices — 403, 404, and the overview's call to action
 * ------------------------------------------------------------------------ */

.notice { max-width: 40rem; }
.notice h1 { margin-bottom: 0.75rem; }
.notice p { margin: 0 0 1rem; }

/* The 403 is reached by a real refusal, so it is coloured like one. The
 * overview's employment prompt is an invitation rather than a rejection, which
 * is why it gets the accent and not the red. */
.notice-denied { border-left: 4px solid var(--absent); }
.notice-error { border-left: 4px solid var(--absent); }
.notice-action { border-left: 4px solid var(--accent); }

/* --------------------------------------------------------------------------
 * Overview and employee list
 * ------------------------------------------------------------------------ */

/* A non-zero overdue count is the one number on the overview that means
 * something is wrong rather than merely being large. */
.stat .value.is-alarming { color: var(--absent); }

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

/* A former employee's row stays legible rather than being greyed to the point of
 * being hard to read — their history is still the answer to "who was AR-...?",
 * which is precisely why the row is there at all. */
.is-former td { color: var(--muted); }

.month-actions { display: flow-root; }
.month-actions form { margin-bottom: 0.5rem; }
.month-actions .helptext { margin: 0; }


/* --------------------------------------------------------------------------
 * The guided tour
 *
 * In this file rather than a second stylesheet, because the header at the top
 * of it states the position — one file, hand-written, no framework and no CDN —
 * and a second `<link>` would be the first exception to it.
 *
 * Sits above everything else on the page. The ladder in use elsewhere is 20 for
 * the card scrim, 30 for an expanded day, 50 for the user menu and 60 for the
 * connection toast; the tour has to cover all four, because it points at them.
 * ------------------------------------------------------------------------ */

/* The spotlight.
 *
 * One element, and the dimming is done by its own shadow: a spread large enough
 * to cover any viewport, so the "hole" is simply where the element is. The
 * alternative — four panels boxing the target in — needs four elements kept in
 * agreement and cannot follow a rounded corner.
 *
 * `pointer-events: none` is what makes the demonstration steps possible at all:
 * the control underneath stays clickable through the ring, so the tour can open
 * a real day card rather than a picture of one. */
.tour-ring {
    position: fixed;
    z-index: 70;
    border: 2px solid var(--tour-ring);
    border-radius: var(--radius);
    box-shadow: 0 0 0 9999px var(--tour-scrim);
    pointer-events: none;
    transition: top 0.18s ease, left 0.18s ease,
                width 0.18s ease, height 0.18s ease;
}

/* The panel. `position: fixed` like the ring, so both are in viewport
 * coordinates and neither needs a scroll offset applied to it — `tour.js`
 * repositions them together when the page moves underneath. */
.tour-panel {
    position: fixed;
    z-index: 71;
    width: min(24rem, calc(100vw - 2rem));
    padding: var(--space-4);
    background: var(--surface);
    color: var(--text);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    /* The one shadow in this file that is about depth rather than emphasis: the
     * panel floats over a dimmed page and needs to read as being in front of
     * it, not as a card printed on it. */
    box-shadow: 0 0.75rem 2rem rgba(0, 0, 0, 0.28);
}

/* The panel takes focus on every step so a screen reader reads the step's title
 * and body in order. That focus is programmatic, so it should not draw a ring —
 * but a keyboard user who tabs back to it should still see one. */
.tour-panel:focus { outline: none; }
.tour-panel:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.tour-progress {
    margin: 0 0 0.35rem;
    font-size: 0.75rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--muted);
}

.tour-title { margin: 0 0 0.4rem; font-size: 1.0625rem; }
.tour-body { margin: 0 0 0.65rem; }

/* Said in the panel because the submission guard is otherwise a surprise, and a
 * button that visibly does nothing reads as a broken page. */
.tour-guard-note {
    margin: 0 0 0.85rem;
    font-size: 0.8125rem;
    line-height: 1.45;
    color: var(--muted);
}

.tour-actions {
    display: flex;
    align-items: center;
    gap: 0.4rem;
}

/* Pushes Back and Next to the right, leaving Skip on its own at the left. The
 * distance is the point: Skip ends the tour for good, and it should not sit
 * under the thumb that is repeatedly pressing Next. */
.tour-actions-spacer { flex: 1 1 auto; }

.tour-skip {
    font-size: 0.8125rem;
    color: var(--muted);
}

.tour-skip:hover { color: var(--text); }

/* A hook for anything that needs to know the tour is up. Deliberately does
 * *not* set `overflow: hidden` the way `.card-open` does — the tour scrolls each
 * step's target into view, and a locked body is a body `scrollIntoView` cannot
 * move. */
body.tour-open { cursor: default; }

/* The header control. Shaped like the push toggle beside it, because it is the
 * same kind of thing: a small pill in a row of them. */
.tour-help {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    border-radius: 999px;
    padding: 0.3rem 0.75rem;
    font-size: 0.8125rem;
    background: var(--bg);
    color: var(--muted);
    white-space: nowrap;
}

.tour-help:hover { color: var(--text); }

/* An unfinished tour waiting to be picked back up. Filled like the current nav
 * pill, because it is the same kind of statement: there is something here. */
.tour-help.is-resume {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--accent-text);
}

/* --------------------------------------------------------------------------
 * The tour on a phone
 * ------------------------------------------------------------------------ */

@media (max-width: 40rem) {
    /* A bottom sheet rather than a floating panel. There is not enough width to
     * put a 24rem card beside anything, and a panel that covers the element it
     * is describing is worse than one that sits below the fold of it.
     *
     * `tour.js` clears the inline `top` and `left` it would otherwise have set
     * at this width, so these win without needing `!important`. */
    .tour-panel {
        top: auto;
        right: 0;
        bottom: 0;
        left: 0;
        width: auto;
        border-radius: var(--radius) var(--radius) 0 0;
        border-bottom: none;
    }

    /* The glyph alone. Same treatment the account chip's name gets at this
     * width — the control stays, its label goes. */
    .tour-help [data-tour-help-label] { display: none; }

    /* Except when there is a tour to resume, which is worth the words. */
    .tour-help.is-resume [data-tour-help-label] { display: inline; }
}


/* --------------------------------------------------------------------------
 * Trend charts (#21)
 *
 * Server-drawn SVG from `reporting.charts`. Bars in the present colour, because
 * what they measure is attendance; the month the page is about in the accent,
 * so it can be found in a row of twelve. An empty month draws only its label:
 * a 0% bar would say something the server did not (decision #34).
 * -------------------------------------------------------------------------- */
.trend-card h2 { margin-top: 0; }
.trend { margin: 0; }
.trend-chart { display: block; width: 100%; height: auto; overflow: visible; }
.trend-guide { stroke: var(--border); stroke-dasharray: 3 4; }
.trend-baseline { stroke: var(--border); }
.trend-bar rect { fill: var(--present); opacity: 0.55; transition: opacity 150ms ease; }
.trend-bar.is-current rect { fill: var(--accent); opacity: 1; }
.trend-chart a:hover .trend-bar rect,
.trend-chart a:focus-visible .trend-bar rect { opacity: 1; }
.trend-value { font-size: 11px; font-weight: 600; fill: var(--text); }
.trend-label { font-size: 11px; fill: var(--muted); }
.trend-bar.is-current .trend-label { fill: var(--accent); font-weight: 600; }

/* The small bar beside each percentage in the monthly report's table. */
.meter { width: 3.5rem; height: 0.4rem; margin-right: 0.4rem; vertical-align: middle; }
.meter-track { fill: var(--present-bg); }
.meter-fill { fill: var(--present); }

@media print {
    /* A printed chart keeps its bars solid: faded ink on paper reads as a
     * printer fault rather than as emphasis. */
    .trend-bar rect { opacity: 1; }
}


/* --------------------------------------------------------------------------
 * The colour key (`partials/status_key.html`)
 *
 * A swatch of each square's own fill beside its label, so the key matches what
 * is on the grid. Outlined, because the closed fill sits close to the page in
 * both themes and would otherwise vanish.
 * -------------------------------------------------------------------------- */
.status-key {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2) var(--space-4);
    margin: var(--space-3) 0;
    padding: 0;
    list-style: none;
    font-size: 0.85rem;
    color: var(--muted);
}
.status-key li { display: inline-flex; align-items: center; gap: var(--space-2); }
.status-swatch {
    width: 0.9rem;
    height: 0.9rem;
    border-radius: 4px;
    border: 1px solid var(--border);
}
.status-swatch.is-present { background: var(--present-bg); }
.status-swatch.is-half_day { background: var(--half-bg); }
.status-swatch.is-leave { background: var(--leave-bg); }
.status-swatch.is-absent { background: var(--absent-bg); }
.status-swatch.is-not_owed { background: var(--closed-bg); }

/* --------------------------------------------------------------------------
 * The year at a glance (#22)
 *
 * Twelve small months, three or four across on a desk and two on a phone. The
 * squares carry no numbers — at this size they would be unreadable — so the
 * date and status are in each square's tooltip and screen-reader text.
 * -------------------------------------------------------------------------- */
.year-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr));
    gap: var(--space-4);
}
.year-month {
    padding: var(--space-3);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
}
.year-month h2 {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    margin: 0 0 var(--space-2);
    font-size: 1rem;
}
.year-month h2 a { color: inherit; text-decoration: none; }
.year-month h2 a:hover { color: var(--accent); }
.year-month-figure { font-size: 0.85rem; font-weight: 600; color: var(--present); }
.year-month-grid { width: 100%; border-collapse: separate; border-spacing: 3px; }
.year-month-grid th {
    padding: 0;
    font-size: 0.7rem;
    font-weight: 500;
    color: var(--muted);
    text-align: center;
}
.year-day {
    padding: 0;
    height: 1.35rem;
    border-radius: 4px;
    background: color-mix(in srgb, var(--border) 45%, transparent);
}
.year-day.is-outside { background: none; }
.year-day.is-present { background: var(--present-bg); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--present) 35%, transparent); }
.year-day.is-half_day,
.year-day.is-half_leave { background: var(--half-bg); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--half) 35%, transparent); }
.year-day.is-leave { background: var(--leave-bg); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--leave) 35%, transparent); }
.year-day.is-absent { background: var(--absent-bg); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--absent) 35%, transparent); }
.year-day.is-not_owed { background: var(--closed-bg); }
.year-day.is-today { outline: 2px solid var(--accent); outline-offset: -1px; }

/* --------------------------------------------------------------------------
 * Printing, and "save as PDF" (#23)
 *
 * The monthly report's PDF is the browser's own print dialog, so these rules
 * are what the PDF looks like. The page keeps its figures and drops everything
 * that is for operating the site: the header, the navigation, the forms that
 * close and reopen a month, the download links, the toasts.
 *
 * Site-wide rather than scoped to the report, because Ctrl+P works on every
 * page and none of them is improved by printing its navigation.
 *
 * Always the light palette on paper, whatever the screen's theme: a dark page
 * printed is a cartridge of ink, and most browsers drop backgrounds anyway,
 * which would leave light text on white.
 * -------------------------------------------------------------------------- */
@media print {
    :root, :root[data-theme] {
        color-scheme: light;
        --bg: #ffffff;
        --surface: #ffffff;
        --text: #000000;
        --muted: #444444;
        --border: #bbbbbb;
        --link: #000000;
        --link-visited: #000000;
        /* The light theme's status and accent colours, so a page printed from
         * the dark theme does not carry its pale-on-dark fills onto paper. */
        --accent: #a5246d;
        --accent-text: #ffffff;
        --present: #1a7f4b;
        --present-bg: #e4f5ec;
        --half: #8a5a00;
        --half-bg: #fbf0d8;
        --leave: #6639ba;
        --leave-bg: #efe7fb;
        --absent: #c0392b;
        --absent-bg: #fceae9;
        --closed-bg: #f6e6ef;
    }
    body { background: #ffffff; color: #000000; }

    .site-header,
    .site-nav,
    .messages,
    .connection-toast,
    .tour,
    .month-actions,
    .month-export,
    [data-print],
    form { display: none !important; }

    /* The month navigation keeps its heading — a printout that does not say
     * which month it is about is not a report — and loses its two buttons. */
    .month-nav a,
    .month-nav button { visibility: hidden; }

    /* The column the screen reads in is too narrow for a table on A4. */
    .page { max-width: none; margin: 0; padding: 0; }
    .card { box-shadow: none; border: 1px solid var(--border); break-inside: avoid; }
    .trend-card { break-inside: avoid; }

    /* A wide table must not be clipped to a scroll box that paper cannot
     * scroll; it wraps instead, and a row is never split across two pages. */
    .table-scroll { overflow: visible; }
    table { width: 100%; font-size: 9pt; }
    thead { display: table-header-group; }
    tr { break-inside: avoid; }

    /* A link on paper is just its text. */
    a { color: inherit; text-decoration: none; }
}

/* --------------------------------------------------------------------------
 * The app's refresh, brought to the website (#29)
 *
 * Three pieces the Android client gained and the site had not: the day card's
 * buttons tinted in the colour of the status they record, a colour key under
 * the calendar (`partials/status_key.html`, styled above), and a ring around
 * the dashboard's month figure. All three reuse the status and accent tokens,
 * so both themes follow without a rule of their own.
 * -------------------------------------------------------------------------- */

/* Each button wears the colour its square will turn, so the choice is made by
 * colour as much as by word — the same palette as `.day.is-*` and the key. The
 * words stay: colour alone is not readable by everyone. */
.card-actions .day-action.is-status-present { background: var(--present-bg); border-color: var(--present); }
.card-actions .day-action.is-status-half_day { background: var(--half-bg); border-color: var(--half); }
.card-actions .day-action.is-status-leave { background: var(--leave-bg); border-color: var(--leave); }
.card-actions .day-action.is-status-absent { background: var(--absent-bg); border-color: var(--absent); }

/* The current status, filled solid in its own colour rather than the accent,
 * so "this is what is recorded" and "this is what it looks like" agree. Later
 * and more specific than the plain `.is-current` rule, which it overrides. */
.card-actions .day-action.is-status-present.is-current { background: var(--present); border-color: var(--present); color: var(--surface); }
.card-actions .day-action.is-status-half_day.is-current { background: var(--half); border-color: var(--half); color: var(--surface); }
.card-actions .day-action.is-status-leave.is-current { background: var(--leave); border-color: var(--leave); color: var(--surface); }
.card-actions .day-action.is-status-absent.is-current { background: var(--absent); border-color: var(--absent); color: var(--surface); }

/* The key under the calendar: inset from the glass like the heading above the
 * grid on a phone, and never allowed to take height from the week rows. */
.calendar + .status-key {
    flex: 0 0 auto;
    margin-inline: var(--space-4);
    justify-content: center;
}

/* The dashboard ring, small, in the card's top corner. Beside the figure it
 * pushed a two-decimal percentage into the card's edge at the narrowest the
 * stats grid allows; up here it costs the figure no width at all, and the
 * label keeps clear of it. */
.stat-ring { position: relative; }
.stat-ring .label { padding-right: 2.75rem; }
.ring {
    position: absolute;
    top: var(--space-3);
    right: var(--space-3);
    width: 2.25rem;
    height: 2.25rem;
    /* Start the arc at twelve o'clock, as a clock face and the app's ring do. */
    transform: rotate(-90deg);
}
.ring circle { fill: none; stroke-width: 5; }
.ring-track { stroke: color-mix(in srgb, var(--accent) 16%, transparent); }
.ring-fill {
    stroke: var(--accent);
    stroke-linecap: round;
}
@media (prefers-reduced-motion: no-preference) {
    /* Drawn in on load, the one flourish: the dash grows from nothing. */
    .ring-fill { animation: ring-draw 700ms var(--ease) both; }
    @keyframes ring-draw { from { stroke-dasharray: 0 100; } }
}

/* --------------------------------------------------------------------------
 * A quieter header for an admin
 *
 * An admin's header used to be eleven links wrapping onto a second row, then
 * four pills — the guide, the theme, the alerts and the account. The office's
 * six screens are now one "Office" menu, and the theme and alerts live in the
 * account menu, where things set once and then left alone belong. What stays
 * in the bar is what is used every day.
 * ------------------------------------------------------------------------ */

/* The Office menu. Its summary is drawn exactly like a nav link — same padding,
 * same hover, same filled pill when you are on one of its screens — so the bar
 * reads as one row of destinations, one of which happens to open. */
.nav-menu { position: relative; }
.nav-menu > summary {
    list-style: none;
    cursor: pointer;
    color: var(--muted);
    padding: var(--space-2) var(--space-3);
    border: 1px solid transparent;
    border-radius: var(--radius-full);
    font-size: var(--text-sm);
    font-weight: var(--weight-medium);
    line-height: var(--leading-tight);
    white-space: nowrap;
    transition: background var(--dur-fast) ease-out,
                color var(--dur-fast) ease-out;
}
/* The browser's own disclosure triangle, in both engines' spellings; the
 * caret after the word replaces it. */
.nav-menu > summary::-webkit-details-marker { display: none; }
.nav-menu > summary:hover,
.nav-menu[open] > summary {
    color: var(--text);
    background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.nav-menu > summary.is-here,
.nav-menu > summary.is-here:hover {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--accent-text);
    font-weight: var(--weight-semibold);
}
.nav-menu-caret { margin-left: 0.3rem; font-size: 0.75em; }

/* The panel: the account menu's look, anchored under the summary's left edge. */
.nav-menu-panel {
    position: absolute;
    top: calc(100% + 0.4rem);
    left: 0;
    min-width: 11rem;
    z-index: 50;
    display: flex;
    flex-direction: column;
    gap: 2px;
    padding: 0.3rem;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
}
/* Rows rather than pills inside a panel; the current screen keeps its fill. */
.nav-menu-panel a { border-radius: calc(var(--radius) - 2px); }

/* Exactly one of the two renderings exists at any width (see `base.html`):
 * the menu on a wide screen, the flat links wherever the nav is the
 * sideways-scrolling row below. */
.nav-flat { display: none; }
@media (max-width: 64rem) {
    .nav-menu { display: none; }
    /* `contents`, so the links are items of the scrolling row itself and take
     * its gap, exactly as they did before the menu existed. */
    .nav-flat { display: contents; }
}

/* The theme row in the account menu: its label on the left, the two buttons
 * on the right, the same height and inset as the menu's other rows. */
.user-menu-setting {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    padding: 0.35rem 0.65rem;
    font-size: 0.875rem;
}
.user-menu-setting-label { color: var(--muted); }

/* The alerts button, as a menu row rather than a pill of its own. */
.user-menu-panel .push-toggle {
    width: 100%;
    border: none;
    justify-content: flex-start;
    border-radius: calc(var(--radius) - 2px);
    padding: 0.5rem 0.65rem;
    font-size: 0.875rem;
    background: none;
}
.user-menu-panel .push-toggle:hover { background: var(--bg); }
.user-menu-panel .push-toggle[data-push-state="on"] {
    background: color-mix(in srgb, var(--accent) 12%, transparent);
    color: var(--text);
}

/* Between a phone and a wide screen, the Guide button drops to its "?" as it
 * does on a phone (a resumable tour keeps its words), and the header's gaps
 * tighten. Without both, an admin's "Office" menu was the one item left over
 * for a second row at laptop widths. */
@media (max-width: 75rem) {
    .site-header { column-gap: var(--space-4); }
    .tour-help [data-tour-help-label] { display: none; }
    .tour-help.is-resume [data-tour-help-label] { display: inline; }
}

/* Tablets and narrow laptop windows get the phone's navigation: the links as
 * one sideways-scrolling row under the brand, instead of wrapping. Between the
 * phone breakpoint and this one the nav used to wrap onto as many as six rows
 * — a header taller than the calendar's first two weeks. The same rules as the
 * phone's own, which stay where they are for the phone-only tightening. */
@media (max-width: 64rem) {
    .site-nav {
        order: 5;
        flex-basis: 100%;
        flex-wrap: nowrap;
        overflow-x: auto;
        scrollbar-width: none;
        -webkit-overflow-scrolling: touch;
    }
    .site-nav::-webkit-scrollbar { display: none; }
    /* With the nav on its own row, the controls share the brand's row and sit
     * at its right-hand end, as they do on a phone. */
    .brand { flex: 1; }
}
