/* ========================================================
   SEMANTIC COLOUR TOKENS (LIGHT & DARK)
   ========================================================
   The meaning-level layer. Components reference THESE, never the raw hex
   palette in base/variables.css.

   Both themes are declared in this file, one block after the other, so a
   value can never be added to light without someone noticing dark is
   missing — which is exactly how the old --color-* set ended up frozen in
   light-mode colours.

   Naming:
     --surface / --surface-sunken / --surface-raised   backgrounds
     --text / --text-muted                             foreground
     --border                                          hairlines
     --brand / --positive / --negative / --caution     accents (FILLS)
     --*-ink                                           same, safe as TEXT
   ======================================================== */
:root {
    /* ---- Semantic: surfaces ----
       THE PAGE IS BUTTER. --surface-sunken is #FFF1A6 at full strength, not
       a wash of it, and cards are PARCHMENT sitting on top.

       Cards were pure white until v2.4.0. The luminance argument for it still
       holds — white on butter is 1.14:1 and the two separate by hue rather
       than by lightness, which is one of the things the eye is best at — but
       "legible" was never the complaint. A white card on a saturated yellow
       page reads as a hole in the design rather than as a surface belonging
       to it. Parchment (#FDF8EE, hue 38 against butter's 51) keeps the hue
       separation and loses the starkness. See base/variables.css for the
       measured cost, which is under a point of contrast everywhere.

       The card edge is still defined by THREE cues, not one — hue, a hairline
       and --elev-1 — because 1.11:1 of luminance is not a boundary on its
       own and never was. */
    --surface:        var(--parchment-200);  /* cards — Warm Parchment */
    --surface-sunken: var(--butter-300);     /* the page — Soft Butter */
    --surface-raised: var(--parchment-100);  /* a tile lifted off a card */
    /* Pure white is now the INPUT well and nothing else. Giving it that one
       job is what buys the parchment card its contrast: a white field on a
       parchment card reads as editable at a glance, where a parchment field
       on a parchment card needed its border to do all the work. */
    --surface-input:  #ffffff;
    --border:         var(--butter-500);  /* reads on butter AND on white */
    /* A second, heavier hairline for boundaries that WCAG 1.4.11 calls
       essential — the outline of a form control, the rule under a table
       header. 3.0:1 against both light surfaces. */
    --border-strong:  var(--butter-600);   /* 3.1:1 on butter, 3.5:1 on white */

    /* ---- Semantic: text ----
       text 15.3:1 on butter and 17.4:1 on white
       text-muted 5.6:1 on butter and 6.4:1 on white */
    --text:       var(--clay-900);
    --text-muted: var(--stone-500);

    /* ---- Semantic: accents (FILLS — white text on them) ----
       brand 9.7:1   positive 5.1:1   negative 6.5:1   caution 5.1:1
       ALL FOUR now carry white at AA, caution included. On the previous
       palette caution was a 2.3:1 yellow and was documented as "a fill only,
       never put text on it" — a rule that was broken twice. It is a burnt
       amber now and the special case is gone. */
    --brand:       var(--clay-600);
    --brand-hover: var(--clay-700);
    --positive:    var(--green-500);
    --negative:    var(--red-500);
    --caution:     var(--amber-500);

    /* ---- Semantic: accents (safe as TEXT on a light surface) ----
       brand-ink 8.5:1  positive-ink 6.9:1  negative-ink 7.8:1  caution-ink 6.3:1 (on butter)
       --brand-ink is the SAME value as --brand: clay is dark enough to be
       both, which the old orange was not. Kept as a distinct name so a rule
       still says which of the two jobs it means. */
    --brand-ink:    var(--clay-600);
    --positive-ink: var(--green-700);
    --negative-ink: var(--red-700);
    --caution-ink:  var(--amber-700);

    /* ---- Semantic: brand washes ----
       Tinted backgrounds for selected rows, active chips and hover states.
       Named separately so a component never has to invent an rgba() of the
       brand and get the opacity subtly different from its neighbour.

       These are CLAY tints, not butter tints, and that matters: almost every
       wash in the app appears INSIDE a white card (a zebra stripe, a hovered
       table row, a selected list item). A butter wash inside a white card
       would read as the page bleeding through the card. */
    --brand-wash:        var(--clay-50);
    --brand-wash-strong: var(--clay-100);
    /* The butter counterpart, for the rarer case of a highlight that sits
       directly ON the page rather than inside a card. */
    --page-wash:         var(--butter-200);

    /* ---- Semantic: INK ON A FILL ----
       What colour text/icons take when they sit ON --brand, --positive etc.
       This pair is the reason the dark theme does not break: in light mode
       the fills are dark enough for white text (5.1-9.7:1), in dark mode the
       same fills are LIGHTER than the page, so white on them collapses and
       the ink has to flip to near-black. Hardcoding `color: #fff` next to
       `background: var(--brand)` is the bug this token prevents. */
    --on-brand:    #ffffff;   /* 9.7:1 */
    --on-positive: #ffffff;   /* 5.1:1 */
    --on-negative: #ffffff;   /* 6.5:1 */
    --on-caution:  #ffffff;   /* 5.1:1 — white now, see the note above */

    /* ---- CHART SERIES ----
       Eight categorical colours, ordered so the first four are the ones a
       reader already knows the meaning of (brand, good, bad, watch) and the
       rest are neutral fillers.

       Every one is >= 4.3:1 against BOTH light surfaces. The WCAG floor for
       a graphical object is 3:1 and these clear it with room, because a
       chart in this app is read on a butter page as often as on a white
       card and the butter is the harder of the two by about 15%.

       There is no yellow in the set, deliberately. On a butter page a yellow
       series is a series that disappears. */
    --chart-1: #6B352A;   /* clay — brand */
    --chart-2: #2F6B3A;   /* forest */
    --chart-3: #17636F;   /* deep teal */
    --chart-4: #A32B2B;   /* brick */
    --chart-5: #8A6B14;   /* olive */
    --chart-6: #3F4E8C;   /* indigo */
    --chart-7: #7A3D6B;   /* plum */
    --chart-8: #6E5B4A;   /* stone */

    /* ---- CHART CHROME (v2.8.0) ----
       The parts of a chart that are NOT a series: the gridlines, the tooltip
       and the corner a bar is drawn with.

       These are tokens for the same reason the series above are. Until
       v2.8.0 main.dashboard.js carried its own literals for all three — an
       `rgba(43,29,20,0.08)` gridline written out in tokens(), a
       `borderRadius: 6` repeated at nine call sites, and NO tooltip styling
       at all, which meant every chart on the platform popped Chart.js's
       default near-black tooltip in the browser's default font. On a butter
       page, next to parchment cards, that black box is the one piece of the
       interface that belongs to no design system.

       --chart-grid is a hairline, not a rule: it has to be visible enough to
       carry the eye across the plot and faint enough that the series stay
       the darkest thing in the frame. 0.08 alpha on the page ink does that
       on both themes without needing two different hues.

       --chart-bar-radius is a PLAIN NUMBER, not a length. Chart.js takes
       borderRadius as a number of pixels and getComputedStyle would hand a
       `12px` string straight through to a canvas API that silently ignores
       it. It is deliberately smaller than --radius-sm: a bar 20px wide with
       an 8px corner is a lozenge, and at four buckets these get narrow. */
    --chart-grid: rgba(43, 29, 20, 0.08);
    --chart-bar-radius: 6;
    /* The tooltip. Surface + ink + hairline, i.e. exactly what a card is
       made of, so the thing that appears over a chart is recognisably part
       of the same interface as the panel behind it. */
    --chart-tooltip-bg: #FFFFFF;
    --chart-tooltip-text: #2A140F;
    --chart-tooltip-muted: #6F5B4C;
    --chart-tooltip-border: rgba(43, 29, 20, 0.18);

    /* ---- Component-level tokens ----
       GRADIENT MESHES, AND WHY THEY ARE NOT THE ONES v2.3.0 DELETED (v2.4.0)

       v2.3.0 flattened the page, the sidebar and the H1, and the reason it
       gave is still correct and still binding: a gradient BEHIND TRANSLUCENT
       CARDS forced a backdrop-filter blur on every card to keep text
       legible, and that blur was the single most expensive thing this UI did
       per scroll frame on a mid-range Android handset.

       Read that sentence again, because it is a rule about TRANSPARENCY, not
       about gradients. The cost came from the cards being see-through. The
       meshes below are re-introduced under three conditions that keep the
       cards opaque and the cost at zero:

         1. THE PAGE MESH PAINTS ONCE. It lives on the `body::before` fixed
            layer that v1.15.0 already built for exactly this — a
            position: fixed element is promoted to its own compositor layer
            and scrolled by the compositor, so the mesh is rasterised on load
            and never again. `background-attachment: fixed` on <body> would
            repaint the viewport every frame; that is the version that was
            slow, and it is not this one.
         2. NOTHING IN FRONT OF IT IS TRANSLUCENT. Cards stay opaque, so no
            backdrop-filter is needed and none is added. The mesh is
            decoration the eye finds at the edges of the layout, never
            something a card has to be legible THROUGH.
         3. THE MESH BARELY DARKENS, AND THE LIMIT IS COMPUTED. Text sits
            directly on the page — every page H1 and the muted subtitle under
            it do — so a mesh that could darken anywhere is a mesh that fails
            contrast somewhere nobody screenshotted. The darkest point
            reachable by stacking BOTH warm blobs at full strength is
            #F4E29A: 4.94:1 against --text-muted and 13.43:1 against --text,
            against 5.61 and 15.25 on the bare page. That is the worst case,
            not the typical one, and it still clears AA. The numbers are
            computed rather than eyeballed and pinned by
            tests/test_v240_surfaces.py — which is why the two alphas below
            look oddly precise. They are the largest values that hold the
            floor.

       WHY EACH STOP FADES TO rgba(<its own colour>, 0) AND NOT `transparent`.
       `transparent` is rgba(0, 0, 0, 0) — transparent BLACK. Interpolating
       from a warm tint to transparent black takes the midpoint through a grey
       haze in any engine that does not premultiply alpha, which is a
       dirty-looking ring around every blob on exactly the older Android
       WebViews this app is read on. Fading a colour to its own zero-alpha
       form has no midpoint to get wrong. */
    --bg-gradient:
        /* two cool-white lifts, top-left and bottom-right, that stop the
           saturated butter from filling the whole frame at one value */
        radial-gradient(1180px 820px at 4% -12%,
                        rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0) 62%),
        radial-gradient(1280px 900px at 82% 108%,
                        rgba(255, 255, 255, 0.62), rgba(255, 255, 255, 0) 60%),
        /* a butter-100 bloom behind the top-right corner, where the filter
           bar and the notification bell sit */
        radial-gradient(920px 660px at 100% -4%,
                        rgba(255, 251, 226, 0.92), rgba(255, 251, 226, 0) 56%),
        /* the one warm blob: butter-500 low and left, so the page has a
           direction to it rather than being uniformly bright */
        radial-gradient(880px 640px at 16% 78%,
                        rgba(216, 193, 119, 0.14), rgba(216, 193, 119, 0) 62%),
        /* and a trace of clay through the middle. 4.5% is the entire clay
           content of the page; it is what keeps the mesh reading as the
           brand's two colours mixing rather than as a yellow gradient. */
        radial-gradient(760px 560px at 58% 36%,
                        rgba(107, 53, 42, 0.045), rgba(107, 53, 42, 0) 64%),
        var(--surface-sunken);

    /* The card's own layered transparency: a near-invisible top-down
       lightening over the parchment, plus one clay-tinted corner. It is a
       BACKGROUND-IMAGE on an opaque element, not opacity on the element, so
       nothing behind the card is composited and the paint is a one-off.

       The whole range from top to bottom is under 2% — enough that a large
       panel does not read as one flat rectangle, not enough that anyone
       would describe the card as "a gradient" if asked what it looked like. */
    --card-mesh:
        linear-gradient(180deg,
                        rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0) 42%),
        radial-gradient(120% 100% at 100% 0%,
                        rgba(216, 193, 119, 0.10), rgba(216, 193, 119, 0) 58%);

    /* The sidebar mesh. Clay-800 stays the base and the blobs only LIGHTEN,
       which is the mirror of the page rule: the sidebar carries white text at
       15.2:1, so anything that lightens the plate reduces that ratio and
       anything that darkens it cannot hurt. The lightest point reachable
       here is #573428: white lands at 10.90:1 and --sidebar-muted (white at
       0.68 alpha) at 6.00:1, against 15.2 and 8.0 on the flat plate. Both
       clear AA for the 14px nav labels with room. */
    --sidebar-mesh:
        radial-gradient(760px 520px at 0% 0%,
                        rgba(214, 148, 123, 0.11), rgba(214, 148, 123, 0) 62%),
        radial-gradient(640px 900px at 100% 100%,
                        rgba(255, 241, 166, 0.05), rgba(255, 241, 166, 0) 58%);

    --card-bg: rgba(253, 248, 238, 0.94);   /* near-opaque, drawer only */
    --text-gradient: none;                  /* kept: rules still reference it */

    --sidebar-width: 260px;
    --sidebar-bg: var(--clay-800);
    --sidebar-text: #ffffff;                /* 15.2:1 on --clay-800 */
    --sidebar-muted: rgba(255, 255, 255, 0.68);
    /* The sidebar is dark in BOTH themes, so its active state needs its own
       pair rather than reusing --brand + --on-brand: in dark mode --brand is
       the lighter clay and the ink flips, which would flip the nav item's
       text colour for no reason the user can see.

       It is BUTTER, and that is the one place the second brand colour does
       structural work rather than decorative: the active page is the single
       most important piece of state in the chrome, and a butter pill on a
       clay sidebar is unmissable at 13.3:1 without being another shade of
       brown to squint at. */
    --sidebar-active-bg:   var(--butter-300);
    --sidebar-active-text: var(--clay-800);  /* 13.3:1 */
    /* The four group headings (v2.4.0). Butter at 70%, which is 7.18:1 on
       --clay-800 and still 5.59:1 at the lightest point --sidebar-mesh can
       reach — AA for small text with room at both ends. Butter rather than
       plain white on purpose: the heading is structure, and structure in
       this design is the second brand colour doing quiet work, the same
       argument as the active pill directly above. */
    --sidebar-section:     rgba(255, 241, 166, 0.70);

    /* ---- LEGACY ALIASES ----
       The names the existing rules already use, now pointing at the
       semantic tokens above rather than carrying their own copies of the
       hex values. One place to change a colour; both themes follow
       automatically.

       The -rgb triplets stay literal because they are consumed inside
       rgba(...) expressions that need the raw channel components — CSS has
       no way to decompose a colour value back into its channels. */
    --bg-color:       var(--surface-sunken);
    --card-bg-solid:  var(--surface);
    --card-border:    var(--border);
    --text-main:      var(--text);
    --input-bg:       var(--surface-input);
    --input-border:   var(--border-strong);

    --accent-blue:        var(--brand);
    --accent-blue-hover:  var(--brand-hover);
    --accent-green:       var(--positive);
    --accent-red:         var(--negative);
    --accent-orange:      var(--caution);
    --accent-green-ink:   var(--positive-ink);
    --accent-red-ink:     var(--negative-ink);
    --accent-orange-ink:  var(--caution-ink);

    --accent-blue-rgb:   107, 53, 42;
    --accent-green-rgb:  46, 125, 50;
    --accent-red-rgb:    176, 42, 42;
    --accent-orange-rgb: 168, 91, 0;
    --butter-rgb:        255, 241, 166;
}

