/* ============================================================
   UX component layer — the parts that make the system *feel* fast.

   Each block below exists because of a specific problem in the old
   build; the comment says which one. Load after tokens.css + aero.css.
   ============================================================ */

/* ------------------------------------------------------------
   1. Command palette  (Ctrl/Cmd-K)

   Problem it solves: to open job file GML-0912 you had to guess which
   of five log lists it lived in, load that page, type into its filter,
   press Search, then click the row. Four page loads to reach a record
   you could already name. The palette is one keystroke to anywhere.
   ------------------------------------------------------------ */

.ux-palette {
    position: fixed;
    inset: 0;
    z-index: var(--z-palette);
    display: none;
    padding: 10vh var(--sp-5) var(--sp-5);
    background: rgba(16, 32, 56, 0.34);
    backdrop-filter: blur(3px);
}
.ux-palette[data-open="true"] { display: block; }

.ux-palette__box {
    max-width: 680px;
    margin: 0 auto;
    background: rgba(250, 252, 254, 0.99);
    border: 1px solid var(--aero-glass-border);
    border-radius: var(--r-lg);
    box-shadow: var(--el-4);
    overflow: hidden;
    animation: uxPop var(--dur) var(--ease);
}
@keyframes uxPop {
    from { opacity: 0; transform: translateY(-10px) scale(0.985); }
    to   { opacity: 1; transform: none; }
}

