/*
 * Application shell.  (Plan section 12)
 *
 * The look is meant to read as an instrument rather than a website: a dark
 * graphite housing, hairline separation instead of drop shadows, one warm accent
 * that means "act here", and the numbers set in a monospaced face because on
 * every screen in this product the numbers are the thing. Somebody has this open
 * for eight hours while they dispatch crews and chase invoices. It should sit
 * still and be legible, not perform.
 *
 * Dark is the primary theme and is authored first, on bare :root. Light is the
 * variant and is written twice below it. That is the inverse of the usual
 * arrangement and it is deliberate: the theme that gets the care should be the
 * one that isn't assembled from two half-definitions.
 *
 * Light-or-dark is one axis. Colour is a second, independent one: six palettes,
 * each authored in both modes, selected by data-app-palette on <html>. The two
 * do not interact -- the neutral ramps below read their values out of
 * --pal-*-dark and --pal-*-light, and the palette blocks near the end of the
 * token section set both halves at once. See "the palettes" below.
 *
 * Dark mode has three states, not two: an explicit choice sets data-bs-theme on
 * <html>, and the default "system" sets nothing and leaves prefers-color-scheme
 * to decide. Reading the four cases against the blocks below:
 *
 *   no attribute + system dark   -> :root (dark). The light media block is
 *                                   scoped :not([data-bs-theme="dark"]) but its
 *                                   media query does not match.        -> dark
 *   no attribute + system light  -> light media block matches.         -> light
 *   attribute="dark"             -> :root, and the light media block excludes
 *                                   it by selector even if the OS is light.
 *   attribute="light"            -> :root[data-bs-theme="light"] wins.
 *
 * Every colour is a custom property and nothing below the token block hardcodes
 * one. That is not tidiness -- it is the whole mechanism behind the dark theme
 * and the per-tenant theme designer that lets a company set its own palette.
 * Both work by rewriting the same dozen variables, one from a media query and a
 * data attribute, the other from a <style> block the tenant's saved theme
 * renders into <head>. A single literal #hex in a component rule is a colour
 * that survives both and looks wrong in one of them.
 */

/* ----------------------------------------------------------------- fonts */

/*
 * Self-hosted, because half the people using this are in a truck on a phone
 * tethered to one bar of signal, and a webfont CDN is a third-party DNS lookup
 * and TLS handshake standing between them and the job list. ~64KB for all three,
 * latin subset, swap so text paints immediately in the fallback.
 *
 * Archivo for the interface: a grotesque with a tall x-height that stays sharp
 * at 13px, which is the size most of this product actually renders at. Plex
 * Mono for money, counts and timestamps -- columns of figures only line up if
 * the digits are the same width, and a proportional font quietly ruins every
 * total on the page.
 *
 * Both are SIL OFL 1.1, which is what makes self-hosting them legal -- and which
 * requires the notice and licence to ship alongside the files. That is
 * frontend/web/fonts/LICENSE.md, and it is a condition of use, not a courtesy:
 * adding a font here without adding its notice there puts the product out of
 * compliance.
 */
@font-face {
    font-family: "Archivo";
    src: url("../fonts/archivo-var-latin.woff2") format("woff2");
    font-weight: 400 700;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "Plex Mono";
    src: url("../fonts/plexmono-400-latin.woff2") format("woff2");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "Plex Mono";
    src: url("../fonts/plexmono-500-latin.woff2") format("woff2");
    font-weight: 500;
    font-style: normal;
    font-display: swap;
}

/* ---------------------------------------------------------------- tokens */

/*
 * The tenant pair, in a cascade layer, and the layer is the entire point.
 *
 * TenantTheme::styleBlock() renders its :root into <head> *before* this
 * stylesheet loads. Same selector, same specificity, so plain source order gives
 * the win to whichever came last -- this file -- and a tenant who set their
 * accent to red would have got the built-in green. It has been that way and gone
 * unnoticed only because the default brand colour and the fallback below are two
 * hex digits apart.
 *
 * An unlayered declaration beats a layered one no matter where it appears, so
 * putting the fallbacks in a layer inverts that: the tenant block wins because
 * it is unlayered, and these values apply only on the pages that render no
 * tenant block at all. Nothing else in this file is layered.
 */