/* Dark Theme Overrides — clay-black surfaces with the two brand colours
   lifted rather than replaced. The old dark theme (before v2.2.0) was a
   different brand from the light one; both themes have been the same palette
   at two lightnesses since, and the Clay & Butter refresh keeps that.

   The move that makes this theme work: BUTTER BECOMES THE TEXT. On the light
   theme butter is the page and clay is the ink; inverted, clay-black is the
   page and a butter-tinted off-white is the ink. The two colours keep their
   relationship to each other, which is why the app still looks like itself
   after someone flips the switch — as opposed to merely still being
   legible. */
html.dark-theme {
    /* ---- Semantic: surfaces ----
       Clay-black (a trace of red, warmed), not neutral #1E1E1E. Next to a
       butter accent a neutral dark reads slightly violet. */
    --surface:        #211611;
    --surface-sunken: #17100C;
    --surface-raised: #2C1E17;
    /* The dark theme had the inverse of the light theme's problem and it was
       already solved: --input-bg here has always been a RECESSED well rather
       than --surface, because on a dark card the editable thing is the darker
       thing. v2.4.0 only gives that well a name of its own so both themes
       resolve --input-bg through the same token. */
    --surface-input:  rgba(23, 16, 12, 0.6);
    --border:         rgba(255, 241, 166, 0.16);
    --border-strong:  rgba(255, 241, 166, 0.34);

    /* ---- Semantic: text ----
       A butter-tinted off-white rather than plain #F5F5F5.
       text 16.0:1 on surface   text-muted 7.9:1 */
    --text:       #FBF3DE;
    --text-muted: #BCAB90;

    /* ---- Semantic: accents (FILLS — DARK text on them on this theme) ----
       brand 7.1:1  positive 7.5:1  negative 5.1:1  caution 7.6:1
       measured against --surface used as the ink colour. */
    --brand:       var(--clay-400);
    --brand-hover: #E5A894;
    --positive:    #66BB6A;
    --negative:    #EF5350;
    --caution:     #E39A4A;

    /* ---- Semantic: accents (safe as text) ----
       On a dark surface the readable variant is LIGHTER, not darker.
       brand-ink 8.7:1  positive-ink 10.3:1  negative-ink 7.8:1  caution-ink 10.0:1 */
    --brand-ink:    #E2A991;
    --positive-ink: #97D49A;
    --negative-ink: #FF8A80;
    --caution-ink:  #F0B87A;

    --brand-wash:        rgba(201, 132, 107, 0.10);
    --brand-wash-strong: rgba(201, 132, 107, 0.18);
    --page-wash:         rgba(255, 241, 166, 0.08);

    /* All four flip to the page ink: every fill is lighter than the surface
       on this theme. */
    --on-brand:    #211611;
    --on-positive: #211611;
    --on-negative: #211611;
    --on-caution:  #211611;

    /* Chart series, lifted for a dark background. Same hues, same order, so
       a chart means the same thing after someone flips the theme. */
    --chart-1: #D89376;
    --chart-2: #7CC47F;
    --chart-3: #5EB2C0;
    --chart-4: #F0736E;
    --chart-5: #D2B453;
    --chart-6: #8E9BE0;
    --chart-7: #C98BBB;
    --chart-8: #BCA48C;

    /* Chart chrome, dark. The gridline flips to the page's LIGHT ink at the
       same alpha, and the tooltip becomes a raised dark surface — the same
       swap every card on this theme makes. */
    --chart-grid: rgba(245, 239, 231, 0.08);
    --chart-tooltip-bg: #2C1F19;
    --chart-tooltip-text: #FBF3DE;
    --chart-tooltip-muted: #BCAB90;
    --chart-tooltip-border: rgba(245, 239, 231, 0.20);

    /* Elevation reads as a lighter surface on dark, not a heavier shadow —
       a black shadow on a near-black background is invisible. */
    --elev-1: 0 1px 2px rgba(0, 0, 0, 0.42);
    --elev-2: 0 4px 12px rgba(0, 0, 0, 0.52);
    --elev-3: 0 12px 28px rgba(0, 0, 0, 0.62);
    --elev-hover: 0 8px 20px rgba(0, 0, 0, 0.55);

    /* ---- Component-level tokens ----
       The dark meshes are the light ones' mirror image and the safety rule
       flips with them: on this theme the text is LIGHT, so a blob that
       lightens the page is the one that can cost contrast and a blob that
       darkens it cannot. Everything below therefore sits at or below the
       base surface except one clay lift kept under 9%.

       Lightest point reachable, stacking every blob: #422B1C. --text lands
       at 11.91:1 and --text-muted at 5.88:1, against 16.0 and 7.9 on the
       flat surface. Both clear AA; --text-muted is the binding one. */
    --bg-gradient:
        radial-gradient(1180px 820px at 4% -12%,
                        rgba(107, 53, 42, 0.34), rgba(107, 53, 42, 0) 62%),
        radial-gradient(1280px 900px at 82% 108%,
                        rgba(58, 40, 30, 0.55), rgba(58, 40, 30, 0) 60%),
        radial-gradient(920px 660px at 100% -4%,
                        rgba(90, 66, 30, 0.22), rgba(90, 66, 30, 0) 56%),
        radial-gradient(760px 560px at 58% 36%,
                        rgba(255, 241, 166, 0.030), rgba(255, 241, 166, 0) 64%),
        var(--surface-sunken);

    /* Same shape as the light card mesh, inverted: the lift comes from the
       top and the warm corner is butter rather than clay. Both are under 5%,
       because a dark surface shows a gradient far more readily than a light
       one — the same 2% that is invisible on parchment is a visible band
       here. */
    --card-mesh:
        linear-gradient(180deg,
                        rgba(255, 241, 166, 0.045), rgba(255, 241, 166, 0) 42%),
        radial-gradient(120% 100% at 100% 0%,
                        rgba(214, 148, 123, 0.06), rgba(214, 148, 123, 0) 58%);

    /* The sidebar mesh does NOT flip. The sidebar is dark on both themes and
       carries the same white text on both, so the light-theme mesh is
       already the correct one — but the plate underneath it is darker here
       (#170F0B against #3D1D16), so the same alphas land dimmer and the
       lightest point carries white at 14.02:1 rather than 10.90:1. Nothing
       to correct. */
    --sidebar-mesh:
        radial-gradient(760px 520px at 0% 0%,
                        rgba(214, 148, 123, 0.11), rgba(214, 148, 123, 0) 62%),
        radial-gradient(640px 900px at 100% 100%,
                        rgba(255, 241, 166, 0.05), rgba(255, 241, 166, 0) 58%);

    --card-bg: rgba(44, 30, 23, 0.94);
    --sidebar-bg: #170F0B;
    --sidebar-muted: rgba(251, 243, 222, 0.64);
    /* Butter stays the active pill on this theme too — it is the one token
       that does NOT flip, because the sidebar it sits on is dark either way
       and the whole point of the pill is that it looks the same wherever
       you are. */
    --sidebar-active-bg:   var(--butter-300);
    --sidebar-active-text: var(--clay-800);
    /* Same value as the light theme, and for the same reason as the active
       pill above it: the sidebar is dark on both themes. Restated rather
       than inherited so the "every token exists in both blocks" test can see
       it, and so nobody has to go and look. It lands at 8.4:1 here — the
       dark theme's plate is darker, so the heading is if anything clearer. */
    --sidebar-section:     rgba(255, 241, 166, 0.70);

    /* ---- LEGACY ALIASES ----
       Only the ones whose dark value differs from the light alias need
       restating; the rest inherit through the semantic tokens above.
       --input-bg is deliberately NOT var(--surface) here: inputs use a
       recessed, slightly transparent well on dark so they read as
       editable against the raised card surface. */
    --input-bg:     rgba(23, 16, 12, 0.6);
    --input-border: rgba(255, 241, 166, 0.30);

    --accent-blue-rgb:   201, 132, 107;
    --accent-green-rgb:  102, 187, 106;
    --accent-red-rgb:    239, 83, 80;
    --accent-orange-rgb: 227, 154, 74;
}