.ux-palette__input {
    width: 100%;
    border: 0;
    border-bottom: 1px solid rgba(0, 0, 0, 0.10);
    padding: var(--sp-6) var(--sp-7);
    font-family: inherit;
    font-size: var(--fs-h3);
    color: var(--text);
    background: transparent;
}
.ux-palette__input:focus { outline: none; box-shadow: none; }
.ux-palette__input::placeholder { color: #8496ad; }

.ux-palette__results { max-height: 56vh; overflow-y: auto; padding: var(--sp-3); }

.ux-palette__group {
    font-size: var(--fs-micro);
    text-transform: uppercase;
    letter-spacing: 0.6px;
    font-weight: var(--fw-semi);
    color: var(--text-muted);
    padding: var(--sp-5) var(--sp-5) var(--sp-2);
}

.ux-palette__item {
    display: flex;
    align-items: center;
    gap: var(--sp-5);
    width: 100%;
    padding: var(--sp-4) var(--sp-5);
    border: 0;
    background: none;
    border-radius: var(--r-md);
    text-align: left;
    font: inherit;
    color: var(--text);
    cursor: pointer;
    text-decoration: none;
}
/* Highlight follows the keyboard, not the mouse: the arrow keys are the
   primary input here, so hover must not fight the selection. */
.ux-palette__item[aria-selected="true"] { background: rgba(31, 111, 235, 0.14); }

.ux-palette__icon {
    flex: none;
    width: 26px; height: 26px;
    display: grid; place-items: center;
    border-radius: var(--r-sm);
    background: var(--neutral-tint);
    font-size: var(--fs-small);
}
.ux-palette__main { min-width: 0; flex: 1; }
.ux-palette__title {
    font-weight: var(--fw-semi);
    font-size: var(--fs-body);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ux-palette__sub {
    font-size: var(--fs-small);
    color: var(--text-muted);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ux-palette__meta { flex: none; font-size: var(--fs-small); color: var(--text-muted); }
.ux-palette__hit { background: #ffe9a8; border-radius: 2px; font-weight: var(--fw-bold); }

.ux-palette__foot {
    display: flex; gap: var(--sp-6);
    padding: var(--sp-4) var(--sp-7);
    border-top: 1px solid rgba(0, 0, 0, 0.08);
    background: rgba(238, 243, 249, 0.9);
    font-size: var(--fs-micro);
    color: var(--text-muted);
}
.ux-palette__empty { padding: var(--sp-9) var(--sp-7); text-align: center; color: var(--text-muted); }

kbd {
    display: inline-block;
    font-family: var(--font-ui);
    font-size: var(--fs-micro);
    font-weight: var(--fw-semi);
    line-height: 1;
    padding: 3px 6px;
    color: var(--text-soft);
    background: linear-gradient(to bottom, #fff 0%, #e8edf3 100%);
    border: 1px solid #b3c0d1;
    border-bottom-width: 2px;
    border-radius: var(--r-sm);
    white-space: nowrap;
}

/* ------------------------------------------------------------
   2. Toasts + Undo

   Problem: Django messages rendered at the top of the *next* page.
   After clicking Approve you got a full reload and had to look up to
   find out whether it worked. And there was no undo anywhere — a
   mis-clicked Delete was final (recoverable only by a manager digging
   through Settings > Trashcan).

   Toasts are announced via aria-live so screen readers get them too.
   ------------------------------------------------------------ */

.ux-toasts {
    position: fixed;
    right: var(--sp-6);
    bottom: var(--sp-6);
    z-index: var(--z-toast);
    display: flex;
    flex-direction: column;
    gap: var(--sp-4);
    max-width: min(420px, calc(100vw - var(--sp-9)));
    pointer-events: none;
}

.ux-toast {
    pointer-events: auto;
    display: flex;
    align-items: flex-start;
    gap: var(--sp-4);
    padding: var(--sp-5) var(--sp-6);
    border-radius: var(--r-md);
    border: 1px solid;
    border-left-width: 4px;
    background: rgba(252, 253, 255, 0.99);
    box-shadow: var(--el-3);
    font-size: var(--fs-body);
    animation: uxToastIn var(--dur-slow) var(--ease);
}
.ux-toast[data-leaving="true"] { animation: uxToastOut var(--dur) var(--ease) forwards; }

@keyframes uxToastIn  { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
@keyframes uxToastOut { to   { opacity: 0; transform: translateX(20px); } }

.ux-toast--success { border-color: var(--ok-border);   background: linear-gradient(to bottom, #f2fbf6 0%, #e6f5ec 100%); color: var(--ok-fg); }
.ux-toast--error   { border-color: var(--bad-border);  background: linear-gradient(to bottom, #fff6f7 0%, #fdeaec 100%); color: var(--bad-fg); }
.ux-toast--warning { border-color: var(--warn-border); background: linear-gradient(to bottom, #fffbef 0%, #fff5da 100%); color: var(--warn-fg); }
.ux-toast--info    { border-color: var(--info-border); background: linear-gradient(to bottom, #f4f8ff 0%, #e8f1ff 100%); color: var(--info-fg); }

.ux-toast__icon { flex: none; font-size: var(--fs-lead); line-height: 1.3; }
.ux-toast__body { flex: 1; min-width: 0; }
.ux-toast__title { font-weight: var(--fw-semi); }
.ux-toast__detail { font-size: var(--fs-small); opacity: 0.85; margin-top: var(--sp-1); }

.ux-toast__undo {
    flex: none;
    align-self: center;
    border: 1px solid currentColor;
    background: rgba(255, 255, 255, 0.7);
    color: inherit;
    font: inherit;
    font-size: var(--fs-small);
    font-weight: var(--fw-semi);
    padding: var(--sp-2) var(--sp-5);
    border-radius: var(--r-sm);
    cursor: pointer;
}
.ux-toast__undo:hover { background: #fff; }

.ux-toast__close {
    flex: none; align-self: flex-start;
    border: 0; background: none; color: inherit;
    opacity: 0.5; cursor: pointer;
    font-size: var(--fs-lead); line-height: 1; padding: 0 var(--sp-1);
}
.ux-toast__close:hover { opacity: 1; }

/* A thin bar drains to show how long undo stays available, so "how long
   do I have?" is answered visually rather than by anxiety. */
.ux-toast__timer { position: absolute; left: 0; bottom: 0; height: 2px; background: currentColor; opacity: 0.35; }
.ux-toast { position: relative; overflow: hidden; }

/* ------------------------------------------------------------
   3. Dialogs

   Problem: rejection reasons were collected with window.prompt() and
   deletions with confirm(). Both are unstyleable, unvalidatable,
   suppressible by the browser ("don't show more dialogs"), useless on
   touch, and — worst for an approvals workflow — they hide the document
   you are deciding about behind a modal grey box.

   These are real dialogs: they name the record, focus-trap, restore
   focus to the trigger on close, validate before allowing submit, and
   make the destructive button the *non*-default action.
   ------------------------------------------------------------ */

.ux-dialog-backdrop {
    position: fixed; inset: 0;
    z-index: var(--z-dialog);
    display: none;
    place-items: center;
    padding: var(--sp-6);
    background: rgba(16, 32, 56, 0.42);
    backdrop-filter: blur(3px);
}
.ux-dialog-backdrop[data-open="true"] { display: grid; }

.ux-dialog {
    width: min(520px, 100%);
    max-height: 88vh;
    overflow: auto;
    background: rgba(251, 252, 254, 0.99);
    border: 1px solid var(--aero-glass-border);
    border-radius: var(--r-lg);
    box-shadow: var(--el-4);
    animation: uxPop var(--dur) var(--ease);
}
.ux-dialog--wide { width: min(860px, 100%); }

.ux-dialog__head {
    display: flex; align-items: flex-start; gap: var(--sp-5);
    padding: var(--sp-7) var(--sp-7) var(--sp-5);
}
.ux-dialog__glyph {
    flex: none; width: 36px; height: 36px;
    display: grid; place-items: center;
    border-radius: var(--r-pill);
    font-size: var(--fs-h3);
}
.ux-dialog--danger  .ux-dialog__glyph { background: var(--bad-tint);  color: var(--bad-border); }
.ux-dialog--warning .ux-dialog__glyph { background: var(--warn-tint); color: #8a6100; }
.ux-dialog--info    .ux-dialog__glyph { background: var(--info-tint); color: var(--info-border); }

.ux-dialog__title { font-size: var(--fs-h3); font-weight: var(--fw-semi); }
.ux-dialog__body { padding: 0 var(--sp-7) var(--sp-6); color: var(--text-soft); }
.ux-dialog__body p { margin: 0 0 var(--sp-4); }

/* Names the exact record being acted on. "Delete Invoice INV-1043 —
   Acme Trading — 12,400.00 USD" is a decidable question;
   "Are you sure?" is not. */
.ux-dialog__subject {
    background: var(--neutral-tint);
    border: 1px solid rgba(0, 0, 0, 0.08);
    border-radius: var(--r-md);
    padding: var(--sp-4) var(--sp-5);
    margin-bottom: var(--sp-5);
    font-size: var(--fs-small);
}
.ux-dialog__subject dl { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-5); margin: 0; }
.ux-dialog__subject dt { color: var(--text-muted); }
.ux-dialog__subject dd { margin: 0; font-weight: var(--fw-semi); }

.ux-dialog__foot {
    display: flex; justify-content: flex-end; gap: var(--sp-4);
    padding: var(--sp-5) var(--sp-7) var(--sp-7);
}
.ux-dialog__foot .spacer { margin-right: auto; }
.ux-dialog__counter { font-size: var(--fs-small); color: var(--text-muted); text-align: right; margin-top: var(--sp-2); }

/* ------------------------------------------------------------
   4. Page header + breadcrumbs

   Problem: "where am I / how do I get back" was answered differently on
   every screen — some had "Back to list", some `history.back()`, some a
   Cancel button, some nothing. Deep screens (Match, Record Payment,
   Attachments) were the worst: you arrived from three possible places
   and only one of them had a way back.
   ------------------------------------------------------------ */

.ux-pagehead {
    display: flex; flex-wrap: wrap;
    align-items: flex-end; justify-content: space-between;
    gap: var(--sp-5);
    margin-bottom: var(--sp-6);
}
.ux-pagehead__titles { min-width: 0; }
.ux-pagehead__actions { display: flex; flex-wrap: wrap; gap: var(--sp-4); align-items: center; }
.ux-pagehead__sub { font-size: var(--fs-small); color: var(--text-muted); margin-top: var(--sp-2); }

.ux-crumbs {
    display: flex; flex-wrap: wrap; align-items: center;
    gap: var(--sp-2);
    font-size: var(--fs-small);
    margin-bottom: var(--sp-3);
    padding: 0; list-style: none;
}
.ux-crumbs li { display: flex; align-items: center; gap: var(--sp-2); }
.ux-crumbs li + li::before { content: "/"; color: var(--text-muted); opacity: 0.7; }
.ux-crumbs a { color: var(--text-link); text-decoration: none; }
.ux-crumbs a:hover { text-decoration: underline; }
.ux-crumbs [aria-current="page"] { color: var(--text-muted); }

/* ------------------------------------------------------------
   5. Filter bar: active-filter chips + saved views

   Problem: after searching, the only evidence of *what* you filtered by
   was the values still sitting in the (collapsible, often scrolled-off)
   form. Two people reading the same screen would disagree about whether
   a number was "everything" or "just Basra, last month". And clearing
   one filter meant emptying a field and re-submitting.

   Chips make the filter state a visible, removable object.
   ------------------------------------------------------------ */

.ux-filterbar {
    display: flex; flex-wrap: wrap; align-items: center;
    gap: var(--sp-4);
    margin-bottom: var(--sp-5);
}

.ux-chip {
    display: inline-flex; align-items: center; gap: var(--sp-3);
    background: rgba(255, 255, 255, 0.78);
    border: 1px solid rgba(255, 255, 255, 0.9);
    box-shadow: var(--el-1);
    border-radius: var(--r-pill);
    padding: var(--sp-2) var(--sp-3) var(--sp-2) var(--sp-5);
    font-size: var(--fs-small);
    color: var(--text-soft);
}
.ux-chip__key { color: var(--text-muted); }
.ux-chip__val { font-weight: var(--fw-semi); color: var(--text); }
.ux-chip__x {
    display: grid; place-items: center;
    width: 18px; height: 18px;
    border: 0; border-radius: var(--r-pill);
    background: rgba(0, 0, 0, 0.07);
    color: var(--text-soft);
    font-size: 11px; line-height: 1; cursor: pointer;
    text-decoration: none;
}
.ux-chip__x:hover { background: var(--bad-border); color: #fff; }

.ux-chip--clear { border-style: dashed; background: transparent; cursor: pointer; text-decoration: none; }
.ux-chip--clear:hover { background: rgba(255, 255, 255, 0.7); }

.ux-savedviews { display: flex; align-items: center; gap: var(--sp-3); margin-left: auto; }

/* Collapsible advanced filters. Default-collapsed keeps the data table
   above the fold; the chip row shows what's applied while it's shut. */
.ux-filters[hidden] { display: none; }
.ux-filters {
    background: rgba(255, 255, 255, 0.4);
    border: 1px solid rgba(255, 255, 255, 0.68);
    border-radius: var(--r-md);
    padding: var(--sp-5) var(--sp-6);
    margin-bottom: var(--sp-5);
}
/* Filter bars only. Bottom alignment is right here: the Search button has to
   line up with the inputs, and every cell is a bare label+input of equal
   height. It is WRONG for data-entry forms — see .ux-formgrid below. */
.ux-filters__grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
    gap: var(--sp-5);
    align-items: end;
}
.ux-filters__actions { display: flex; gap: var(--sp-4); align-items: end; }

/* ------------------------------------------------------------
   Data-entry form grid.

   Fixes a real layout bug: the forms were reusing .ux-filters__grid, whose
   `align-items: end` bottom-aligns every cell. In a filter bar each cell is
   the same height so that is invisible. In a form, some fields carry help
   text and some do not — so the taller cells pushed their inputs up while
   their neighbours stayed pinned to the bottom, and a row of inputs came out
   staggered at two or three different heights. On a 25-field job form that
   reads as fields sliding up and down at random.

   The fix is structural rather than a nudge. Each field is a 3-row grid
   (label / control / notes) and, where the browser supports subgrid, all
   fields share those rows — so every label aligns with every other label and
   every input with every other input, no matter how much help text sits
   underneath or whether a label wraps to two lines. Without subgrid it falls
   back to top alignment, which is still correct for the common case.
   ------------------------------------------------------------ */
.ux-formgrid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
    gap: var(--sp-6) var(--sp-5);
    align-items: start;
}

/* One field: label, then the control, then help text and errors. */
.ux-formgrid > * {
    display: grid;
    grid-template-rows: auto auto auto;
    align-content: start;
    row-gap: 0;
    min-width: 0;          /* let long values ellipsis instead of overflowing */
}

/* `display: grid` above outranks the browser's own `[hidden]` rule — a
   bare class selector beats the UA stylesheet — so a field hidden by
   script stayed on screen. Anything relying on `hidden` inside a form
   grid silently did nothing until this existed. */
.ux-formgrid > [hidden] { display: none; }

@supports (grid-template-rows: subgrid) {
    .ux-formgrid > * {
        grid-row: span 3;
        grid-template-rows: subgrid;
    }
}

/* A field that should run the full width of the form (long remarks, a
   tracking number with a preview underneath). */
.ux-formgrid > .span-2 { grid-column: span 2; }
.ux-formgrid > .span-all { grid-column: 1 / -1; }

/* `auto-fit` collapses the empty tracks, so a grid holding a single field
   stretches that field across the whole card — a 1200px-wide dropdown over
   six short words. `auto-fill` keeps the empty tracks, so one field in a
   five-column form stays one column wide. */
.ux-formgrid--fill { grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }

/* A value the system owns, shown inside a form: the job number, an
   assigned reference. Deliberately not a disabled <input> — a greyed-out
   box still looks like somewhere to type, so people click it, find they
   cannot, and go looking for what they did wrong. This reads as a stated
   fact, which is what it is. */
.ux-readonly {
    display: grid; gap: var(--sp-2);
    margin-bottom: var(--sp-6); padding: var(--sp-4) var(--sp-5);
    border-left: 3px solid var(--gml-blue);
    border-radius: var(--r-sm, 4px);
    background: var(--gml-blue-tint);
}
.ux-readonly__label {
    font-size: var(--fs-small); font-weight: var(--fw-semi);
    color: var(--text-soft);
}
.ux-readonly__value {
    font-size: var(--fs-h3, 1.125rem); font-weight: var(--fw-semi);
    color: var(--gml-blue); letter-spacing: 0.02em;
}
.ux-readonly__note { font-size: var(--fs-small); color: var(--text-muted); }

@media (max-width: 700px) {
    /* One column: spans would overflow the grid. */
    .ux-formgrid > .span-2 { grid-column: auto; }
}

/* A labelled break inside one card — "Cargo", "Exceptions". Cheaper than a
   second card when the fields belong to the same decision but want reading
   as a group; a card per group turns one form into six boxes. */
.ux-subhead {
    display: flex;
    align-items: baseline;
    gap: var(--sp-3);
    margin: var(--sp-6) 0 var(--sp-4);
    padding-top: var(--sp-5);
    border-top: 1px solid var(--hair, rgba(0, 67, 95, 0.14));
    font-size: var(--fs-small);
    font-weight: var(--fw-bold, 600);
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-soft);
}
.card-body > .ux-subhead:first-child {
    margin-top: 0;
    padding-top: 0;
    border-top: 0;
}

/* ------------------------------------------------------------
   Switch — a setting with two states, not a box to tick

   `Active` arrives already set: every customer and supplier is active
   from the moment the form opens. A 17px checkbox reads as an item on
   a checklist — something you add — so a field that ships ticked looks
   like a box somebody else filled in for you, and turning an account
   off reads as *undoing* rather than as setting it. A switch reads as
   what the field actually is: this is on, and you may turn it off.

   It is still `<input type="checkbox">`. Same posted value, same Space
   key, same `checked` handling, and with scripting off it behaves
   exactly as before — only the paint changes, plus `role="switch"`,
   which makes a screen reader say "Active, switch, on" rather than
   "Active, checked".

   The knob is a background image rather than a `::before`, because
   generated content on a replaced element (which is what an <input>
   is) is not reliable across browsers — this is the one technique that
   paints everywhere. State is carried by the knob's POSITION as well
   as by the track colour, so it reads in greyscale and to a colour
   blind eye; and it opts out of forced-colours mode rather than let
   the fills be substituted, which would flatten on and off into the
   same empty pill.
   ------------------------------------------------------------ */
.ux-switch {
    appearance: none;
    -webkit-appearance: none;
    box-sizing: border-box;
    display: inline-block;
    flex: none;
    width: 40px;
    height: 22px;
    /* Nudged down to sit on the same baseline as the inputs beside it:
       a text field's own border and padding put its text lower than a
       22px control centred in the same grid row. */
    margin: var(--sp-2) 0 0;
    border: 1px solid var(--aero-silver-border);
    border-radius: var(--r-pill);
    background-color: #e6ecf3;
    background-image:
        url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23ffffff' stroke='%238ea1b8' stroke-width='0.4'/%3e%3c/svg%3e"),
        linear-gradient(to bottom, #d9e1ea 0%, #eef3f8 100%);
    background-repeat: no-repeat;
    background-position: left center, center;
    background-size: contain, auto;
    box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12);
    cursor: pointer;
    /* The travel is the point. Without it the knob teleports and you
       cannot tell, from the flash, which way you just moved it. Zeroed
       for anyone who asked for reduced motion — see tokens.css. */
    transition: background-position var(--dur) var(--ease),
                background-color var(--dur) var(--ease),
                border-color var(--dur) var(--ease);
    /* A form is not something you print, but a switch printed as a
       blank pill asserts the opposite of whatever it was set to. */
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
}

.ux-switch:checked {
    border-color: var(--aero-btn-border);
    background-color: #1f6feb;
    background-image:
        url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23ffffff'/%3e%3c/svg%3e"),
        linear-gradient(to bottom, var(--aero-btn-top) 0%, var(--aero-btn-bottom) 100%);
    background-position: right center, center;
}

/* The whole control is the hit area — 40x22 rather than the 17px box it
   replaces, which is the other half of why this is easier to use. */
.ux-switch:hover:not(:disabled) { border-color: var(--focus-color); }

.ux-switch:disabled { cursor: not-allowed; opacity: 0.55; }

/* Focus is the shared ring from aero.css (`input:focus-visible`), which
   outranks the inset shadow above. Nothing to repeat here. */

@media (forced-colors: active) {
    .ux-switch { forced-color-adjust: none; }
}

/* ------------------------------------------------------------
   6. Sortable columns

   Problem: no list in the app could be sorted. To find the biggest
   overdue receivable you exported to CSV and sorted in Excel.
   ------------------------------------------------------------ */

th.ux-sort { padding: 0; }
th.ux-sort > a {
    display: flex; align-items: center; gap: var(--sp-3);
    justify-content: inherit;
    padding: var(--sp-4) var(--cell-pad-x);
    color: inherit; text-decoration: none;
    font-weight: inherit;
}
th.ux-sort > a:hover { background: rgba(31, 111, 235, 0.10); }
th.ux-sort--num > a { justify-content: flex-end; }
th.ux-sort__arrow { opacity: 0.28; font-size: 9px; }
th.ux-sort[aria-sort] .ux-sort__arrow { opacity: 1; color: var(--text-link); }
th.ux-sort[aria-sort] > a { background: rgba(31, 111, 235, 0.08); }

/* ------------------------------------------------------------
   7. Empty states

   Problem: every empty table said "No documents found." — a dead end
   that does not distinguish "you have no invoices yet" (create one)
   from "your filters match nothing" (clear them). Different problem,
   different next action.
   ------------------------------------------------------------ */

.ux-empty { padding: var(--sp-10) var(--sp-7); text-align: center; }
.ux-empty__glyph { font-size: 30px; opacity: 0.35; margin-bottom: var(--sp-4); }
.ux-empty__title { font-size: var(--fs-lead); font-weight: var(--fw-semi); color: var(--text-soft); }
.ux-empty__hint { font-size: var(--fs-small); color: var(--text-muted); margin: var(--sp-3) auto var(--sp-6); max-width: 46ch; }
.ux-empty__actions { display: flex; gap: var(--sp-4); justify-content: center; flex-wrap: wrap; }

/* ------------------------------------------------------------
   8. Bulk action bar

   Problem: the approvals queue made you approve one document at a
   time, each one a full page reload. A supervisor clearing 30 documents
   on Monday morning did 30 round trips.
   ------------------------------------------------------------ */

.ux-bulkbar {
    position: sticky;
    bottom: var(--sp-5);
    z-index: var(--z-sticky);
    display: none;
    align-items: center;
    gap: var(--sp-5);
    margin: var(--sp-5) 0 0;
    padding: var(--sp-4) var(--sp-6);
    background: rgba(23, 42, 71, 0.96);
    color: #fff;
    border-radius: var(--r-md);
    box-shadow: var(--el-3);
    font-size: var(--fs-body);
}
.ux-bulkbar[data-visible="true"] { display: flex; animation: uxToastIn var(--dur) var(--ease); }
.ux-bulkbar__count { font-weight: var(--fw-semi); }
.ux-bulkbar__spacer { margin-left: auto; }
.ux-bulkbar .btn-secondary { background: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.35); color: #fff; text-shadow: none; }
.ux-bulkbar .btn-secondary:hover { background: rgba(255, 255, 255, 0.26); color: #fff; }

/* ------------------------------------------------------------
   9. Pending / loading feedback

   Problem: clicking Approve produced no visible change until the new
   page painted. On a slow link people clicked twice, which is how you
   get duplicate payments. Now every in-flight request marks its trigger
   busy and disables it.
   ------------------------------------------------------------ */

.htmx-request.ux-busy,
[aria-busy="true"] { position: relative; pointer-events: none; opacity: 0.72; }

.htmx-request .ux-spin,
.ux-busy .ux-spin { display: inline-block; }
.ux-spin {
    display: none;
    width: 12px; height: 12px;
    border: 2px solid currentColor;
    border-right-color: transparent;
    border-radius: 50%;
    animation: uxSpin 640ms linear infinite;
    vertical-align: -1px;
}
@keyframes uxSpin { to { transform: rotate(360deg); } }

/* Row-level progress while a workflow action is in flight. */
tr.htmx-request > td { opacity: 0.55; }
tr.htmx-request > td:first-child { box-shadow: inset 3px 0 0 var(--text-link); }

/* Top-of-page progress bar for any HTMX navigation. */
.ux-progress {
    position: fixed; top: 0; left: 0;
    height: 2px; width: 0;
    background: var(--text-link);
    z-index: var(--z-palette);
    transition: width 200ms var(--ease), opacity var(--dur);
    opacity: 0;
}
.ux-progress[data-active="true"] { opacity: 1; }

/* Skeleton rows: reserve the layout so refreshing a KPI block does not
   make the page jump under the pointer. */
.ux-skeleton {
    background: linear-gradient(90deg, rgba(255,255,255,0.35) 25%, rgba(255,255,255,0.7) 37%, rgba(255,255,255,0.35) 63%);
    background-size: 400% 100%;
    animation: uxShimmer 1.3s ease infinite;
    border-radius: var(--r-sm);
    color: transparent !important;
}
@keyframes uxShimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }

/* ------------------------------------------------------------
   10. Sticky action bar for long forms

   Problem: the invoice/bill form put Save at the bottom, below a
   line-item table that can run past a screen. You scrolled to save,
   then scrolled back to check what you saved.
   ------------------------------------------------------------ */

.ux-actionbar {
    position: sticky;
    bottom: 0;
    z-index: var(--z-sticky);
    display: flex; flex-wrap: wrap;
    align-items: center; gap: var(--sp-5);
    margin: 0;
    padding: var(--sp-5) var(--sp-6);
    background: rgba(244, 248, 252, 0.97);
    backdrop-filter: blur(12px);
    border-top: 1px solid var(--aero-glass-border);
    box-shadow: 0 -6px 18px rgba(16, 32, 56, 0.10);
    border-radius: 0 0 var(--r-lg) var(--r-lg);
}
/* When the bar sits *inside* a padded card body rather than as a direct
   child of the card, it has to break out of that padding to reach the
   card's edges. */
.card-body > .ux-actionbar,
.ux-section__body > .ux-actionbar {
    margin: var(--sp-6) calc(-1 * var(--sp-6)) calc(-1 * var(--sp-6));
}
.ux-actionbar__spacer { margin-left: auto; }

/* Running document total, live as you type. This number is the whole
   reason the screen exists; it should not require a save to see. */
.ux-total { display: flex; align-items: baseline; gap: var(--sp-4); }
.ux-total__label { font-size: var(--fs-small); color: var(--text-muted); }
.ux-total__value { font-size: var(--fs-h2); font-weight: var(--fw-semi); font-variant-numeric: tabular-nums; }
.ux-total__cur { font-size: var(--fs-small); color: var(--text-muted); }
.ux-total--flash { animation: uxFlash 500ms var(--ease); }
@keyframes uxFlash { 0% { background: var(--warn-tint); } 100% { background: transparent; } }

/* Unsaved-changes marker in the action bar. */
.ux-dirty {
    display: none; align-items: center; gap: var(--sp-3);
    font-size: var(--fs-small); color: #8a6100; font-weight: var(--fw-medium);
}
form[data-dirty="true"] .ux-dirty { display: inline-flex; }

/* ------------------------------------------------------------
   11. Combobox (type-ahead over a <select>)

   Problem: Customer/Supplier/Service were native <select>s with
   hundreds of options. Native selects only jump by first letter, so
   picking "Zubair Petroleum Services" meant scrolling. Progressive
   enhancement: the real <select> stays in the DOM as the form value, so
   with JS off the form still works exactly as before.
   ------------------------------------------------------------ */

.ux-combo { position: relative; }
.ux-combo__list {
    position: absolute; left: 0; right: 0; top: calc(100% + 2px);
    z-index: var(--z-dropdown);
    max-height: 260px; overflow-y: auto;
    background: #fff;
    border: 1px solid #9fb0c8;
    border-radius: var(--r-md);
    box-shadow: var(--el-3);
    padding: var(--sp-2);
    display: none;
}
.ux-combo__list[data-open="true"] { display: block; }
.ux-combo__opt {
    padding: var(--sp-3) var(--sp-4);
    border-radius: var(--r-sm);
    font-size: var(--fs-body);
    cursor: pointer;
}
.ux-combo__opt[aria-selected="true"] { background: rgba(31, 111, 235, 0.14); }
.ux-combo__opt mark { background: #ffe9a8; border-radius: 2px; }
/* Suggestion rows carry a second column — a customer code beside a name,
   "invoice" beside a document number. Two candidates that read the same
   ("Basra" the branch, "Basra" the port) are only distinguishable by it. */
.ux-combo__opt:has(.ux-combo__hint) { display: flex; align-items: baseline; gap: var(--sp-4); }
.ux-combo__hint {
    margin-left: auto; flex: none;
    font-size: var(--fs-small); color: var(--text-muted);
}
.ux-combo__none { padding: var(--sp-4); color: var(--text-muted); font-size: var(--fs-small); }
/* Why the text snapped back to the previous choice (ux.js, commit()). */
.ux-combo__note { display: none; margin-top: 2px; font-size: var(--fs-small); color: var(--warn-fg); }
.ux-combo__note[data-show="true"] { display: block; }

/* ------------------------------------------------------------
   12. KPI cards / stat blocks

   Problem: dashboard tiles were clickable <div onclick> — not focusable,
   not announced as links, no keyboard access. They are <a> now; this
   only styles them.
   ------------------------------------------------------------ */

.ux-stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
    gap: 0;
    padding: var(--sp-4) var(--sp-5);
}
.ux-stat {
    position: relative;
    display: block;
    padding: var(--sp-5) var(--sp-6);
    text-align: center;
    text-decoration: none;
    color: inherit;
    border-radius: var(--r-md);
    transition: background var(--dur) var(--ease);
}
.ux-stat + .ux-stat { box-shadow: inset 1px 0 0 rgba(11, 61, 145, 0.12); }
.ux-stat:hover { background: rgba(255, 255, 255, 0.5); }
.ux-stat:hover .ux-stat__drill { opacity: 1; }

.ux-stat__value { font-size: var(--fs-kpi); font-weight: var(--fw-semi); color: #1e293b; font-variant-numeric: tabular-nums; line-height: 1.15; }
.ux-stat__second { font-size: var(--fs-body); font-weight: var(--fw-semi); color: var(--text-soft); font-variant-numeric: tabular-nums; }
.ux-stat__label { font-size: var(--fs-small); color: var(--text-muted); font-weight: var(--fw-medium); margin-top: var(--sp-2); }
.ux-stat__sub { font-size: var(--fs-micro); color: var(--text-muted); margin-top: var(--sp-3); }
.ux-stat__drill { position: absolute; top: var(--sp-3); right: var(--sp-4); font-size: var(--fs-micro); color: var(--text-link); opacity: 0; transition: opacity var(--dur); }

.ux-stat--money .ux-stat__value { color: var(--money-in); }
.ux-stat--neg   .ux-stat__value { color: var(--money-out); }
.ux-stat--alert .ux-stat__value { color: var(--money-out); }
.ux-stat--zero  .ux-stat__value { color: var(--text-muted); font-weight: var(--fw-medium); }

/* Period-over-period delta: a number without a trend is hard to judge. */
.ux-delta { display: inline-flex; align-items: center; gap: 3px; font-size: var(--fs-micro); font-weight: var(--fw-semi); }
.ux-delta--up   { color: var(--money-in); }
.ux-delta--down { color: var(--money-out); }
.ux-delta--flat { color: var(--text-muted); }

/* Wrapping variant. `.ux-stats` draws its dividers with `.ux-stat + .ux-stat`,
   which is fine on one row and wrong the moment tiles wrap: the first tile of
   every new row gets a left divider hanging off nothing. In the narrower
   dashboard columns tiles always wrap, so here the divider is replaced by a
   gap and a faint surface, which survives any number of rows. */
.ux-stats--grid {
    /* Narrower track than the default 180px: in a half-width column four
       money tiles at 180px wrap 3 + 1, leaving the fourth alone beside a
       gap. 132px fits all four on one row and still fits six across the
       wider column. */
    grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
    gap: var(--sp-4); padding: var(--sp-5);
}
/* Money tiles need their own track width. A figure like $25,150,000.00 at
   --fs-kpi wants ~200px, and in a 132px track it simply runs out of the
   tile. Two-up is the honest fit for four of them in a half-width column. */
.ux-stats--money { grid-template-columns: repeat(auto-fit, minmax(228px, 1fr)); }
/* `auto-fit` collapses empty tracks, so a row holding one tile stretches
   it the full width of the page — a 1500px box around one figure. Same
   reason as `.ux-formgrid--fill`: `auto-fill` keeps the tracks, so one
   currency's total stays the width of a tile. */
.ux-stats--fill { grid-template-columns: repeat(auto-fill, minmax(228px, 1fr)); }
/* Compact counters — exception and status counts, as opposed to money.
   A count is one to three digits and is usually zero; at --fs-kpi in a
   228px track, three of them filled a third of the first screen to report
   that nothing was wrong, and pushed the charts below the fold. The tile
   stays a full link with a full hit area; only the type and the padding
   come down. */
.ux-stats--compact {
    grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
    gap: var(--sp-3);
}
.ux-stats--compact .ux-stat { padding: var(--sp-4) var(--sp-4); }
.ux-stats--compact .ux-stat__value { font-size: var(--fs-h2); }
.ux-stats--compact .ux-stat__label { font-size: var(--fs-micro); margin-top: 0; }
/* The drill affordance is a hover-only cue that needs room the compact
   tile does not have; the whole tile is the link anyway. */
.ux-stats--compact .ux-stat__drill { display: none; }
.ux-stats--grid .ux-stat { background: rgba(255, 255, 255, 0.40); border: 1px solid rgba(255, 255, 255, 0.66); }
.ux-stats--grid .ux-stat + .ux-stat { box-shadow: none; }
.ux-stats--grid .ux-stat:hover { background: rgba(255, 255, 255, 0.72); }

/* ------------------------------------------------------------
   12b. Charts

   The SVG comes from `bar_chart` / `stack_bar` in ux_tags.py and carries
   tone classes, never fills — so the palette stays in tokens.css and the
   high-contrast override at the bottom of that file keeps working.

   One custom property drives both the SVG `fill` and the legend swatch's
   `background`, so a tone is defined once rather than twice in two
   different properties that can drift apart.
   ------------------------------------------------------------ */

.ux-tone--in         { --chart-tone: var(--money-in); }
.ux-tone--out        { --chart-tone: var(--money-out); }
.ux-tone--accent     { --chart-tone: var(--text-link); }
.ux-tone--neutral    { --chart-tone: var(--neutral-border); }
.ux-tone--warn       { --chart-tone: var(--warn-border); }
.ux-tone--bad        { --chart-tone: var(--bad-border); }
/* GML's own blue, for series that are simply "the company" — job volume,
   revenue by customer — rather than one side of a comparison. */
.ux-tone--brand      { --chart-tone: var(--gml-blue); }
.ux-tone--brand-soft { --chart-tone: var(--gml-blue-soft); }
/* Categorical identities. Fixed order — see the note on the ramp in
   tokens.css for why re-ordering these is not a cosmetic change. */
.ux-tone--cat-1    { --chart-tone: var(--cat-1); }
.ux-tone--cat-2    { --chart-tone: var(--cat-2); }
.ux-tone--cat-3    { --chart-tone: var(--cat-3); }
.ux-tone--cat-4    { --chart-tone: var(--cat-4); }
.ux-tone--cat-5    { --chart-tone: var(--cat-5); }
.ux-tone--cat-6    { --chart-tone: var(--cat-6); }
.ux-tone--cat-rest { --chart-tone: var(--cat-rest); }

/* Height is set here, not by the viewBox: a chart that keeps its aspect
   ratio gets taller every time the column gets wider, which is how a trend
   ends up as 250px of empty plot. */
.ux-chart { display: block; }
.ux-chart__plot { display: block; width: 100%; height: var(--chart-h, 116px); }
.ux-chart--short { --chart-h: 84px; }
.ux-chart--tall  { --chart-h: 168px; }
.ux-chart__bar, .ux-chart__seg { fill: var(--chart-tone, var(--neutral-border)); }
.ux-chart__axis { stroke: rgba(11, 61, 145, 0.28); stroke-width: 1; }

/* ---- Value axis --------------------------------------------------
   The charts used to have no vertical scale at all: you could see that
   March was taller than April and had no way to learn by how much
   without hovering every bar. The labels are HTML, positioned over the
   plot's own coordinate space, for the same reason the tick labels are:
   text inside a `preserveAspectRatio="none"` viewBox smears sideways as
   the container widens. */
.ux-chart--scaled { position: relative; padding-left: var(--chart-axis, 52px); }
.ux-chart__scale {
    position: absolute; left: 0; top: 0;
    width: var(--chart-axis, 52px); height: var(--chart-h, 116px);
}
.ux-chart__scale span {
    position: absolute; right: var(--sp-3);
    transform: translateY(-50%);
    font-size: var(--fs-micro); color: var(--text-muted);
    font-variant-numeric: tabular-nums; white-space: nowrap;
}
/* Behind the data, never level with it. A gridline as visible as a bar
   turns a chart into a grid with some bars in it. */
.ux-chart__grid { stroke: rgba(11, 61, 145, 0.10); stroke-width: 1; }
.ux-chart__grid--zero { stroke: rgba(11, 61, 145, 0.26); }

/* The overlay trend line (net, running total). Sized in screen pixels so
   the stretched viewBox cannot thicken it. */
.ux-chart__line {
    stroke: var(--gml-blue); stroke-width: 2;
    stroke-linejoin: round; stroke-linecap: round;
}
.ux-chart__dot { stroke: var(--gml-blue); stroke-width: 6; }

/* ---- Waterfall ---------------------------------------------------
   The connector carries the running total from one bar to the next. It
   is dashed and faint on purpose: it is the chart explaining its own
   arithmetic, not a sixth quantity being plotted. */
.ux-chart__connector {
    stroke: rgba(11, 61, 145, 0.34); stroke-width: 1; stroke-dasharray: 3 2;
}
.ux-chart--waterfall { --chart-h: 190px; }
/* Every step's figure, printed under its bar. A waterfall's whole claim
   is that the arithmetic adds up, and a reader cannot check that against
   bar heights alone. */
.ux-chart__values {
    display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
    margin-top: var(--sp-3);
}
.ux-chart__values span {
    font-size: var(--fs-small); font-weight: var(--fw-semi);
    font-variant-numeric: tabular-nums; text-align: center;
    color: var(--text-soft);
}
/* Step names are words, not month abbreviations, so they wrap rather
   than overflowing into their neighbours. */
.ux-chart--waterfall .ux-chart__ticks { margin-top: var(--sp-2); }
.ux-chart--waterfall .ux-chart__ticks span {
    white-space: normal; overflow: hidden; padding: 0 var(--sp-2);
    line-height: 1.25;
}

/* ---- Ranked horizontal bars --------------------------------------
   "Which five customers are most of the revenue" is a different question
   from "how did revenue move", and a column chart answers it badly: the
   labels are names, and names do not fit under a 40px column. */
.ux-rank { display: grid; gap: var(--sp-3); }
.ux-rank__row {
    display: grid;
    grid-template-columns: minmax(88px, 0.9fr) minmax(0, 2.4fr) auto;
    align-items: center; gap: var(--sp-4);
    padding: var(--sp-2) var(--sp-3);
    border-radius: var(--r-sm, 4px);
    color: inherit; text-decoration: none;
}
a.ux-rank__row:hover { background: rgba(255, 255, 255, 0.6); }
a.ux-rank__row:focus-visible { box-shadow: var(--focus-ring); outline: none; }
.ux-rank__label {
    font-size: var(--fs-small); color: var(--text-soft);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ux-rank__track {
    position: relative; display: block; height: 14px;
    border-radius: var(--r-pill); background: rgba(11, 61, 145, 0.07);
}
.ux-rank__fill {
    position: absolute; top: 0; height: 100%;
    min-width: 2px; border-radius: var(--r-pill);
    background: var(--chart-tone, var(--neutral-border));
}
.ux-rank__value {
    font-size: var(--fs-small); font-variant-numeric: tabular-nums;
    font-weight: var(--fw-semi); text-align: right; white-space: nowrap;
}
.ux-rank__zero {
    position: absolute; top: -2px; bottom: -2px; width: 1px;
    background: rgba(11, 61, 145, 0.32);
}

/* One equal cell per bar, so a label sits under the group it names. */
.ux-chart__ticks {
    display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
    margin-top: var(--sp-3);
}
/* Labels overflow their cell rather than being clipped by it. With twenty
   buckets a cell is ~25px and "Jul 25" would be cut to "Jul :" — but the
   cells either side are blank (the labels are thinned), so there is nothing
   to collide with. */
.ux-chart__ticks span {
    font-size: var(--fs-micro); color: var(--text-muted); text-align: center;
    white-space: nowrap; overflow: visible;
}
/* The mix bar is a proportion, not a shape: it may stretch freely. */
.ux-chart--stack {
    display: block; width: 100%; height: 14px; border-radius: var(--r-pill);
    overflow: hidden; box-shadow: inset 0 0 0 1px rgba(11, 61, 145, 0.10);
}

/* ---- Donut ------------------------------------------------------
   Part-to-whole. The ring is the only chart in this app that keeps its
   aspect ratio: a circle in a stretched viewBox is an ellipse, and an
   ellipse misstates every angle it draws.

   The arcs are dashes on one circle rather than <path> wedges, so there
   are no sub-pixel seams where two fills meet and the whole geometry is
   `stroke-dasharray: <percent> <rest>`.

   Ring and legend sit side by side and wrap to stacked in a narrow
   column: the legend is not an optional caption here, it is what makes
   the slices identifiable at all. */
.ux-donut {
    display: flex; flex-wrap: wrap; align-items: center;
    gap: var(--sp-6) var(--sp-8);
}
.ux-donut__ring { position: relative; flex: none; width: var(--donut-size, 168px); }
.ux-donut__svg { display: block; width: 100%; height: auto; }
.ux-donut__track {
    fill: none; stroke: rgba(11, 61, 145, 0.08); stroke-width: 5;
}
/* No rotation here: an SVG circle's path already starts at three o'clock
   and runs clockwise, and `donut_chart` offsets the dash pattern by a
   quarter turn so slice one begins at twelve — where a reader expects a
   mix to start. Rotating the element as well would move it to nine. */
.ux-donut__seg {
    fill: none; stroke: var(--chart-tone, var(--neutral-border));
    stroke-width: 5;
}
/* The total the slices add up to, printed in the hole. Without it the
   shares are percentages of an unstated number, and "42%" of an unknown
   total is not a fact anyone can act on. HTML rather than <text> so it
   is at a real font size and stays selectable. */
.ux-donut__hole {
    position: absolute; inset: 0;
    display: flex; flex-direction: column; align-items: center;
    justify-content: center; text-align: center; pointer-events: none;
    padding: 0 22%;
}
.ux-donut__fig {
    font-size: var(--fs-h3); font-weight: var(--fw-semi);
    font-variant-numeric: tabular-nums; line-height: 1.1; color: var(--text);
}
.ux-donut__cap {
    font-size: var(--fs-micro); color: var(--text-muted); line-height: 1.2;
    margin-top: var(--sp-1);
}

/* Capped, and the cap is the point. The legend row puts the name in a
   `1fr` column with the figure and the share pinned right, so in the
   full-width card on the finance dashboard the two ran to opposite ends
   of a 3,000px row — "Sea Freight Import" on the left and "51,000.00"
   a metre away on the right, with nothing joining them. A legend entry
   has to be readable as one line, so it stops growing well before the
   card does. */
/* The one-category case. A ring drawn for a single slice is a circle
   labelled 100%, so it is replaced by the sentence that says the same
   thing in less space and without inviting a hunt for the other slices. */
.ux-donut-sole {
    margin: 0; padding: var(--sp-4) 0;
    font-size: var(--fs-body); color: var(--text-soft); line-height: 1.5;
}
.ux-donut-sole b { color: var(--text); font-variant-numeric: tabular-nums; }

.ux-donut__legend {
    flex: 1 1 240px; max-width: 32rem;
    display: grid; gap: var(--sp-1); min-width: 0;
}
.ux-donut__row {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto auto;
    align-items: center; gap: var(--sp-4);
    padding: var(--sp-2) var(--sp-3);
    border-radius: var(--r-sm); color: inherit; text-decoration: none;
    font-size: var(--fs-small);
}
a.ux-donut__row:hover { background: rgba(255, 255, 255, 0.6); }
a.ux-donut__row:focus-visible { box-shadow: var(--focus-ring); outline: none; }
.ux-donut__name {
    color: var(--text-soft);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ux-donut__val {
    font-variant-numeric: tabular-nums; font-weight: var(--fw-semi);
    text-align: right; white-space: nowrap;
}
/* The share, kept visually subordinate to the money. A percentage is how
   you compare two slices; the figure is what you act on. */
.ux-donut__pct {
    font-variant-numeric: tabular-nums; color: var(--text-muted);
    text-align: right; min-width: 3.2em; white-space: nowrap;
}

/* Two trends abreast where the column allows it. Stacking one chart per
   currency made the trend column twice as tall as the tiles beside it,
   which put the empty space back — just in the other column. */
.ux-chartgrid {
    display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
    gap: var(--sp-5) var(--sp-7);
}

.ux-chartcard { padding: var(--sp-5) var(--sp-7) var(--sp-6); }
.ux-chartcard__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-3) var(--sp-5); margin-bottom: var(--sp-5); }
.ux-chartcard__fig { font-size: var(--fs-h2); font-weight: var(--fw-semi); font-variant-numeric: tabular-nums; }
.ux-chartcard__meta { font-size: var(--fs-small); color: var(--text-muted); }
/* A second, smaller figure under the main one — the mix bar under the trend.
   Needs its own rule because a <figcaption> butted straight up against the
   tick labels above it reads as part of the same axis. */
.ux-chartcard__aside {
    margin-top: var(--sp-7); padding-top: var(--sp-5);
    border-top: 1px solid rgba(11, 61, 145, 0.10);
}
.ux-chartcard__aside .ux-chart--stack { margin-top: var(--sp-3); }

.ux-chart-legend {
    display: flex; flex-wrap: wrap; gap: var(--sp-3) var(--sp-6);
    margin-top: var(--sp-5); font-size: var(--fs-small); color: var(--text-soft);
}
.ux-chart-legend__item { display: inline-flex; align-items: center; gap: var(--sp-3); }
.ux-chart-legend__item b { font-variant-numeric: tabular-nums; font-weight: var(--fw-semi); }
.ux-key {
    display: inline-block; width: 10px; height: 10px; flex: none;
    border-radius: 2px; background: var(--chart-tone, var(--neutral-border));
}

/* A caveat that belongs to the chart above it — an excluded figure, an
   assumption the shape rests on. Set apart from the plot by a rule rather
   than by distance alone, because a paragraph merely sitting under a
   chart reads as body copy about the section, not as a footnote on the
   picture. */
.ux-chart-note {
    margin: var(--sp-6) 0 0; padding-top: var(--sp-4);
    border-top: 1px solid rgba(11, 61, 145, 0.10);
    font-size: var(--fs-micro); color: var(--text-muted); line-height: 1.45;
}
.ux-chart-note b { font-variant-numeric: tabular-nums; color: var(--text-soft); }

/* ------------------------------------------------------------
   Help text, and turning it off

   Every field help line and every instructional paragraph carries a
   class, so one attribute on <html> can hide the lot. Written as an
   opt-out rather than an opt-in because the text is worth having the
   first few times somebody meets a screen and tiresome forever after —
   and a system whose explanations can only be added by a developer ends
   up with none.

   WHAT GOES: `.form-text` (the line under a field) and `.ux-help`
   (instructional prose — a card's subtitle, a form's preamble).

   WHAT STAYS, deliberately, and this is the whole design of it:

    * Errors. Hiding why a save failed is not a preference.
    * Empty states. "No job files match these filters" is the only thing
      on the screen; without it the page looks broken.
    * Anything that qualifies a figure — a section hint naming the
      currency ("invoiced by business unit, USD"), a chart footnote
      saying which documents were counted. Those change what a number
      MEANS. Hiding them leaves the number looking more authoritative
      than it is, which is the opposite of a clean screen.

   So `.ux-help` is applied by hand, never to a `.text-muted` wholesale.
   If prose can be removed without changing what a number on the page
   means, it may carry the class. If it cannot, it may not.
   ------------------------------------------------------------ */
:root[data-help="off"] .form-text,
:root[data-help="off"] .ux-help { display: none; }

/* The help line under a field. Styled here rather than by Bootstrap so
   the toggle above has one thing to switch. */
.ux-help {
    font-size: var(--fs-small);
    color: var(--text-muted);
    line-height: 1.45;
}

/* A neutral label for a value that is neither a status nor a link — the
   services a supplier provides, a tag on a record. Deliberately not the
   `.st` status pill: those carry a glyph and a meaning ("draft",
   "approved"), and borrowing one to say "Ocean Freight" puts a pencil
   icon and a workflow colour on a word that has no workflow. */
.ux-tag {
    display: inline-block;
    padding: 1px var(--sp-4);
    margin: 1px 0;
    border-radius: var(--r-pill);
    background: rgba(11, 61, 145, 0.07);
    border: 1px solid rgba(11, 61, 145, 0.12);
    font-size: var(--fs-micro);
    color: var(--text-soft);
    white-space: nowrap;
}

/* The all-clear. Reserving a grid of tiles to report that nothing is
   wrong spends the most valuable space on the page on an absence; one
   quiet line says it and gives the room back. Deliberately not green and
   not a badge — it is the normal state, not an achievement. */
.ux-allclear {
    display: flex; align-items: baseline; gap: var(--sp-4);
    margin: 0; padding: var(--sp-5) var(--sp-6);
    font-size: var(--fs-small); color: var(--text-muted); line-height: 1.5;
}
.ux-allclear__mark { flex: none; color: var(--money-in); font-weight: var(--fw-bold); }

/* Said out loud rather than drawn: a chart of two data points is a lie
   with axes on it. */
.ux-chart-empty {
    display: flex; align-items: center; justify-content: center;
    min-height: 96px; padding: var(--sp-6);
    font-size: var(--fs-small); color: var(--text-muted); text-align: center;
}

/* ------------------------------------------------------------
   12c. Dashboard column layout

   A single full-width column was the real source of the dashboard's empty
   space: `.ux-stats` uses `auto-fit`, so empty tracks collapse and three
   tiles stretch to fill a 1600px page — a 500px-wide box around one digit.
   Pairing each tile group with something that wants the width (a trend, a
   queue) fixes the cause rather than shrinking the symptom.
   ------------------------------------------------------------ */

/* Narrow column first: the headline figures are what someone came for, and
   the trend that explains them reads second. Vertical spacing stays on the
   sections' own margin so the rhythm is unchanged when this collapses. */
.ux-dashgrid {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
    gap: 0 var(--sp-7);
    align-items: start;
}
.ux-dashgrid--even { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
/* Below this the two columns are narrower than a comfortable reading
   measure, and stacking beats cramming. */
@media (max-width: 1180px) {
    .ux-dashgrid, .ux-dashgrid--even { grid-template-columns: minmax(0, 1fr); }
}

/* ------------------------------------------------------------
   13. Section shell (used by dashboard + detail screens)
   ------------------------------------------------------------ */

.ux-section {
    background: rgba(255, 255, 255, 0.6);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border: 1px solid var(--aero-glass-border);
    border-radius: var(--r-lg);
    margin-bottom: var(--sp-7);
    box-shadow: var(--el-2), var(--el-inset-top);
}
.ux-section__head {
    display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-4);
    padding: var(--sp-5) var(--sp-7);
    background: linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.4) 100%);
    border-bottom: 1px solid rgba(0, 0, 0, 0.05);
    border-radius: var(--r-lg) var(--r-lg) 0 0;
}
.ux-section__head h2 { font-size: var(--fs-lead); font-weight: var(--fw-semi); }
.ux-section__hint { font-size: var(--fs-small); color: var(--text-muted); font-weight: var(--fw-normal); }
.ux-section__spacer { margin-left: auto; }
.ux-section__body { padding: var(--sp-6) var(--sp-7); }
.ux-section__body--flush { padding: 0; }

/* ------------------------------------------------------------
   14. Peek panel — read a document without leaving the queue

   Problem: the only way to see an invoice's line items from the
   approvals queue was to open the print view, which navigated away and
   lost your place in the list. Approvers were opening tabs to cope.
   ------------------------------------------------------------ */

.ux-peek { border-top: 2px solid var(--text-link); background: rgba(244, 248, 253, 0.96); }
.ux-peek__inner { padding: var(--sp-6) var(--sp-7); }
.ux-peek__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: var(--sp-5); margin-bottom: var(--sp-5); }
/* Grid items default to min-width:auto, so a party name longer than its
   column pushed out of the cell and ran straight into the date beside it. */
.ux-peek__grid > * { min-width: 0; overflow-wrap: anywhere; }
.ux-peek__k { font-size: var(--fs-micro); text-transform: uppercase; letter-spacing: 0.4px; color: var(--text-muted); }
.ux-peek__v { font-size: var(--fs-body); font-weight: var(--fw-semi); word-break: break-word; white-space: normal; }
.ux-peek__close { margin-left: auto; }

/* The peek's two columns.
   `auto-fit` with a `min(100%, …)` floor so they sit side by side when
   there is room and stack when there is not — the FSL panes are half the
   width of the approvals queue and the fixed two-column version
   overflowed them. `min-width: 0` lets a wide line-item table shrink
   instead of escaping its column; the wrapper scrolls it instead.

   The 460px floor is deliberately larger than the table's comfortable
   width: at 280px two columns *did* fit inside an FSL pane, and the
   result was a five-column money table in 370px, scrolling to read a
   single line. Better to stack and give it the whole pane. */
.ux-peek__cols {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 460px), 1fr));
    gap: var(--sp-6) var(--sp-8);
    align-items: start;
}
.ux-peek__cols > * { min-width: 0; }
.ux-peek__scroll { overflow-x: auto; }

/* Audit trail as a vertical timeline: who did what, in order. */
.ux-trail { list-style: none; margin: 0; padding: 0 0 0 var(--sp-6); border-left: 2px solid rgba(11, 61, 145, 0.18); }
.ux-trail li { position: relative; padding: 0 0 var(--sp-5); font-size: var(--fs-small); }
.ux-trail li::before {
    content: ""; position: absolute; left: calc(-1 * var(--sp-6) - 5px); top: 5px;
    width: 8px; height: 8px; border-radius: 50%;
    background: var(--text-link); box-shadow: 0 0 0 2px #fff;
}
.ux-trail li[data-kind="reject"]::before { background: var(--bad-border); }
.ux-trail li[data-kind="approve"]::before { background: var(--ok-border); }
.ux-trail__who { font-weight: var(--fw-semi); }
.ux-trail__when { color: var(--text-muted); }

/* ------------------------------------------------------------
   15. Ageing / SLA signal

   Problem: the queue showed "3 days ago" as plain grey text, so an
   item sitting for three weeks looked exactly like one from lunchtime.
   ------------------------------------------------------------ */

.ux-age { display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-small); }
.ux-age__dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
.ux-age--fresh .ux-age__dot { background: var(--ok-border); }
.ux-age--warn  .ux-age__dot { background: var(--warn-border); }
.ux-age--late  .ux-age__dot { background: var(--bad-border); }
.ux-age--late  { color: var(--bad-fg); font-weight: var(--fw-semi); }

/* ------------------------------------------------------------
   16. Keyboard shortcut help sheet (?)
   ------------------------------------------------------------ */

.ux-keys { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--sp-7); }
.ux-keys h3 { font-size: var(--fs-small); text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: var(--sp-4); }
.ux-keys dl { display: grid; grid-template-columns: 1fr auto; gap: var(--sp-3) var(--sp-5); margin: 0; align-items: center; }
.ux-keys dt { font-size: var(--fs-small); color: var(--text-soft); }
.ux-keys dd { margin: 0; text-align: right; }

/* ------------------------------------------------------------
   17. Misc utilities
   ------------------------------------------------------------ */

.ux-sr {
    position: absolute !important;
    width: 1px; height: 1px;
    padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0, 0, 0, 0);
    white-space: nowrap; border: 0;
}

/* A dotted underline that signals "there is more detail on hover/focus"
   — replaces bare title="" attributes, which touch users never see. */
.ux-hint {
    border-bottom: 1px dotted var(--text-muted);
    cursor: help;
}

.ux-stack { display: flex; flex-direction: column; gap: var(--sp-5); }
.ux-row   { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-4); }
.ux-grow  { flex: 1; min-width: 0; }
.ux-nowrap { white-space: nowrap; }
.ux-mono { font-variant-numeric: tabular-nums; }

/* Split a long screen into a scrollable data area with a fixed header. */
.ux-tablewrap { overflow: auto; max-height: min(68vh, 760px); }
.ux-tablewrap--tall { max-height: min(78vh, 1000px); }

@media (max-width: 992px) {
    .ux-toasts { left: var(--sp-5); right: var(--sp-5); max-width: none; }
    .ux-palette { padding-top: var(--sp-6); }
    .ux-actionbar { gap: var(--sp-4); }
    .ux-stat + .ux-stat { box-shadow: none; border-top: 1px solid rgba(11, 61, 145, 0.12); }
}

/* List-page search (includes/list_search.html). */
.ux-listsearch { display: inline-flex; align-items: center; gap: var(--sp-2); margin: 0; }
.ux-listsearch input[type="search"] { width: 280px; max-width: 60vw; }
.ux-listsearch__count { font-size: var(--fs-small); white-space: nowrap; }

/* Trash button on invoice / bill lines (form_with_formset.html). */
.ux-line-remove {
    display: inline-flex; align-items: center; justify-content: center;
    width: 30px; height: 30px; padding: 0;
    border: 1px solid transparent; border-radius: var(--r-sm);
    background: transparent; color: var(--text-muted); cursor: pointer;
}
.ux-line-remove:hover, .ux-line-remove:focus-visible {
    color: var(--bad-border); background: var(--bad-tint); border-color: var(--bad-border);
}
/* On a page with a sticky save bar, toasts sit above it — a "Line removed
   · Undo" toast used to cover the Save button for six seconds. */
body:has(.ux-actionbar) .ux-toasts { bottom: 96px; }