@layer fallback {
    :root {
        --app-accent:      #2f5d3f;  /* matches common/config/brand.php */
        --app-accent-2:    #c8862a;
        --app-accent-text: #ffffff;
        --app-accent-soft: #14241a;
    }

    :root[data-bs-theme="light"] { --app-accent-soft: #e6ece8; }

    @media (prefers-color-scheme: light) {
        :root:not([data-bs-theme="dark"]) { --app-accent-soft: #e6ece8; }
    }
}

:root {
    /*
     * A green-shifted graphite ramp, not the usual blue-grey. Every near-black
     * here sits at roughly the accent's hue with the chroma taken almost all the
     * way out, so the darks read as relatives of the brand colour rather than as
     * a stock slate someone dropped a green button onto. It is a small
     * difference per swatch and the whole character of the theme in aggregate.
     *
     * The steps between them are spaced in CIE L*, not by eye and not by even
     * hex intervals, because lightness is not linear down here: the gap from
     * #0c to #13 looks like nothing while the same arithmetic gap higher up the
     * ramp is obvious. Measured, this ladder runs roughly
     *
     *     sidebar 2.5 -> page 4 -> surface 11 -> surface-2 16 -> border 22
     *
     * and the page-to-surface step is the one that matters. It was under four
     * points, which is at the edge of perceptible, and cards dissolved into the
     * background on every screen: the border was doing all the work and a card
     * read as a rectangle drawn on the page rather than a panel sitting above
     * it. Seven points is a card you can see without looking for it, reached by
     * lowering the page as much as by raising the surface -- so the shell got
     * deeper and more deliberate rather than greyer.
     *
     * Every value below is a var() with the measured forest hex as its fallback,
     * and the fallback is what makes the indirection free. A palette block near
     * the bottom of this file sets --pal-*-dark and --pal-*-light; forest has no
     * block at all, because "no palette selected" and "forest selected" should
     * resolve identically and the shortest way to guarantee that is for forest
     * to be the fallback rather than a fifth block that could drift from it.
     *
     * Done this way rather than as one :root[data-app-palette="x"] block per
     * palette carrying finished --app-* values, which was the obvious shape and
     * is wrong: [data-app-palette] and [data-bs-theme] have identical
     * specificity, so a palette block would beat the light overrides below it by
     * source order alone and every palette would need :not([data-bs-theme=...])
     * guards to stay out of the other mode's way. Naming the two modes
     * separately keeps all of that in one place -- here -- where the cascade is
     * already understood.
     */
    --app-bg:            var(--pal-bg-dark,          #0a100d);
    --app-surface:       var(--pal-surface-dark,     #17201c);
    --app-surface-2:     var(--pal-surface-2-dark,   #1f2a25);
    --app-border:        var(--pal-border-dark,      #2b3830);
    --app-text:          var(--pal-text-dark,        #e6ece8);
    --app-text-muted:    var(--pal-text-muted-dark,  #93a49b);

    /* Faint is the quietest text in the product, and it was quiet enough to
       fail. #66776e scored 3.13:1 on --app-surface-2 and 3.52:1 on
       --app-surface -- under AA on both, and this token is not decorative: it
       carries .faint, .sub, every th, every dt, and .form-text, so the failure
       covered every table header and every helper caption in the application.
       #84958b scores 4.70 / 5.28 / 6.09 against surface-2, surface and bg.
       Chosen rather than simply reusing --app-text-muted because faint has to
       stay audibly quieter than muted or the hierarchy those two express stops
       existing; muted sits at 5.67 on the same worst background, so the gap
       survives the lift. Measured through a canvas, not eyeballed. */
    --app-text-faint:    var(--pal-text-faint-dark,  #84958b);

    /* Deliberately not palette-driven, in either mode. Green means the number
       went the right way and red means it did not; that is a convention people
       arrive already knowing, and it survives a change of skin. A palette that
       recoloured its own "paid" pill would be asking every user to relearn the
       one part of the interface they read without looking. */
    --app-positive:      #56d98a;
    --app-negative:      #f8807c;
    --app-warning:       #f0b429;
    --app-info:          #48b8ec;

    /* The sidebar stays the floor of the ladder. It is the frame, so it sits
       under the page rather than on it, and it moved down with the page to keep
       that relationship rather than collapsing into it. */
    --app-sidebar-bg:    var(--pal-sidebar-bg-dark,    #060a08);
    --app-sidebar-text:  var(--pal-sidebar-text-dark,  #9fb3a8);
    --app-sidebar-hover: var(--pal-sidebar-hover-dark, #141d19);
    /* 3.71:1 on --app-sidebar-bg before this. The sidebar section headings are
       10px and letter-spaced, which is the worst combination to be low-contrast
       at, and they are the only labels telling you which part of the product a
       nav item belongs to. #71847a scores 5.01 and is still clearly quieter
       than --app-sidebar-text. */
    --app-sidebar-label: var(--pal-sidebar-label-dark, #71847a);

    /* What a guide dims the page with. A token rather than a literal because
       the same alpha over a light and a dark surface are not the same amount
       of dimming -- .55 black over #0a100d is a black screen. */
    --app-scrim:         rgba(0, 0, 0, .66);

    /*
     * Elevation in the dark theme is a light edge, not a shadow. A shadow needs
     * something lighter behind it to fall onto; on a near-black page it is
     * invisible and every card floats without explanation. A 1px top highlight
     * says "this surface faces up" using the one cue that still works.
     *
     * It is a finishing cue, not the separation itself. When the surface was
     * four points of lightness off the page this highlight was being asked to
     * carry the whole distinction, at which point turning it up only produced a
     * bright line on a card you still could not see. The ramp above does the
     * separating now, so this can go back to describing an edge.
     */
    --app-lift:          inset 0 1px 0 rgba(255, 255, 255, .06);
    --app-shadow:        0 1px 2px rgba(0, 0, 0, .5);
    --app-shadow-lg:     0 18px 44px rgba(0, 0, 0, .55);
    --app-shadow-color:  rgba(0, 0, 0, .55);
    --app-ring:          0 0 0 3px var(--app-accent-soft);

    /* ---- invariants: identical in both themes ---------------------------- */

    --app-radius:        8px;
    --app-radius-sm:     5px;
    --app-radius-lg:     13px;

    --app-font:          "Archivo", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
    --app-mono:          "Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;

    /*
     * Two gradients, and they do different jobs.
     *
     * --app-grad-accent is built from the accent alone: a lift, the colour
     * itself, then a deepened edge. Because it is derived with color-mix it
     * works for any hue a tenant picks, which a hand-tuned pair of hex stops
     * would not.
     *
     * --app-grad-duo is the two brand colours together. It is reserved for
     * things one to three pixels tall -- the sidebar edge, the active nav rail,
     * a progress fill. Green to bronze across a thin rail is handsome; across a
     * button face it passes through olive and looks like a mistake.
     */
    --app-accent-lift:   color-mix(in oklab, var(--app-accent) 78%, #ffffff);
    --app-accent-deep:   color-mix(in oklab, var(--app-accent) 86%, #000000);
    --app-grad-accent:   linear-gradient(140deg, var(--app-accent-lift) 0%, var(--app-accent) 58%, var(--app-accent-deep) 100%);
    --app-grad-duo:      linear-gradient(90deg, var(--app-accent) 0%, var(--app-accent-2) 100%);

    /*
     * The accent as INK, which is not the same colour as the accent as paint.
     *
     * A brand colour is chosen against white. The default #2f5d3f is a good
     * green on a page and 1.95:1 against --app-surface-2 in dark mode -- so
     * every link in the product was failing WCAG AA on the theme that is the
     * house preference, and failing invisibly, because links are underlined and
     * the eye fills in a word it can half-see. Measured in a browser through a
     * canvas, not eyeballed and not read off getComputedStyle, which hands back
     * an unresolved oklab() for a color-mix and will quietly give you numbers
     * that are the same for every mix you try. The raw accent scores 2.62 on
     * --app-sidebar-bg, 2.52 on --app-bg, 2.19 on --app-surface, 1.95 on
     * --app-surface-2. Every text background in the theme, and it fails on all
     * of them.
     *
     * 62% is the first mix that clears 4.5:1 on the worst of those, and it
     * clears by 0.20 -- close enough that a tenant palette one shade lighter
     * puts it back under. 48% scores 6.27 there and 8.13 on --app-bg, which is
     * AA everywhere with room and AAA on the two backgrounds most body text
     * actually sits on. Mixing toward white also fixes the general case rather
     * than this one green: a darker brand accent gets lifted further, and a
     * light one was never in trouble here.
     *
     * Only for text. Borders and fills keep the raw accent -- they are judged
     * against 3:1 as UI components, and paling them out is what makes an
     * interface look washed.
     */
    --app-accent-ink:    color-mix(in oklab, var(--app-accent) 48%, #ffffff);

    /* One duration and one curve, referenced everywhere, so the interface has a
       single tempo instead of nine hand-picked ones. */
    --app-ease:          cubic-bezier(.22, .61, .36, 1);
    --app-fast:          .12s;

    color-scheme: dark;
}

/* ---- the light variant ---------------------------------------------------
 *
 * Written twice, once per state, because CSS has no way to share a declaration
 * block between a media query and a selector. Keep them identical: a colour that
 * exists in only one of these is a colour that appears or vanishes when somebody
 * touches the theme toggle.
 */

@media (prefers-color-scheme: light) {
    :root:not([data-bs-theme="dark"]) {
        /* Back to the brand colour itself: on white the accent is what the
           tenant picked it to be, and the lift that rescues it on black would
           only wash it out here. */
        --app-accent-ink:    var(--app-accent);

        --app-bg:            var(--pal-bg-light,           #f2f5f3);
        --app-surface:       var(--pal-surface-light,      #ffffff);
        --app-surface-2:     var(--pal-surface-2-light,    #f6f8f7);
        --app-border:        var(--pal-border-light,       #dde5e0);
        --app-text:          var(--pal-text-light,         #17211c);
        /* Both lifted for contrast; see the dark block for the measurements.
           Muted moved as well as faint because the board shades its weekend
           columns to #e6ece8, and the old #5f6f66 scored 4.43 on that cell --
           the day names above Saturday and Sunday were the only text in the
           light theme failing AA on a background the theme itself paints.
           #59685f scores 4.91 there. Faint at #64736b scores 4.55 on the
           worst real background it lands on, and stays lighter than muted. */
        --app-text-muted:    var(--pal-text-muted-light,   #59685f);
        --app-text-faint:    var(--pal-text-faint-light,   #64736b);

        --app-positive:      #16794a;
        --app-negative:      #b8342f;
        --app-warning:       #9a6207;
        --app-info:          #0e6ea3;

        --app-sidebar-bg:    var(--pal-sidebar-bg-light,    #101a15);
        --app-sidebar-text:  var(--pal-sidebar-text-light,  #b6c7bd);
        --app-sidebar-hover: var(--pal-sidebar-hover-light, #1a2a22);
        /* The sidebar stays dark in the light theme, so this is measured
           against #101a15, not against the page. 4.41 before, 4.92 now. */
        --app-sidebar-label: var(--pal-sidebar-label-light, #748c80);

        --app-scrim:         rgba(15, 23, 42, .5);

        --app-lift:          inset 0 1px 0 rgba(255, 255, 255, .9);
        --app-shadow:        0 1px 2px rgba(var(--pal-shadow-rgb, 19, 32, 26), .05), 0 1px 3px rgba(var(--pal-shadow-rgb, 19, 32, 26), .04);
        --app-shadow-lg:     0 18px 44px rgba(var(--pal-shadow-rgb, 19, 32, 26), .12);
        --app-shadow-color:  rgba(var(--pal-shadow-rgb, 19, 32, 26), .16);

        color-scheme: light;
    }
}

:root[data-bs-theme="light"] {
    /* Back to the brand colour itself: on white the accent is what the
       tenant picked it to be, and the lift that rescues it on black would
       only wash it out here. */
    --app-accent-ink:    var(--app-accent);

    --app-bg:            var(--pal-bg-light,           #f2f5f3);
    --app-surface:       var(--pal-surface-light,      #ffffff);
    --app-surface-2:     var(--pal-surface-2-light,    #f6f8f7);
    --app-border:        var(--pal-border-light,       #dde5e0);
    --app-text:          var(--pal-text-light,         #17211c);
    /* Both lifted for contrast; see the dark block for the measurements.
       Muted moved as well as faint because the board shades its weekend
       columns to #e6ece8, and the old #5f6f66 scored 4.43 on that cell --
       the day names above Saturday and Sunday were the only text in the
       light theme failing AA on a background the theme itself paints.
       #59685f scores 4.91 there. Faint at #64736b scores 4.55 on the
       worst real background it lands on, and stays lighter than muted. */
    --app-text-muted:    var(--pal-text-muted-light,   #59685f);
    --app-text-faint:    var(--pal-text-faint-light,   #64736b);

    --app-positive:      #16794a;
    --app-negative:      #b8342f;
    --app-warning:       #9a6207;
    --app-info:          #0e6ea3;

    --app-sidebar-bg:    var(--pal-sidebar-bg-light,    #101a15);
    --app-sidebar-text:  var(--pal-sidebar-text-light,  #b6c7bd);
    --app-sidebar-hover: var(--pal-sidebar-hover-light, #1a2a22);
    /* The sidebar stays dark in the light theme, so this is measured
       against #101a15, not against the page. 4.41 before, 4.92 now. */
    --app-sidebar-label: var(--pal-sidebar-label-light, #748c80);

    --app-scrim:         rgba(15, 23, 42, .5);

    --app-lift:          inset 0 1px 0 rgba(255, 255, 255, .9);
    --app-shadow:        0 1px 2px rgba(var(--pal-shadow-rgb, 19, 32, 26), .05), 0 1px 3px rgba(var(--pal-shadow-rgb, 19, 32, 26), .04);
    --app-shadow-lg:     0 18px 44px rgba(var(--pal-shadow-rgb, 19, 32, 26), .12);
    --app-shadow-color:  rgba(var(--pal-shadow-rgb, 19, 32, 26), .16);

    color-scheme: light;
}

/* ---- quiet text on a tinted surface ---------------------------------------
 *
 * --app-text-faint is calibrated against the three neutral backgrounds: page,
 * surface, surface-2. --app-accent-soft is none of those. It is a tint, so it
 * is darker than all three, and in the light theme faint lands on it at 4.17:1
 * -- under AA, on the one slot the scheduler has already selected for you.
 *
 * Fixed here rather than by lightening the tint, because the tint is doing a
 * job: it is the whole visual difference between the chosen slot and the
 * eleven below it, between today and the rest of the week, between a read row
 * and an unread one. Lightening it until the quietest text in the product
 * cleared would have left it a shade off --app-surface-2 and the selection
 * would have stopped reading as a selection. Six palettes share one L* ladder,
 * so it would also have been six retunes rather than one.
 *
 * Also not fixed by lifting the token globally: faint already sits at 4.55 on
 * its worst neutral background and muted at 4.91, so there is no value that
 * clears the tint and still sounds quieter than muted.
 *
 * So the quietest step is simply unavailable on a tinted surface, and faint
 * resolves to muted there -- 4.91:1, and the only place in the product where
 * the two are the same colour. Set as a variable rather than a colour so that
 * anything nested inside inherits it without knowing this rule exists. */
.slot:has(input:checked),
.pal-opt:has(input:checked),
.board-head.is-today,
.row-unread td,
.dropzone.is-over,
body.help-open #help-toggle {
    --app-text-faint: var(--app-text-muted);
}

/* ---- the palettes ---------------------------------------------------------
 *
 * Six colour schemes, chosen on the profile and stamped onto <html> as
 * data-app-palette by common\components\Appearance. Each block below names both
 * modes at once; which half gets used is decided by the light overrides above,
 * so a palette is a choice of colour and never a choice of light-or-dark.
 *
 * They are not six independent designs. Forest -- the original -- was measured
 * in CIE LCh, and the other five were generated by holding its L* ladder and its
 * chroma fixed and rotating hue alone, then pulling chroma back only where the
 * rotation left sRGB. That is why the page-to-surface step is seven points of
 * lightness in all of them, and why a dense table is exactly as readable in
 * Orchid as in the one it was tuned against. Picking hues by eye would have
 * meant re-tuning that ladder five times, and some of them would not have got
 * it.
 *
 * Forest has no block. It is the fallback in every var() above, so "nobody has
 * chosen" and "chose forest" resolve to the same bytes rather than to two lists
 * that have to be kept in agreement.
 *
 * --pal-shadow-rgb is bare channels, not a colour, because it is interpolated
 * into rgba() at four different alphas. Light-mode shadows are tinted with the
 * palette's own darkest neutral: a neutral-black shadow under a warm card reads
 * as dirt on the page.
 */

/* Cool teal. The lowest-chroma of the coloured set -- reads as a grey that
   happens to be cold rather than as a blue interface. */
:root[data-app-palette="tidal"] {
    --pal-bg-dark:            #081011;
    --pal-surface-dark:       #142022;
    --pal-surface-2-dark:     #1c2a2d;
    --pal-border-dark:        #25383c;
    --pal-text-dark:          #e4ecee;
    --pal-text-muted-dark:    #8ea4a8;
    --pal-text-faint-dark:    #61777b;
    --pal-sidebar-bg-dark:    #050a0b;
    --pal-sidebar-text-dark:  #99b3b8;
    --pal-sidebar-hover-dark: #111d1f;
    --pal-sidebar-label-dark: #566f74;

    --pal-bg-light:            #f1f5f6;
    --pal-surface-light:       #ffffff;
    --pal-surface-2-light:     #f6f8f8;
    --pal-border-light:        #dae5e7;
    --pal-text-light:          #132123;
    --pal-text-muted-light:    #596f73;
    --pal-text-faint-light:    #879a9e;
    --pal-sidebar-bg-light:    #0c1a1c;
    --pal-sidebar-text-light:  #b0c7cb;
    --pal-sidebar-hover-light: #132a2e;
    --pal-sidebar-label-light: #64848a;

    --pal-shadow-rgb: 19, 33, 35;
}

/* Slate blue. The most conventional of the six and the one to reach for when a
   screen is going to be photographed for a slide. */
:root[data-app-palette="harbor"] {
    --pal-bg-dark:            #0d0f13;
    --pal-surface-dark:       #1b1e25;
    --pal-surface-2-dark:     #242830;
    --pal-border-dark:        #2f3541;
    --pal-text-dark:          #e8ebf0;
    --pal-text-muted-dark:    #9aa0ae;
    --pal-text-faint-dark:    #6d7382;
    --pal-sidebar-bg-dark:    #08090c;
    --pal-sidebar-text-dark:  #a7aec0;
    --pal-sidebar-hover-dark: #181b22;
    --pal-sidebar-label-dark: #636b7b;

    --pal-bg-light:            #f3f4f7;
    --pal-surface-light:       #ffffff;
    --pal-surface-2-light:     #f7f8f9;
    --pal-border-light:        #e0e3ea;
    --pal-text-light:          #1b1f27;
    --pal-text-muted-light:    #656b79;
    --pal-text-faint-light:    #9197a3;
    --pal-sidebar-bg-light:    #141820;
    --pal-sidebar-text-light:  #bdc3d2;
    --pal-sidebar-hover-light: #202734;
    --pal-sidebar-label-light: #767f93;

    --pal-shadow-rgb: 27, 31, 39;
}

/* Warm plum. The darkest-feeling of the six despite measuring the same, because
   the hue sits where the eye expects shadow. */
:root[data-app-palette="orchid"] {
    --pal-bg-dark:            #130d10;
    --pal-surface-dark:       #241c20;
    --pal-surface-2-dark:     #2f252a;
    --pal-border-dark:        #3f3138;
    --pal-text-dark:          #f0e9ec;
    --pal-text-muted-dark:    #ad9ba4;
    --pal-text-faint-dark:    #806e77;
    --pal-sidebar-bg-dark:    #0c080a;
    --pal-sidebar-text-dark:  #bea9b3;
    --pal-sidebar-hover-dark: #21191d;
    --pal-sidebar-label-dark: #79656f;

    --pal-bg-light:            #f7f3f5;
    --pal-surface-light:       #ffffff;
    --pal-surface-2-light:     #f9f7f8;
    --pal-border-light:        #eae1e5;
    --pal-text-light:          #261c21;
    --pal-text-muted-light:    #77676f;
    --pal-text-faint-light:    #a2929a;
    --pal-sidebar-bg-light:    #1f151a;
    --pal-sidebar-text-light:  #d0bec7;
    --pal-sidebar-hover-light: #32222a;
    --pal-sidebar-label-light: #907884;

    --pal-shadow-rgb: 38, 28, 33;
}

/* Warm brown. Lamplit rather than orange -- the chroma is the same as every
   other palette here, which at this hue reads as wood instead of as a warning. */
:root[data-app-palette="ember"] {
    --pal-bg-dark:            #130e0a;
    --pal-surface-dark:       #241d18;
    --pal-surface-2-dark:     #2f2620;
    --pal-border-dark:        #3f332b;
    --pal-text-dark:          #f0e9e6;
    --pal-text-muted-dark:    #ad9d93;
    --pal-text-faint-dark:    #807067;
    --pal-sidebar-bg-dark:    #0c0806;
    --pal-sidebar-text-dark:  #bdab9f;
    --pal-sidebar-hover-dark: #211a15;
    --pal-sidebar-label-dark: #78675d;

    --pal-bg-light:            #f7f4f2;
    --pal-surface-light:       #ffffff;
    --pal-surface-2-light:     #f9f7f6;
    --pal-border-light:        #eae2dd;
    --pal-text-light:          #261d18;
    --pal-text-muted-light:    #77685f;
    --pal-text-faint-light:    #a2948b;
    --pal-sidebar-bg-light:    #1f1610;
    --pal-sidebar-text-light:  #d0c0b6;
    --pal-sidebar-hover-light: #31241b;
    --pal-sidebar-label-light: #8f7b6d;

    --pal-shadow-rgb: 38, 29, 24;
}

/* No hue at all: chroma zero at every step. Here because a tinted near-black is
   a taste and some people do not share it, and because it is the honest choice
   for anyone comparing two screenshots for a colour difference. */
:root[data-app-palette="graphite"] {
    --pal-bg-dark:            #0f0f0f;
    --pal-surface-dark:       #1e1e1e;
    --pal-surface-2-dark:     #282828;
    --pal-border-dark:        #353535;
    --pal-text-dark:          #eaeaea;
    --pal-text-muted-dark:    #a0a0a0;
    --pal-text-faint-dark:    #737373;
    --pal-sidebar-bg-dark:    #090909;
    --pal-sidebar-text-dark:  #aeaeae;
    --pal-sidebar-hover-dark: #1b1b1b;
    --pal-sidebar-label-dark: #6b6b6b;

    --pal-bg-light:            #f4f4f4;
    --pal-surface-light:       #ffffff;
    --pal-surface-2-light:     #f8f8f8;
    --pal-border-light:        #e3e3e3;
    --pal-text-light:          #1f1f1f;
    --pal-text-muted-light:    #6b6b6b;
    --pal-text-faint-light:    #969696;
    --pal-sidebar-bg-light:    #181818;
    --pal-sidebar-text-light:  #c3c3c3;
    --pal-sidebar-hover-light: #272727;
    --pal-sidebar-label-light: #7f7f7f;

    --pal-shadow-rgb: 31, 31, 31;
}

/* ---- the palette picker ---------------------------------------------------
 *
 * Six options in a settings form, and a list of colour names would be a guess
 * every time. Each chip is the palette itself: a page and a card in light on
 * the left, the same two in dark on the right, an accent bar under both --
 * because a palette here is a pair of modes rather than one skin, and the
 * picker ought to say so before somebody saves to find out.
 *
 * The hexes below repeat values from the blocks above rather than reading them
 * through var(), and they have to: a chip shows a palette the page is *not*
 * wearing, and a custom property only ever resolves to the value inherited onto
 * the element asking for it. Kept adjacent so the two are edited together.
 *
 * The accents are not here. They arrive as inline --chip-accent from the view,
 * out of Palette::accents(), for the same reason TenantTheme owns --app-accent:
 * that pair has one home and a copy in a stylesheet would eventually disagree
 * with it.
 */
.pal-chip[data-pal="forest"] {
    --chip-bg-light: #f2f5f3; --chip-surface-light: #ffffff; --chip-border-light: #dde5e0;
    --chip-bg-dark:  #0a100d; --chip-surface-dark:  #17201c; --chip-border-dark:  #2b3830;
}

.pal-chip[data-pal="tidal"] {
    --chip-bg-light: #f1f5f6; --chip-surface-light: #ffffff; --chip-border-light: #dae5e7;
    --chip-bg-dark:  #081011; --chip-surface-dark:  #142022; --chip-border-dark:  #25383c;
}

.pal-chip[data-pal="harbor"] {
    --chip-bg-light: #f3f4f7; --chip-surface-light: #ffffff; --chip-border-light: #e0e3ea;
    --chip-bg-dark:  #0d0f13; --chip-surface-dark:  #1b1e25; --chip-border-dark:  #2f3541;
}

.pal-chip[data-pal="orchid"] {
    --chip-bg-light: #f7f3f5; --chip-surface-light: #ffffff; --chip-border-light: #eae1e5;
    --chip-bg-dark:  #130d10; --chip-surface-dark:  #241c20; --chip-border-dark:  #3f3138;
}

.pal-chip[data-pal="ember"] {
    --chip-bg-light: #f7f4f2; --chip-surface-light: #ffffff; --chip-border-light: #eae2dd;
    --chip-bg-dark:  #130e0a; --chip-surface-dark:  #241d18; --chip-border-dark:  #3f332b;
}

.pal-chip[data-pal="graphite"] {
    --chip-bg-light: #f4f4f4; --chip-surface-light: #ffffff; --chip-border-light: #e3e3e3;
    --chip-bg-dark:  #0f0f0f; --chip-surface-dark:  #1e1e1e; --chip-border-dark:  #353535;
}

.pal-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(228px, 1fr));
    gap: 8px;
}

.pal-opt {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 10px;
    margin: 0;
    border: 1px solid var(--app-border);
    border-radius: var(--app-radius);
    background: var(--app-surface);
    cursor: pointer;
    transition: border-color var(--app-fast) var(--app-ease),
                background var(--app-fast) var(--app-ease);
}

.pal-opt:hover { background: var(--app-surface-2); }

/* Off-screen rather than display:none, so the control keeps its place in the
   tab order and arrow keys still walk the group. A hidden radio is not a
   focusable radio. */
.pal-opt input {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

/* The ring on the chip is the adjacent-sibling rule, which has worked
   everywhere for twenty years; :has() only adds the tint behind the whole row.
   Written in that order on purpose -- if :has() is ever unavailable the option
   still reads as chosen. */
.pal-opt input:checked + .pal-chip {
    border-color: transparent;
    box-shadow: 0 0 0 2px var(--app-accent);
}

.pal-opt input:focus-visible + .pal-chip { box-shadow: 0 0 0 2px var(--app-accent), var(--app-ring); }
.pal-opt:has(input:checked) { border-color: var(--app-accent); background: var(--app-accent-soft); }

.pal-chip {
    position: relative;
    flex: 0 0 auto;
    display: flex;
    width: 58px;
    height: 40px;
    border: 1px solid var(--app-border);
    border-radius: var(--app-radius-sm);
    overflow: hidden;
    transition: box-shadow var(--app-fast) var(--app-ease);
}

/* Every class inside the picker carries the prefix, including the ones that
   read fine unprefixed. An unprefixed `.note` here inherited the form-callout
   panel -- border, tinted background, the lot -- and drew each one-line
   description as a boxed aside. Descendant selectors do not scope a name, they
   only raise its specificity, so the collision is a matter of which properties
   the outer rule happens to set. */
.pal-chip .pal-half { flex: 1 1 50%; padding: 6px 5px 10px; }
.pal-chip .pal-half i { display: block; height: 100%; border: 1px solid; border-radius: 3px; }
.pal-chip .pal-light { background: var(--chip-bg-light); }
.pal-chip .pal-light i { background: var(--chip-surface-light); border-color: var(--chip-border-light); }
.pal-chip .pal-dark { background: var(--chip-bg-dark); }
.pal-chip .pal-dark i { background: var(--chip-surface-dark); border-color: var(--chip-border-dark); }

.pal-chip .pal-accent {
    position: absolute;
    inset: auto 0 0 0;
    height: 7px;
    background: linear-gradient(90deg, var(--chip-accent, var(--app-accent)), var(--chip-accent-2, var(--app-accent-2)));
}

.pal-opt .pal-text { display: block; min-width: 0; }
.pal-opt .pal-name { display: block; font-size: 13px; font-weight: 600; color: var(--app-text); }
.pal-opt .pal-note { display: block; margin-top: 2px; font-size: 12px; line-height: 1.35; color: var(--app-text-faint); }

/* Shown rather than hidden when the viewer may not change it, same as the
   permission checkboxes: a setting that disappears looks like a setting that
   was never there, and the first question is then what the company is using. */
.pal-opt.is-locked { cursor: default; opacity: .6; }

/* Cancelling the hover highlight, not the selection.
 *
 * `:has()` takes the specificity of its argument, so `.pal-opt:has(input:checked)`
 * above scores (0,2,1) and the plain `.pal-opt.is-locked:hover` this replaced
 * scored (0,3,0) -- meaning a pointer passing over the option actually in force
 * painted out the tint that said so, on the one page whose job is to report the
 * house style. Excluding the checked option leaves the tint alone and still
 * withholds the highlight from the six that cannot be clicked.
 *
 * If :has() is ever unavailable this rule drops and locked options light up on
 * hover, which is the harmless half of the pair to lose. */
.pal-opt.is-locked:not(:has(input:checked)):hover { background: var(--app-surface); }

/* ---- Bootstrap bridge -----------------------------------------------------
 *
 * Bootstrap components this application never styles directly -- the
 * notification dropdown, modals, pagination, its own .table striping -- draw
 * from --bs-* variables. Left alone they follow Bootstrap's idea of the theme,
 * which is keyed off data-bs-theme and therefore has no opinion at all in the
 * "system" state where that attribute is absent. Pointing them at the tokens
 * above makes one palette govern everything on the page.
 */
:root {
    --bs-body-bg:          var(--app-bg);
    --bs-body-color:       var(--app-text);
    --bs-border-color:     var(--app-border);
    --bs-secondary-bg:     var(--app-surface-2);
    --bs-tertiary-bg:      var(--app-surface-2);
    --bs-secondary-color:  var(--app-text-muted);
    --bs-emphasis-color:   var(--app-text);
    --bs-link-color:       var(--app-accent-ink);
    --bs-link-hover-color: var(--app-accent-ink);
    --bs-border-radius:    var(--app-radius);
    --bs-dropdown-bg:              var(--app-surface);
    --bs-dropdown-color:           var(--app-text);
    --bs-dropdown-border-color:    var(--app-border);
    --bs-dropdown-link-color:      var(--app-text);
    --bs-dropdown-link-hover-bg:   var(--app-surface-2);
    --bs-dropdown-link-hover-color: var(--app-text);
    --bs-modal-bg:         var(--app-surface);
    --bs-modal-color:      var(--app-text);
    --bs-modal-border-color: var(--app-border);
}

/* ------------------------------------------------------------------ base */

body {
    background: var(--app-bg);
    color: var(--app-text);
    font-family: var(--app-font);
    font-size: 13.5px;
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-rendering: optimizeLegibility;
}

a { color: var(--app-accent-ink); text-decoration-thickness: 1px; text-underline-offset: 2px; }

h1, h2, h3, h4, h5, h6 { color: var(--app-text); letter-spacing: -.011em; }

/* Every figure in the product, in one face and one width. .money and .num carry
   it explicitly further down; this catches <code>, and .mono where a view has
   asked for it by name. */
code, kbd, samp, pre, .mono {
    font-family: var(--app-mono);
    font-size: .93em;
    font-variant-numeric: tabular-nums;
}

/*
 * Focus, on :focus-visible only. The browser decides what "visible" means, and
 * it means a keyboard: someone tabbing through a form gets an unmissable ring,
 * someone clicking a button does not get a halo stuck to it afterwards. Two
 * layers so the ring stays legible whether it lands on a surface or on the
 * sidebar.
 */
:focus-visible {
    outline: 2px solid var(--app-accent);
    outline-offset: 2px;
    border-radius: 3px;
}

::selection { background: color-mix(in oklab, var(--app-accent) 34%, transparent); }

/*
 * One movement, at the top of the page, and then the interface holds still.
 * Content fades up 4px on load so a navigation reads as an arrival rather than
 * a flicker. Everything else in this file transitions in --app-fast or not at
 * all -- an operations tool that animates on every hover is exhausting by
 * mid-afternoon.
 */
@keyframes app-rise {
    from { opacity: 0; transform: translateY(4px); }
    to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: .001ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: .001ms !important;
        scroll-behavior: auto !important;
    }
}

/* --------------------------------------------------------------- shell */

.app {
    display: flex;
    min-height: 100vh;
}

.app-sidebar {
    width: 228px;
    flex: 0 0 228px;
    background: var(--app-sidebar-bg);
    color: var(--app-sidebar-text);
    display: flex;
    flex-direction: column;
    position: sticky;
    top: 0;
    height: 100vh;
    overflow-y: auto;
    /* The rail is the only place the two brand colours meet, and it is three
       pixels tall. See --app-grad-duo. */
    border-right: 1px solid rgba(255, 255, 255, .06);
    background-image: linear-gradient(var(--app-sidebar-bg), var(--app-sidebar-bg)), var(--app-grad-duo);
    background-size: 100% calc(100% - 3px), 100% 3px;
    background-position: 0 3px, 0 0;
    background-repeat: no-repeat;
    scrollbar-width: thin;
    scrollbar-color: var(--app-sidebar-hover) transparent;
}

.app-brand {
    padding: 17px 18px 15px;
    font-size: 16px;
    font-weight: 600;
    letter-spacing: -.015em;
    color: #fff;
    display: flex;
    align-items: center;
    gap: 9px;
}

.app-brand img { max-height: 26px; max-width: 130px; }

.app-brand small {
    display: block;
    font-size: 10.5px;
    font-weight: 400;
    color: var(--app-sidebar-label);
    letter-spacing: .01em;
}

/*
 * The tenant's own name, under the lockup. It is a caption belonging to the
 * brand above it, so it is pulled up out of that block's bottom padding rather
 * than reading as the first item of the navigation below.
 *
 * Truncated, not wrapped. Company names are user-supplied and some of them are
 * long enough to push the whole navigation off a laptop screen.
 */
.app-brand-tenant {
    margin-top: -8px;
    padding: 0 18px 13px;
    font-size: 11.5px;
    letter-spacing: .01em;
    color: var(--app-sidebar-label);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.app-nav { padding: 2px 9px 24px; }

.app-nav .nav-section {
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: .11em;
    font-weight: 600;
    color: var(--app-sidebar-label);
    padding: 17px 9px 6px;
}

.app-nav a {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 7px 9px;
    border-radius: var(--app-radius-sm);
    color: var(--app-sidebar-text);
    text-decoration: none;
    font-size: 13px;
    line-height: 1.25;
    position: relative;
    transition: background var(--app-fast) var(--app-ease), color var(--app-fast) var(--app-ease);
}

.app-nav a:hover { background: var(--app-sidebar-hover); color: #fff; }

/*
 * Active is a wash and a rail, not a filled block. A solid accent bar down the
 * sidebar competes with the primary button on the page for the eye, and the
 * primary button should win -- that one is the thing to press, this one is just
 * where you already are. The wash is mixed from the accent so it tracks a
 * tenant's colour without a second variable.
 */
.app-nav a.active {
    background: color-mix(in oklab, var(--app-accent) 24%, transparent);
    color: #fff;
    font-weight: 500;
}

.app-nav a.active::before {
    content: "";
    position: absolute;
    left: 0;
    top: 6px;
    bottom: 6px;
    width: 2.5px;
    border-radius: 0 2px 2px 0;
    background: var(--app-grad-duo);
}

.app-nav .nav-icon { width: 17px; text-align: center; opacity: .8; flex: 0 0 17px; }
.app-nav a.active .nav-icon { opacity: 1; }

.app-nav .nav-count {
    margin-left: auto;
    font-family: var(--app-mono);
    font-size: 10.5px;
    font-variant-numeric: tabular-nums;
    background: rgba(255, 255, 255, .1);
    padding: 1px 6px;
    border-radius: 20px;
}

.app-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }

.app-header {
    background: color-mix(in oklab, var(--app-surface) 82%, transparent);
    -webkit-backdrop-filter: saturate(1.4) blur(10px);
    backdrop-filter: saturate(1.4) blur(10px);
    border-bottom: 1px solid var(--app-border);
    padding: 9px 20px;
    display: flex;
    align-items: center;
    gap: 13px;
    position: sticky;
    top: 0;
    z-index: 20;
}

.app-header .spacer { flex: 1 1 auto; }

.app-content {
    padding: 20px;
    flex: 1 1 auto;
    animation: app-rise .18s var(--app-ease) both;
}

.page-head {
    display: flex;
    align-items: flex-start;
    gap: 16px;
    margin-bottom: 17px;
    flex-wrap: wrap;
}

.page-head h1 { font-size: 20px; font-weight: 600; margin: 0; letter-spacing: -.02em; }
.page-head .sub { color: var(--app-text-muted); font-size: 12.5px; margin-top: 3px; }
.page-head .actions { margin-left: auto; display: flex; gap: 8px; }

/* --------------------------------------------------------------- cards */

/*
 * A card is a lighter plane, a hairline, and a faint lift along its top edge,
 * in that order of importance. The plane is what separates it from the page --
 * see the ramp at the top of this file, where the page-to-surface step is set
 * wide enough to be seen rather than inferred. The hairline sharpens the
 * boundary and the lift describes which way the surface faces.
 *
 * Deliberately no fill gradient. A gradient across a panel this large reads as
 * a sheen sliding off the bottom edge, and with several cards down a page the
 * sheens do not line up. The gradients in this interface are reserved for
 * things small enough to take one: buttons, rails, the two-pixel duo strip.
 */
.card {
    background: var(--app-surface);
    border: 1px solid var(--app-border);
    border-radius: var(--app-radius);
    box-shadow: var(--app-lift), var(--app-shadow);
}

/*
 * Stacked sections get the same 15px as the grid gap inside .cols-2, so a page
 * reads as one rhythm whether its blocks sit side by side or one under another.
 *
 * The pair list rather than `.card + .card` alone, because that narrower rule
 * had a hole in it that only showed up on the fullest pages. Job view lays out
 * .page-head, .cols-2, the permits panel and the attachments panel as siblings;
 * the permits card's previous sibling is the *grid*, not a card, so it matched
 * nothing and sat flush against the bottom of the Crew card above it -- two
 * borders touching, reading as one malformed box. The same seam appears
 * wherever a partial that renders its own .card is dropped in after a grid,
 * which is most of the view/_panel partials.
 *
 * :is() keeps this to one rule rather than four, and both classes appear on
 * each side because a grid can just as easily follow a card.
 */
:is(.card, .cols-2) + :is(.card, .cols-2) { margin-top: 15px; }

/*
 * The head's height is a property of the head, not of what happens to be in it.
 * A flex line box takes the height of its tallest child, and the head's
 * children are not the same size -- the 11px heading, an optional 12px "Open"
 * link, an optional 12px sub-line. So a card with a link had a head five pixels
 * taller than one without, which nothing revealed until the dashboard put tiles
 * side by side and the headings in a single row failed to line up. Pinning the
 * line box fixes the height for every combination; min-height is the floor for
 * a head holding nothing but the heading, and neither constrains a head that
 * legitimately holds a control.
 */
.card-head {
    padding: 11px 15px;
    border-bottom: 1px solid var(--app-border);
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    column-gap: 10px;
    row-gap: 2px;
    line-height: 18px;
    min-height: 41px;
    background: linear-gradient(color-mix(in oklab, var(--app-surface-2) 60%, transparent), transparent);
    border-radius: var(--app-radius) var(--app-radius) 0 0;
}

/*
 * The heading never breaks. In a narrow column -- the right-hand side of a
 * `.cols-2` is 379px -- "CHANGE STATUS" was wrapping onto two lines beside a
 * sub-line that was also wrapping, and two ragged stacks side by side read as a
 * rendering fault rather than a heading. Since the head now wraps as a flex
 * container, a pair that no longer fits becomes a title over its subtitle,
 * which is what it actually is.
 */
.card-head h2 {
    font-size: 11px;
    font-weight: 600;
    margin: 0;
    text-transform: uppercase;
    letter-spacing: .08em;
    color: var(--app-text-muted);
    white-space: nowrap;
}

/*
 * min-width:0 lets the sub shrink when it does share the line. A flex item
 * otherwise refuses to go below its min-content width, which would push the
 * head wider than its card instead of wrapping the text inside it.
 */
.card-head .sub { font-size: 12px; color: var(--app-text-faint); min-width: 0; }
.card-head .spacer { flex: 1 1 auto; }
.card-body { padding: 15px; }

/*
 * A <details> used as an editing panel inside a card. The default triangle and
 * the browser's own summary styling look like a debug widget next to the rest
 * of this sheet, so the marker goes and the summary becomes a quiet button --
 * which is what it behaves like.
 *
 * The rule is on a class rather than on <details> itself: this stylesheet also
 * dresses genuine disclosure text elsewhere, and that should keep its triangle.
 */
.edit-panel { border-top: 1px solid var(--app-border); }
.edit-panel > summary {
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: 12px;
    font-weight: 500;
    color: var(--app-text-muted);
    list-style: none;
    user-select: none;
}
.edit-panel > summary::-webkit-details-marker { display: none; }
.edit-panel > summary::before {
    content: "+";
    font-size: 13px;
    line-height: 1;
    width: 13px;
    text-align: center;
    color: var(--app-text-faint);
}
.edit-panel[open] > summary::before { content: "\2212"; } /* minus */
.edit-panel > summary:hover { color: var(--app-text); }
.edit-panel[open] > summary { margin-bottom: 11px; }
.edit-panel form + form { margin-top: 15px; padding-top: 15px; border-top: 1px solid var(--app-border); }

/* Stat tiles. Clickable ones link to a list that must return exactly the rows
   the number counted -- a tile whose count disagrees with the page it opens is
   worse than no tile, because it teaches people not to trust the dashboard. */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(178px, 1fr)); gap: 13px; }

/*
 * The strip keeps the same vertical rhythm as the cards it sits above.
 * `.card + .card` handled card-to-card and nothing handled strip-to-card, so on
 * every record screen that did not patch it by hand the strip's bottom border
 * and the next card's top border met on adjacent pixels and read as one heavy
 * rule -- the numbers looked welded to the table under them. Roughly half the
 * screens carried an inline `margin-top:16px` to work around it and the other
 * half did not, which is why it was easy to miss.
 *
 * Targeting the sibling rather than giving `.stats` a bottom margin means a
 * strip that ends a page adds no trailing space, and `.card + .card` cannot
 * also fire (its previous sibling is the strip, not a card), so the gap is
 * never applied twice.
 */
.stats + * { margin-top: 15px; }

.stat {
    background: var(--app-surface);
    border: 1px solid var(--app-border);
    border-radius: var(--app-radius);
    padding: 14px 15px;
    box-shadow: var(--app-lift), var(--app-shadow);
    display: block;
    text-decoration: none;
    color: inherit;
    position: relative;
    overflow: hidden;
    transition: border-color var(--app-fast) var(--app-ease),
                transform var(--app-fast) var(--app-ease),
                box-shadow var(--app-fast) var(--app-ease);
}

/* The duo rail again, hidden until the tile is worth pressing. A tile that is
   only a number does not get one. */
a.stat::after {
    content: "";
    position: absolute;
    inset: 0 0 auto 0;
    height: 2px;
    background: var(--app-grad-duo);
    opacity: 0;
    transition: opacity var(--app-fast) var(--app-ease);
}

a.stat:hover {
    border-color: color-mix(in oklab, var(--app-accent) 55%, var(--app-border));
    text-decoration: none;
    transform: translateY(-1px);
    box-shadow: var(--app-lift), 0 6px 18px rgba(0, 0, 0, .16);
}

a.stat:hover::after { opacity: 1; }

.stat .label {
    font-size: 10.5px;
    text-transform: uppercase;
    letter-spacing: .09em;
    font-weight: 600;
    color: var(--app-text-faint);
}

.stat .value {
    font-family: var(--app-mono);
    font-size: 24px;
    font-weight: 500;
    margin-top: 6px;
    letter-spacing: -.03em;
    font-variant-numeric: tabular-nums;
    line-height: 1.15;
}

.stat .foot { font-size: 12px; color: var(--app-text-muted); margin-top: 3px; }
.stat .foot.up { color: var(--app-positive); }
.stat .foot.down { color: var(--app-negative); }

/* --------------------------------------------------------------- tables */

.table { color: var(--app-text); margin: 0; }
.table > :not(caption) > * > * { background: transparent; }

.table thead th {
    font-size: 10.5px;
    text-transform: uppercase;
    letter-spacing: .08em;
    color: var(--app-text-faint);
    font-weight: 600;
    border-bottom: 1px solid var(--app-border);
    padding: 8px 15px;
    white-space: nowrap;
}

.table tbody td {
    padding: 10px 15px;
    border-bottom: 1px solid color-mix(in oklab, var(--app-border) 62%, transparent);
    vertical-align: middle;
}

.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr:hover td { background: var(--app-surface-2); }

.table .num {
    text-align: right;
    font-family: var(--app-mono);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    letter-spacing: -.01em;
}

.empty { padding: 42px 16px; text-align: center; color: var(--app-text-muted); }
.empty .big { font-size: 15px; color: var(--app-text); margin-bottom: 4px; }

/* --------------------------------------------------------------- pills */

/*
 * Squared off rather than rounded, uppercase and tracked out: a status pill here
 * should read like a label stamped on a panel, not like a chat bubble.
 *
 * Each variant sets one property -- its hue -- and the base rule derives the
 * background, text and border from it with color-mix, per theme. That replaces
 * the previous arrangement, which listed three literal hexes per status and then
 * pushed the whole set through `filter: brightness(.82) saturate(1.25)` in dark
 * mode, twice, because the literals were light-theme colours. The filter worked
 * but it was a correction applied to a wrong answer, and it dimmed the text and
 * the border along with the fill. Deriving both themes from the hue gives dark
 * mode a legible pill instead of a dimmed one, and adding a ninth status is a
 * single line with no dark-mode counterpart to remember.
 */
.pill {
    --pill: var(--app-text-muted);
    display: inline-block;
    padding: 2px 7px;
    border-radius: var(--app-radius-sm);
    font-size: 10.5px;
    font-weight: 600;
    letter-spacing: .05em;
    text-transform: uppercase;
    line-height: 1.65;
    white-space: nowrap;
    background: color-mix(in oklab, var(--pill) 17%, transparent);
    color: color-mix(in oklab, var(--pill) 88%, var(--app-text));
    border: 1px solid color-mix(in oklab, var(--pill) 34%, transparent);
}

/* Light needs the opposite adjustment: the same hue at full strength is too
   loud on white, and the text has to go darker rather than lighter to hold
   contrast against a pale fill. */
@media (prefers-color-scheme: light) {
    :root:not([data-bs-theme="dark"]) .pill {
        background: color-mix(in oklab, var(--pill) 13%, #ffffff);
        color: color-mix(in oklab, var(--pill) 78%, #000000);
        border-color: color-mix(in oklab, var(--pill) 28%, #ffffff);
    }
}

:root[data-bs-theme="light"] .pill {
    background: color-mix(in oklab, var(--pill) 13%, #ffffff);
    color: color-mix(in oklab, var(--pill) 78%, #000000);
    border-color: color-mix(in oklab, var(--pill) 28%, #ffffff);
}

.pill-new       { --pill: #3b82f6; }
.pill-working   { --pill: #eab308; }
.pill-qualified { --pill: #8b5cf6; }
.pill-converted { --pill: #22c55e; }
.pill-lost      { --pill: #ef4444; }
.pill-open      { --pill: #06b6d4; }
.pill-done      { --pill: #22c55e; }
.pill-danger    { --pill: #ef4444; }

/* Semantic variants, for the modules whose vocabulary is not lead statuses. A
   tree's risk rating, a party's role, a campaign's state -- all want "this one
   is fine / worth noting / act on it" without borrowing a word that means
   something specific elsewhere. They reuse the same palettes on purpose: one
   visual language across the product, several vocabularies on top of it. */
.pill-info      { --pill: #3b82f6; }
.pill-success   { --pill: #22c55e; }
.pill-warning   { --pill: #eab308; }

/* Restates the base style rather than relying on inheritance. A view asking for
   pill-muted should get muted because that rule exists, not because the class it
   named happens to match nothing -- those two are indistinguishable on screen
   and very distinguishable the day someone adds a `.pill-muted` meaning
   something else. */
.pill-muted {
    background: var(--app-surface-2);
    color: var(--app-text-muted);
    border-color: var(--app-border);
}

.money {
    font-family: var(--app-mono);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    letter-spacing: -.01em;
}

.pos { color: var(--app-positive); }
.neg { color: var(--app-negative); }
.warn { color: var(--app-warning); }
.muted { color: var(--app-text-muted); }
.faint { color: var(--app-text-faint); }
.small { font-size: 12px; }

/* --------------------------------------------------------------- bars */

/* Inline proportion bars. Used everywhere a report compares rows: they let the
   eye rank a column before it reads a single number. */
.bar {
    background: color-mix(in oklab, var(--app-text) 8%, transparent);
    border-radius: 20px;
    height: 6px;
    overflow: hidden;
    min-width: 60px;
}

.bar > span { display: block; height: 100%; background: var(--app-grad-duo); border-radius: 20px; }
.bar.neg > span { background: var(--app-negative); }
.bar.warn > span { background: var(--app-warning); }

/* -------------------------------------------------------------- buttons */

/*
 * The primary button is the loudest thing on any screen in this product and it
 * is the only thing wearing a gradient face.
 *
 * The gradient is derived from --app-accent rather than written as two stops, so
 * a tenant who sets an accent gets a coherent one instead of their colour fading
 * into somebody else's. Over the top of it sits a one-pixel inset highlight,
 * which is the whole difference between a gradient rectangle and something that
 * looks like a key you can press: light catches the top edge of a raised
 * surface, and the eye reads that as depth without being told.
 *
 * background-color is set as well as background-image. If color-mix is ever
 * unavailable the image drops out and the flat accent is still there, which is
 * exactly the old button rather than a transparent one.
 */
.btn {
    border-radius: var(--app-radius-sm);
    font-size: 13px;
    font-weight: 500;
    letter-spacing: -.005em;
    padding: 6px 13px;
    transition: filter var(--app-fast) var(--app-ease),
                transform var(--app-fast) var(--app-ease),
                background-color var(--app-fast) var(--app-ease),
                border-color var(--app-fast) var(--app-ease);
}

.btn-sm { font-size: 12px; padding: 4px 10px; }

/*
 * The house buttons are expressed by overriding Bootstrap's own --bs-btn-*
 * custom properties rather than by setting background and colour directly, and
 * that choice is load-bearing rather than stylistic.
 *
 * Bootstrap's pressed state is selected as ":not(.btn-check) + .btn:active",
 * which outranks any single class. Restate a fill on .btn-accent and it holds
 * everywhere except the one frame the user is actually touching the control,
 * where Bootstrap's own colour flashes through. Feeding the variables instead
 * means every Bootstrap rule -- base, hover, active, disabled, focus --
 * computes a house colour no matter what specificity it was written at.
 */
.btn-accent,
.btn-primary {
    --bs-btn-color: var(--app-accent-text);
    --bs-btn-bg: var(--app-accent);
    --bs-btn-border-color: var(--app-accent-deep);
    --bs-btn-hover-color: var(--app-accent-text);
    --bs-btn-hover-bg: var(--app-accent);
    --bs-btn-hover-border-color: var(--app-accent-deep);
    --bs-btn-active-color: var(--app-accent-text);
    --bs-btn-active-bg: var(--app-accent);
    --bs-btn-active-border-color: var(--app-accent-deep);
    --bs-btn-disabled-color: var(--app-accent-text);
    --bs-btn-disabled-bg: var(--app-accent);
    --bs-btn-disabled-border-color: var(--app-accent-deep);
    --bs-btn-focus-box-shadow: none;

    background-image: var(--app-grad-accent);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, .22), 0 1px 2px rgba(0, 0, 0, .22);
}

/*
 * Hover and press are a filter and a nudge, not a different colour. The fill
 * stays exactly the accent the tenant chose, so a company with a pale brand and
 * a company with a dark one both get a button that lightens on approach rather
 * than one lightening and one turning muddy.
 */
.btn-accent:hover,
.btn-accent:focus,
.btn-primary:hover,
.btn-primary:focus {
    filter: brightness(1.07) saturate(1.04);
    transform: translateY(-1px);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, .26), 0 4px 12px rgba(0, 0, 0, .26);
}

/* Pressed sits back down on the page and loses the cast shadow. Two frames of
   feedback that cost nothing and make the control feel connected. */
.btn-accent:active,
.btn-primary:active {
    transform: translateY(0);
    filter: brightness(.96);
    box-shadow: inset 0 2px 5px rgba(0, 0, 0, .28);
}

/*
 * .btn-primary is folded in with .btn-accent above rather than left to
 * Bootstrap, and that closes a branding hole. Bootstrap's primary is a fixed
 * blue, and every submit button in the customer portal carries it -- on the one
 * surface whose entire purpose is to wear the tenant's colours. A white-labelled
 * company could set brand_primary_color and still hand their customer a
 * Bootstrap button. Pointing the class at the accent token means the tenant
 * override reaches it for free.
 *
 * Every remaining Bootstrap variant becomes the quiet button. This too is a bug
 * fix rather than a house style: Bootstrap 5.3 restyles its text and surface
 * colours for dark mode but leaves the solid .btn-* fills alone, so .btn-dark on
 * a dark body is a dark rectangle on a dark page and .btn-light on a light one
 * is white on white. The control is still there, still focusable, still
 * clickable, and completely invisible. The admin tier shipped exactly that and
 * nobody noticed until the body went dark.
 *
 * Routing them through surface tokens makes the failure impossible to
 * reintroduce, because no fill is left in the file that does not move with the
 * background behind it.
 */
.btn-quiet,
.btn-secondary, .btn-outline-secondary,
.btn-light,     .btn-outline-light,
.btn-dark,      .btn-outline-dark,
.btn-outline-primary {
    --bs-btn-color: var(--app-text);
    --bs-btn-bg: var(--app-surface);
    --bs-btn-border-color: var(--app-border);
    --bs-btn-hover-color: var(--app-text);
    --bs-btn-hover-bg: var(--app-surface-2);
    --bs-btn-hover-border-color: color-mix(in oklab, var(--app-accent) 40%, var(--app-border));
    --bs-btn-active-color: var(--app-text);
    --bs-btn-active-bg: var(--app-surface-2);
    --bs-btn-active-border-color: color-mix(in oklab, var(--app-accent) 40%, var(--app-border));
    --bs-btn-disabled-color: var(--app-text-faint);
    --bs-btn-disabled-bg: var(--app-surface);
    --bs-btn-disabled-border-color: var(--app-border);
    --bs-btn-focus-box-shadow: none;

    background-image: none;
    box-shadow: var(--app-lift);
}

.btn-quiet:active,
.btn-secondary:active, .btn-outline-secondary:active,
.btn-light:active,     .btn-outline-light:active,
.btn-dark:active,      .btn-outline-dark:active,
.btn-outline-primary:active { transform: translateY(1px); }

/*
 * Meaning-carrying buttons are quiet buttons wearing their meaning as ink and
 * edge rather than as a fill.
 *
 * The semantic tokens are stated as text colours -- light tints on the dark
 * palette, deep ones on the light palette -- so using one as a fill would put
 * white type on pale red the moment the body went dark. Used as ink against the
 * surface, a single declaration is legible in both. Destructive actions read
 * better this way regardless: a page full of red rectangles stops meaning
 * "careful" and starts meaning nothing.
 */
.btn-danger,  .btn-outline-danger  { --btn-tone: var(--app-negative); }
.btn-success, .btn-outline-success { --btn-tone: var(--app-positive); }
.btn-warning, .btn-outline-warning { --btn-tone: var(--app-warning); }
.btn-info,    .btn-outline-info    { --btn-tone: var(--app-info); }

.btn-danger,  .btn-outline-danger,
.btn-success, .btn-outline-success,
.btn-warning, .btn-outline-warning,
.btn-info,    .btn-outline-info {
    --bs-btn-color: var(--btn-tone);
    --bs-btn-bg: var(--app-surface);
    --bs-btn-border-color: color-mix(in oklab, var(--btn-tone) 45%, var(--app-border));
    --bs-btn-hover-color: var(--btn-tone);
    --bs-btn-hover-bg: color-mix(in oklab, var(--btn-tone) 12%, var(--app-surface));
    --bs-btn-hover-border-color: var(--btn-tone);
    --bs-btn-active-color: var(--btn-tone);
    --bs-btn-active-bg: color-mix(in oklab, var(--btn-tone) 18%, var(--app-surface));
    --bs-btn-active-border-color: var(--btn-tone);
    --bs-btn-disabled-color: var(--app-text-faint);
    --bs-btn-disabled-bg: var(--app-surface);
    --bs-btn-disabled-border-color: var(--app-border);
    --bs-btn-focus-box-shadow: none;

    background-image: none;
    box-shadow: var(--app-lift);
}

.btn-danger:active,  .btn-outline-danger:active,
.btn-success:active, .btn-outline-success:active,
.btn-warning:active, .btn-outline-warning:active,
.btn-info:active,    .btn-outline-info:active { transform: translateY(1px); }

/* --------------------------------------------------------------- forms */

.form-control, .form-select {
    background: var(--app-bg);
    border: 1px solid var(--app-border);
    border-radius: var(--app-radius-sm);
    color: var(--app-text);
    font-size: 13px;
    padding: 6px 10px;
    transition: border-color var(--app-fast) var(--app-ease),
                box-shadow var(--app-fast) var(--app-ease),
                background var(--app-fast) var(--app-ease);
}

/*
 * Inputs sit recessed and come up to the card colour on focus: the field you
 * are typing in is lit, the rest of the form is not.
 *
 * Recessed means the page colour, which is a step *below* the card a form
 * usually sits in, and lit means the card's own. It used to rest on
 * --app-surface-2, a step above the card, so "recessed" was the comment's
 * intention and the opposite of what the page did -- and focus then moved the
 * field downwards. Nobody caught it because the two colours were five hex units
 * apart. They are further apart now, and a field that visibly darkens when you
 * click into it would read as broken.
 */
.form-control:focus, .form-select:focus {
    background: var(--app-surface);
    color: var(--app-text);
    border-color: var(--app-accent);
    box-shadow: var(--app-ring);
    outline: none;
}

.form-control::placeholder { color: var(--app-text-faint); }

/* Numbers, dates and money get the mono face in the field as well as in the
   table, so what you typed and what it becomes look like the same value. */
input[type="number"].form-control,
input[type="date"].form-control,
input[type="time"].form-control,
input[type="datetime-local"].form-control {
    font-family: var(--app-mono);
    font-variant-numeric: tabular-nums;
}

.form-label {
    font-size: 11.5px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: .06em;
    color: var(--app-text-faint);
    margin-bottom: 5px;
}

.filters {
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
    align-items: flex-end;
    margin-bottom: 14px;
}

.filters .form-control, .filters .form-select { min-width: 150px; width: auto; }

/* ---- Form layout ----------------------------------------------------------
 *
 * Two columns on a desktop, one on a phone. Fields that hold a sentence rather
 * than a value -- an address, a note, a list of checkboxes -- take .wide and
 * span both, because wrapping a street address at 300px reads worse than the
 * empty space beside a short field looks.
 */
.form-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 0 16px;
}

.form-grid .wide { grid-column: 1 / -1; }

@media (max-width: 760px) {
    .form-grid { grid-template-columns: 1fr; }
}

/* Help text under a field. Bootstrap renders ActiveField::hint() as .form-text
   in the same grey as .form-label, which makes a hint and the next field's
   label look like the same kind of thing; a step fainter separates them. */
.form-text { font-size: 12px; color: var(--app-text-faint); margin-top: 4px; }

.form-actions {
    display: flex;
    gap: 8px;
    align-items: center;
    padding: 13px 15px;
    border-top: 1px solid var(--app-border);
    background: var(--app-surface-2);
    border-radius: 0 0 var(--app-radius) var(--app-radius);
}

.form-actions .spacer { flex: 1 1 auto; }

/* A short explanatory panel inside a form -- why a field is locked, what will
   happen on save. Reads as information, not as an error. */
.note {
    background: color-mix(in oklab, var(--app-info) 8%, var(--app-surface-2));
    border: 1px solid var(--app-border);
    border-left: 2px solid var(--app-info);
    border-radius: var(--app-radius-sm);
    padding: 10px 12px;
    font-size: 12.5px;
    color: var(--app-text-muted);
}

.note + .note { margin-top: 8px; }
.note strong { color: var(--app-text); font-weight: 600; }

/* ---- Checkbox pickers -----------------------------------------------------
 *
 * Roles, branches, permissions. A plain stacked checkbox list of forty
 * permissions is a page of scrolling; in columns it is a glance.
 */
.check-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
    gap: 2px 16px;
}

.check {
    display: flex;
    align-items: flex-start;
    gap: 8px;
    padding: 6px 8px;
    border-radius: var(--app-radius-sm);
    font-size: 13px;
    cursor: pointer;
    transition: background var(--app-fast) var(--app-ease);
}

.check:hover { background: var(--app-surface-2); }
.check input { margin-top: 2px; flex: 0 0 auto; }
.check .desc { display: block; font-size: 12px; color: var(--app-text-faint); }

/* Held by a role the viewer cannot grant, or forced on by another setting.
   Shown rather than hidden: a permission that vanishes is indistinguishable
   from one that was never there. */
.check.is-locked { cursor: default; opacity: .6; }
.check.is-locked:hover { background: transparent; }

/* A grant, plus the branches it is limited to.
 *
 * The wrapper is the grid cell, not the label, so the branch list sits under
 * its own role instead of pushing the whole row down. min-width:0 because a
 * long branch name in a grid track would otherwise widen every column.
 */
.grant { min-width: 0; }

.grant-scope {
    display: none;
    margin: -2px 0 8px 30px;
    padding-left: 10px;
    border-left: 2px solid var(--app-border);
}

/* Only under a ticked grant. Scoping something nobody holds is a question
   about nothing, and asking it makes the list twice as long to read. */
.grant.is-on .grant-scope { display: block; }

.grant-scope .scope-head {
    display: block;
    font-size: 11px;
    letter-spacing: .04em;
    text-transform: uppercase;
    color: var(--app-text-faint);
    margin-bottom: 2px;
}

.scope-pick {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    margin: 0 10px 2px 0;
    font-size: 12px;
    color: var(--app-text-muted);
    cursor: pointer;
}

.scope-pick input { margin: 0; }

.grant-scope .scope-note {
    display: block;
    font-size: 11px;
    color: var(--app-text-faint);
}

.form-check-input { background-color: var(--app-surface-2); border-color: var(--app-border); }
.form-check-input:checked { background-color: var(--app-accent); border-color: var(--app-accent); }
.form-check-input:focus { border-color: var(--app-accent); box-shadow: var(--app-ring); }

/* --------------------------------------------------------------- login */

/*
 * The sign-in page is the one screen that gets to be atmospheric, because it is
 * the one screen nobody is trying to get work done on. Two very wide, very faint
 * radial washes in the brand colours sit behind the card -- enough to make the
 * page feel lit from somewhere rather than flat, not enough to compete with a
 * password field.
 */
.auth-wrap {
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--app-bg);
    background-image:
        radial-gradient(60rem 40rem at 12% -10%, color-mix(in oklab, var(--app-accent) 20%, transparent), transparent 60%),
        radial-gradient(50rem 34rem at 105% 108%, color-mix(in oklab, var(--app-accent-2) 15%, transparent), transparent 62%);
    padding: 24px;
}

.auth-card {
    width: 100%;
    max-width: 396px;
    background: var(--app-surface);
    border: 1px solid var(--app-border);
    border-radius: var(--app-radius-lg);
    box-shadow: var(--app-lift), var(--app-shadow-lg);
    padding: 30px;
    animation: app-rise .28s var(--app-ease) both;
}

.auth-card h1 { font-size: 19px; font-weight: 600; margin-bottom: 4px; letter-spacing: -.02em; }
.auth-card .sub { color: var(--app-text-muted); font-size: 13px; margin-bottom: 22px; }

/*
 * The customer portal's front door stacks a tenant logo and a footer around the
 * card, so it needs a column rather than the bare card. Slightly wider than the
 * card on purpose: the wordmark and the "Questions? Call ..." line read as
 * belonging to the page rather than to the form, and matching widths exactly
 * would make the card's border look like it had been drawn around all three.
 */
.auth-col { width: 100%; max-width: 420px; }
.auth-brand { text-align: center; margin-bottom: 22px; }
.auth-brand img { max-height: 52px; max-width: 210px; }
.auth-wordmark { font-size: 21px; font-weight: 600; color: var(--app-text); }
.auth-tagline { margin-top: 4px; }
.auth-foot { text-align: center; margin-top: 18px; }

/* --------------------------------------------------------------- misc */

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

.timeline li {
    padding: 10px 0 10px 20px;
    border-left: 1px solid var(--app-border);
    position: relative;
    font-size: 13px;
}

.timeline li::before {
    content: "";
    position: absolute;
    left: -4px;
    top: 15px;
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: var(--app-accent);
    box-shadow: 0 0 0 3px var(--app-bg);
}

.timeline li.first::before { background: var(--app-info); }
.timeline li.last::before { background: var(--app-positive); }

/*
 * The contact log entries that hang off .timeline.
 *
 * .timeline was built for a short list of one-line facts and is still used that
 * way in half a dozen places, so none of its own rules change here. A logged
 * call is a taller thing -- a heading, who and when, then whatever was actually
 * said -- and these are the three lines it needs.
 *
 * .tl-body keeps white-space: pre-wrap because a call note is typed with
 * newlines in it and body_format is 'text'. Rendering it collapsed turns four
 * short observations into one run-on paragraph, which is exactly the shape
 * nobody reads.
 */
.tl-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.tl-head strong { font-weight: 600; }
.tl-meta { color: var(--app-text-faint); font-size: 11.5px; margin-top: 3px; }
.tl-body { margin-top: 6px; white-space: pre-wrap; word-break: break-word; }
.tl-acts { margin-top: 6px; display: flex; gap: 6px; align-items: center; }
.tl-acts form { display: inline; }

/* Pinned entries keep their dot regardless of position, so the newest/oldest
   colours above cannot silently reclaim it. Winning is purely source order --
   same specificity, later rule -- which is why this sits below them and not
   next to them. */
.timeline li.pinned::before { background: var(--app-warning); }

.kv { display: grid; grid-template-columns: 148px 1fr; gap: 7px 14px; font-size: 13px; }

.kv dt {
    color: var(--app-text-faint);
    font-weight: 500;
    font-size: 11.5px;
    text-transform: uppercase;
    letter-spacing: .05em;
    padding-top: 1px;
}

.kv dd { margin: 0; }

.cols-2 { display: grid; grid-template-columns: 2fr 1fr; gap: 15px; align-items: start; }

@media (max-width: 980px) {
    .cols-2 { grid-template-columns: 1fr; }
    .app-sidebar { display: none; }
}

.table-wrap { overflow-x: auto; }

/* ---- The dispatch board --------------------------------------------------
 *
 * One row per crew, one column per day. This replaced a seven-column week of
 * cards, and the reason is the question being asked: a dispatcher with a sold
 * job in hand is not asking "what is on Thursday", they are asking "who has
 * room", and a week of undifferentiated cards answers the first question while
 * making the second one arithmetic the reader has to do in their head.
 *
 * The lane column is sticky. Scrolled three days right, a cell full of jobs
 * with no crew name beside it is unreadable, and the whole grid is inside
 * .table-wrap, which already scrolls horizontally.
 *
 * It stays a horizontal scroll on a phone rather than collapsing to a stack.
 * Collapsing would put each crew's week in a column and lose the comparison
 * between crews on the same day, which is the entire reason for the layout.
 * Narrower columns and a smaller lane header, and the board survives the trip.
 */
.board {
    display: grid;
    grid-template-columns: minmax(160px, 200px) repeat(var(--board-days, 7), minmax(132px, 1fr));
    gap: 1px;
    background: var(--app-border);
    border: 1px solid var(--app-border);
    border-radius: 10px;
    overflow: hidden;
    min-width: 780px;
}

.board-corner,
.board-head,
.board-lane,
.board-cell {
    background: var(--app-surface);
    padding: 8px 9px;
}

.board-corner,
.board-lane {
    position: sticky;
    left: 0;
    z-index: 2;
}

/* The sticky column sits over scrolled cells, so it needs its own right edge
 * -- the 1px grid gap scrolls away underneath it. */
.board-lane { box-shadow: 1px 0 0 var(--app-border); }
.board-corner { box-shadow: 1px 0 0 var(--app-border); z-index: 3; }

.board-head {
    display: flex;
    align-items: baseline;
    gap: 6px;
    background: var(--app-surface-2);
}

.board-head.is-today { background: var(--app-accent-soft, var(--app-surface)); }
.board-head.is-past { opacity: .62; }

.board-day {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: .04em;
    color: var(--app-text-muted);
}

.board-num { font-size: 15px; font-weight: 600; }

.board-lane {
    display: flex;
    flex-direction: column;
    gap: 2px;
    justify-content: center;
    background: var(--app-surface-2);
}

.board-crew { font-size: 13px; font-weight: 600; line-height: 1.25; }
.board-rig { line-height: 1.3; }

/* The empty state, and only the empty state, runs the full width. */
.board-span { grid-column: 1 / -1; position: static; box-shadow: none; }
.board-span .empty { margin: 0; }

.board-cell {
    display: flex;
    flex-direction: column;
    gap: 6px;
    min-height: 92px;
}

.board-cell.is-today { background: color-mix(in oklab, var(--app-accent) 5%, var(--app-surface)); }
.board-cell.is-past { opacity: .62; }
.board-cell.is-over { background: color-mix(in oklab, var(--app-negative) 7%, var(--app-surface)); }

/* Unassigned work is the lane a dispatcher is meant to empty, so it is the one
 * that is allowed to look unfinished. */
.board-lane.is-orphan,
.board-cell.is-orphan { background: color-mix(in oklab, var(--app-warning) 6%, var(--app-surface)); }

.board-cap {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 11px;
}

.board-cap .bar { display: block; flex: 1 1 auto; min-width: 0; }

/* A day the crew does not work, and a day they work with nothing on it, are
 * different facts and must not look the same. The dash says "do not bother";
 * the blank space says "here is your answer". */
.board-off {
    color: var(--app-text-faint);
    text-align: center;
    padding: 14px 0;
    font-size: 12px;
}

.board-idle { flex: 1 1 auto; min-height: 8px; }

.board-job {
    display: block;
    padding: 7px 8px;
    border-radius: 8px;
    border: 1px solid var(--app-border);
    background: var(--app-bg);
    text-decoration: none;
    color: inherit;
}

.board-job:hover {
    border-color: var(--app-accent);
    text-decoration: none;
    color: inherit;
}

.board-job.is-urgent { border-left: 3px solid var(--app-negative); }
.board-job.is-orphan { border-style: dashed; }

.board-time { display: block; font-size: 11px; font-weight: 600; color: var(--app-accent-ink); }
.board-title { display: block; font-size: 12px; line-height: 1.3; margin: 2px 0; }
.board-meta { display: block; font-size: 11px; color: var(--app-text-muted); }

@media (max-width: 980px) {
    .board {
        grid-template-columns: minmax(112px, 128px) repeat(var(--board-days, 7), minmax(112px, 1fr));
        min-width: 0;
    }

    .board-cell { min-height: 76px; }
    .board-crew { font-size: 12px; }

    /* The rig list is the first thing to go: it is context, and on a phone the
     * lane header is competing with the cells for the only column that matters. */
    .board-rig { display: none; }
}

/* ---- The slot finder -----------------------------------------------------
 *
 * A ranked list of radio buttons, each one carrying the reasons it ranked
 * where it did. The reasons are the point. A dispatcher who disagrees with a
 * suggestion is usually right about something the estimate or the promise
 * window has wrong, and naming the reason is what lets them go and fix it
 * instead of quietly overriding the same suggestion every week.
 */
.slots { display: flex; flex-direction: column; gap: 8px; }

.slot {
    display: grid;
    grid-template-columns: auto minmax(96px, 132px) minmax(120px, 1fr) 2fr;
    align-items: center;
    gap: 10px;
    padding: 9px 10px;
    border: 1px solid var(--app-border);
    border-radius: 9px;
    cursor: pointer;
}

.slot:hover { border-color: var(--app-accent); }
.slot:has(input:checked) {
    border-color: var(--app-accent);
    background: var(--app-accent-soft, transparent);
}

/* A slot the job does not fit inside is still offered -- sometimes the right
 * answer is a long Thursday -- but it should not read as a recommendation. */
.slot.is-tight { opacity: .72; }
.slot.is-tight:has(input:checked) { opacity: 1; }

.slot.is-manual { border-style: dashed; }

.slot-when,
.slot-who { display: flex; flex-direction: column; gap: 1px; line-height: 1.3; }

.slot-when strong { font-size: 13px; }
.slot-who strong { font-size: 13px; font-weight: 600; }

.slot-why { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }

.manual-grid {
    display: grid;
    grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, .8fr);
    gap: 6px;
}

@media (max-width: 720px) {
    .slot {
        grid-template-columns: auto 1fr;
        align-items: start;
    }

    .slot-why { grid-column: 1 / -1; }
    .manual-grid { grid-template-columns: 1fr; }
}

/* ---------------------------------------------------------------------------
   Notifications

   The bell is the only piece of chrome in the header that changes on its own,
   so it is the only one allowed a colour that pulls the eye. Everything else
   here is deliberately quiet: a list of alerts that is itself alarming is a
   list people stop opening.
   --------------------------------------------------------------------------- */

.bell { position: relative; }

.bell > button { position: relative; padding-right: 12px; }

/* Sits on the corner of the bell rather than beside it, so the header does not
   reflow by a few pixels every time the count crosses into two digits. */
.bell-badge {
    position: absolute;
    top: -3px;
    right: -4px;
    min-width: 17px;
    padding: 0 4px;
    border-radius: 9px;
    background: var(--app-negative);
    color: var(--app-bg);
    font-size: 10px;
    font-weight: 700;
    line-height: 17px;
    text-align: center;
}

.bell-menu {
    width: 340px;
    max-width: calc(100vw - 24px);
    padding: 0;
    overflow: hidden;
}

.bell-loading { padding: 18px; text-align: center; }

.bell-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    padding: 10px 14px;
    border-bottom: 1px solid var(--app-border);
    font-size: 12px;
    font-weight: 600;
    color: var(--app-text-muted);
}

.bell-readall { margin: 0; }

.bell-readall .btn-link {
    background: none;
    border: 0;
    padding: 0;
    font-size: 12px;
    color: var(--app-accent-ink);
    cursor: pointer;
}

.bell-empty { padding: 26px 18px; text-align: center; font-size: 13px; }
.bell-empty p { margin: 0 0 4px; }

.bell-list {
    list-style: none;
    margin: 0;
    padding: 0;
    /* Eight rows fit; the ninth would mean the list is doing the full page's
       job badly. The cap is here rather than in the query as a second line of
       defence if a caller ever asks for more. */
    max-height: 400px;
    overflow-y: auto;
}

.bell-list li { border-bottom: 1px solid var(--app-border); }
.bell-list li:last-child { border-bottom: 0; }

.bell-list a {
    display: flex;
    gap: 9px;
    padding: 10px 14px;
    text-decoration: none;
    color: inherit;
}

.bell-list a:hover { background: var(--app-surface-2); text-decoration: none; color: inherit; }

/* The unread marker is a bar on the leading edge, not a background wash: it
   survives dark mode without a second colour, and it still reads at a glance
   when half the list is unread. */
.bell-list li.is-unread a { box-shadow: inset 3px 0 0 var(--app-accent); }

.bell-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.bell-title { font-size: 13px; font-weight: 600; line-height: 1.3; }
.bell-body {
    font-size: 12px;
    color: var(--app-text-muted);
    /* One line. A notification body that needs a paragraph belongs on the page
       it links to, and letting it wrap turns the dropdown into a wall. */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.bell-when { font-size: 11px; color: var(--app-text-faint); }

.bell-foot {
    display: flex;
    justify-content: space-between;
    padding: 9px 14px;
    border-top: 1px solid var(--app-border);
    background: var(--app-surface-2);
    font-size: 12px;
}

/* Shared by the dropdown and the full list, so an event's icon means the same
   thing in both places. */
.note-icon { display: inline-block; width: 18px; text-align: center; opacity: .85; }

.row-unread td { background: var(--app-accent-soft); }
.row-muted td { opacity: .55; }
.strong { font-weight: 600; }

/* ---- Files ------------------------------------------------------------- */

/* Stands in for a thumbnail when the file is not an image. Fixed to the same
   44px box the real thumbnails use, so a mixed list does not jog left and
   right as it scrolls. */
.ext-badge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    border-radius: 4px;
    background: var(--app-surface-2);
    border: 1px solid var(--app-border);
    font-size: 10px;
    font-weight: 600;
    letter-spacing: .04em;
    color: var(--app-text-muted);
}

.dropzone {
    border: 1.5px dashed var(--app-border);
    border-radius: 6px;
    padding: 22px 16px;
    text-align: center;
    color: var(--app-text-muted);
    font-size: 13px;
    background: var(--app-surface-2);
    transition: border-color .12s, background .12s;
    cursor: pointer;
}
.dropzone:hover { border-color: var(--app-accent); }

/* Only while something is actually over it. A permanently highlighted target
   teaches nothing about whether the drop will land. */
.dropzone.is-over {
    border-color: var(--app-accent);
    background: var(--app-accent-soft);
    color: var(--app-text);
}

.dropzone input[type=file] { display: none; }
.dropzone .dz-hint { font-size: 12px; color: var(--app-text-faint); margin-top: 3px; }

/* Progress and per-file errors from a background upload. Errors stay until the
   next attempt rather than fading -- a message about why a file was rejected is
   the one thing on this widget worth reading twice. */
.dz-status { margin-top: 10px; font-size: 12.5px; text-align: left; }
.dz-status .dz-err { color: var(--app-negative); }
.dz-status .dz-ok  { color: var(--app-text-muted); }

/* Attachment gallery. auto-fill rather than a fixed count so the same partial
   works in a wide card and in a narrow sidebar. */
.file-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
    gap: 10px;
}

.file-tile {
    position: relative;
    border: 1px solid var(--app-border);
    border-radius: 6px;
    overflow: hidden;
    background: var(--app-surface-2);
}

.file-tile img {
    display: block;
    width: 100%;
    height: 96px;
    object-fit: cover;
}

.file-tile .ft-meta {
    padding: 5px 7px;
    font-size: 11px;
    color: var(--app-text-muted);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.file-tile .ft-none {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 96px;
    font-size: 11px;
    font-weight: 600;
    color: var(--app-text-faint);
}

/* Appears on hover only. Always-visible remove buttons on a photo grid get
   pressed by accident on a phone, and the photo they remove is usually the
   evidence somebody needed. */
.file-tile .ft-drop {
    position: absolute;
    top: 4px;
    right: 4px;
    opacity: 0;
    transition: opacity .12s;
}
.file-tile:hover .ft-drop { opacity: 1; }

/* ---- Button rows outside the page head -----------------------------------
 *
 * .page-head .actions adds margin-left:auto to shove its own row against the
 * right edge; on its own the class means only "these sit in a line", which is
 * what a footer inside a card wants. Element-qualified deliberately: three
 * tables carry <td class="actions">, and a flex table cell stops taking part in
 * column sizing, which misaligns every row above it.
 */
div.actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }

/* ---- Long-form text -------------------------------------------------------
 *
 * .prose is for HTML somebody wrote rather than HTML a view assembled: article
 * bodies, guide steps. It has already been through HtmlPurifier by the time it
 * reaches here, so this is not a safety measure -- it is only undoing
 * Bootstrap's reboot, which strips the margins off headings and paragraphs and
 * turns a well-structured article into one unbroken block.
 */
.prose { font-size: 14px; line-height: 1.65; color: var(--app-text); }

.prose > :first-child { margin-top: 0; }
.prose > :last-child  { margin-bottom: 0; }

.prose h2 { font-size: 16px;   font-weight: 600; margin: 22px 0 8px; }
.prose h3 { font-size: 14.5px; font-weight: 600; margin: 18px 0 6px; }
.prose h4 { font-size: 13.5px; font-weight: 600; margin: 16px 0 6px; }

.prose p { margin: 0 0 12px; }
.prose ul, .prose ol { margin: 0 0 12px; padding-left: 22px; }
.prose li { margin-bottom: 4px; }
.prose li > ul, .prose li > ol { margin-top: 4px; }
.prose a { text-decoration: underline; }

.prose blockquote {
    margin: 0 0 12px;
    padding: 8px 14px;
    border-left: 3px solid var(--app-border);
    color: var(--app-text-muted);
}

.prose code {
    background: var(--app-surface-2);
    border: 1px solid var(--app-border);
    border-radius: 4px;
    padding: 1px 4px;
    font-size: 12.5px;
}

.prose pre {
    background: var(--app-surface-2);
    border: 1px solid var(--app-border);
    border-radius: 6px;
    padding: 10px 12px;
    margin: 0 0 12px;
    overflow-x: auto;
}

.prose pre code { background: none; border: 0; padding: 0; }

.prose img { max-width: 100%; height: auto; border-radius: 6px; }

.prose table { width: 100%; border-collapse: collapse; margin: 0 0 12px; font-size: 13px; }
.prose th, .prose td { border: 1px solid var(--app-border); padding: 6px 9px; text-align: left; }
.prose th { background: var(--app-surface-2); font-weight: 600; }

.prose hr { border: 0; border-top: 1px solid var(--app-border); margin: 18px 0; }

/* Raw markup, an id, a stored value -- anywhere the characters matter more
   than the reading of them. */
.mono {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
    font-size: 12.5px;
}

textarea.mono { line-height: 1.5; }

/* ---------------------------------------------------------------------------
   Help

   Three things share this section because they share a job: the drawer that
   answers "what is this screen", the article pages, and the guide runner that
   walks somebody through a task on the real screen rather than a picture of it.

   The runner is the reason for the unusual shape below. It never covers the
   page. The dimming is one element with an enormous box-shadow and
   pointer-events: none, so the bright patch is not a hole punched in a click
   target -- there is no click target. Everything underneath stays usable, which
   is what lets a step say "now press Save" and mean the real Save button.
   --------------------------------------------------------------------------- */

/* ---- The drawer ---------------------------------------------------------- */

.help-drawer { position: fixed; inset: 0; z-index: 1080; }
.help-drawer[hidden] { display: none; }

/* Transparent on purpose. A scrim behind a help panel says "deal with me
   first", and the whole point of contextual help is reading it against the
   screen it describes. This catches the click that closes the drawer and
   nothing else. */
.help-drawer-veil { position: absolute; inset: 0; }

.help-drawer-panel {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    width: 360px;
    max-width: calc(100vw - 32px);
    display: flex;
    flex-direction: column;
    background: var(--app-surface);
    border-left: 1px solid var(--app-border);
    box-shadow: -8px 0 30px var(--app-shadow-color);
    overflow-y: auto;
}

/* The ? in the header stays lit while its panel is open, because a drawer that
   opens off-centre with no visible source looks like something went wrong. */
body.help-open #help-toggle {
    background: var(--app-accent-soft);
    border-color: var(--app-accent);
}

.help-panel-loading { padding: 22px 16px; text-align: center; }

.help-panel-head {
    position: sticky;
    top: 0;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    padding: 12px 14px;
    background: var(--app-surface);
    border-bottom: 1px solid var(--app-border);
}

.help-panel-search { padding: 12px 14px 4px; }

.help-panel-section { padding: 8px 14px 12px; }

.help-panel-label {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: .05em;
    color: var(--app-text-muted);
    margin-bottom: 6px;
}

/* A guide offer and an article link are the same size and shape because they
   are the same decision -- "this one, then" -- and making the button louder
   than the reading would push people into a tour they did not want. */
.help-panel-guide,
.help-panel-item {
    display: block;
    width: 100%;
    text-align: left;
    padding: 8px 10px;
    margin-bottom: 2px;
    border: 1px solid transparent;
    border-radius: 6px;
    background: none;
    color: var(--app-text);
    text-decoration: none;
    font: inherit;
    cursor: pointer;
}

.help-panel-guide:hover,
.help-panel-item:hover {
    background: var(--app-surface-2);
    border-color: var(--app-border);
    color: var(--app-text);
    text-decoration: none;
}

.help-panel-guide-name,
.help-panel-item-title { display: block; font-size: 13.5px; font-weight: 500; }

.help-panel-guide-sub,
.help-panel-item-sub {
    display: block;
    font-size: 12px;
    color: var(--app-text-faint);
    margin-top: 2px;
}

.help-panel-guide-name::before { content: "▶ "; color: var(--app-accent-ink); font-size: 10px; }

.help-panel-empty { padding: 2px 10px 0; margin: 0; }

/* margin-top:auto pins this to the bottom of a short panel instead of leaving
   it floating under two lines of text. */
.help-panel-foot {
    margin-top: auto;
    display: flex;
    gap: 8px;
    padding: 12px 14px;
    border-top: 1px solid var(--app-border);
    background: var(--app-surface-2);
}

/* ---- Article pages ------------------------------------------------------- */

.help-layout {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 300px;
    gap: 16px;
    align-items: start;
}

@media (max-width: 980px) {
    .help-layout { grid-template-columns: 1fr; }
}

/* A grid item's default min-width is auto, which means a wide table or an
   unbroken line of code inside .prose sets the column width rather than
   scrolling inside it -- and the whole page grows a horizontal scrollbar
   instead. This is what makes overflow-x on .prose pre actually do something. */
.help-main { min-width: 0; }

/* No layout of its own: the cards inside it space themselves with .card + .card
   and would double up against a flex gap here. */
.help-side { min-width: 0; }

/* The summary, printed once at the top. Larger than the body it introduces,
   because the whole reason it exists is that some people only read it. */
.help-lede {
    font-size: 15px;
    line-height: 1.6;
    color: var(--app-text-muted);
    margin: 0 0 14px;
}

.help-rate {
    border-top: 1px solid var(--app-border);
    background: var(--app-surface-2);
    border-radius: 0 0 var(--app-radius) var(--app-radius);
}

.help-rate-ask { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.help-rate-ask[hidden] { display: none; }

.help-rate-why { margin-top: 10px; }

/* Sits between two cards' worth of content, so it needs its own separator --
   without one a list of three tours reads as one paragraph. */
.help-side-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    padding: 8px 0;
}

.help-side-row + .help-side-row { border-top: 1px solid var(--app-border); }
.help-side-row:first-child { padding-top: 0; }
.help-side-row:last-child { padding-bottom: 0; }

/* ---- Revision history ---------------------------------------------------- */

.help-revision + .help-revision { border-top: 1px solid var(--app-border); }

.help-revision-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }

.help-revision-title { color: var(--app-text-muted); margin-top: 3px; }

.help-revision-body { margin-top: 8px; }
.help-revision-body > summary { cursor: pointer; font-size: 12px; color: var(--app-text-muted); }
.help-revision-body > summary:hover { color: var(--app-accent-ink); }

/* Capped and scrolling. An old copy of a long article is worth keeping and
   almost never worth reading top to bottom, and letting one expand to nine
   screens buries the twelve revisions under it. */
.help-revision-pre {
    margin: 8px 0 0;
    padding: 10px 12px;
    max-height: 320px;
    overflow: auto;
    background: var(--app-surface-2);
    border: 1px solid var(--app-border);
    border-radius: 6px;
    white-space: pre-wrap;
    word-break: break-word;
    color: var(--app-text-muted);
}

/* ---- The guide runner ---------------------------------------------------- */

.guide-layer {
    position: fixed;
    inset: 0;
    z-index: 1085;
    /* The layer is scenery. Only .guide-pop below turns clicks back on. */
    pointer-events: none;
}

.guide-layer[hidden] { display: none; }

/* The whole dimming effect, drawn by one element's shadow. The element itself
   is the bright patch; the 9999px spread is everything else on screen. It
   animates between steps so the eye follows the highlight instead of hunting
   for where it went. */
.guide-spot {
    position: fixed;
    border-radius: 6px;
    outline: 2px solid var(--app-accent);
    box-shadow: 0 0 0 9999px var(--app-scrim);
    pointer-events: none;
    transition: top .18s ease, left .18s ease, width .18s ease, height .18s ease;
}

.guide-spot[hidden] { display: none; }

.guide-pop {
    position: fixed;
    width: 320px;
    max-width: calc(100vw - 24px);
    padding: 13px 15px 12px;
    background: var(--app-surface);
    border: 1px solid var(--app-border);
    border-radius: var(--app-radius);
    box-shadow: var(--app-shadow-lg);
    pointer-events: auto;
}

.guide-pop-head { display: flex; align-items: center; gap: 8px; }
.guide-pop-head .spacer { flex: 1 1 auto; }

.guide-pop-name {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: .05em;
    font-weight: 600;
    color: var(--app-accent-ink);
}

.guide-pop-count { font-variant-numeric: tabular-nums; }

.guide-pop-x {
    border: 0;
    background: none;
    color: var(--app-text-faint);
    font-size: 13px;
    line-height: 1;
    padding: 2px 4px;
    cursor: pointer;
}

.guide-pop-x:hover { color: var(--app-text); }

.guide-pop-title { font-size: 15px; font-weight: 600; margin: 6px 0 6px; }

.guide-pop-body { font-size: 13px; line-height: 1.55; color: var(--app-text-muted); }
.guide-pop-body > :last-child { margin-bottom: 0; }
.guide-pop-body p { margin: 0 0 8px; }
.guide-pop-body ul, .guide-pop-body ol { margin: 0 0 8px; padding-left: 20px; }
.guide-pop-body code {
    background: var(--app-surface-2);
    border: 1px solid var(--app-border);
    border-radius: 4px;
    padding: 1px 4px;
    font-size: 12px;
}

.guide-pop-foot {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-top: 12px;
    padding-top: 10px;
    border-top: 1px solid var(--app-border);
}

.guide-pop-foot .spacer { flex: 1 1 auto; }

/* On a phone the popover stops chasing the highlight and parks at the bottom.
   A 320px card placed beside a 340px-wide screen has nowhere to go, and the
   placement solver's answer -- clamped hard against one edge, usually on top of
   the thing it is pointing at -- is worse than a fixed position everybody can
   predict. */
@media (max-width: 560px) {
    .guide-pop {
        top: auto !important;
        left: 12px !important;
        right: 12px;
        bottom: 12px;
        width: auto;
        max-width: none;
    }
}

/* Sortable column headings.

   A heading that sorts is a link, and a link in a table head that looks like
   every other link turns the header row into a wall of blue. This keeps the
   heading's own colour and only signals interactivity on hover, where a
   pointer already tells you the same thing. Underline on hover rather than a
   colour change so it still reads as clickable without colour vision. */
   The dotted rule is a text-decoration and not a border-bottom, which is what
   it used to be. A border is part of the box, so the link measured 19px where
   every sibling in a card head measured 18, and the head it sat in came out a
   pixel taller than every other head on the page -- one card in a stack sitting
   1px low for a reason nothing about the card explained. A text-decoration
   draws in the same place and contributes nothing to layout. */
.quiet-link {
    color: inherit;
    text-decoration: underline dotted var(--app-border);
    text-underline-offset: 3px;
}

.quiet-link:hover,
.quiet-link:focus-visible {
    color: var(--app-accent-ink);
    text-decoration-color: currentColor;
}

/* Custom dashboard grid.

   Tiles declare a column span out of the dashboard's own width, so a layout
   built for twelve columns still reads correctly on a phone: below the
   breakpoint every tile takes the full row rather than being squeezed into a
   third of a 360px screen, which is where a revenue figure starts wrapping
   mid-number. */
.dash-grid {
    display: grid;
    grid-template-columns: repeat(var(--dash-cols, 12), minmax(0, 1fr));
    gap: 1rem;
    margin-top: 1rem;
}

.dash-tile {
    grid-column: span var(--tile-span, 3);
    min-width: 0;
}

.dash-tile > .card {
    height: 100%;
    margin: 0;
}

/* A tile's table scrolls inside the tile. Without this a wide report drags the
   whole grid sideways and every other tile moves with it. */
.dash-tile .table-wrap {
    max-height: 20rem;
    overflow: auto;
}

@media (max-width: 900px) {
    .dash-grid {
        grid-template-columns: 1fr;
    }

    .dash-tile {
        grid-column: span 1;
    }
}

/* The layout editor: one row per tile, arrows on the left, fields inline.
   Deliberately not a drag-and-drop canvas -- see DashboardController::actionCustomize. */
.tile-row {
    display: flex;
    flex-wrap: wrap;
    gap: .75rem;
    align-items: flex-end;
    padding: .75rem 0;
    border-bottom: 1px solid var(--app-border);
}

.tile-row:last-child {
    border-bottom: 0;
}

.tile-row .grow {
    flex: 1 1 12rem;
    min-width: 0;
}

.tile-row .nudge {
    display: flex;
    gap: .25rem;
}