body {
    margin: 0;
    padding: 0;
    font-family: 'Inter', sans-serif;
    background: var(--bg-color);
    color: var(--text-main);
    display: flex;
    min-height: 100vh;
    transition: background 0.3s ease, color 0.3s ease;
}

/* The page gradient, as a fixed pseudo-element instead of
   `background-attachment: fixed` on <body>.

   `background-attachment: fixed` forces the browser to repaint the whole
   viewport background on every scroll frame, and combined with the
   backdrop-filter blurs on cards it was the main cause of the dashboard
   feeling slightly sticky to scroll. A fixed-position layer paints once and
   is composited thereafter — visually identical, no per-frame repaint. */
body::before {
    content: '';
    position: fixed;
    inset: 0;
    z-index: -1;
    background: var(--bg-gradient);
    pointer-events: none;
}

/* ========================================================
   LAYOUT: SIDEBAR, MOBILE HEADER & MAIN CONTENT
   ======================================================== */
.mobile-header {
    display: none;
    position: fixed;
    top: 0; left: 0; right: 0;
    height: calc(60px + env(safe-area-inset-top, 0px));
    padding-top: env(safe-area-inset-top, 0px);   /* clear the notch */
    background: var(--card-bg-solid);
    border-bottom: 1px solid var(--card-border);
    z-index: var(--z-mobile-header, 950);
    align-items: center;
    padding-left: 16px;
    padding-right: 16px;
    gap: 12px;
}
.mobile-header h2 { margin: 0; font-size: 16px; font-weight: 700; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* The gap between the menu button and the title is the flex `gap: 15px` on
   .mobile-header. With the mark now between them that reads as too much, so
   the mark closes up against its own title and keeps the 15px to the button. */
.mobile-header .brand-lockup { margin-right: -7px; }
.mobile-header .brand-mark { width: 30px; height: 30px; border-radius: var(--radius-sm); }
#mobile-menu-btn { background: none; border: none; font-size: 20px; color: var(--text-main); cursor: pointer; min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; }
#mobile-menu-btn:focus-visible {
    outline: 2px solid var(--brand);
    outline-offset: 2px;
    border-radius: var(--radius-sm);
}

.sidebar {
    width: var(--sidebar-width);
    /* The mesh, then the plate. Both blobs LIGHTEN, which is the correct
       direction for a surface carrying white text: see --sidebar-mesh in the
       token block above for the measured lightest point. */
    background: var(--sidebar-mesh), var(--sidebar-bg);
    color: var(--sidebar-text);
    position: fixed;
    height: 100vh;
    left: 0; top: 0;
    display: flex;
    flex-direction: column;
    z-index: var(--z-sidebar, 1000);
    transition: transform var(--dur-slow, 320ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
    box-shadow: 4px 0 15px rgba(0,0,0,0.1);
    will-change: transform;
}

.sidebar-overlay {
    display: none;
    position: fixed;
    top: 0; left: 0; right: 0; bottom: 0;
    background: rgba(0,0,0,0.5);
    z-index: var(--z-overlay, 999);
    opacity: 0;
    transition: opacity 0.3s;
}

.sidebar-header { padding: 22px 18px; text-align: left; border-bottom: 1px solid rgba(255, 255, 255, 0.12); }
.sidebar-header h2 { margin: 0; font-size: 18px; font-weight: 700; letter-spacing: .5px; }

/* ========================================================
   BRAND LOCKUP (logo + wordmark)
   ========================================================
   One set of rules for every place the State Timber Corporation mark
   appears — sidebar, mobile header, login card, error pages — so the mark
   is never resized by eye in a page-local <style> block. Markup lives in
   partials/_brand.html; change the file, not these numbers.

   The mark is a WHITE line drawing on the orange plate. On the dark
   sidebar the plate would disappear into the background, so .brand-mark
   keeps its own orange plate and rounded corners rather than being made
   transparent. */
.brand-lockup {
    display: flex;
    align-items: center;
    gap: 12px;
    justify-content: center;
    text-decoration: none;
    color: inherit;
}
.brand-mark {
    flex: none;
    width: 40px;
    height: 40px;
    border-radius: var(--radius-sm);
    /* --timber-logo, not --brand: this is the plate the artwork itself sits
       on, so it must be the sampled colour rather than the AA-adjusted fill,
       or a transparent-background version of the logo would show a hairline
       of the wrong orange around the mark. Nothing is drawn on top of it. */
    background: var(--clay-logo);
    /* The source artwork is a square plate with the mark already on it, so
       it fills the box edge to edge. object-fit guards against a
       replacement file with a different aspect ratio silently stretching. */
    object-fit: contain;
    display: block;
}
/* Shown when static/brand/ has no logo file — see partials/_brand.html. */
.brand-mark--fallback {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    color: #fff;
}
.brand-mark--fallback .icon { width: 55%; height: 55%; }

.brand-text { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.15; min-width: 0; }
.brand-name {
    font-size: 15px;
    font-weight: 700;
    letter-spacing: .3px;
    white-space: nowrap;
}
.brand-sub {
    font-size: 10px;
    font-weight: 600;
    letter-spacing: 1.2px;
    text-transform: uppercase;
    opacity: .72;
    white-space: nowrap;
}
/* Sidebar variant: left-aligned, on the dark plate. */
.sidebar-header .brand-lockup { justify-content: flex-start; }
.sidebar-header .brand-sub { color: var(--sidebar-muted); opacity: 1; }
/* The "Logged in as ..." line. It used the .text-muted utility, which paints
   --text-muted — a colour tuned for dark text on a light card. On the dark
   sidebar that was 2.0:1. The sidebar has its own muted token for the same
   reason it has its own active-state pair: it is dark in both themes. */
.sidebar-meta { color: var(--sidebar-muted); text-align: left; line-height: 1.6; margin: 14px 0 0; }
.sidebar-meta strong { color: var(--sidebar-text); font-weight: 600; }

/* ========================================================
   SIDEBAR NAVIGATION — GROUPED (v2.4.0)
   ========================================================
   The nav is four labelled <ul>s inside a scrolling region, with Dark Mode
   and Logout in a sibling footer that the nav cannot push. See the long
   comment in partials/_sidebar.html for why the groups are what they are.

   THE FLEX SPLIT, WHICH IS THE WHOLE MECHANISM:
     .sidebar        flex column, height 100vh
       .sidebar-header   natural height
       .sidebar-nav      flex: 1, min-height: 0, overflow-y: auto   <- absorbs
       .sidebar-foot     natural height, never shrinks              <- anchored

   `min-height: 0` is the load-bearing line and it is the one that gets
   deleted by someone tidying up. A flex item's default `min-height: auto`
   refuses to shrink below its content, so without it .sidebar-nav grows to
   fit ten links plus four headings, the sidebar overflows 100vh, and the
   footer is pushed off the bottom of the screen with no scrollbar to reach
   it — which is worse than the `mt-auto` this replaced, not better. */
.sidebar-nav {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;   /* don't chain a scroll to the page behind */
    padding: var(--space-2) 0 var(--space-3);
    /* A thin scrollbar in the sidebar's own colours. Firefox honours the
       two-value shorthand; Chromium takes the ::-webkit- rules below. */
    scrollbar-width: thin;
    scrollbar-color: rgba(255, 255, 255, 0.22) transparent;
}
.sidebar-nav::-webkit-scrollbar { width: 6px; }
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.sidebar-nav::-webkit-scrollbar-thumb {
    background: rgba(255, 255, 255, 0.18);
    border-radius: var(--radius-full);
}
.sidebar-nav:hover::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.30); }

/* ---- The group heading ----
   A label plus a hairline that runs from it to the edge of the sidebar. The
   rule is what makes four small labels read as STRUCTURE rather than as four
   more items in the list — without it the eye has only a font-size change to
   go on, and at 10px that is not enough to divide anything.

   It is a <p>, not an <h3>. These label lists inside a <nav> that already has
   its own accessible name; promoting them to headings would put four more
   entries in the document's heading outline and in the "jump to heading"
   menu of every screen reader, on every page, above the heading that says
   what the page actually is. The <ul>s point at them with aria-labelledby
   instead, which is what gives "list, 3 items, Analyse". */
.nav-section {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    margin: var(--space-3) 0 var(--space-2);
    padding: 0 20px 0 22px;
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 1.2px;
    text-transform: uppercase;
    /* 7.18:1 on --clay-800, and 5.59:1 at the lightest point the sidebar
       mesh can reach. Both clear AA for small text, which this very much is. */
    color: var(--sidebar-section);
    white-space: nowrap;
}
/* The rule. `flex: 1` on a pseudo-element after the label, so it fills
   whatever the text leaves — "Administration" gets a short one and "Record"
   a long one, with no per-label measurement anywhere. */
.nav-section::after {
    content: '';
    flex: 1;
    height: 1px;
    background: linear-gradient(90deg,
                rgba(255, 241, 166, 0.22), rgba(255, 241, 166, 0) 92%);
}
/* The first heading sits directly under the header's own border; the extra
   top margin there would read as a gap rather than as separation. */
.sidebar-nav > .nav-section:first-child { margin-top: var(--space-1); }

/* `display: block` here made the `flex-direction` and `gap` declarations
   inert — the nav items were only being separated by whitespace in the
   markup, and multi-word labels ("Records & Export", "Data Change Log")
   wrapped underneath the icon instead of indenting to align with the
   first line. Switched to flex so both actually apply.

   `flex: 1` was removed in v2.4.0. It used to stretch the single list to
   fill the sidebar so that `mt-auto` on the theme toggle had space to work
   with; there are four lists now and the flexible space belongs to
   .sidebar-nav, not to each list in turn. Leaving it on would have divided
   the leftover height between the four groups and spread them evenly down
   the sidebar like a menu bar. */
.nav-links { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
.nav-links li { padding: 0 15px; }
.nav-links a {
    display: flex; align-items: center; gap: 12px; padding: 10px 16px;
    color: var(--sidebar-muted); text-decoration: none;
    border-radius: var(--radius-md); font-weight: 500;
    font-size: 14px;
}
/* Fixed-width icon column so every label starts on the same x-position.
   Without this the labels sit ragged, because the glyphs have different
   advance widths. */
.nav-links a i,
.nav-links a > .icon { width: 20px; height: 20px; text-align: center; flex-shrink: 0; }

/* ---- Low-stock badge ----
   Count of inventory lines below their reorder threshold. Sits at the end
   of the Dashboard nav item so the condition is visible from any page. */
.nav-badge {
    margin-left: auto;
    min-width: 20px;
    height: 20px;
    padding: 0 6px;
    border-radius: var(--radius-full);
    background: var(--negative);
    color: #fff;
    font-size: 11px;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    line-height: 1;
}

/* On the bottom nav the item is a vertical stack, so the badge is pinned
   to the top-right of the icon instead of pushed to the end of a row. */
.bottom-nav-badge {
    position: absolute;
    top: 4px;
    left: 50%;
    margin-left: 4px;
    height: 16px;
    min-width: 16px;
    font-size: 10px;
    padding: 0 4px;
}
.nav-links a:hover  { background: rgba(255, 255, 255, 0.08); color: var(--sidebar-text); }
.nav-links a.active { background: var(--sidebar-active-bg); color: var(--sidebar-active-text); font-weight: 600; }
.nav-links a:focus-visible {
    outline: 2px solid #fff;
    outline-offset: -2px;
}

/* ---- The anchored footer ----
   Dark Mode and Logout, held at the bottom of the sidebar by being OUTSIDE
   the scrolling region rather than by margin. `flex-shrink: 0` is the pair
   to `min-height: 0` on .sidebar-nav: without it a tall nav compresses the
   footer instead of scrolling, and the two buttons squash into each other
   before anything overflows.

   The top border is the same 12% white as the header's bottom one, so the
   sidebar reads as three bands — who you are, where you can go, and the two
   controls that are not navigation at all. That last distinction is the
   point of moving them: a theme toggle and a sign-out are not places, and
   they spent this app's whole life pretending to be the last two. */
.sidebar-foot {
    flex-shrink: 0;
    padding: var(--space-2) 15px calc(var(--space-2) + env(safe-area-inset-bottom, 0px));
    border-top: 1px solid rgba(255, 255, 255, 0.12);
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    /* The plate under the footer, so a nav item scrolling behind it is
       covered rather than showing through. Matches the sidebar's own base;
       the mesh does not repeat here on purpose — a second copy of a
       viewport-sized radial gradient inside a 100px box is a different
       gradient, and the seam is visible. */
    background: var(--sidebar-bg);
}

/* Logout submit button, styled to be indistinguishable from the nav <a>
   items above it (it is a form button so the route can be POST-only). */
.nav-logout {
    display: flex; align-items: center; gap: 12px; padding: 10px 16px;
    width: 100%; background: transparent; border: none; cursor: pointer;
    border-radius: var(--radius-md); font-weight: 500; font-size: 14px;
    font-family: inherit; text-align: left;
    color: var(--accent-red); transition: all 0.2s;
}
.nav-logout i { width: 20px; text-align: center; flex-shrink: 0; }
.nav-logout:hover { background: var(--negative); color: #fff; }
.nav-logout:focus-visible { outline: 2px solid #fff; outline-offset: -2px; }

/* Was `padding: 12px 20px; gap: 15px; font-size: 16px` — measurements from
   before the nav links were settled at 10px/16px, 12px and 14px. In the flat
   list that difference was absorbed by the distance from the other items;
   sitting directly above Logout in a 2-item footer, the mismatch is the
   first thing you see. Matched to .nav-logout exactly. */
.theme-btn {
    width: 100%; display: flex; align-items: center; gap: 12px; padding: 10px 16px;
    background: transparent; border: none; color: var(--sidebar-muted);
    border-radius: var(--radius-md); font-weight: 500; cursor: pointer;
    font-family: inherit; font-size: 14px; text-align: left;
}
.theme-btn .icon, .theme-btn i { width: 20px; height: 20px; flex-shrink: 0; }
.theme-btn:hover { background: rgba(255,255,255,0.1); color: var(--sidebar-text); }
.theme-btn:focus-visible {
    outline: 2px solid #fff;
    outline-offset: -2px;
}

.main-content {
    margin-left: var(--sidebar-width);
    padding: 40px;
    flex: 1;
    width: calc(100% - var(--sidebar-width));
    box-sizing: border-box;
}

/* ========================================================
   CARDS & PANELS (GLASSMORPHISM)
   ======================================================== */
.card, .panel {
    /* SHORTHAND, DELIBERATELY, AND IN THIS ORDER. `background:` sets
       background-image and background-color in one declaration, so the mesh
       and the parchment underneath it can never be set by two rules that
       disagree — which is exactly how a card ends up with the mesh from this
       theme over the surface colour of the other one. A page-local rule that
       wants to re-tint a card sets `background:` too and replaces both. */
    background: var(--card-mesh), var(--card-bg-solid);
    border-radius: var(--radius-lg);
    padding: 28px;
    box-shadow: var(--shadow-sm);
    border: 1px solid var(--card-border);
    margin-bottom: 30px;
    transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s ease, border 0.25s ease;
}

/* backdrop-filter is now limited to elements that genuinely OVERLAY page
   content — the mobile header and the sidebar drawer. Cards sit on a static
   background, so blurring behind them produced no visible glass effect while
   still forcing an expensive per-frame repaint of everything underneath.
   Dropping it from .card/.panel/.kpi-card is the single biggest scroll
   performance win on the dashboard, which can carry 20+ blurred surfaces. */
@media (min-width: 1024px) {
    @supports (backdrop-filter: blur(10px)) or (-webkit-backdrop-filter: blur(10px)) {
        .mobile-header {
            background: var(--card-bg);
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
        }
        .sidebar {
            backdrop-filter: blur(15px);
            -webkit-backdrop-filter: blur(15px);
        }
    }
}

/* Hover lift only where the card is actually interactive, and only on
   devices with a real pointer. On touch screens a :hover state STICKS after
   a tap, leaving the panel visibly raised until you tap elsewhere. */
@media (hover: hover) and (pointer: fine) {
    .card:hover, .panel:hover {
        box-shadow: var(--shadow-md);
    }
}

/* ========================================================
   FORMS, INPUTS & PASSWORD TOGGLE
   ======================================================== */
.form-group { margin-bottom: 20px; }
/* Uppercase text always needs extra tracking — the letterforms were not
   designed to sit at their default spacing when set in caps, and at 13px
   without it the words read as a solid block. */
.form-group label {
    display: block; font-size: 13px; font-weight: 600; color: var(--text-muted);
    margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.04em;
    cursor: pointer;   /* now that every label is bound to its field */
}

/* Added 'input[type="search"]' for DataTables Search Box, and 'textarea' so multi-line
   fields (e.g. remarks/notes) pick up the same styling without needing .form-control */
.form-control, select, textarea, input[type="text"], input[type="number"], input[type="date"], input[type="password"], input[type="search"] {
    width: 100%; padding: 12px 15px;
    border: 1px solid var(--input-border);
    border-radius: var(--radius-md);
    background: var(--input-bg);
    color: var(--text-main);
    font-size: 16px; font-family: inherit;
    box-sizing: border-box; transition: all 0.2s;
    min-height: 44px;
}
@media (min-width: 1024px) {
    .form-control, select, textarea, input[type="text"], input[type="number"], input[type="date"], input[type="password"], input[type="search"] {
        font-size: 14px;
    }
}
.form-control:focus, select:focus, textarea:focus, input:focus {
    border-color: var(--brand); outline: none;
    box-shadow: 0 0 0 3px rgba(var(--accent-blue-rgb), 0.18);
}

/* FORCE NATIVE SELECT OPTIONS TO BE READABLE IN DARK MODE */
html.dark-theme select option {
    /* Literal, not var(--surface-raised): several Android WebView builds
       resolve a custom property to nothing here and fall back to white text
       on white. A hex is the only value that is reliably honoured. Keep it
       in step with --surface-raised (#2C1E17) and --text (#FBF3DE). */
    background-color: #2C1E17;
    color: #FBF3DE;
}

.password-wrapper { position: relative; display: flex; align-items: center; }
.password-wrapper input { padding-right: 45px; }
.password-wrapper .toggle-eye {
    position: absolute;
    top: 50%;
    right: 15px;
    transform: translateY(-50%);
    color: var(--text-muted); cursor: pointer;
    font-size: 16px; transition: color 0.2s;
}
.password-wrapper .toggle-eye:hover { color: var(--brand-ink); }

/* Was a blue-to-navy gradient clipped to the glyphs. Gradient text cannot be
   contrast-checked (every pixel is a different colour), it renders as a solid
   block in high-contrast mode, and it printed as nothing at all until
   print.css added an override for it. It is now the flat brand ink, which is
   7.2:1 on white and needs no exceptions anywhere.

   The class name is kept: it is applied to the revenue KPI in dashboard.html
   and to headings, and renaming it across the templates would put unrelated
   churn in a colour-only change. */
.text-gradient {
    color: var(--brand-ink);
    -webkit-text-fill-color: currentColor;  /* undo any inherited clipping */
    display: inline-block;
}

.kpi-icon {
    font-size: 28px;
    margin-bottom: 15px;
    color: var(--brand);
    -webkit-text-fill-color: currentColor;
}

/* BUTTONS CONSOLIDATED IN components_buttons.css */

/* ========================================================
   SKELETONS & EMPTY STATES
   ======================================================== */
/* A skeleton stands in for whatever is about to replace it, so it takes the
   radius of the smallest thing it can be standing in for. */
.skeleton {
    background: linear-gradient(90deg, rgba(216, 193, 119, 0.30) 25%, rgba(216, 193, 119, 0.65) 50%, rgba(216, 193, 119, 0.30) 75%);
    background-size: 200% 100%;
    animation: loadingSkeleton 1.5s infinite;
    border-radius: var(--radius-sm);
}
html.dark-theme .skeleton {
    background: linear-gradient(90deg, rgba(90, 76, 62, 0.3) 25%, rgba(90, 76, 62, 0.6) 50%, rgba(90, 76, 62, 0.3) 75%);
    background-size: 200% 100%;
}
@keyframes loadingSkeleton {
    0% { background-position: 200% 0; }
    100% { background-position: -200% 0; }
}

.empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 40px 20px;
    text-align: center;
    color: var(--text-muted);
}
.empty-state i {
    font-size: 48px;
    margin-bottom: 15px;
    opacity: 0.5;
}

/* ========================================================
   FIELDSETS & INLINE VALIDATION
   ======================================================== */
.fieldset-group {
    /* ⚠ THE FIELDSET QUIRK. Do not remove this line.
       Every browser applies `min-inline-size: min-content` to a <fieldset>
       in its UA stylesheet, and unlike an ordinary min-width it is not
       overridden by setting `width`. The effect is that a fieldset REFUSES
       to be narrower than its widest descendant, and it ignores any
       `overflow: auto` inside it while doing so — the scroll container never
       gets the chance to scroll, because it is already as wide as its
       content.
       This was found the hard way in v2.6.0. The Sawn Timber Order Form's
       26-column grid made its fieldset 2,031px wide inside a 659px column,
       and the grid rendered straight over the Recent Records panel beside
       it. The <form> around it measured a correct 659px throughout: nothing
       in the flex/grid layer was wrong, and every `min-width: 0` in the
       world would not have helped, because the offending property is
       min-INLINE-size on the fieldset itself.
       Set here rather than on the one form that hit it, because the next
       wide thing put in a fieldset would hit it too, and the symptom looks
       nothing like its cause. */
    min-inline-size: 0;
    border: 1px solid var(--card-border);
    /* A fieldset is a panel, so it takes the panel radius. Its own controls
       sit --space-2 inside it and take --radius-md, which is exactly
       --radius-lg minus --space-2 — the concentric pair from the scale. */
    border-radius: var(--radius-lg);
    padding: 20px;
    margin-bottom: 25px;
    background: rgba(0,0,0,0.02);
}
html.dark-theme .fieldset-group {
    background: rgba(255,255,255,0.02);
}
.fieldset-group legend {
    font-size: 14px;
    font-weight: 700;
    color: var(--text-main);
    padding: 0 10px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}
.validation-msg {
    display: block;
    font-size: 12px;
    color: var(--accent-red);
    margin-top: 5px;
    min-height: 14px;
}

/* ========================================================
   TOAST / FLASH NOTIFICATIONS (FIXED STACKING & PADDING)
   ======================================================== */
.toast-container {
    position: fixed;
    bottom: 30px;
    right: 30px;
    z-index: var(--z-toast, 1200);
    display: flex;
    flex-direction: column;
    gap: 15px;
    /* Never intercept clicks on the page behind an empty container. */
    pointer-events: none;
}
.toast-container > * { pointer-events: auto; }

/* ---- Phones ----
   Two separate faults lived in the previous version of this block, and both
   are worth naming because they are easy to reintroduce.

   1. `.toast { width: 100%; }` on an element that also carries
      `padding: 16px 20px`. This stylesheet has NO global border-box reset
      (the same trap is documented in layout/mobile.css §6 for .filter-panel),
      so the toast measured its container's full width PLUS 40px. It hung off
      the right-hand edge of the screen — taking the dismiss button with it —
      and made the whole page scroll sideways for as long as it was visible.

   2. The stack was pinned to the TOP, at the same offset as the sticky
      "All forms" bar on the Data Entry screen (mobile.css §5, also
      `60px + safe-area-inset-top`). The toast wins on z-index, so every
      successful save covered the operator's only way back out of the form
      for four seconds.

   Fixed by sizing with border-box and moving the stack to the bottom, above
   the bottom nav and clear of the home indicator. Nothing else is pinned
   there, the default slide-up animation now matches the direction it enters
   from, and the dismiss button lands in the thumb's natural reach. */
@media (max-width: 767px) {
    .toast-container {
        /* max() rather than a flat gutter: in the installed app the page
           really does run edge to edge on a curved or notched screen. */
        left: max(var(--space-2), env(safe-area-inset-left, 0px));
        right: max(var(--space-2), env(safe-area-inset-right, 0px));
        top: auto;
        /* Login page — no bottom nav to clear, just the home indicator. */
        bottom: calc(var(--space-3) + env(safe-area-inset-bottom, 0px));
        gap: var(--space-2);
        align-items: stretch;
    }

    /* Signed in, so partials/_bottom_nav.html is on the page: clear its
       56px row as well. Keyed off the attribute base.html already sets
       rather than a new class, and reading it this way means the two can
       never disagree about whether the nav is there. */
    body:not([data-user-role=""]) .toast-container {
        bottom: calc(56px + env(safe-area-inset-bottom, 0px) + var(--space-2));
    }

    .toast {
        min-width: 0;
        /* The desktop 420px cap would leave the toast short and left-aligned
           on a 600px-wide handset; here the container's own gutters do the
           limiting. */
        max-width: none;
        width: 100%;
        padding: var(--space-3);
        font-size: 13px;
    }

    /* WCAG 2.5.5. 24px is a comfortable mouse target and an impossible
       gloved-thumb one. */
    .toast-close { min-width: 44px; min-height: 44px; }
}
.toast {
    /* Load-bearing, see the phone block above: .toast sets its own padding
       AND is given a width on small screens, and there is no global reset. */
    box-sizing: border-box;
    max-width: 420px;
    min-width: 280px;
    padding: 16px 20px;
    border-radius: var(--radius-md);
    color: white;
    font-weight: 500;
    font-size: 14px;
    box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
    opacity: 0;
    transform: translateY(20px);
    animation: slideUp 0.3s forwards;
    display: flex;
    align-items: center;
    gap: 12px;
}
/* Toast surfaces are deliberately the DARKEST step of each status hue
   (--*-700), not the --positive / --negative fills. The fills are tuned to
   carry white text at ~5:1; a toast is a floating opaque overlay that has to
   stay legible over any page content underneath, so it takes the 7-8:1 step.

   They reference the RAW palette rather than the semantic tokens on purpose:
   the semantic --positive flips lighter in dark mode, and a toast must read
   the same in both themes — it is its own surface, not part of the page. */
.toast.success { background-color: var(--green-700);  color: #ffffff; }  /* 7.9:1 */
.toast.error   { background-color: var(--red-700);    color: #ffffff; }  /* 7.9:1 */
.toast.warning { background-color: var(--amber-700);  color: #ffffff; }  /* 5.1:1 */
/* Info was a navy that existed nowhere else in the app. It is now the brand
   ink, so an informational toast reads as coming from this product. */
.toast.info    { background-color: var(--clay-700);   color: #ffffff; }  /* 12.4:1 */

.toast > span { flex: 1; }

/* Dismiss control — WCAG 2.2.1 requires timed content to be dismissible. */
.toast-close {
    background: transparent;
    border: none;
    color: inherit;
    opacity: 0.7;
    cursor: pointer;
    font-size: 14px;
    line-height: 1;
    padding: 4px;
    min-height: 24px;
    min-width: 24px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* The close button sits 4px inside a --radius-md toast, so it is
       --radius-md minus --space-1 = --radius-sm. */
    border-radius: var(--radius-sm);
    flex-shrink: 0;
}
.toast-close:hover { opacity: 1; background: rgba(255, 255, 255, 0.15); }
.toast-close:focus-visible { outline: 2px solid #fff; outline-offset: 1px; opacity: 1; }

@keyframes slideUp {
    to { opacity: 1; transform: translateY(0); }
}

/* ========================================================
   DATATABLES OVERRIDES
   ======================================================== */
table.dataTable { border-collapse: collapse !important; width: 100% !important; margin-top: 20px !important; }
table.dataTable thead th {
    border-bottom: 2px solid var(--card-border) !important;
    color: var(--text-muted); font-size: 12px; text-transform: uppercase; padding: 15px !important;
}
table.dataTable tbody td {
    border-bottom: 1px solid var(--card-border);
    padding: 12px 15px !important; font-size: 14px; color: var(--text-main); vertical-align: middle;
}
table.dataTable tbody tr:nth-child(even) {
    background-color: rgba(0, 0, 0, 0.02) !important;
}
html.dark-theme table.dataTable tbody tr:nth-child(even) {
    background-color: rgba(255, 255, 255, 0.02) !important;
}

/* ---- DataTables toolbar: length selector + search box ----
   These rules used to live in a <style> block inside records.html, so only
   that one page got them. Every other DataTable — Order Status, Login
   Activity — fell back to the plugin's own defaults, where the search box is
   a float:right, text-align:right label with an unconstrained input: the
   "Search:" caption wrapped onto its own line and the input ran past the
   right edge of the panel and got clipped by .responsive-wrapper's
   overflow-x. Shared here so all tables lay their toolbar out identically. */
.dataTables_wrapper .dataTables_length {
    margin-bottom: 5px;
}
.dataTables_wrapper .dataTables_filter {
    /* flex, not the plugin's float — the label and input stay on one line at
       any panel width, which is what stopped the caption wrapping. */
    display: flex;
    align-items: center;
    justify-content: flex-end;
    margin-bottom: 10px;
    /* The wrapper is a block child of .responsive-wrapper, so it is exactly
       as wide as the panel while the table beside it overflows and scrolls.
       max-width belts-and-braces that: no toolbar element can be pushed
       outside the visible box by a wide table. */
    max-width: 100%;
}
.dataTables_wrapper .dataTables_filter input {
    padding: 6px 12px !important;
    border-radius: var(--radius-md);
    border: 1px solid var(--input-border);
    background-color: var(--input-bg);
    color: var(--text-main);
    font-size: 13px;
    margin-left: 8px;
    /* A fixed width is what keeps it inside the panel. The plugin ships no
       width at all, leaving it at the browser's default ~270px, which is
       wider than the space left over after the "Search:" caption. */
    width: 200px;
    max-width: 100%;
}
@media (max-width: 600px) {
    .dataTables_wrapper .dataTables_filter input { width: 150px; }
}

/* Make DataTables select dropdowns visually fit our theme */
.dataTables_wrapper select {
    border: 1px solid var(--input-border);
    border-radius: var(--radius-sm);
    background: var(--input-bg);
    color: var(--text-main);
    padding: 1px 4px !important;
    margin: 0 4px;
    height: auto !important;
    line-height: 1.2;
    /* components/forms.css styles every bare <select> as a full-size field.
       Inside the "Show [10] entries" sentence that made the control taller
       than its own line and pushed the caption apart, so it is pinned back
       to an inline control here. */
    font-size: 13px;
    width: auto !important;
    display: inline-block;
}

/* Advanced Dark Mode DataTables Fixes */
html.dark-theme table.dataTable tbody tr { background-color: transparent !important; }
html.dark-theme table.dataTable.display tbody tr.odd > .sorting_1,
html.dark-theme table.dataTable.order-column.stripe tbody tr.odd > .sorting_1 { background-color: rgba(255,255,255,0.02) !important; }
html.dark-theme table.dataTable.display tbody tr.even > .sorting_1,
html.dark-theme table.dataTable.order-column.stripe tbody tr.even > .sorting_1 { background-color: transparent !important; }

html.dark-theme .dataTables_wrapper .dataTables_info,
html.dark-theme .dataTables_wrapper .dataTables_length,
html.dark-theme .dataTables_wrapper .dataTables_filter { color: var(--text-main) !important; }

html.dark-theme .dataTables_wrapper .dataTables_paginate .paginate_button:not(.current) { color: var(--text-muted) !important; }
html.dark-theme .dataTables_wrapper .dataTables_paginate .paginate_button:not(.current):hover { background: rgba(255,255,255,0.1) !important; border-color: transparent !important; color: white !important; }

.dataTables_wrapper .dataTables_paginate .paginate_button.current {
    background: var(--brand) !important; color: var(--on-brand) !important; border: none !important; border-radius: var(--radius-sm);
}

/* DataTables Skeletons & Empty States */
.dataTables_processing {
    background: linear-gradient(90deg, rgba(216, 193, 119, 0.30) 25%, rgba(216, 193, 119, 0.65) 50%, rgba(216, 193, 119, 0.30) 75%) !important;
    background-size: 200% 100% !important;
    animation: loadingSkeleton 1.5s infinite !important;
    border-radius: var(--radius-sm);
    height: 100px !important;
    color: transparent !important;
    box-shadow: none !important;
    border: none !important;
}
html.dark-theme .dataTables_processing {
    background: linear-gradient(90deg, rgba(90, 76, 62, 0.3) 25%, rgba(90, 76, 62, 0.6) 50%, rgba(90, 76, 62, 0.3) 75%) !important;
    background-size: 200% 100% !important;
}
.dataTables_empty {
    padding: 40px 20px !important;
    color: var(--text-muted) !important;
    font-size: 15px !important;
}

/* ========================================================
   RESPONSIVE DESIGN (MOBILE OVERRIDES)
   ======================================================== */
@media (max-width: 1024px) {
    .mobile-header { display: flex; }
    .sidebar { transform: translateX(-100%); }
    .sidebar.active { transform: translateX(0); }
    .sidebar-overlay.active { display: block; opacity: 1; }
    .main-content { margin-left: 0; width: 100%; padding: 80px 20px 20px 20px; }
}

/* ========================================================
   CSV IMPORT PREVIEW
   ======================================================== */
.import-report {
    margin-top: var(--space-3);
    padding: var(--space-3);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background: var(--input-bg);
    font-size: 13px;
    max-height: 260px;
    overflow-y: auto;
}

.import-summary {
    display: flex; flex-wrap: wrap; gap: var(--space-3);
    margin-bottom: var(--space-2);
}
.import-stat {
    font-variant-numeric: tabular-nums;
    color: var(--text-muted);
}
.import-stat strong { color: var(--text); font-size: 15px; }
.import-stat.ok strong { color: var(--positive-ink); }
.import-stat.bad strong { color: var(--negative-ink); }

.import-errors {
    list-style: none;
    margin: var(--space-2) 0 0 0;
    padding: 0;
    border-top: 1px solid var(--border);
}
.import-errors li {
    padding: 6px 0;
    border-bottom: 1px solid var(--border);
    color: var(--text-muted);
    line-height: 1.45;
}
.import-errors li:last-child { border-bottom: none; }
.import-errors strong { color: var(--negative-ink); font-variant-numeric: tabular-nums; }

.import-fatal {
    margin: 0;
    color: var(--negative-ink);
    font-weight: 600;
    line-height: 1.5;
}
.import-hint {
    margin: var(--space-2) 0 0 0;
    color: var(--text-muted);
    font-size: 12px;
    line-height: 1.5;
}
.import-hint code {
    background: rgba(0, 0, 0, 0.05);
    padding: 1px 5px;
    border-radius: var(--radius-sm);
    word-break: break-word;
}
html.dark-theme .import-hint code { background: rgba(255, 255, 255, 0.08); }

/* ========================================================
   RECORDS DATE FILTER
   ======================================================== */
.date-filter-panel { padding: var(--space-3) var(--space-4); margin-bottom: var(--space-4); }

.date-filter {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    gap: var(--space-3);
}

.date-filter-field { display: flex; flex-direction: column; min-width: 150px; flex: 1 1 150px; }
.date-filter-field label {
    font-size: 12px; font-weight: 600; text-transform: uppercase;
    letter-spacing: 0.04em; color: var(--text-muted); margin-bottom: 4px;
    cursor: pointer;
}

.date-filter-actions { display: flex; gap: var(--space-2); flex: 0 0 auto; }

/* Presets wrap to their own row on narrow screens. */
.date-filter-presets {
    display: flex; flex-wrap: wrap; gap: var(--space-2);
    flex: 1 1 100%; padding-top: var(--space-1);
}
@media (min-width: 900px) {
    .date-filter-presets { flex: 0 1 auto; padding-top: 0; margin-left: auto; }
}

.preset-btn {
    background: var(--input-bg);
    border: 1px solid var(--input-border);
    color: var(--text-muted);
    border-radius: var(--radius-full);
    padding: 6px 14px;
    min-height: 36px;
    font-size: 12px;
    font-weight: 600;
    font-family: inherit;
    cursor: pointer;
    transition: all var(--dur-fast, 120ms) ease;
    white-space: nowrap;
}
.preset-btn:hover { border-color: var(--brand); color: var(--brand); }
.preset-btn.active { background: var(--brand); border-color: var(--brand); color: var(--on-brand); }
.preset-btn:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }

.date-filter-status:empty { display: none; }

/* ========================================================
   CONFIRM DIALOG
   ========================================================
   Replaces window.confirm() for destructive and duplicate-warning actions.
   A native confirm() cannot show the structured detail that makes a
   duplicate warning useful ("here is the record you'd be duplicating"), and
   on mobile it renders detached from the page.
   ======================================================== */
.confirm-backdrop {
    position: fixed;
    inset: 0;
    z-index: var(--z-modal, 1050);
    background: rgba(0, 0, 0, 0.55);
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--space-3);
    animation: confirmFade var(--dur-fast, 120ms) var(--ease-out) both;
}

.confirm-dialog {
    background: var(--surface);
    color: var(--text);
    border: 1px solid var(--border);
    border-radius: var(--radius-xl);
    box-shadow: var(--elev-3);
    padding: var(--space-4);
    width: 100%;
    max-width: 460px;
    animation: confirmRise var(--dur-base, 200ms) var(--ease-out) both;
}

.confirm-title {
    margin: 0 0 var(--space-2) 0;
    font-size: 1.125rem;
    line-height: 1.3;
}

.confirm-body {
    margin: 0 0 var(--space-3) 0;
    color: var(--text-muted);
    font-size: 0.9375rem;
    line-height: 1.55;
}

/* The existing record being echoed back. Monospaced-ish and boxed so it
   reads as data rather than prose. */
.confirm-detail {
    background: var(--input-bg);
    border: 1px solid var(--input-border);
    border-left: 3px solid var(--caution);
    border-radius: var(--radius-md);
    padding: var(--space-3);
    margin-bottom: var(--space-4);
    font-size: 0.875rem;
    font-variant-numeric: tabular-nums;
    word-break: break-word;
}

.confirm-actions {
    display: flex;
    gap: var(--space-3);
    justify-content: flex-end;
    flex-wrap: wrap;
}
.confirm-actions .btn { flex: 1 1 auto; }
@media (min-width: 480px) {
    .confirm-actions .btn { flex: 0 0 auto; min-width: 120px; }
}

@keyframes confirmFade { from { opacity: 0; } to { opacity: 1; } }
@keyframes confirmRise {
    from { opacity: 0; transform: translateY(12px) scale(0.98); }
    to   { opacity: 1; transform: none; }
}

/* Entry form button row: primary action first, "add another" beside it.
   Stacks full-width on narrow screens so both stay easy to hit. */
.entry-actions { margin-top: var(--space-4); }
.entry-actions .btn { flex: 1 1 100%; }
@media (min-width: 560px) {
    .entry-actions { justify-content: flex-start; }
    .entry-actions .btn { flex: 0 1 auto; }
}

/* ========================================================
   TASK-FIRST DATA ENTRY  (§5.2, v1.19.0)
   ========================================================
   The phone has drilled in through a card picker since v1.13.0 and it works:
   every record type visible at once, then one form filling the screen. The
   desktop kept the seven-tab strip with all seven forms stacked behind it, so
   an operator who only ever logs conversions still landed on Raw Receipt and
   had to know that the second tab was theirs.

   This makes the two screens the same shape at every width. Nothing about the
   tab machinery changes — the cards call the same openTab() the tab buttons
   call, and the URL hash, the linked Recent Records pane and the AJAX submit
   handlers are all untouched. `is-focused` on the scope remains the ONLY
   state; the rules below are the desktop half of what layout/mobile.css §5
   already does for phones.

   THE TAB STRIP SURVIVES, and only inside a form. On a phone it is replaced
   entirely, because seven tabs do not fit; on a desk PC it is the fastest way
   to move between forms once you are in one, and there is room for it. So:
   picker on the landing screen, strip once you have chosen.
   ======================================================== */
@media (min-width: 768px) {

    /* ---- Step 1: the landing screen ---- */
    .tab-group-scope:not(.is-focused) .entry-picker {
        display: grid;
        /* Four across at desktop width, dropping to three and two as the
           column narrows. auto-fit rather than a fixed count so the tablet in
           landscape — the most common non-phone device here — is not left
           with one card on a second row. */
        grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
        gap: var(--space-3);
        margin-bottom: 0;
    }

    .tab-group-scope:not(.is-focused) .entry-card {
        display: flex;
        flex-direction: column;
        align-items: flex-start;
        gap: var(--space-2);
        min-height: 120px;
        padding: var(--space-4);
        text-align: left;
        background: var(--input-bg);
        border: 1px solid var(--input-border);
        border-radius: var(--radius-md);
        color: var(--text-main);
        font-family: inherit;
        cursor: pointer;
        transition: border-color var(--dur-fast, 120ms) ease,
                    background var(--dur-fast, 120ms) ease,
                    box-shadow var(--dur-fast, 120ms) ease;
    }
    .tab-group-scope:not(.is-focused) .entry-card:hover {
        border-color: var(--brand);
        box-shadow: var(--elev-1);
    }
    .tab-group-scope:not(.is-focused) .entry-card:focus-visible {
        outline: 2px solid var(--brand);
        outline-offset: 2px;
    }
    .tab-group-scope:not(.is-focused) .entry-card .icon {
        width: 26px; height: 26px;
        color: var(--brand);
        flex-shrink: 0;
    }
    .tab-group-scope:not(.is-focused) .entry-card-name {
        font-size: 16px; font-weight: 700; line-height: 1.25;
    }
    .tab-group-scope:not(.is-focused) .entry-card-hint {
        font-size: 13px; font-weight: 500;
        color: var(--text-muted); line-height: 1.35;
    }

    /* No strip, no form and no Recent Records pane until something is chosen.
       `.tab-content.active` carries display:block, so this needs the same
       specificity weight to win — hence the compound selector rather than
       !important, exactly as the mobile rules do. */
    .tab-group-scope:not(.is-focused) .tab-container { display: none; }
    .tab-group-scope:not(.is-focused) .tab-content.active { display: none; }
    .tab-group-scope:not(.is-focused) .recent-panel { display: none; }

    /* The panel carries `h-full` so that, inside a form, it lines up with the
       Recent Records column beside it. On the picker screen that column is
       hidden and there is no form, so the same rule stretches a card grid
       four rows tall into an empty box the height of the viewport. */
    .tab-group-scope:not(.is-focused) .panel.h-full { height: auto; }

    /* ---- Step 2: inside a form ---- */
    .tab-group-scope.is-focused .entry-picker { display: none; }

    .tab-group-scope.is-focused .entry-back {
        display: inline-flex;
        align-items: center;
        gap: var(--space-2);
        min-height: 44px;
        margin: 0 0 var(--space-3);
        padding: 0;
        background: none;
        border: none;
        color: var(--brand);
        font-family: inherit;
        font-size: 14px;
        font-weight: 700;
        cursor: pointer;
    }
    .tab-group-scope.is-focused .entry-back:hover { text-decoration: underline; }
    .tab-group-scope.is-focused .entry-back:focus-visible {
        outline: 2px solid var(--brand);
        outline-offset: 2px;
        border-radius: var(--radius);
    }
    /* The form name is already the highlighted tab two lines below, so
       repeating it here is noise on a screen that has the room to show both. */
    .tab-group-scope.is-focused .entry-back-current { display: none; }
}

/* ---- Repeat last entry, and the soft anomaly warning (§5.2) ---- */

.entry-repeat { margin-bottom: var(--space-4); }

/* The warning that a figure is unlike the usual one. Amber, NOT red: red is
   what --negative-ink means everywhere else in this app, and it is reserved
   for a value the server refused. This is advice about a value that is
   perfectly legal and probably fine.

   Hidden until it has something to say — an empty span with margin would push
   every field apart by a line for a warning that is not there. */
.entry-warning {
    display: none;
    margin-top: 4px;
    font-size: 12.5px;
    line-height: 1.4;
    font-weight: 600;
    color: var(--caution-ink);
}
.entry-warning.is-shown { display: block; }

/* ========================================================
   MOBILE BOTTOM NAVIGATION
   ========================================================
   Only below 768px. Above that the sidebar is always visible and a bottom
   bar would be redundant.
   ======================================================== */
.bottom-nav { display: none; }

@media (max-width: 767px) {
    .bottom-nav {
        display: flex;
        position: fixed;
        inset: auto 0 0 0;
        z-index: var(--z-bottom-nav, 900);
        background: var(--surface);
        border-top: 1px solid var(--border);

        /* env(safe-area-inset-bottom) keeps the bar clear of the iPhone
           home indicator. Without it the bottom row of controls sits under
           the system gesture area and taps get swallowed. */
        padding-bottom: env(safe-area-inset-bottom, 0px);
        box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.06);
    }

    .bottom-nav-item {
        flex: 1;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 3px;
        min-height: 56px;
        padding: 6px 4px;
        background: transparent;
        border: none;
        font-family: inherit;
        font-size: 11px;
        font-weight: 600;
        color: var(--text-muted);
        text-decoration: none;
        cursor: pointer;
        transition: color var(--dur-fast, 120ms) ease;
        -webkit-tap-highlight-color: transparent;
    }

    .bottom-nav-item .icon { width: 20px; height: 20px; }

    .bottom-nav-item.active { color: var(--brand); }

    /* A short bar above the active item — clearer at a glance than colour
       alone, and it does not rely on colour perception. */
    .bottom-nav-item.active::before {
        content: '';
        position: absolute;
        top: 0;
        width: 28px;
        height: 3px;
        border-radius: 0 0 var(--radius-xs) var(--radius-xs);
        background: var(--brand);
    }
    .bottom-nav-item { position: relative; }

    .bottom-nav-item:active { background: rgba(var(--accent-blue-rgb), 0.08); }

    .bottom-nav-item:focus-visible {
        outline: 2px solid var(--brand);
        outline-offset: -3px;
        border-radius: var(--radius-sm);
    }

    /* Reserve room so the last row of page content is never trapped
       underneath the fixed bar. */
    .main-content {
        padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
    }
}

/* ========================================================
   REDUCED MOTION
   ========================================================
   The app animates card hover lifts, the sidebar slide, toast slide-up,
   tab fade-in, progress-bar fills and an infinite skeleton shimmer. For
   users who have asked their OS to minimise motion — including people
   with vestibular disorders — all of that should stop.

   Kept as the LAST rule in the last-linked stylesheet so it wins without
   needing to out-specify every individual animation.
   ======================================================== */
@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }

    /* Hover lifts translate content under the cursor; drop them entirely
       rather than just making them instant. */
    .card:hover, .panel:hover, .kpi-card:hover,
    .inv-item:hover, .order-card:hover, .recent-item:hover,
    .btn-primary:hover, .btn-submit:hover {
        transform: none !important;
    }
}


/* ---------------------------------------------------------------------------
   Print-only blocks
   ---------------------------------------------------------------------------
   A printed dashboard carries a header naming the period and when it was
   produced, so a filed copy is self-describing. On screen that information is
   already in the page furniture, so the block is hidden.

   This rule has to be HERE and not in layout/print.css, which is where it
   used to be and where it did nothing: base.html links print.css with
   media="print", so every rule in that file — inside the @media block or not
   — applies only when printing. The header was therefore visible on screen on
   every dashboard view. */
.print-header { display: none; }
