/*
 * Jambo Cafe POS — design system.
 * Hand-rolled rather than Bootstrap: a POS needs big, unambiguous touch targets
 * and a layout that survives being used one-handed on a phone behind a counter.
 */

/* ---------- tokens ---------- */
:root {
    color-scheme: light;

    --bg: #faf7f2;
    --surface: #ffffff;
    --surface-2: #f4efe7;
    --surface-3: #ebe4d9;
    --ink: #1c1917;
    --ink-2: #57534e;
    --ink-3: #8b8378;
    --line: #e4dcd0;
    --line-strong: #d3c8b8;

    --brand: #c2571b;
    --brand-ink: #ffffff;
    --brand-soft: #fdf0e5;
    --brand-strong: #9a4315;

    --ok: #1f7a4d;
    --ok-soft: #e6f4ec;
    --warn: #a86a00;
    --warn-soft: #fdf3e0;
    --danger: #b3261e;
    --danger-soft: #fceceb;
    --info: #2563a8;
    --info-soft: #e8f0fa;

    --radius: 14px;
    --radius-sm: 10px;
    --radius-lg: 20px;

    --shadow-sm: 0 1px 2px rgb(28 25 23 / .06), 0 1px 3px rgb(28 25 23 / .04);
    --shadow: 0 2px 4px rgb(28 25 23 / .05), 0 8px 20px -6px rgb(28 25 23 / .12);
    --shadow-lg: 0 10px 40px -8px rgb(28 25 23 / .28);

    --tap: 48px;
    /* How wide the slide-in panel is. Named because it is not only the panel's
       business: the toasts step clear of it by exactly this much. Wide enough
       that the recipe's five columns fit without a sideways scroll. */
    --offcanvas-w: 40rem;
    --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

    /* Used as a mask, so the shape is all that matters here and the colour is
       decided where it is drawn — see the date field rules. Kept as a token
       because it is one shape used in more than one state. */
    --calendar-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2.75v3.5M16 2.75v3.5M3.75 9.75h16.5M5.5 4.75h13a1.75 1.75 0 0 1 1.75 1.75v12.5A1.75 1.75 0 0 1 18.5 20.75h-13a1.75 1.75 0 0 1-1.75-1.75V6.5A1.75 1.75 0 0 1 5.5 4.75Z'/%3E%3C/svg%3E");
}

/* Light is the default: with nothing chosen there is no data-theme at all, and
   the tokens above stand. Following the operating system is a choice someone
   makes on the profile page, and it is that choice — not the absence of one —
   that this block keys off. */
@media (prefers-color-scheme: dark) {
    :root[data-theme="system"] {
        color-scheme: dark;

        --bg: #14110e;
        --surface: #1e1a16;
        --surface-2: #272119;
        --surface-3: #332b21;
        --ink: #f7f2ea;
        --ink-2: #c4bbae;
        --ink-3: #94897b;
        --line: #332c23;
        --line-strong: #453b2f;

        --brand: #e2762f;
        --brand-ink: #1a0f07;
        --brand-soft: #33210f;
        --brand-strong: #f08d47;

        --ok: #4ade80;
        --ok-soft: #12251a;
        --warn: #e0a13a;
        --warn-soft: #2b2110;
        --danger: #f87171;
        --danger-soft: #2c1615;
        --info: #7dabe4;
        --info-soft: #14202e;

        --shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
        --shadow: 0 2px 4px rgb(0 0 0 / .3), 0 8px 20px -6px rgb(0 0 0 / .5);
        --shadow-lg: 0 10px 40px -8px rgb(0 0 0 / .7);
    }
}

:root[data-theme="dark"] {
    color-scheme: dark;

    --bg: #14110e;
    --surface: #1e1a16;
    --surface-2: #272119;
    --surface-3: #332b21;
    --ink: #f7f2ea;
    --ink-2: #c4bbae;
    --ink-3: #94897b;
    --line: #332c23;
    --line-strong: #453b2f;

    --brand: #e2762f;
    --brand-ink: #1a0f07;
    --brand-soft: #33210f;
    --brand-strong: #f08d47;

    --ok: #4ade80;
    --ok-soft: #12251a;
    --warn: #e0a13a;
    --warn-soft: #2b2110;
    --danger: #f87171;
    --danger-soft: #2c1615;
    --info: #7dabe4;
    --info-soft: #14202e;

    --shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
    --shadow: 0 2px 4px rgb(0 0 0 / .3), 0 8px 20px -6px rgb(0 0 0 / .5);
    --shadow-lg: 0 10px 40px -8px rgb(0 0 0 / .7);
}

/* ---------- base ---------- */
*, *::before, *::after { box-sizing: border-box; }

html, body { height: 100%; }

body {
    margin: 0;
    font-family: var(--font);
    font-size: 16px;
    line-height: 1.5;
    color: var(--ink);
    background: var(--bg);
    -webkit-font-smoothing: antialiased;
    -webkit-tap-highlight-color: transparent;
}

h1, h2, h3, h4 { margin: 0; font-weight: 650; letter-spacing: -.015em; line-height: 1.25; }
h1 { font-size: 1.5rem; }
h2 { font-size: 1.175rem; }
h3 { font-size: 1rem; }
p { margin: 0 0 .75rem; }
a { color: var(--brand); text-decoration: none; }

/* Underlining on hover is right for a link sitting in a sentence and wrong for
   everything else — a button that sprouts an underline reads as text, not as a
   control. So: bare links in prose underline, and anything wearing a class of
   its own says how it responds, below. `.link` opts back in where a prose link
   needed a class for its size or weight. */
a:not([class]):hover, .link:hover { text-decoration: underline; text-underline-offset: .15em; }
.link:hover { color: var(--brand-strong); }

:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; border-radius: 6px; }

.num { font-variant-numeric: tabular-nums; }
.muted { color: var(--ink-2); }
.dim { color: var(--ink-3); }
.danger { color: var(--danger); }
.tiny { font-size: .75rem; }
.small { font-size: .875rem; }
.strong { font-weight: 650; }
.right { text-align: right; }
.center { text-align: center; }
.nowrap { white-space: nowrap; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stack { display: flex; flex-direction: column; gap: .75rem; }
.row { display: flex; align-items: center; gap: .625rem; }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.wrap { flex-wrap: wrap; }
.grow { flex: 1 1 auto; min-width: 0; }
.hide { display: none !important; }

/* A word that earns its place on a laptop and costs a whole line on a phone.
   Used in the middle of the stock buttons, where "Fresh stock count" and "Move
   stock location" say the same thing without it and then sit beside each other
   rather than one under the next. Either a word inside a label, or something
   that has another home on a phone (a topbar action re-homed into the page,
   paired with .only-narrow) — anything a reader would actually lose belongs
   in .sr-only below. Important like .hide above it, and for the same reason:
   it now hides elements that set their own display, like .btn. */
@media (max-width: 640px) {
    .hide-narrow { display: none !important; }

    /* Two up, rather than a column of four full-width buttons that pushes the
       balance off the top of a phone.

       Narrower padding and a smaller label, and then left to wrap on its own —
       not a two-column grid, which was the first try and the wrong one: a 1fr
       track floors at the width of its content, so the buttons kept their full
       size and hung off the side of the screen instead of sharing the row. Sized
       by the words, the pairs fall where they fit. The tap target keeps its full
       height throughout; it is only the horizontal padding that gives. */
    .stock-actions .btn { padding: 0 .75rem; font-size: .8125rem; gap: .375rem; }
}

/* The other way about: only there on a phone. For the action a one-line
   topbar has no room to keep beside a heading and its trail, re-homed as the
   first row of the page it acts on. Sits after .row in source on purpose —
   the two share specificity, so this one hiding a row depends on coming
   later. */
@media (min-width: 641px) {
    .only-narrow { display: none; }
}

/* Gone from the screen, still read out. */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

/* ---------- app shell ---------- */
.app {
    display: grid;
    /* Wide enough for the longest item and its badge on one line — "Cash
       drawer" with a count beside it was wrapping at 232px. */
    grid-template-columns: 264px 1fr;
    min-height: 100dvh;

    /* How much of the viewport the fixed chrome above the content takes: the
       topbar, plus the impersonation strip when there is one. Pages that size
       themselves to the screen rather than to their content — the till — read
       it instead of hard-coding the bar's height, which is how the cart used to
       end up a strip's worth off the bottom of an owner's screen. */
    --chrome: 65px;
}

.app.is-impersonating { --chrome: 97px; }

.sidebar {
    display: flex;
    flex-direction: column;
    gap: .25rem;
    padding: 1rem .75rem;
    background: var(--surface);
    border-right: 1px solid var(--line);
    position: sticky;
    top: 0;
    height: 100dvh;
    overflow-y: auto;
}

.brand {
    display: flex;
    align-items: center;
    gap: .625rem;
    padding: .375rem .5rem 1.125rem;
}

.brand-mark {
    display: grid;
    place-items: center;
    width: 38px;
    height: 38px;
    border-radius: 11px;
    background: linear-gradient(145deg, var(--brand), var(--brand-strong));
    color: var(--brand-ink);
    font-size: 1.25rem;
    flex: none;
}

.brand-name { font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }

.nav-link {
    display: flex;
    align-items: center;
    gap: .75rem;
    min-height: var(--tap);
    padding: 0 .75rem;
    border-radius: var(--radius-sm);
    color: var(--ink-2);
    font-weight: 550;
    white-space: nowrap;
    text-decoration: none;
}

.nav-link:hover { background: var(--surface-2); color: var(--ink); text-decoration: none; }
.nav-link[aria-current="page"] { background: var(--brand-soft); color: var(--brand-strong); }
.nav-icon { font-size: 1.15rem; width: 1.5rem; text-align: center; flex: none; }

.sidebar-foot { margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--line); }

/* ---------- the account, and what folds out of it ---------- */
.avatar {
    display: grid;
    place-items: center;
    width: 34px;
    height: 34px;
    border-radius: 50%;
    background: var(--brand-soft);
    color: var(--brand-strong);
    font-size: .8125rem;
    font-weight: 700;
    letter-spacing: .01em;
    flex: none;
}

/* Same circle, with a photo in it instead of initials. */
img.avatar { object-fit: cover; }

.avatar-lg { width: 58px; height: 58px; font-size: 1.25rem; }

.user-menu { position: relative; }

.user-trigger {
    display: flex;
    align-items: center;
    gap: .625rem;
    width: 100%;
    min-height: var(--tap);
    padding: .25rem .5rem;
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    color: var(--ink-2);
    font: inherit;
    text-align: left;
    cursor: pointer;
}

.user-trigger:hover, .user-trigger[aria-expanded="true"] { background: var(--surface-2); color: var(--ink); }
.user-trigger.is-current { background: var(--brand-soft); color: var(--brand-strong); }

.user-caret { flex: none; color: var(--ink-3); transition: transform .15s ease; }
.user-trigger[aria-expanded="true"] .user-caret { transform: rotate(180deg); }

/* Upward: there is nothing below the foot of the sidebar but the edge of the
   screen. Sized to the trigger so it never widens the drawer on a phone. */
.menu-pop {
    position: absolute;
    left: 0;
    right: 0;
    bottom: calc(100% + .375rem);
    z-index: 30;
    display: flex;
    flex-direction: column;
    gap: .125rem;
    padding: .3125rem;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius);
    background: var(--surface);
    box-shadow: var(--shadow-lg);
}

.menu-pop[hidden] { display: none; }

.menu-item {
    display: flex;
    align-items: center;
    gap: .625rem;
    width: 100%;
    min-height: 42px;
    padding: 0 .5rem;
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    color: var(--ink-2);
    font: inherit;
    font-size: .9375rem;
    font-weight: 550;
    text-align: left;
    text-decoration: none;
    cursor: pointer;
}

.menu-item:hover, .menu-item:focus-visible { background: var(--surface-2); color: var(--ink); text-decoration: none; }
.menu-item[aria-current="page"] { color: var(--brand-strong); }
.menu-item-danger { color: var(--danger); }
.menu-item-danger:hover, .menu-item-danger:focus-visible { background: var(--danger-soft); color: var(--danger); }

.menu-sep { height: 1px; margin: .25rem; background: var(--line); }

@media (prefers-reduced-motion: reduce) {
    .user-caret { transition: none; }
}

/* ---------- profile page ---------- */
/* Two columns: who you are stays put on the left while the sections scroll past
   it on the right. One column as soon as that stops being comfortable — on a
   phone the card simply leads. */
.profile-grid {
    display: grid;
    grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
    gap: 1.5rem;
    align-items: start;
    max-width: 78rem;
    margin-inline: auto;
}

.profile-aside { position: sticky; top: 5.25rem; min-width: 0; }

@media (max-width: 900px) {
    .profile-grid { grid-template-columns: 1fr; gap: 1rem; }
    .profile-aside { position: static; }
}

/* Cleared past the sticky topbar, or the heading of whatever was jumped to
   lands underneath it. */
.profile-section, #stock-movements { scroll-margin-top: 5.5rem; }

.profile-nav { display: flex; flex-direction: column; gap: .125rem; padding: .5rem; border-top: 1px solid var(--line); }

.profile-nav-link {
    display: flex;
    align-items: center;
    gap: .625rem;
    min-height: 42px;
    padding: 0 .625rem;
    border-radius: var(--radius-sm);
    color: var(--ink-2);
    font-size: .9375rem;
    font-weight: 550;
    text-decoration: none;
}

.profile-nav-link:hover { background: var(--surface-2); color: var(--ink); text-decoration: none; }
.profile-nav-link.is-here { background: var(--brand-soft); color: var(--brand-strong); }

/* ---------- the photo ---------- */
.avatar-edit { position: relative; display: inline-block; }

.avatar-shell {
    position: relative;
    display: block;
    width: 96px;
    height: 96px;
    border-radius: 50%;
    overflow: hidden;
    cursor: pointer;
    box-shadow: 0 0 0 4px var(--surface), 0 0 0 5px var(--line);
}

.avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-xl { width: 96px; height: 96px; font-size: 2rem; }

.avatar-overlay {
    position: absolute;
    inset: 0;
    display: grid;
    place-content: center;
    justify-items: center;
    gap: .0625rem;
    background: rgb(0 0 0 / .5);
    color: #fff;
    opacity: 0;
    transition: opacity .15s ease;
}

.avatar-shell:hover .avatar-overlay, .avatar-shell:focus-within .avatar-overlay { opacity: 1; }

.avatar-remove-form { position: absolute; right: -2px; bottom: -2px; }

.avatar-remove {
    display: grid;
    place-items: center;
    width: 28px;
    height: 28px;
    border: 2px solid var(--surface);
    border-radius: 50%;
    background: var(--danger);
    color: #fff;
    font-size: .75rem;
    line-height: 1;
    cursor: pointer;
    transition: transform .15s ease;
}

.avatar-remove:hover { transform: scale(1.08); }

/* The cropper. Square, because the result is. */
.crop-stage {
    position: relative;
    width: min(320px, 100%);
    margin: 0 auto;
    border-radius: var(--radius);
    overflow: hidden;
    background: var(--surface-3);
}

.crop-canvas { display: block; width: 100%; height: auto; touch-action: none; cursor: grab; }
.crop-canvas:active { cursor: grabbing; }

/* What will be kept, drawn over the top: the corners stay visible while the
   photo is being positioned, dimmed rather than hidden, because aiming at a
   circle you cannot see past is guesswork. */
.crop-mask {
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: rgb(0 0 0 / .45);
    /* Transparent in the middle, so the dimming is only painted around it. */
    -webkit-mask: radial-gradient(circle at 50% 50%, transparent 0 49.5%, #000 50%);
    mask: radial-gradient(circle at 50% 50%, transparent 0 49.5%, #000 50%);
    box-shadow: inset 0 0 0 1px rgb(255 255 255 / .35);
    border-radius: inherit;
}

.crop-slider { max-width: 14rem; }

/* ---------- a field you click to change ---------- */
/* At rest it is the value and nothing else — no box, no border, no hint that a
   form is hiding here. The pencil turns up under the pointer, and the input
   only exists once you have asked for it. The height is held steady so the
   swap does not shift the page, and stays a comfortable target on a tablet. */
.editable-value {
    display: flex;
    align-items: center;
    gap: .5rem;
    width: 100%;
    min-height: 2.5rem;
    padding: 0;
    border: 0;
    background: none;
    color: var(--ink);
    font: inherit;
    font-size: 1.0625rem;
    text-align: left;
    cursor: text;
    transition: color .15s ease;
}

.editable-value:hover { color: var(--brand-strong); }
.editable-value.is-empty { color: var(--ink-3); font-style: italic; }
.editable-pen { flex: none; color: var(--ink-3); opacity: 0; transition: opacity .15s ease; }
.editable-value:hover .editable-pen, .editable-value:focus-visible .editable-pen { opacity: 1; }

/* The input that takes its place, at the same size so nothing jumps. */
.editable .input { min-height: 2.5rem; padding: .3125rem .625rem; font-size: 1.0625rem; }

/* Its read-only neighbours, so a field nobody may edit still lines up with one
   that can be. */
.readonly {
    display: flex;
    align-items: center;
    min-height: var(--tap);
    padding: 0 .75rem;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    background: var(--surface-2);
    color: var(--ink-2);
}

/* ---------- label/value rows ---------- */
.rows { display: flex; flex-direction: column; }

.row-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: .75rem;
    padding: .75rem 1rem;
    border-bottom: 1px solid var(--line);
    min-width: 0;
}

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

.modal[hidden] { display: none; }

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

/* The bar, plus anything glued underneath it. The stack is what sticks: give
   the bar its own `position: sticky` as well and the strip below would scroll
   away behind it, which is the one thing a warning strip must not do. */
.topbar-stack {
    position: sticky;
    top: 0;
    z-index: 20;
}

.topbar {
    display: flex;
    align-items: center;
    gap: .75rem;
    padding: .75rem 1.25rem;
    background: var(--surface);
    border-bottom: 1px solid var(--line);
}

.titlebar { display: flex; flex-direction: column; justify-content: center; gap: .0625rem; min-width: 0; }

/* One line, always: this sits in a bar that is stuck to the top of a till, and
   a trail that wraps would push the page down every time a name got long.
   Where you are gives up its tail first; how you got here stays readable. */
.crumbs { font-size: .75rem; line-height: 1.3; }

.crumbs ol {
    display: flex;
    flex-wrap: nowrap;
    align-items: center;
    min-width: 0;
    margin: 0;
    padding: 0;
    list-style: none;
}

.crumbs li { flex: none; white-space: nowrap; }
.crumbs li:last-child { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.crumbs li + li::before { content: "›"; margin: 0 .375rem; color: var(--ink-3); }
.crumbs a { color: var(--ink-3); text-decoration: none; }
.crumbs a:hover { color: var(--brand-strong); text-decoration: underline; }
.crumbs [aria-current="page"] { color: var(--ink-2); font-weight: 600; }

.content { padding: 1.25rem; flex: 1 1 auto; min-width: 0; }
.content-flush { padding: 0; }

/* ---------- buttons ---------- */
.btn {
    /* The working state hangs two pseudo-elements on this — see .is-busy. */
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: .5rem;
    min-height: var(--tap);
    padding: 0 1.125rem;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--ink);
    font: inherit;
    font-weight: 600;
    cursor: pointer;
    user-select: none;
    /* A label is one line or it is not a label. Without this a two-word button
       squeezed by its neighbours breaks in half — "Put back" sitting two lines
       tall beside a one-line Recipe and Edit — while single-word buttons in the
       same row have nothing to break and stay put. Every button in this app is
       two or three short words; if one ever needs more room than it has, the
       row it is in should give it up. */
    white-space: nowrap;
    transition: transform .12s ease, background .15s ease, border-color .15s ease,
                box-shadow .15s ease, color .15s ease;
}

.btn:hover { background: var(--surface-2); border-color: var(--line-strong); text-decoration: none; }
.btn:active { transform: translateY(1px); box-shadow: none; }
/* The fade is for a button that cannot be pressed. A button that is working can
   be pressed — it just already has been — and fading it is what made it read as
   an empty outline, so it keeps its contrast and says so its own way instead. */
.btn:disabled:not(.is-busy) { opacity: .45; cursor: not-allowed; }
.btn:disabled:hover { transform: none; box-shadow: none; }
.btn:disabled:not(.is-busy):hover { background: var(--surface); }

.btn-primary { background: var(--brand); border-color: var(--brand); color: var(--brand-ink); }
.btn-primary:hover { background: var(--brand-strong); border-color: var(--brand-strong); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--ink-2); }
.btn-ghost:hover { background: var(--surface-2); color: var(--ink); }
.btn-danger { background: var(--danger-soft); border-color: transparent; color: var(--danger); }
.btn-danger:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
.btn-sm { min-height: 36px; padding: 0 .75rem; font-size: .875rem; }
.btn-lg { min-height: 56px; padding: 0 1.5rem; font-size: 1.0625rem; }
.btn-block { width: 100%; }
.btn-icon { width: var(--tap); padding: 0; flex: none; }
/* An icon button is square, whatever size it is. The width above is the full
   tap target, so a small one paired with it kept a 48px width against a 36px
   height — a wide box with a 16px mark adrift in the middle of it, which reads
   as a button whose label failed to render rather than a button that never had
   one. The size classes set the height; these set the matching width. */
.btn-sm.btn-icon { width: 36px; }
.btn-lg.btn-icon { width: 56px; }

/* ---------- a card that folds ---------- */
/*
 * A card whose head is the control that opens it. The browser's own marker is
 * dropped for a chevron that turns, because the default triangle sits outside
 * the padding and puts the heading a character-width off from every other card
 * head on the page.
 *
 * The head keeps the size and weight it has everywhere else — it is still the
 * card's heading, and only its being a control is new — so a page of folded
 * cards still reads as a page of cards rather than as a menu.
 */
.card-fold > summary {
    display: flex;
    align-items: center;
    gap: .5rem;
    cursor: pointer;
    user-select: none;
    list-style: none;
}

.card-fold > summary::-webkit-details-marker { display: none; }

.card-fold > summary::after {
    content: '';
    width: .5rem;
    height: .5rem;
    margin-left: auto;
    border-right: 2px solid var(--ink-3);
    border-bottom: 2px solid var(--ink-3);
    /* Pointing down when shut, up when open, and the box is nudged up by a
       quarter of itself so the corner rather than the gap reads as centred. */
    transform: translateY(-25%) rotate(45deg);
    transition: transform .18s ease;
}

.card-fold[open] > summary::after { transform: translateY(25%) rotate(-135deg); }

.card-fold > summary:hover { background: var(--surface-2); }
.card-fold > summary:hover::after { border-color: var(--ink-2); }

/*
 * Opening and shutting, rather than snapping.
 *
 * <details> has nothing to animate on its own — the content is simply there or
 * not — so this transitions the part that holds it. Two things make that
 * possible: interpolate-size, which is what lets a height animate to `auto`
 * without anyone measuring it in script, and allow-discrete, which holds the
 * content visible for the length of the close instead of cutting it at the
 * first frame.
 *
 * interpolate-size is set here rather than at :root so it applies to this and
 * nothing else. A browser that does not know ::details-content skips the whole
 * block and keeps the instant open, which is what it does today.
 */
.card-fold {
    interpolate-size: allow-keywords;
}

.card-fold::details-content {
    block-size: 0;
    overflow: hidden;
    transition:
        block-size .26s ease,
        content-visibility .26s allow-discrete;
}

.card-fold[open]::details-content { block-size: auto; }

@media (prefers-reduced-motion: reduce) {
    .card-fold > summary::after { transition: none; }
    .card-fold::details-content { transition: none; }
}

/* ---------- a button that is working ---------- */
/*
 * Two halves of one signal, sharing a duration so they turn in step: a light
 * that runs the border, which is what carries to somebody on the other side of
 * the counter, and a ring where the label was, for whoever is stood over it.
 * Between them they answer the two questions a pressed button has to answer —
 * is anything happening, and was it *this* one.
 *
 * Nothing moves or resizes. The label stays where it is holding the box open,
 * turned transparent rather than taken out, so no row reflows mid-tap and the
 * button keeps its accessible name for the whole wait.
 */
@property --btn-busy-angle {
    syntax: '<angle>';
    initial-value: 0deg;
    inherits: false;
}

@keyframes btn-busy-orbit { to { --btn-busy-angle: 360deg; } }
@keyframes btn-busy-ring { to { transform: rotate(1turn); } }

/* The face a button turns to the room decides the colour: brand orange on
   anything pale, the ink colour on the brand itself — an orange light on an
   orange button is no light at all. */
.btn { --busy-light: var(--brand); }
.btn-primary { --busy-light: var(--brand-ink); }
.btn-danger { --busy-light: var(--danger); }

.btn.is-busy { color: transparent; cursor: progress; }

/* Emoji carry their own colour and ignore `color`, so anything that is an
   element rather than a bare word has to be faded by hand. */
.btn.is-busy > * { opacity: 0; transition: opacity .15s ease; }

/* The light: a one-pixel ring cut out of a turning conic gradient and laid over
   the border the button already has. The shape underneath is never given up —
   the light rides on it rather than standing in for it. */
.btn.is-busy::before {
    content: '';
    position: absolute;
    /* Sits a touch proud of the button's own border and is drawn a touch
       thicker than it: at exactly 1px the light is lost against a row of
       borders that are already there. */
    inset: -1.5px;
    border-radius: inherit;
    padding: 1.5px;
    background: conic-gradient(from var(--btn-busy-angle),
                transparent 0deg 230deg,
                color-mix(in srgb, var(--busy-light) 45%, transparent) 320deg,
                var(--busy-light) 354deg,
                transparent 360deg);
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    mask-composite: exclude;
    animation: btn-busy-orbit 1.4s linear infinite;
    pointer-events: none;
}

/* The ring, in the middle where the label was: same tapered head as the light
   and the same 1.4s, which is what makes the two read as one thing turning. */
.btn.is-busy::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 1.25rem;
    height: 1.25rem;
    /* Offsets rather than a translate — the transform belongs to the spin. */
    margin: -.625rem 0 0 -.625rem;
    border-radius: 50%;
    background: conic-gradient(from 0deg,
                transparent 0deg 40deg,
                color-mix(in srgb, var(--busy-light) 30%, transparent) 210deg,
                var(--busy-light) 350deg,
                transparent 360deg);
    -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px));
    mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px));
    animation: btn-busy-ring 1.4s linear infinite;
    pointer-events: none;
}

/* Standing still says the same thing: a lit rim and a closed ring. */
@media (prefers-reduced-motion: reduce) {
    .btn.is-busy::before,
    .btn.is-busy::after {
        animation: none;
        background: var(--busy-light);
    }
}

/* ---------- how things answer a pointer ---------- */
/*
   One vocabulary across the whole app, so that "this is a control" never has to
   be said twice: a surface tint for anything sitting in a list, a one-pixel
   lift with a soft shadow for anything standing on its own, and the brand
   colour for the thing about to happen. Nothing underlines — see the note by
   the link rules at the top.

   The lifts are behind `hover: hover` on purpose. On the counter tablet there
   is no pointer to leave, and a lifted, shadowed button that stays that way
   until something else is tapped looks like a bug.
*/
@media (hover: hover) {
    .btn:hover { box-shadow: var(--shadow-sm); transform: translateY(-1px); }
    .btn-primary:hover { box-shadow: 0 6px 16px -8px var(--brand-strong); }
    .btn-ghost:hover { box-shadow: none; transform: none; }
    .card-link:hover { border-color: var(--brand); box-shadow: var(--shadow); transform: translateY(-2px); }
    .card-link:hover .card-link-go { transform: translateX(3px); color: var(--brand); }
}

.cat, .chip, .lock-choice, .card-link, .card-link-go, .crumbs a, .profile-nav-link {
    transition: background .15s ease, border-color .15s ease, color .15s ease,
                box-shadow .15s ease, transform .15s ease;
}

.cat:hover { background: var(--surface-2); border-color: var(--brand); color: var(--ink); text-decoration: none; }
.cat[aria-pressed="true"]:hover { background: var(--brand-strong); border-color: var(--brand-strong); color: var(--brand-ink); }
.crumbs a:hover { color: var(--brand-strong); text-decoration: none; }

/* A whole card that is one link: a settings category, and anything else that
   is best pointed at rather than read. */
.card-link { display: block; color: inherit; text-decoration: none; }
.card-link:hover { text-decoration: none; }
.card-link-go { flex: none; color: var(--ink-3); }

.card-mark {
    display: grid;
    place-items: center;
    width: 44px;
    height: 44px;
    border-radius: 12px;
    background: var(--brand-soft);
    color: var(--brand-strong);
    font-size: 1.375rem;
    flex: none;
}

/* Each category gets its own so the settings index is scannable by colour
   rather than by reading every heading. */
.card-mark-info { background: var(--info-soft); color: var(--info); }
.card-mark-ok { background: var(--ok-soft); color: var(--ok); }
.card-mark-warn { background: var(--warn-soft); color: var(--warn); }
.card-mark-plain { background: var(--surface-3); color: var(--ink-2); }

/* One card per row, and the column sits in the middle of the page. Content with
   a natural width pinned to the left edge leaves a metre of nothing beside it,
   which reads as a mistake rather than as breathing room. */
.settings-cards {
    display: flex;
    flex-direction: column;
    gap: .875rem;
    max-width: 46rem;
    margin-inline: auto;
}

/* The same trick, for anything else that caps its own width. */
.middle { margin-inline: auto; }

/* ---------- surfaces ---------- */
.card {
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm);
}

.card-pad { padding: 1rem; }
.card-head { padding: .875rem 1rem; border-bottom: 1px solid var(--line); font-weight: 650; }
.card-foot { display: flex; gap: .625rem; padding: .875rem 1rem; border-top: 1px solid var(--line); }

/* A card body that stops growing and scrolls instead — for the dashboard's
   stock lists, which are as long as the café's problems that day. The cap is
   roughly four rows of chips: enough to read the shape of the trouble, with
   the count in the card's header saying how much is below the fold. */
.card-pad-capped {
    max-height: 13.5rem;
    overflow-y: auto;
    overscroll-behavior: contain;
}

.badge {
    display: inline-flex;
    align-items: center;
    gap: .3rem;
    padding: .2rem .5rem;
    border-radius: 999px;
    font-size: .75rem;
    font-weight: 650;
    background: var(--surface-3);
    color: var(--ink-2);
    white-space: nowrap;
}

.badge-ok { background: var(--ok-soft); color: var(--ok); }
.badge-warn { background: var(--warn-soft); color: var(--warn); }
.badge-danger { background: var(--danger-soft); color: var(--danger); }
.badge-info { background: var(--info-soft); color: var(--info); }
.badge-brand { background: var(--brand-soft); color: var(--brand-strong); }

/* ---------- forms ---------- */
.field { display: flex; flex-direction: column; gap: .375rem; }
.label { font-size: .8125rem; font-weight: 650; color: var(--ink-2); }

.input, .select, .textarea {
    width: 100%;
    min-height: var(--tap);
    padding: .625rem .875rem;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--ink);
    font: inherit;
}

.textarea { min-height: 84px; resize: vertical; }
.input::placeholder { color: var(--ink-3); }

/* A box that is filled in for you, wearing the same face as the read-only
   values above — a field that cannot be typed into should not look like one
   that can. The spinner goes with it, since it promises an edit it will not
   make. Not `.readonly`, which is a div; this is still an input, and still
   posts. */
.input[readonly] { background: var(--surface-2); color: var(--ink-2); border-color: var(--line); }
.input[readonly]::-webkit-inner-spin-button, .input[readonly]::-webkit-outer-spin-button { display: none; }
.input[readonly] { -moz-appearance: textfield; }

/*
 * The padlock that locks such a box, and the only way back to typing in it.
 *
 * Both icons are in the markup and this picks one off `aria-pressed`, so the
 * button's look and the state a screen reader announces cannot disagree — there
 * is only one of them. Quiet while it is doing the sum for you, brand-coloured
 * once you have taken the box over, since that is the state worth noticing.
 *
 * `[hidden]` is a UA rule and .btn's own `display` outranks it, which is the
 * same trap the category rows hit — hence spelling it out.
 */
.lock-toggle { flex: none; color: var(--ink-3); }
.lock-toggle:hover { color: var(--ink); }
.lock-toggle[hidden] { display: none; }
.lock-toggle .lock-open { display: none; }
.lock-toggle[aria-pressed="false"] { color: var(--brand); }
.lock-toggle[aria-pressed="false"] .lock-shut { display: none; }
.lock-toggle[aria-pressed="false"] .lock-open { display: block; }

/*
 * The calendar button inside a date field.
 *
 * Left to itself every browser draws something different there, and on the one
 * these tills actually run on — Chrome on Android — it is a dropdown arrow,
 * which reads as "there is a list behind this" rather than "there is a
 * calendar". That arrow is not the picker button in a shadow tree that a
 * -webkit- rule can repaint: Android renders the whole control as a menulist
 * and draws the arrow as part of it, which is why styling the picker indicator
 * changed nothing there. So the appearance goes first, and the icon is drawn by
 * something of ours.
 *
 * That something is a wrapper, the way .search-box draws its magnifier: an
 * input cannot carry a pseudo-element of its own. The browser's own button is
 * kept rather than hidden — stretched over the icon and made invisible — so
 * where it exists (Chrome and Safari on a desktop) clicking the calendar opens
 * the real picker, and where it does not (Android) the icon sits inside the
 * field's own padding, so the tap lands on the field, which opens the picker
 * anyway. Nothing here is a replacement picker; the native one always runs.
 *
 * A mask rather than a background image, so the icon takes its colour from the
 * theme instead of needing a second copy of the SVG for dark mode.
 */
.input[type="date"],
.input[type="datetime-local"] {
    -webkit-appearance: none;
    appearance: none;
}

/* Safari puts a stepper in the field, which is the same wrong promise. */
.input[type="date"]::-webkit-inner-spin-button,
.input[type="datetime-local"]::-webkit-inner-spin-button {
    -webkit-appearance: none;
    display: none;
}

.date-box { position: relative; display: inline-flex; }
.date-box .input { padding-right: 2.5rem; }

.date-box::after {
    content: "";
    position: absolute;
    right: .875rem;
    top: 50%;
    width: 1.125rem;
    height: 1.125rem;
    transform: translateY(-50%);
    background: var(--ink-3);
    -webkit-mask: var(--calendar-icon) center / contain no-repeat;
    mask: var(--calendar-icon) center / contain no-repeat;
    pointer-events: none;
}

.date-box:focus-within::after { background: var(--brand); }

/* Kept for the click and nothing else. */
.date-box .input::-webkit-calendar-picker-indicator {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: auto;
    width: 2.5rem;
    height: auto;
    margin: 0;
    padding: 0;
    opacity: 0;
    cursor: pointer;
}

/* Firefox ignores every -webkit- rule above and draws its own calendar inside
   the field, so ours would be the second one on the line. Let it keep its own:
   a different icon, not two. */
@supports (-moz-appearance: none) {
    .date-box::after { display: none; }
    .date-box .input { padding-right: .875rem; }
}

/* Chrome pads the editable part of a date field, which leaves it a couple of
   pixels taller than a text input with the same padding beside it. */
.input[type="date"]::-webkit-datetime-edit-fields-wrapper { padding: 0; }
.input:focus, .select:focus, .textarea:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }

/* ---------- dropdown ---------- */
/* The select is still there, still in the form, still the thing that gets
   posted — see assets/controllers/dropdown_controller.js. It is taken out of
   the layout rather than hidden outright so that the browser can still put its
   own "choose one of these" against a required field left empty. */
.select.is-enhanced {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: 0;
    border: 0;
    opacity: 0;
    pointer-events: none;
}

.dd { position: relative; }

.dd-trigger {
    display: flex;
    align-items: center;
    gap: .5rem;
    width: 100%;
    min-height: var(--tap);
    padding: .625rem .875rem;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--ink);
    font: inherit;
    text-align: left;
    cursor: pointer;
    transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
}

.dd-trigger:hover { border-color: var(--brand); }
.dd-trigger[aria-expanded="true"] { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
.dd-text { flex: 1 1 auto; min-width: 0; }
.dd-text.is-placeholder { color: var(--ink-3); }
.dd-caret { display: flex; flex: none; color: var(--ink-3); transition: transform .15s ease; }
.dd-trigger[aria-expanded="true"] .dd-caret { transform: rotate(180deg); }

/* Pinned to the viewport by the controller, so nothing it sits inside can clip
   it — the till's discount box is a modal that scrolls. */
.dd-panel {
    position: fixed;
    z-index: 90;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: .3125rem;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius);
    background: var(--surface);
    box-shadow: var(--shadow-lg);
    animation: dd-in .12s ease-out;
}

.dd-panel[hidden] { display: none; }
.dd-panel:focus-visible { outline: none; }

/* Sticky, so narrowing a long list never scrolls the box you are typing in out
   of sight.
 *
 * `top` is negative and equal to the panel's own padding, and that is the whole
 * of the fix for the strip that used to sit above this box with options sliding
 * visibly through it. A sticky inset is measured to the element's *margin* box,
 * not its border box, so `top: 0` against a margin-top of -.3125rem parked the
 * border edge .3125rem *below* the scrollport — exactly the panel's padding,
 * left uncovered, with the list scrolling behind it. Pulling the inset up
 * instead lands the border edge flush on the panel's inner edge, and this
 * element's own top padding is what paints over that strip once the list moves
 * under it.
 *
 * The negative margins went with it. overflow-y makes the panel a scroll box in
 * both directions, so anything wider than its content box is clipped — the same
 * thing that was taking the corner off the icon picker's No icon button. Same
 * shape as .icon-pop-head now, for both reasons. */
.dd-search {
    position: sticky;
    top: -.3125rem;
    z-index: 1;
    padding: .3125rem 0 .4375rem;
    background: var(--surface);
    border-bottom: 1px solid var(--line);
}

.dd-search .input { min-height: 38px; padding: .375rem .625rem; font-size: .9375rem; }
.dd-empty { padding: .75rem .625rem; color: var(--ink-3); font-size: .875rem; }
.dd-option[hidden] { display: none; }

.dd-option {
    display: flex;
    align-items: center;
    gap: .5rem;
    min-height: 44px;
    padding: .5rem .625rem;
    border-radius: var(--radius-sm);
    color: var(--ink-2);
    cursor: pointer;
}

.dd-option:hover, .dd-option.is-active { background: var(--surface-2); color: var(--ink); }
.dd-option[aria-selected="true"] { color: var(--brand-strong); font-weight: 650; }

/* The tick is drawn rather than reserved: a column of empty space in front of
   every unselected row makes a short list look like a form. */
.dd-option[aria-selected="true"]::after {
    content: "✓";
    margin-left: auto;
    font-size: .8125rem;
}

.dd-option[aria-disabled="true"] { opacity: .45; cursor: not-allowed; }

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

@media (prefers-reduced-motion: reduce) {
    .dd-panel { animation: none; }
    .dd-caret { transition: none; }
}

.alert { padding: .75rem 1rem; border-radius: var(--radius-sm); font-weight: 550; }
.alert-danger { background: var(--danger-soft); color: var(--danger); }
.alert-ok { background: var(--ok-soft); color: var(--ok); }
/* Was never written, though two inventory pages have been asking for it since
   the low-stock strip was added — they have been rendering as a bare .alert,
   which is a warning with nothing warning about it. */
.alert-warn { background: var(--warn-soft); color: var(--warn); }

/* ---------- tables ---------- */
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: .75rem 1rem; text-align: left; border-bottom: 1px solid var(--line); }
.table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; color: var(--ink-3); font-weight: 700; }
.table tbody tr:last-child td { border-bottom: 0; }
.table-hover tbody tr:hover { background: var(--surface-2); }
.table-wrap { overflow-x: auto; }

/* ---------- the inventory list and its errand card ---------- */
/* The ingredient table kept to a window — roughly half a screen — with the
   rest of the page still in reach below it. Under 720px the header row is
   gone anyway (.table-stack) and the cap simply scrolls the stacked cards. */
.inv-scroll {
    max-height: min(60vh, 42rem);
    overflow-y: auto;
    overscroll-behavior: contain;
}

/* The header row stays put while the rows scroll under it. Its line is a
   shadow rather than the th's own border: with border-collapse the border
   belongs to the table's grid and stays behind when the cell sticks, which
   left the header floating over the rows with no edge at all. */
.inv-scroll thead th {
    position: sticky;
    top: 0;
    z-index: 1;
    background: var(--surface);
    border-bottom: 0;
    box-shadow: 0 1px 0 var(--line);
}

/* One column until there is honestly room for two, and two only when the view
   carries an errand card at all — the shopping list café-wide, the move-stock
   list standing in one place. With nothing low, the list keeps the whole
   width rather than leaving an empty half where the trouble would go. */
.inv-split { display: grid; gap: 1.25rem; align-items: start; }

@media (min-width: 1100px) {
    .inv-split.has-side {
        grid-template-columns: repeat(2, minmax(0, 1fr));
        grid-template-rows: auto auto;
        /* Row spacing comes from the toolbars' own margins, the same as on
           the full-width page; the grid's gap stays between the columns. */
        gap: 0 1.25rem;
    }

    /* Each half is a toolbar over a card. Subgrid hands both halves the same
       two row tracks, so the cards' edges share their lines even when one
       toolbar wraps taller than the other — which the table's does at half
       width, where its search and chips no longer fit side by side. Matching
       the toolbars' heights was the first try and the wrong one: a wrap on
       either side broke the promise the moment it was made. */
    .inv-split.has-side > .inv-main,
    .inv-split.has-side > .inv-side {
        display: grid;
        grid-template-rows: subgrid;
        /* Spelled out because the implicit column a nested grid would mint is
           content-sized: the table's natural width would set it, and the whole
           half would ride out of its track and over the card beside it. */
        grid-template-columns: minmax(0, 1fr);
        grid-row: 1 / span 2;
    }
    .inv-split.has-side > .inv-main { grid-column: 1; }
    .inv-split.has-side > .inv-side { grid-column: 2; }

    /* A toolbar keeps its own height and parks against its card, rather than
       stretching to the shared track and centring its box mid-air. */
    .inv-split.has-side > .inv-main > .row,
    .inv-split.has-side > .inv-side > .row { align-self: end; }

    /* Beside the capped table the card may use the same window, not the four
       chip rows it gets on the dashboard. */
    .inv-split.has-side .chips-capped { max-height: min(60vh, 42rem); }

    /* At half width the table gives up columns rather than asking for a
       sideways scroll — a column the reader must scroll to is not a column,
       it is a secret. They go in the order of what the rest of the row
       already says: "Reorder at" (or "Elsewhere") first, since the status
       badge tells how the stock stands against that figure; then Value and
       Unit cost below the widths that can pay for them, both still on the
       ingredient's panel and the full-width page behind View all. Hidden by
       position rather than markup because the streamed ingredient-row-{id}
       fragments serve this table and the full-length one with the same row;
       the two blocks after this one hand the columns back as the screen
       widens. */
    .inv-split.has-side .table-inventory th:nth-child(3),
    .inv-split.has-side .table-inventory td:nth-child(3),
    .inv-split.has-side .table-inventory th:nth-child(4),
    .inv-split.has-side .table-inventory td:nth-child(4),
    .inv-split.has-side .table-inventory th:nth-child(5),
    .inv-split.has-side .table-inventory td:nth-child(5) { display: none; }

    /* The last of the room the narrowest halves need: the cells give up a
       little side padding, and the switch loses the "On" beside it — the
       switch already shows its state, which is why the stacked mobile rows
       drop the same word. */
    .inv-split.has-side .table-inventory th,
    .inv-split.has-side .table-inventory td { padding-inline: .625rem; }
    .inv-split.has-side .cell-actions-label { display: none; }
}

/* Unit cost is back once the half column clears its price tag… */
@media (min-width: 1750px) {
    .inv-split.has-side .table-inventory th:nth-child(4),
    .inv-split.has-side .table-inventory td:nth-child(4) { display: table-cell; }
}

/* …and Value on a full-HD screen, where the half fits everything but the
   reorder column. */
@media (min-width: 1900px) {
    .inv-split.has-side .table-inventory th:nth-child(5),
    .inv-split.has-side .table-inventory td:nth-child(5) { display: table-cell; }
}

/* A person in a table cell: their face, then their name over their email. The
   whole cell is the link, so the target is a row's worth of tap rather than
   eleven characters of underlined name. */
.user-cell { display: flex; align-items: center; gap: .625rem; min-width: 0; color: inherit; text-decoration: none; }
.user-cell:hover .strong { color: var(--brand-strong); }

/*
 * On a phone a six-column table is a sideways scroll inside a page that already
 * scrolls down, which is a poor thing to hand someone holding a phone behind a
 * counter. `.table-stack` turns each row into a card at that width: the cell
 * left without a data-label heads the card, and every other cell prints its
 * column name beside its value. Nothing is hidden — the same figures are all
 * still there, just down the screen instead of across it.
 */
@media (max-width: 720px) {
    .table-stack thead { display: none; }
    .table-stack, .table-stack tbody { display: block; width: 100%; }

    /* A flex column rather than a plain block, so the heading cell can be
       pulled to the top of the card even when its column sits mid-table. */
    .table-stack tbody tr {
        display: flex;
        flex-direction: column;
        width: 100%;
        padding: .875rem 1rem;
        border-bottom: 1px solid var(--line);
    }
    .table-stack tbody tr:last-child { border-bottom: 0; }

    .table-stack td {
        display: flex;
        align-items: baseline;
        justify-content: space-between;
        gap: 1rem;
        padding: .1875rem 0;
        border: 0;
        text-align: right;
    }

    .table-stack td[data-label]::before {
        content: attr(data-label);
        flex: none;
        color: var(--ink-3);
        font-size: .75rem;
        font-weight: 700;
        letter-spacing: .04em;
        text-transform: uppercase;
        text-align: left;
    }

    /* Unlabelled cells carry the row on their own: the name, or its actions. */
    .table-stack td:not([data-label]) { text-align: left; }

    .table-stack .cell-title {
        order: -1;
        padding-bottom: .375rem;
        font-size: 1rem;
        font-weight: 650;
    }

    .table-stack .cell-actions { order: 1; padding-top: .5rem; }
    /* A labelled button spans the card; an icon button keeps its square.
       Stretching one of those leaves the mark floating in a full-width box,
       and the zero padding it carries collapses it to the width of the icon
       the moment anything else takes the width back. */
    .table-stack .cell-actions .btn:not(.btn-icon) { width: 100%; }

    /* A cell holding more than one control — a form and a link, say — shares the
       card's width between them rather than letting the pair keep their
       desktop widths and push the card past the screen. */
    .table-stack .cell-actions > .row { width: 100%; }
    .table-stack .cell-actions > .row > * { flex: 1 1 0; min-width: 0; }

    /* Nothing overflows once the rows are stacked. */
    .table-stack { min-width: 0; }

    /* The inventory's stacked card puts its controls on the name's own line —
       a pencil hard right, the on/off switch just inside it — rather than
       spending a full-width row at the bottom of every card on two buttons.
       Grid rather than the flex column above, because that is what lets two
       cells share a line; scoped to this table so the menu and accounts lists
       keep the layout they were designed with. */
    .table-inventory tbody tr {
        display: grid;
        grid-template-columns: minmax(0, 1fr) auto;
        align-items: center;
        column-gap: .75rem;
    }

    .table-inventory td { grid-column: 1 / -1; }

    .table-inventory .cell-title {
        grid-column: 1;
        grid-row: 1;
        padding-bottom: 0;
    }

    .table-inventory .cell-actions {
        grid-column: 2;
        grid-row: 1;
        order: -1;
        padding: 0;
        justify-content: flex-end;
    }

    /* The switch says on or off by its own position; the word beside it is for
       the desktop row, where it sits in a column of its own with room to spare. */
    .table-inventory .cell-actions-label { display: none; }

    /* Undoes the stacked default that shares the card's width between the
       controls in a cell: these two are icons and want to stay their own size.
       The button's own width is left alone — .btn-icon already sets it, and
       overriding it here with `auto` against its zero padding is what squeezed
       the pencil down to a bordered sliver. */
    .table-inventory .cell-actions > .row > * { flex: 0 0 auto; }

    /* On the card the pencil is a touch target, not table furniture. Its
       desktop size is .btn-sm's 36px, which is both under --tap and tight
       around a 16px mark — nine pixels of air where every other icon button in
       the app has fifteen, so it reads as a pencil pressed against its own
       border. The box goes to the full tap target here and the mark grows with
       it, which keeps the proportion the rest of the app set. The desktop row
       keeps 36px: it is a six-column table and the density is the point. */
    .table-inventory .cell-actions .btn-icon {
        width: var(--tap);
        min-height: var(--tap);
    }

    .table-inventory .cell-actions .btn-icon svg { width: 20px; height: 20px; }
}

.empty { padding: 3rem 1.25rem; text-align: center; color: var(--ink-3); }
.empty-mark { font-size: 2.5rem; display: block; margin-bottom: .5rem; }

/* ---------- responsive shell ---------- */
.sidebar-toggle { display: none; }

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

    .sidebar {
        position: fixed;
        inset: 0 auto 0 0;
        width: 264px;
        z-index: 60;
        transform: translateX(-100%);
        transition: transform .2s ease;
        box-shadow: var(--shadow-lg);
    }

    .app[data-nav="open"] .sidebar { transform: none; }

    .app[data-nav="open"]::after {
        content: "";
        position: fixed;
        inset: 0;
        background: rgb(0 0 0 / .45);
        z-index: 50;
    }

    .sidebar-toggle { display: inline-flex; }
    .content { padding: 1rem; }
}

/* ---------- POS ---------- */
.pos {
    display: grid;
    grid-template-columns: 1fr minmax(340px, 400px);
    height: calc(100dvh - var(--chrome));
    min-height: 0;
}

.pos-menu {
    display: flex;
    flex-direction: column;
    min-width: 0;
    min-height: 0;
}

.pos-search {
    display: flex;
    gap: .625rem;
    padding: .875rem 1rem .625rem;
}

.search-box { position: relative; flex: 1 1 auto; }
/* Narrow enough to read as a setting on the row rather than a second search
   box, and it never shrinks below the three characters it has to show. */
.pos-display-currency { flex: 0 0 auto; width: auto; min-width: 6.5rem; }
.search-box .input { padding-left: 2.5rem; }
.search-box::before {
    content: "⌕";
    position: absolute;
    left: .875rem;
    top: 50%;
    transform: translateY(-50%);
    font-size: 1.25rem;
    color: var(--ink-3);
    pointer-events: none;
}

.cats {
    display: flex;
    flex-shrink: 0;
    gap: .5rem;
    padding: 0 1rem .75rem;
    overflow-x: auto;
    scrollbar-width: none;
}

.cats::-webkit-scrollbar { display: none; }

.cat {
    display: inline-flex;
    align-items: center;
    gap: .4rem;
    min-height: 42px;
    padding: 0 .9rem;
    border: 1px solid var(--line-strong);
    border-radius: 999px;
    background: var(--surface);
    color: var(--ink-2);
    font: inherit;
    font-weight: 600;
    font-size: .9375rem;
    white-space: nowrap;
    cursor: pointer;
    flex: none;
}

.cat[aria-pressed="true"] {
    background: var(--brand);
    border-color: var(--brand);
    color: var(--brand-ink);
}

/* The same box and pills over an ordinary page rather than the till's flush
   scrolling column: that one carries the padding itself because it runs edge to
   edge, and .content has already paid for it everywhere else. The pills still
   scroll sideways on a phone, so a long list of categories does not wrap into
   three rows above a table. */
.filters {
    display: flex;
    flex-direction: column;
    gap: .625rem;
    margin-bottom: 1rem;
}

.filters .cats { padding: 0; }

/*
 * Side by side once there is room, which is the container's question and not
 * the window's — the sidebar takes a fixed 262px out of this page the moment it
 * appears, so a viewport breakpoint would fire at a width the row does not
 * actually have. The box stops growing at that point: a search field the width
 * of the page is a field that looks like it wants a sentence, and what goes in
 * it is a word. The pills keep their sideways scroll and take what is left.
 */
.menu-filters { container: menu-filters / inline-size; }

@container menu-filters (min-width: 46rem) {
    .filters { flex-direction: row; align-items: center; }
    .filters .search-box { flex: 0 0 18rem; }
}

.products {
    flex: 1 1 auto;
    overflow-y: auto;
    padding: 0 1rem 1.25rem;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
    gap: .75rem;
    align-content: start;
}

.tile {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    gap: .5rem;
    min-height: 104px;
    padding: .75rem;
    border: 1px solid var(--line);
    border-radius: var(--radius);
    background: var(--surface);
    color: var(--ink);
    font: inherit;
    text-align: left;
    cursor: pointer;
    box-shadow: var(--shadow-sm);
    transition: transform .06s ease, border-color .12s ease, box-shadow .12s ease;
}

.tile:hover { border-color: var(--brand); box-shadow: var(--shadow); }
.tile:active { transform: scale(.975); }
.tile-name { font-weight: 620; line-height: 1.25; }
.tile-foot { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.tile-price { font-weight: 700; color: var(--brand-strong); }
.tile-star { color: var(--warn); font-size: .8rem; }

/* How many of this are on the ticket. It lives in the foot, beside the price,
   because anything added to the tile's own flow grows the row it sits in — and
   a grid that shifts under a finger tapping the same tile four times in a row
   is worse than no count at all. margin-right keeps it next to the price when
   the star or the out badge is holding the other end. */
.tile-added {
    margin-right: auto;
    font-size: .6875rem;
    font-weight: 650;
    color: var(--ok);
    white-space: nowrap;
}

.tile-added.is-bumped { animation: tile-added-in .28s ease; }

@keyframes tile-added-in {
    from { opacity: 0; transform: translateY(2px); }
}

@media (prefers-reduced-motion: reduce) {
    .tile-added.is-bumped { animation: none; }
}

/* Out of an ingredient. Dimmed and flagged, but deliberately still tappable —
   the till does not refuse a sale over a stock figure nobody has corrected. */
.tile-out { border-style: dashed; border-color: var(--line-strong); }
.tile-out .tile-name, .tile-out .tile-price { opacity: .55; }
.tile-out-badge {
    font-size: .625rem;
    font-weight: 700;
    letter-spacing: .04em;
    text-transform: uppercase;
    color: var(--danger);
    background: var(--danger-soft);
    padding: .0625rem .3125rem;
    border-radius: 999px;
}

/* ---------- cart ---------- */
.cart {
    display: flex;
    flex-direction: column;
    min-height: 0;
    background: var(--surface);
    border-left: 1px solid var(--line);
}

.cart-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: .5rem;
    padding: .875rem 1rem;
    border-bottom: 1px solid var(--line);
}

.types { display: flex; gap: .3rem; padding: .625rem 1rem; border-bottom: 1px solid var(--line); }

.type {
    flex: 1 1 0;
    min-height: 40px;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--ink-2);
    font: inherit;
    font-size: .875rem;
    font-weight: 600;
    cursor: pointer;
}

.type[aria-pressed="true"] { background: var(--surface-3); color: var(--ink); border-color: var(--line-strong); }

.lines { flex: 1 1 auto; overflow-y: auto; padding: .5rem; min-height: 0; }

.line {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: .375rem .75rem;
    padding: .625rem;
    border-radius: var(--radius-sm);
}

.line + .line { border-top: 1px solid var(--line); }
.line-name { font-weight: 600; }
.line-total { font-weight: 700; font-variant-numeric: tabular-nums; }
.line-note { grid-column: 1 / -1; font-size: .8125rem; color: var(--brand-strong); }
.line-controls { grid-column: 1 / -1; display: flex; align-items: center; gap: .375rem; }

.line-controls .comp-toggle { margin-left: auto; min-height: 34px; border-radius: 999px; }
.comp-toggle[aria-pressed="true"] { background: var(--warn-soft); border-color: var(--warn); color: var(--warn); }

.qty { display: inline-flex; align-items: center; border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden; }
.qty button {
    width: 34px;
    height: 34px;
    border: 0;
    background: var(--surface-2);
    color: var(--ink);
    font: inherit;
    font-size: 1.125rem;
    font-weight: 700;
    cursor: pointer;
}
.qty button:hover { background: var(--surface-3); }
.qty span { min-width: 2.25rem; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }

.cart-foot { border-top: 1px solid var(--line); padding: .875rem 1rem 1rem; }
.totals { display: flex; flex-direction: column; gap: .3rem; margin-bottom: .75rem; }
.total-row { display: flex; justify-content: space-between; font-variant-numeric: tabular-nums; }
.total-row.grand { font-size: 1.375rem; font-weight: 750; padding-top: .4rem; border-top: 1px dashed var(--line-strong); }

/* The toggle bar, its grabber, and the running total in the topbar all belong
   to the phone layout, where the cart is a sheet instead of a column. The
   handle disappears as a box rather than as a rule of its own, so on a wide
   screen the cart is laid out exactly as it was before it had one. */
.cart-toggle { display: none; }
.cart-handle { display: contents; }
.cart-grip { display: none; }

/* The order, in the topbar: the same two figures the sheet's own bar carries,
   for the half of the screen where the sheet is shut and out of sight. */
.cart-badge {
    display: none;
    align-items: center;
    gap: .5rem;
    min-height: 40px;
    padding: 0 .75rem 0 .5rem;
    border: 0;
    border-radius: 999px;
    background: var(--brand);
    color: var(--brand-ink);
    font: inherit;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    cursor: pointer;
}

.cart-badge-count {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 24px;
    height: 24px;
    padding: 0 .375rem;
    border-radius: 999px;
    background: rgb(255 255 255 / .24);
    font-size: .8125rem;
}

/* The way back to the orders list. On a wide screen it simply sits in the bar:
   the badge that would otherwise want the slot never appears at this width, so
   there is nothing for it to give way to however full the ticket gets. Narrow,
   the two take turns — see the rule under the breakpoint. */
.cart-exit { display: inline-flex; }

/* ---------- modal / sheet ---------- */
.modal {
    position: fixed;
    inset: 0;
    z-index: 80;
    display: grid;
    place-items: center;
    padding: 1rem;
    background: rgb(0 0 0 / .5);
}

/* The card is a column that never grows past the screen, and the *body* is what
   scrolls inside it — not the card. Scrolling the whole card took the title and
   the ✕ off the top and left the buttons off the bottom, so a long modal on a
   phone — the payment one, which is tips, methods, currencies, tenders and
   change — opened with no way out visible and no way to finish, and reaching
   either meant scrolling past the other. What a modal says it is and what it
   can do are the two things that must never leave the screen. */
.modal-card {
    display: flex;
    flex-direction: column;
    width: min(520px, 100%);
    max-height: 90dvh;
    background: var(--surface);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-lg);
}

.modal-head { flex: none; display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.125rem; border-bottom: 1px solid var(--line); }
/* min-height: 0 or the flex item refuses to shrink below its content and the
   overflow never engages — the default min-height:auto on a flex child. */
.modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 1.125rem; }
.modal-foot { flex: none; display: flex; gap: .625rem; padding: 1rem 1.125rem; border-top: 1px solid var(--line); }

/* The page underneath holds still while a box is over it. Its own class rather
   than the offcanvas one, which also shifts the toasts clear of the panel —
   there is nothing at the right edge to make room for here. */
body.has-modal { overflow: hidden; }

/* ---------- icon picker ---------- */
/* The box that holds the emoji, with the shortlist under it. The box stays a
   text field — see assets/controllers/icon_picker_controller.js — so what the
   swatches write and what a keyboard writes land in the same place. */
.icon-field { display: flex; align-items: center; gap: .625rem; }

.icon-input {
    flex: none;
    width: 4.5rem;
    text-align: center;
    font-size: 1.25rem;
}

/* auto-fill rather than a fixed count: this field is meant to be reusable, and
   the grid should answer to the width it is given rather than to the modal it
   happens to be in today. */
.icon-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(var(--tap), 1fr));
    gap: .375rem;
    margin-top: .625rem;
}

.icon-swatch {
    display: grid;
    place-items: center;
    min-height: var(--tap);
    padding: 0;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    background: var(--surface);
    font-size: 1.25rem;
    line-height: 1;
    cursor: pointer;
    transition: border-color .15s ease, background .15s ease, transform .15s ease;
}

/* Behind the hover query with every other lift in the app: on the till there is
   no pointer to leave one behind, and a swatch stuck looking chosen is worse
   here than anywhere, since being chosen is the only state it has. */
@media (hover: hover) {
    .icon-swatch:hover { border-color: var(--brand); background: var(--brand-soft); }
}

.icon-swatch[aria-pressed="true"] {
    border-color: var(--brand);
    background: var(--brand-soft);
    box-shadow: 0 0 0 3px var(--brand-soft);
}

.icon-swatch:active { transform: scale(.94); }

@media (prefers-reduced-motion: reduce) {
    .icon-swatch { transition: none; }
    .icon-swatch:active { transform: none; }
}

/* ---------- icon menu (the row's version) ---------- */
/* Nothing here applies until the controller says it is running: without it the
   row keeps the plain text box it has always had, and the button that would
   replace it never appears. */
.icon-trigger { display: none; }

/* Only while it is still sitting in the row. The controller moves this very
   input into the panel, where it becomes the way to type or paste an emoji the
   shortlist does not carry — the child combinator is what stops it being
   hidden all over again once it gets there. */
.icon-cell.is-enhanced > .category-icon {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: 0;
    border: 0;
    opacity: 0;
    pointer-events: none;
}

.icon-cell.is-enhanced .icon-trigger {
    display: grid;
    place-items: center;
    width: 100%;
    min-height: 40px;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-sm);
    background: var(--surface);
    font-size: 1.25rem;
    line-height: 1;
    cursor: pointer;
    transition: border-color .15s ease, background .15s ease;
}

/* The plus shown by a category with no icon at all: a prompt rather than a
   value, so it reads at the weight of one. */
.icon-trigger.is-empty { color: var(--ink-3); font-size: 1rem; }

@media (hover: hover) {
    .icon-cell.is-enhanced .icon-trigger:hover { border-color: var(--brand); background: var(--brand-soft); }
}

.icon-cell.is-enhanced .icon-trigger[aria-expanded="true"] {
    border-color: var(--brand);
    box-shadow: 0 0 0 3px var(--brand-soft);
}

/*
 * The top layer, which is the whole reason this is a popover and not a panel.
 * .category-list is a `container: inline-size`, and container-type implies
 * `contain: layout` — so the card is the containing block for anything
 * position:fixed inside it, and the dropdown's way of pinning a panel to the
 * viewport would place it against the card instead. A popover is outside all of
 * that: no clipping, no containment, and it closes on Escape and on a click
 * beside it without a line of script.
 *
 * Placed by the controller on toggle; the offscreen start is so the first frame
 * is never drawn at 0,0 while that is being worked out.
 */
.icon-pop {
    position: fixed;
    top: -100vh;
    left: 0;
    /* The UA sheet gives every popover `inset: 0; margin: auto` so that one left
       to itself lands in the middle of the screen. Setting top and left is not
       enough to undo that — auto margins go on centring in whatever space is
       left over, which put this 250px right and 126px below the button it was
       supposed to be under. */
    right: auto;
    bottom: auto;
    margin: 0;
    width: min(23rem, calc(100vw - 2rem));
    max-height: min(24rem, calc(100dvh - 2rem));
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: .625rem;
    border: 1px solid var(--line-strong);
    border-radius: var(--radius);
    background: var(--surface);
    box-shadow: var(--shadow-lg);
}

.icon-pop:popover-open { animation: dd-in .12s ease-out; }
.icon-pop .icon-grid { margin-top: 0; }

/* Sticky, so scrolling the grid never scrolls away the box being typed in —
   the same reason the dropdown's search sits still. No negative margins to pull
   it out over the panel's padding: overflow-y makes the panel a scroll box in
   both directions, so anything wider than its content box is clipped, and it
   was taking the corner off the No icon button. The vertical padding is what
   covers the gap above once the grid scrolls under it. */
.icon-pop-head {
    position: sticky;
    top: -.625rem;
    z-index: 1;
    display: flex;
    align-items: center;
    gap: .5rem;
    margin-bottom: .5rem;
    padding: .625rem 0;
    background: var(--surface);
}

/* min-width is the whole of it. A flex item's automatic minimum size is its
   content's, and an <input> with no `size` is intrinsically twenty characters
   wide — 271px at this font size, against about 245px of room once the No icon
   button and the gap are paid for. So `flex-shrink: 1` had nothing it was
   allowed to do: the box stayed at its twenty characters, pushed the head wider
   than the popover, and left the whole panel scrolling sideways over a grid
   that fitted perfectly well. The field takes one emoji and is capped at two
   characters — it has no business asking for twenty. */
.icon-pop-head .category-icon { flex: 1 1 auto; width: auto; min-width: 0; min-height: 40px; }
.icon-pop-clear { flex: none; }

@media (prefers-reduced-motion: reduce) {
    .icon-pop:popover-open { animation: none; }
}

.chips { display: flex; flex-wrap: wrap; gap: .5rem; }
/* The shopping list grows a chip per low ingredient, and a bad delivery week
   can push the stock tables below the fold. Four rows of chips, then the
   card scrolls inside itself instead of burying the page. */
.chips-capped { max-height: 11.5rem; overflow-y: auto; }
.chip {
    /* Flex rather than a line-height trick, because a chip is not always a
       button: the shopping list draws them as links, and an inline <a> ignores
       min-height and drops its text onto the baseline — which read as
       top-heavy pills wherever a low ingredient was listed. */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: .375rem;
    min-height: 40px;
    line-height: 1.2;
    padding: 0 .875rem;
    border: 1px solid var(--line-strong);
    border-radius: 999px;
    background: var(--surface);
    color: var(--ink-2);
    font: inherit;
    font-weight: 600;
    font-size: .9rem;
    cursor: pointer;
}
.chip:hover { background: var(--surface-2); }
.chip[aria-pressed="true"] { background: var(--brand-soft); border-color: var(--brand); color: var(--brand-strong); }

/* The location pills on the inventory list. All four must share a phone's one
   row — Store wrapping alone under the other three read as a fifth option
   rather than the fourth place — and the icons stay wherever the row can pay
   for them, so first it is the type, the padding and both gaps that pull in.
   Measured, not guessed: the compressed row is 343px, so it holds from a
   390px phone up. */
@media (max-width: 640px) {
    .loc-chips { gap: .25rem; }
    .loc-chips .chip { padding: 0 .4375rem; font-size: .8125rem; gap: .25rem; }
}

/* Only the very narrowest screens give up the icons — below here even the
   compressed row cannot hold all four names and three pictures, and the
   names are the half that cannot go. Without icons it fits a 320. */
@media (max-width: 375px) {
    .loc-chips .chip-ico { display: none; }
}

/* The jump-to-ledger shortcut on the inventory page's location row. Desktop
   only — on a phone the ledger is a thumb-swipe away and the row has no
   width to spare for a pill that isn't about where you're standing. */
.chip-jump { display: none; }
@media (min-width: 900px) {
    .chip-jump { display: inline-flex; }
}

/* And only while the ledger card is actually on the page: with no movements
   the card is never rendered, and a shortcut to a target that is not there
   is worse than none. Gated here on the target's presence rather than in the
   template on data — the chip's region also renders as a stream fragment
   that does not carry the movements at all (see the note beside the chip),
   and the card lives outside that fragment, so the markup cannot know. */
body:not(:has(#stock-movements)) .chip-jump { display: none; }

.pay-methods { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; }
.keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; }
.keypad button { min-height: 52px; font-size: 1.125rem; }

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

    .cart {
        /* How much of the sheet stays on screen when it is shut. The closed
           transform and the header's own height are both built from it, so the
           bar you can see and the bar you can grab are the same 68px, and the
           drag has one number to measure against. */
        --peek: 68px;
        --grip-band: 14px;

        position: fixed;
        inset: auto 0 0 0;
        z-index: 40;
        max-height: 82dvh;
        border-left: 0;
        border-top: 1px solid var(--line);
        border-radius: var(--radius-lg) var(--radius-lg) 0 0;
        box-shadow: var(--shadow-lg);
        transform: translateY(calc(100% - var(--peek)));
        transition: transform .22s cubic-bezier(.32, .72, 0, 1);
    }

    .pos[data-cart="open"] .cart { transform: none; }

    .cart-handle {
        display: block;
        position: relative;
        padding-top: var(--grip-band);
        background: var(--brand);
        border-radius: var(--radius-lg) var(--radius-lg) 0 0;
        /* The sheet follows the finger, so the page must not: without this the
           browser claims the gesture as a scroll and the drag never starts. */
        touch-action: none;
        user-select: none;
        -webkit-user-select: none;
        cursor: grab;
    }

    .cart-handle.is-dragging { cursor: grabbing; filter: brightness(1.06); }

    .cart-grip {
        display: block;
        width: 40px;
        height: 5px;
        margin: 0 auto;
        border-radius: 999px;
        background: rgb(255 255 255 / .5);
        transition: transform .18s cubic-bezier(.32, .72, 0, 1), background-color .18s ease;
    }

    /* Held. The grabber swells and brightens under the finger the moment it
       lands, before the sheet has moved at all — which is the difference
       between a bar that has been touched and one that is being dragged, and
       the only way a thumb resting on the header knows it has hold of it. */
    .cart-handle.is-dragging .cart-grip {
        transform: scale(1.45, 1.4);
        background: rgb(255 255 255 / .95);
    }

    @media (prefers-reduced-motion: reduce) {
        .cart-grip { transition: none; }
        .cart { transition: none; }
    }

    .cart-toggle {
        display: flex;
        align-items: center;
        justify-content: space-between;
        width: 100%;
        min-height: calc(var(--peek) - var(--grip-band));
        padding: 0 1rem;
        border: 0;
        background: transparent;
        color: var(--brand-ink);
        font: inherit;
        font-weight: 700;
        font-size: 1.0625rem;
        cursor: inherit;
    }

    .cart-badge { display: inline-flex; }

    /* Here the slot holds one thing at a time, and the ticket wins it: the
       sidebar is behind a hamburger, so the way off this page is worth showing
       only while there are no figures to show instead. */
    .cart-exit[data-cart-exit="filled"] { display: none; }

    /* The head and the order-type row used to be display:none until the sheet
       was open. They are only ever off the bottom of the screen while it is
       shut, and hiding them made the sheet two different heights — which a drag
       measured against one of them would have snapped to the wrong place, and
       which popped at the end of every open. */
    .lines { max-height: 40dvh; }
    .products { padding-bottom: 90px; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); }
}

/* ---------- receipt ---------- */
.receipt {
    width: min(360px, 100%);
    margin: 0 auto;
    padding: 1.25rem;
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    font-family: var(--mono);
    font-size: .8125rem;
}

.receipt hr { border: 0; border-top: 1px dashed var(--line-strong); margin: .75rem 0; }
.receipt-row { display: flex; justify-content: space-between; gap: .75rem; }
.receipt-head { text-align: center; margin-bottom: .75rem; }

@media print {
    .sidebar, .topbar, .no-print { display: none !important; }
    .app { display: block; }
    .content { padding: 0; }
    .receipt { border: 0; box-shadow: none; width: 100%; }
    body { background: #fff; }

    /* A panel open over a list is what somebody meant to put on paper. The list
       behind it is not, and printing both is how you get a receipt on page two.
       So the panel stops being a slide-in and becomes the page: static, full
       width, no shadow, nothing clipped by the scroller it lives in on screen.
       Keyed off the class the controllers already put on the body, which saves
       leaning on :has() for something a till has to get right every time. */
    body.has-offcanvas .content { display: none !important; }

    .offcanvas-backdrop { display: none !important; }
    .offcanvas:not(.is-open) { display: none !important; }

    .offcanvas.is-open {
        position: static;
        width: 100%;
        height: auto;
        border-left: 0;
        box-shadow: none;
        visibility: visible;
    }

    .offcanvas.is-open .offcanvas-body { overflow: visible; }
}

/* ---------- stat tiles ---------- */
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: .75rem; }

/* On a phone the row settles at two across, which leaves an odd third tile
   sitting half-width beside a hole. Whichever tile is last and lands on an odd
   count takes the whole row instead — three tiles read as two and a banner,
   which is a shape, where two and a gap only reads as something missing. */
@media (max-width: 640px) {
    .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .kpis > :last-child:nth-child(odd) { grid-column: 1 / -1; }
}

.kpi {
    padding: 1rem 1.125rem;
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: var(--shadow-sm);
}

/* Values and labels wear ink tokens — the number carries the meaning, not a colour. */
.kpi-label { font-size: .75rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--ink-3); }
.kpi-value { font-size: 1.75rem; font-weight: 720; letter-spacing: -.02em; font-variant-numeric: tabular-nums; margin-top: .2rem; }
.kpi-note { font-size: .8125rem; color: var(--ink-2); }

/* ---------- kitchen ---------- */
.tickets { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: .875rem; align-items: start; }

.ticket { display: flex; flex-direction: column; overflow: hidden; }
.ticket-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; padding: .75rem 1rem; border-bottom: 1px solid var(--line); }
.ticket-num { font-weight: 750; font-size: 1.125rem; font-variant-numeric: tabular-nums; }
.ticket-items { list-style: none; margin: 0; padding: .625rem 1rem; display: flex; flex-direction: column; gap: .5rem; }
.ticket-items li { display: flex; gap: .625rem; }
.ticket-qty { min-width: 1.75rem; font-weight: 750; font-variant-numeric: tabular-nums; color: var(--brand-strong); }
.ticket-foot { padding: .75rem 1rem 1rem; margin-top: auto; }

/* ---------- charts ----------
 * Series colours live in their own tokens, kept apart from the UI palette: a bar
 * is data, not decoration, and the three tender hues were validated for
 * colourblind separation and contrast against both surfaces. Magnitude charts use
 * a single hue (the brand accent) — identity only earns extra colours where the
 * categories themselves are the subject, which here is the tender split alone.
 */
:root {
    --viz-accent: var(--brand);
    --viz-mute: #cbbfad;
    --viz-grid: var(--line);

    --viz-series-1: #2a78d6;
    --viz-series-2: #eb6834;
    --viz-series-3: #1baf7a;
}

@media (prefers-color-scheme: dark) {
    :root[data-theme="system"] {
        --viz-mute: #4a4034;

        --viz-series-1: #3987e5;
        --viz-series-2: #d95926;
        --viz-series-3: #199e70;
    }
}

:root[data-theme="dark"] {
    --viz-mute: #4a4034;

    --viz-series-1: #3987e5;
    --viz-series-2: #d95926;
    --viz-series-3: #199e70;
}

.chart { display: grid; grid-template-columns: auto 1fr; gap: .5rem; }

.chart-yaxis { position: relative; height: 160px; min-width: 3rem; }
.chart-ytick {
    position: absolute;
    right: 0;
    transform: translateY(-50%);
    font-size: .6875rem;
    font-variant-numeric: tabular-nums;
    color: var(--ink-3);
}

.chart-plot { position: relative; height: 160px; border-bottom: 1px solid var(--line-strong); }
.chart-rule { position: absolute; left: 0; right: 0; height: 0; border-top: 1px solid var(--viz-grid); }

.chart-cols { position: absolute; inset: 0; display: flex; align-items: flex-end; gap: 2px; }
.chart-col { position: relative; display: flex; flex: 1 1 0; align-items: flex-end; justify-content: center; height: 100%; }

.chart-bar {
    position: relative;
    width: 100%;
    max-width: 24px;
    border-radius: 4px 4px 0 0;
    background: var(--viz-accent);
}

/* Context bars recede so the one that matters — today — reads first. */
.chart-bar-mute { background: var(--viz-mute); }

.chart-xaxis { display: flex; gap: 2px; margin-top: .375rem; }

/* min-width:0 because a flex item will not shrink below its content by default,
   and the content here is a date. With thirty columns that made the axis — not
   the bars — decide how wide the chart had to be, and the card was pushed off
   the side of a phone by a row of numbers. The columns above are free to shrink
   already; this lets the labels under them do the same, and a label that runs
   out of room is clipped rather than widening everything. */
.chart-xlabel {
    flex: 1 1 0;
    min-width: 0;
    overflow: hidden;
    text-align: center;
    font-size: .6875rem;
    color: var(--ink-3);
}

.chart-value {
    position: absolute;
    bottom: 100%;
    left: 50%;
    transform: translateX(-50%);
    margin-bottom: .25rem;
    font-size: .6875rem;
    font-weight: 650;
    white-space: nowrap;
    color: var(--ink-2);
}

/* Hover and keyboard focus show the same readout; values are also in the table below. */
.chart-tip {
    position: absolute;
    bottom: 100%;
    left: 50%;
    transform: translateX(-50%);
    z-index: 5;
    margin-bottom: .375rem;
    padding: .3rem .5rem;
    border-radius: 8px;
    background: var(--ink);
    color: var(--surface);
    font-size: .75rem;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transition: opacity .12s ease;
}

.chart-col:hover .chart-tip, .chart-col:focus .chart-tip, .chart-col:focus-visible .chart-tip { opacity: 1; }
.chart-col:hover .chart-value, .chart-col:focus .chart-value { opacity: 0; }
.chart-col:first-child .chart-tip { left: 0; transform: none; }
.chart-col:last-child .chart-tip { left: auto; right: 0; transform: none; }
.chart-col:focus { outline: none; }
.chart-col:focus-visible .chart-bar { outline: 3px solid var(--brand); outline-offset: 2px; }

/* Horizontal magnitude bars — one hue, value at the tip. */
.hbars { display: grid; gap: .625rem; }
.hbar { display: grid; grid-template-columns: minmax(5rem, 8rem) 1fr auto; align-items: center; gap: .75rem; }
.hbar-name { font-size: .875rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hbar-fill { display: block; height: 12px; min-width: 2px; border-radius: 0 4px 4px 0; background: var(--viz-accent); }
.hbar-value { font-size: .8125rem; font-variant-numeric: tabular-nums; color: var(--ink-2); }

/* Part-to-whole: 2px of surface separates the segments, never a border. */
.tender-bar { display: flex; gap: 2px; height: 14px; }
.tender-seg { display: block; height: 100%; }
.tender-seg:first-child { border-radius: 4px 0 0 4px; }
.tender-seg:last-child { border-radius: 0 4px 4px 0; }
.tender-seg:only-child { border-radius: 4px; }

.legend { display: flex; flex-wrap: wrap; gap: .25rem 1rem; margin-top: .75rem; }
.legend-key { display: inline-flex; align-items: center; gap: .4rem; font-size: .8125rem; color: var(--ink-2); }
.legend-swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; }

.viz-table { margin-top: .75rem; }
.viz-table summary { font-size: .8125rem; color: var(--ink-2); cursor: pointer; }
.viz-table summary:hover { color: var(--ink); }
.viz-table table { margin-top: .5rem; }

.dash-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1.25rem; align-items: start; }

/* Three across at most. auto-fit fits as many columns as the window allows, and
   on a wide screen that is four — which leaves the six cards as a row of four
   and an orphaned pair. Capping it keeps them two even rows of three. */
@media (min-width: 1500px) {
    .dash-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

/* And where there are four cards rather than six — the cash drawer — two rows
   of two beats three and a straggler. */
@media (min-width: 1100px) {
    .dash-grid.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.hero-value { font-size: 2.75rem; font-weight: 750; letter-spacing: -.03em; line-height: 1.1; margin-top: .25rem; }

.delta { display: inline-flex; align-items: center; gap: .25rem; font-size: .8125rem; font-weight: 650; }
.delta-up { color: var(--ok); }
.delta-down { color: var(--danger); }

/* Headline row: one hero figure, then the supporting tiles filling the rest. */
.dash-head { display: grid; grid-template-columns: minmax(240px, 1fr) 3fr; gap: 1.25rem; align-items: stretch; }

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

/* ---------- streamed alerts ---------- */
/* Bottom-right, clear of the sidebar and of the till's total, which sits top-right. */
/* ---------- off-canvas panel ---------- */
.offcanvas-backdrop {
    position: fixed;
    inset: 0;
    z-index: 70;
    background: rgb(0 0 0 / .38);
    opacity: 0;
    visibility: hidden;
    transition: opacity .2s ease, visibility .2s ease;
}

.offcanvas-backdrop.is-open { opacity: 1; visibility: visible; }

.offcanvas {
    position: fixed;
    inset: 0 0 0 auto;
    z-index: 71;
    display: flex;
    flex-direction: column;
    width: min(var(--offcanvas-w), 100%);
    background: var(--surface);
    border-left: 1px solid var(--line);
    box-shadow: var(--shadow-lg);
    visibility: hidden;
    /* Only while it is shut. An element with a transform becomes the containing
       block for its position:fixed children, and the enhanced dropdowns place
       their panels against the viewport — leave a transform on the open state
       and every select inside opens in the wrong place. */
    transform: translateX(100%);
    transition: transform .24s ease, visibility .24s ease;
}

.offcanvas.is-open { visibility: visible; transform: none; }

.offcanvas-head {
    display: flex;
    align-items: center;
    gap: .75rem;
    padding: .875rem 1.125rem;
    border-bottom: 1px solid var(--line);
}

.offcanvas-head h2 { margin: 0; font-size: 1.0625rem; }

.offcanvas-form { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }

/* No padding of its own: the fields bring their own, the same ones the
   full-page form uses. */
.offcanvas-body { flex: 1 1 auto; overflow-y: auto; min-height: 0; }

/* For content that was written for a page and is being shown in the panel: the
   page's own gutter, which .content would otherwise have been paying for. */
.panel-pad { padding: 1rem; }

.offcanvas-foot {
    display: flex;
    gap: .625rem;
    justify-content: flex-end;
    padding: .875rem 1.125rem;
    border-top: 1px solid var(--line);
}

/* The page behind stays put while the panel is over it. */
body.has-offcanvas { overflow: hidden; }

/* The shape of what is on its way, held for as long as the network takes. Bars
   rather than a spinner: a spinner says "something is happening", where this
   says "something this shape is happening", and the panel does not appear to
   change its mind when the answer lands. */
.panel-skeleton { gap: .75rem; }

.skeleton-bar {
    display: block;
    height: 12px;
    border-radius: 999px;
    background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface-3) 50%, var(--surface-2) 75%);
    background-size: 240% 100%;
    animation: skeleton-sweep 1.3s ease-in-out infinite;
}

@keyframes skeleton-sweep {
    from { background-position: 120% 0; }
    to { background-position: -120% 0; }
}

@media (prefers-reduced-motion: reduce) {
    .skeleton-bar { animation: none; }
}

@media (prefers-reduced-motion: reduce) {
    .offcanvas, .offcanvas-backdrop { transition: none; }
}

.toasts {
    position: fixed;
    right: 1rem;
    /* Under the bar rather than over it: the topbar is stuck to the top of the
       page, and a notice landing on the heading covers the one line that says
       where you are. --chrome is that bar's height and already knows about the
       impersonation strip, so this follows it down when there is one. */
    top: calc(var(--chrome) + 1rem);
    /* Above everything but the lock: a refusal is often about the panel or the
       dialog that is covering the page, and it has to be readable over it. */
    z-index: 100;
    display: flex;
    flex-direction: column;
    gap: .5rem;
    width: min(360px, calc(100vw - 2rem));
    pointer-events: none;
}

/* An open panel owns the right-hand edge, which is where these live. Step them
   clear of it rather than let a notice land on top of the thing it is about.
   Only where the panel is a panel — under that width it is the whole screen,
   and there is nowhere to step to. */
@media (min-width: 720px) {
    body.has-offcanvas .toasts { right: calc(var(--offcanvas-w) + 1rem); }
}

.toast {
    display: flex;
    gap: .625rem;
    padding: .75rem .875rem;
    border-radius: var(--radius-sm);
    border: 1px solid var(--line-strong);
    border-left: 3px solid var(--ink-3);
    background: var(--surface);
    box-shadow: var(--shadow);
    font-size: .875rem;
    pointer-events: auto;
    animation: toast-in .2s ease-out;
}

.toast-warn { border-left-color: var(--warn); }
.toast-danger { border-left-color: var(--danger); }
.toast-ok { border-left-color: var(--ok); }

.toast-mark { flex: none; }
.toast-body { min-width: 0; }
.toast-title { font-weight: 650; }
.toast-note { color: var(--ink-2); font-size: .8125rem; }

.toast-leaving { opacity: 0; transition: opacity .2s ease-in; }

@keyframes toast-in {
    /* Down from under the bar, now that it is what they come out of. */
    from { opacity: 0; transform: translateY(-6px); }
    to { opacity: 1; transform: none; }
}

/* A notice that slides in beside someone pulling shots is a distraction, not a
   feature — respect the setting and just appear. */
@media (prefers-reduced-motion: reduce) {
    .toast { animation: none; }
    .toast-leaving { transition: none; }
}

/* ---------- connection banner ---------- */
/* Top-centre, right under the bar — the one sliver of the till that is never
   any page's actual content: .pos-search's own top padding, between the
   header's border and the search box's visible edge. Everywhere on POS below
   that is search, then category chips, then the product grid with no gap
   anywhere among them, so this is the only spot a floating pill can sit
   without landing on something — sized down to fit it, the way
   .impersonating-stop is sized for the strip it sits in rather than the
   ordinary .btn scale. --chrome is the topbar's own height, already kept
   current for the impersonation strip — .toasts below reads it the same way. */
.net-banner {
    position: fixed;
    /* --chrome is a round estimate (see its own comment) 8px short of the
       topbar's real rendered height — close enough for the till's height
       calc, not close enough here, where landing even a few px into the bar
       is the one thing this cannot do. */
    top: calc(var(--chrome) + 8px);
    left: 50%;
    z-index: 90;
    display: flex;
    align-items: center;
    gap: .375rem;
    padding: .1875rem .75rem;
    border-radius: 999px;
    background: var(--warn);
    color: #fff;
    font-size: .75rem;
    font-weight: 750;
    box-shadow: var(--shadow-lg);
    transform: translate(-50%, -.75rem);
    opacity: 0;
    transition: opacity .25s ease, transform .25s ease;
}

/* [hidden] is a UA rule, and any author `display` outranks it — same trap as
   .btn, see the note near .lock-toggle. Harmless on a `position: fixed`
   element (it never reserved layout space either way) but the honest rule. */
.net-banner[hidden] { display: none; }

.net-banner-visible { opacity: 1; transform: translate(-50%, 0); }

.net-banner-spinner {
    width: 10px;
    height: 10px;
    border: 1.5px solid rgb(255 255 255 / .35);
    border-top-color: #fff;
    border-radius: 50%;
    flex: none;
    animation: net-spin .8s linear infinite;
}

@keyframes net-spin { to { transform: rotate(360deg); } }

@media (prefers-reduced-motion: reduce) {
    .net-banner { transition: none; }
    .net-banner-spinner { animation-duration: 2.4s; }
}

/* ---------- sync status pill ---------- */
/* The banner's quieter sibling: sits in the topbar and reports rather than
   interrupts — offline, sending held sales, or holding one that needs a
   person. Hidden the whole time there is nothing to say. */
.sync-pill {
    display: inline-flex;
    align-items: center;
    gap: .4rem;
    padding: .3rem .7rem;
    border: 1px solid transparent;
    border-radius: 999px;
    background: var(--surface-2);
    color: var(--ink-2);
    font: inherit;
    font-size: .75rem;
    font-weight: 600;
    white-space: nowrap;
    cursor: pointer;
}

.sync-pill-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: currentColor;
    flex: none;
}

.sync-pill-offline { background: var(--warn-soft); border-color: var(--warn); color: var(--warn); }

.sync-pill-sending { background: var(--info-soft); border-color: var(--info); color: var(--info); }

.sync-pill-sending .sync-pill-dot { animation: sync-pulse 1.2s ease-in-out infinite; }

.sync-pill-attention { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }

@keyframes sync-pulse {
    50% { opacity: .35; }
}

@media (prefers-reduced-motion: reduce) {
    .sync-pill-sending .sync-pill-dot { animation: none; }
}

/* The held-sales panel the pill opens. A card pinned above the content, not a
   modal: it reports and offers two buttons, and the page stays usable. */
.queue-panel {
    position: fixed;
    right: 1rem;
    bottom: 1rem;
    z-index: 85;
    width: min(24rem, calc(100vw - 2rem));
    max-height: 60vh;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: .6rem;
    padding: 1rem;
    background: var(--surface);
    border: 1px solid var(--line-strong);
    border-radius: var(--radius);
    box-shadow: var(--shadow-lg);
}

.queue-row {
    padding: .6rem .7rem;
    background: var(--surface-2);
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
}

/* ---------- impersonation ---------- */
/* An owner standing in for someone else. On every page for as long as it lasts,
   because the danger of impersonation is entirely in forgetting you are in it —
   an order rung up from here belongs to the account being stood in for, and no
   amount of explaining afterwards changes that.

   A full-width strip under the topbar, inside .topbar-stack so it is stuck to
   the bar and on screen at any scroll position. It was a floating pill until it
   turned out to be sitting on top of the page heading and whatever button the
   page had put in the corner: an element that cannot be dismissed has no
   business covering the controls. Inverted rather than coloured — it has to
   read as a different mode of the app, not as one more coloured alert. */
.impersonating {
    display: flex;
    align-items: center;
    gap: .625rem;
    /* Fixed rather than grown from its contents, so --chrome above can be an
       exact number instead of a guess that drifts with the font. */
    height: 32px;
    padding: 0 1.25rem;
    background: var(--ink);
    color: var(--surface);
    font-size: .8125rem;
    font-weight: 600;
    /* Two lines of this would push the page about every time a long name was
       stood in for; the name itself truncates instead. */
    white-space: nowrap;
}

.impersonating strong { font-weight: 750; }

.impersonating-stop {
    flex: none;
    padding: .1875rem .75rem;
    border-radius: 999px;
    background: var(--surface);
    color: var(--ink);
    font-size: .75rem;
    font-weight: 750;
    text-decoration: none;
}

.impersonating-stop:hover { background: var(--surface-2); text-decoration: none; }

/* ---------- floating nav (installed app only) ---------- */
/* A standalone window has no browser chrome, so there is no way back out of a
   page without this. In a tab it would only duplicate controls already there. */
.pwa-nav { display: none; }

@media (display-mode: standalone), (display-mode: minimal-ui), (display-mode: fullscreen) {
    .pwa-nav { display: block; }
}

/* iOS reports standalone on the navigator rather than answering the query. */
.pwa-nav.is-standalone { display: block; }

.pwa-nav {
    /* The page is drawn under the notch and the home indicator, so a puck
       dragged into a corner has to keep clear of them the way the default
       position below does. env() cannot be read from script, so the controller
       reads the insets back off these. */
    --safe-top: env(safe-area-inset-top, 0px);
    --safe-right: env(safe-area-inset-right, 0px);
    --safe-bottom: env(safe-area-inset-bottom, 0px);
    --safe-left: env(safe-area-inset-left, 0px);

    /* How much of the brand colour actually gets laid down. It floats over a
       work surface for a whole shift and is wanted for a second at a time, so
       at rest it lets the till read through it, and comes back to full strength
       the moment it is reached for — what the transparency is spent on is the
       contrast of the icons sitting on it, and that is not a thing to be
       spending while someone is looking straight at it. */
    --pwa-nav-tint: 62%;

    position: fixed;
    left: 50%;
    bottom: calc(1rem + var(--safe-bottom));
    transform: translateX(-50%);
    z-index: 80;
    touch-action: none;
}

.pwa-nav.is-dragging { transition: none; }

/* Being touched, tabbed to or carried all count as reaching for it. Hover is
   behind the query the rest of the app uses — there is no pointer on the till
   itself, and a tap that leaves a sticky hover behind would just keep it
   opaque. */
.pwa-nav:focus-within,
.pwa-nav.is-dragging { --pwa-nav-tint: 94%; }

@media (hover: hover) {
    .pwa-nav:hover { --pwa-nav-tint: 94%; }
}

.pwa-nav-puck,
.pwa-nav-bar {
    background: color-mix(in srgb, var(--brand) var(--pwa-nav-tint), transparent);
    color: var(--brand-ink);
    box-shadow: var(--shadow-lg);
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
    transition: background-color .18s ease;
}

/* The transparency *is* the effect here, so honouring the setting means
   dropping it in every state rather than only at rest. After the rule it
   undoes, not before: these selectors are no more specific than the ones
   above, so order is the only thing deciding which of them lands. */
@media (prefers-reduced-transparency: reduce) {
    .pwa-nav,
    .pwa-nav:hover,
    .pwa-nav:focus-within,
    .pwa-nav.is-dragging { --pwa-nav-tint: 100%; }

    .pwa-nav-puck,
    .pwa-nav-bar {
        -webkit-backdrop-filter: none;
        backdrop-filter: none;
    }
}

@media (prefers-reduced-motion: reduce) {
    .pwa-nav-puck,
    .pwa-nav-bar { transition: none; }
}

.pwa-nav-puck {
    display: grid;
    place-items: center;
    width: 52px;
    height: 52px;
    border: 0;
    border-radius: 50%;
    cursor: grab;
}

.pwa-nav-bar {
    display: none;
    align-items: center;
    gap: .125rem;
    padding: .375rem .5rem;
    border-radius: 999px;
}

.pwa-nav.is-expanded .pwa-nav-puck { display: none; }
.pwa-nav.is-expanded .pwa-nav-bar { display: flex; }

.pwa-nav-bar button,
.pwa-nav-bar a {
    display: grid;
    place-items: center;
    width: 44px;
    height: 44px;
    border: 0;
    border-radius: 50%;
    background: none;
    color: inherit;
    cursor: pointer;
}

.pwa-nav-bar button:hover:not(:disabled),
.pwa-nav-bar a:hover { background: rgb(255 255 255 / .18); }

.pwa-nav-bar button:disabled { opacity: .35; cursor: default; }

.pwa-nav-grip {
    cursor: grab;
    border-right: 1px solid rgb(255 255 255 / .25) !important;
    border-radius: 0 !important;
    width: 34px !important;
    margin-right: .25rem;
}

/* Lit up, not repainted. The grip has no background of its own so either way
   looks the same on it, but the puck *is* the pill — setting `background` here
   dropped the brand colour for a wash of 22% white, which over a light till is
   very close to dragging nothing at all. */
.pwa-nav.is-dragging .pwa-nav-puck,
.pwa-nav.is-dragging .pwa-nav-grip {
    background-image: linear-gradient(rgb(255 255 255 / .22), rgb(255 255 255 / .22));
    cursor: grabbing;
}

/* ---------- screen lock ---------- */

/*
 * The glass. `backdrop-filter` is doing the real work here: the till behind
 * stays where it is — mid-order, streaming updates — and is simply not legible
 * any more. The tint underneath it is what stops a large flat colour (a full
 * white card, a photo) reading through the blur.
 */
.lockscreen {
    position: fixed;
    inset: 0;
    z-index: 200;
    display: grid;
    place-items: center;
    padding: 1.25rem;
    padding-top: max(1.25rem, env(safe-area-inset-top));
    padding-bottom: max(1.25rem, env(safe-area-inset-bottom));
    background: color-mix(in srgb, var(--bg) 55%, transparent);
    backdrop-filter: blur(22px) saturate(115%);
    -webkit-backdrop-filter: blur(22px) saturate(115%);
    opacity: 0;
    transition: opacity .22s ease;
}

.lockscreen.is-open { opacity: 1; }
.lockscreen[hidden] { display: none; }

/* No app behind it, so the glass gets something of its own to sit on. */
.lock-page {
    min-height: 100dvh;
    background:
        radial-gradient(70rem 40rem at 15% -10%, var(--brand-soft), transparent 60%),
        radial-gradient(60rem 40rem at 110% 110%, var(--surface-3), transparent 55%),
        var(--bg);
}

.lock-card {
    width: min(23rem, 100%);
    max-height: calc(100dvh - 2.5rem);
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: .875rem;
    padding: 1.5rem 1.375rem 1.25rem;
    border: 1px solid color-mix(in srgb, var(--line-strong) 70%, transparent);
    border-radius: var(--radius-lg);
    background: color-mix(in srgb, var(--surface) 78%, transparent);
    backdrop-filter: blur(28px) saturate(150%);
    -webkit-backdrop-filter: blur(28px) saturate(150%);
    box-shadow: var(--shadow-lg);
    transform: translateY(.5rem) scale(.985);
    transition: transform .22s ease;
}

.lockscreen.is-open .lock-card { transform: none; }
.lock-card:focus { outline: none; }

.lock-clock {
    font-size: 2.5rem;
    font-weight: 300;
    line-height: 1;
    letter-spacing: -.02em;
    font-variant-numeric: tabular-nums;
    color: var(--ink);
}

.lock-who { display: flex; flex-direction: column; align-items: center; gap: .375rem; }

.lock-avatar {
    display: grid;
    place-items: center;
    width: 52px;
    height: 52px;
    border-radius: 50%;
    background: var(--brand);
    color: var(--brand-ink);
    font-size: 1.25rem;
    font-weight: 650;
}

.lock-name { margin: 0; font-size: 1.0625rem; font-weight: 650; }
.lock-sub { margin: 0; font-size: .8125rem; color: var(--ink-3); }

/* Holds its height whether or not there is anything in it, so the keypad does
   not jump down the screen under a finger the moment a PIN is refused. */
.lock-error {
    min-height: 1.25rem;
    font-size: .8125rem;
    font-weight: 550;
    line-height: 1.25rem;
    text-align: center;
    color: var(--danger);
}

.lock-pin {
    width: 100%;
    min-height: var(--tap);
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-sm);
    background: color-mix(in srgb, var(--surface-2) 80%, transparent);
    color: var(--ink);
    font: inherit;
    font-size: 1.5rem;
    text-align: center;
    letter-spacing: .5em;
    text-indent: .5em; /* centres text that letter-spacing pads on the right */
}

.lock-pin::placeholder { color: var(--ink-3); letter-spacing: .5em; }
.lock-pin:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }

.lock-field { width: 100%; }
.lock-pad { width: 100%; gap: .5rem; }

.lock-key {
    min-height: 56px;
    font-size: 1.25rem;
    font-weight: 550;
    background: color-mix(in srgb, var(--surface) 70%, transparent);
}

.lock-key-soft { color: var(--ink-2); font-size: 1.0625rem; }

.lock-hint { margin: 0; font-size: .75rem; text-align: center; color: var(--ink-3); }

.lock-out {
    font-size: .8125rem;
    font-weight: 550;
    color: var(--ink-3);
    text-decoration: none;
}

.lock-out:hover { color: var(--ink-2); text-decoration: underline; }

.lock-card.is-wrong { animation: lock-shake .4s; }

@keyframes lock-shake {
    0%, 100% { transform: translateX(0); }
    20% { transform: translateX(-7px); }
    40% { transform: translateX(6px); }
    60% { transform: translateX(-4px); }
    80% { transform: translateX(2px); }
}

/* Where the browser cannot blur, fall back to something opaque rather than
   leaving the takings legible through a half-transparent panel. */
@supports not (backdrop-filter: blur(1px)) {
    .lockscreen { background: var(--bg); }
    .lock-card { background: var(--surface); }
}

.lock-choice {
    padding: .625rem .75rem;
    border: 1px solid var(--line);
    border-radius: var(--radius-sm);
    cursor: pointer;
}

.lock-choice:hover { background: var(--surface-2); }
.lock-choice.is-chosen { border-color: var(--brand); background: var(--brand-soft); }
.lock-choice.is-chosen .strong { color: var(--brand-strong); }

@media (prefers-reduced-motion: reduce) {
    .lockscreen, .lock-card { transition: none; }
    .lock-card.is-wrong { animation: none; }
}

/* ---------- category rows ---------- */
/*
 * Editable in place, one form per row: eight categories do not warrant a page
 * each, and the whole point is comparing them while you reorder.
 *
 * Which only works if they line up, so the cells are placed rather than left to
 * wrap. Wrapping had been picking the line breaks itself and picking them
 * badly — the name is the field told to grow, so it took a whole line on a
 * 1280px laptop and pushed the number, the switch and both buttons onto a
 * third. Three lines of row on a screen with room for one, and because each row
 * broke wherever its own name ran out, no two broke in the same place: the
 * orders you had come to compare sat at eight different depths down the card.
 *
 * Narrow, it is still three lines, but they are chosen ones: the icon beside
 * the name it marks, then the three things you might change, then the buttons
 * on a line of their own where a thumb reaching for Save cannot catch Delete.
 */
.category-row {
    display: grid;
    grid-template-columns: 4rem minmax(0, 1fr) auto;
    grid-template-areas:
        "icon    name    name"
        "order   till    count"
        "station station station"
        "act     act     act";
    gap: .5rem;
    align-items: center;
    padding: .625rem 1rem;
    border-bottom: 1px solid var(--line);
}

.category-row:last-child { border-bottom: 0; }
.category-row .input { min-height: 40px; padding: .375rem .625rem; }

.icon-cell { grid-area: icon; }
.category-icon { width: 100%; text-align: center; font-size: 1.25rem; }

/* The name reads as a line and edits as a box. Both live here; which one is
   showing is the controller's business. The cell holds its own height so the
   swap between them never nudges the row. */
.category-name-cell {
    grid-area: name;
    display: flex;
    align-items: center;
    gap: .375rem;
    min-width: 0;
    min-height: 40px;
}

.category-name { flex: 1 1 auto; min-width: 0; }
.category-name-value { min-height: 40px; font-size: 1rem; }

/* [hidden] is a UA rule, and any author `display` outranks it — .btn is
   inline-flex and .editable-value is flex, so both went on showing while the
   controller believed it had put them away. The same reason .modal[hidden] is
   spelled out further up this file. */
.editable-value[hidden],
.category-row .btn[hidden] { display: none; }

/* Square, so it reads as a mark against the box rather than a second Save. */
.category-commit { flex: none; padding-inline: .625rem; }
.category-order { grid-area: order; }
.category-count { grid-area: count; justify-self: end; }

/* Which station the things in this category are made at, and therefore which
   shelf selling one deducts from.

   A seventh control on a row that was already full. It gets a line of its own
   at the widths where the row is already stacking, and joins the single line
   further down — but never stretches to fill either: a select is as wide as its
   longest option needs and no wider, and the name is the field that should be
   taking any slack going. */
.category-station {
    grid-area: station;
    justify-self: start;
    width: 100%;
    max-width: 13rem;
}

/* The spinners are half the width of a box this size the moment a pointer is
   anywhere near it, and the numbers here are typed rather than nudged. */
.category-order::-webkit-outer-spin-button,
.category-order::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.category-order { -moz-appearance: textfield; appearance: textfield; }

.category-till {
    grid-area: till;
    display: flex;
    align-items: center;
    gap: .375rem;
    white-space: nowrap;
}

/*
 * A switch, which is a checkbox wearing a track.
 *
 * The box itself is still there and still what posts — taken out of the layout
 * the same way the enhanced select is, rather than display:none, so the browser
 * can still put focus on it and draw its own ring. The track is what moves; the
 * thumb slides across it. Nothing here needs a controller, so it looks the same
 * with the script off. What the script adds is the saving.
 */
/* Positioned so the hidden checkbox below anchors to the label it belongs to.
   Without this its containing block is the page: each input parks 1px of
   itself at the document position its row would have had, outside any
   overflow clip — which held thousands of pixels of blank page open under
   the inventory list the moment its table was capped to a scroll window. */
.switch { position: relative; cursor: pointer; }

.switch input {
    position: absolute;
    width: 1px;
    height: 1px;
    opacity: 0;
    pointer-events: none;
}

.switch-track {
    position: relative;
    flex: none;
    display: block;
    width: 2.25rem;
    height: 1.375rem;
    border-radius: 999px;
    background: var(--line-strong);
    transition: background .18s ease;
}

.switch-thumb {
    position: absolute;
    top: 3px;
    left: 3px;
    width: 1rem;
    height: 1rem;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 3px rgb(0 0 0 / .3);
    transition: transform .18s ease;
}

.switch input:checked + .switch-track { background: var(--brand); }
.switch input:checked + .switch-track .switch-thumb { transform: translateX(.875rem); }
.switch input:focus-visible + .switch-track { box-shadow: 0 0 0 3px var(--brand-soft); }

/* The label beside it goes quiet when the thing is off, so a glance down the
   column reads as a column of states rather than a column of the same words. */
.switch input:not(:checked) ~ .small { color: var(--ink-3); }

/* Flipped, but not yet agreed to by the server.
 *
 * The thumb slides the instant it is tapped — that is the browser, and it is
 * right to be that quick — so for the length of the post the switch is showing
 * a state that nothing has stored yet. Left plain, a slow connection reads as
 * an instant save, and the row rebuilding a second later looks like a second,
 * unasked-for change. The pulse is the switch saying it is still being kept.
 *
 * A dialog was the other way to do this and it is the wrong one here: the
 * question would come before the tap, every time, to cover the once the network
 * is slow — and the switch already shows its own state, so flipping it back is
 * the same tap in the same place. Confirm what cannot be undone; report what
 * is merely in the air.
 */
.switch.is-saving { cursor: progress; }
.switch.is-saving .switch-track { animation: switch-saving 1.1s ease-in-out infinite; }

@keyframes switch-saving {
    0%, 100% { box-shadow: 0 0 0 0 var(--brand-soft); }
    50%      { box-shadow: 0 0 0 4px var(--brand-soft); }
}

/* The same again, for a box that saves itself on the way out of it.
 *
 * A quantity has the switch's problem in a quieter form: the number is whatever
 * was typed from the moment it was typed, so for the length of the post the box
 * is showing a figure nothing has stored. The line cost beside it and the margin
 * above it are still the old ones — honest, but it reads as nothing having
 * happened, and on a café connection that gap is long enough to retype into.
 *
 * The recipe's Optional boxes are plain checkboxes rather than switches, so they
 * need saying here too: a tick appears the instant it is tapped, exactly like a
 * thumb sliding, and means just as little until the post lands.
 */
.input.is-saving,
input[type="checkbox"].is-saving { animation: switch-saving 1.1s ease-in-out infinite; }

@media (prefers-reduced-motion: reduce) {
    .switch-track, .switch-thumb { transition: none; }

    /* Same message, held still: the ring is on the whole time it is saving
       rather than breathing. */
    .switch.is-saving .switch-track {
        animation: none;
        box-shadow: 0 0 0 3px var(--brand-soft);
    }
}

/*
 * The grip, and what the list looks like once it can be dragged.
 *
 * Both are switched on by .is-sortable, which the controller adds as it
 * connects — so the handle appearing and the numbered box going happen
 * together, and neither happens where the script did not run. touch-action is
 * the part that matters on a till: without it the browser reads a dragging
 * thumb as a page scroll and the row never moves.
 */
.category-grip {
    grid-area: grip;
    display: none;
    align-items: center;
    justify-content: center;
    align-self: stretch;
    padding: 0;
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    color: var(--ink-3);
    font-size: 1.125rem;
    line-height: 1;
    cursor: grab;
    touch-action: none;
    user-select: none;
}

.is-sortable .category-grip { display: flex; }
.is-sortable .category-order { display: none; }

@media (hover: hover) {
    .category-grip:hover { background: var(--surface-2); color: var(--ink-2); }
}

.category-row.is-dragging {
    cursor: grabbing;
    background: var(--brand-soft);
    box-shadow: var(--shadow);
    border-radius: var(--radius-sm);
    position: relative;
    z-index: 2;
}

.category-row.is-dragging .category-grip { cursor: grabbing; }

/* Stacked, with the number gone and the grip down the side of both lines.
 *
 * Two lines, not three. The rule above puts the buttons on a line of their own
 * so a thumb reaching for Save cannot catch Delete — but by the time this
 * applies the row-edit controller has hidden Save, and that line was a whole
 * strip of card holding one trash icon, with the count pill stranded on the
 * line above it. There is no Save to catch here, so the two sit together at the
 * end of the second line. Without the script the rule above still stands, and
 * so does the separation.
 *
 * `till` spans two columns rather than overflowing one. It was drawn in a 3.5rem
 * track and allowed to bleed into whatever the count was not using, which held
 * only because the pill sat at the far end of a full-width column; giving the
 * button its own track at that end would have pulled the pill back into the
 * text. Spanned, the switch is measured where it actually is.
 *
 * And it is asked for rather than assumed. "On the till" is nowrap, so a track
 * too narrow for it does not wrap or truncate — it draws straight on out and
 * under whatever is beside it, which on a 320px phone was the pill sitting on
 * the word. Below the width where all four fit, the pill keeps its own line.
 * The floor is what the second line actually costs: grip, switch, the widest
 * pill, the button, three gaps and the row's padding. */
.is-sortable .category-row {
    grid-template-columns: 2rem 3.5rem minmax(0, 1fr);
    grid-template-areas:
        "grip    icon    name"
        "grip    till    count"
        "station station station"
        "act     act     act";
}

@container category-list (min-width: 21.5rem) {
    .is-sortable .category-row {
        grid-template-columns: 2rem 3.5rem minmax(0, 1fr) auto auto;
        grid-template-areas:
            "grip icon    name    name    name"
            "grip station station station station"
            "grip till    till    count   act";
    }
}

.category-actions {
    grid-area: act;
    display: flex;
    justify-content: flex-end;
    gap: .5rem;
}

/*
 * One line, once there is room for one — and the room is the card's, not the
 * window's. This page is capped at 52rem and the sidebar appears at 901px, so
 * the width a row actually gets does not climb with the viewport: it is widest
 * at 900px with the sidebar still away, drops by a third the moment the sidebar
 * arrives, and only recovers past about 1100. A viewport breakpoint set where
 * the row fits at 900 therefore also fired at 901, where it does not, and the
 * buttons went out past the edge of the card.
 *
 * So the row asks the container it is in. That is the measurement the layout
 * actually depends on, and it stays right if this list is ever put somewhere
 * narrower. Anything too old to understand the question keeps the stacked
 * layout, which is the one that works at any width.
 */
.category-list { container: category-list / inline-size; }

@container category-list (min-width: 42rem) {
    .category-row {
        /* Every row is its own form and so its own grid, which means a column
           left to size itself sizes itself per row: "10 products" is wider than
           "9 products", and that one row's switch, buttons and name field all
           sat a few pixels off the seven above it. The count gets a width of
           its own instead — wide enough for any of them, so all eight agree —
           and keeps max-content behind it for the café that ever runs three
           digits of one thing. */
        grid-template-columns:
            4rem minmax(5rem, 1fr) 4.5rem minmax(5.5rem, auto) auto minmax(6.25rem, max-content) auto;
        grid-template-areas: "icon name order station till count act";
    }

    /* The same one line, with the grip where the eye starts and the number
       gone. The count keeps its given width, for the reason above. */
    .is-sortable .category-row {
        grid-template-columns:
            2rem 4rem minmax(5rem, 1fr) minmax(5.5rem, auto) auto minmax(6.25rem, max-content) auto;
        grid-template-areas: "grip icon name station till count act";
    }
}

/* A control that narrows the page it sits on, at the top of the content rather
   than up in the topbar. The bar says where you are; this says what you are
   looking at, and putting the second in the first crowded the heading out of a
   phone. Baseline-aligned so the label reads as part of the sentence the
   controls finish. */
.page-filter {
    align-items: center;
    margin: 0 0 1rem;
}

.page-filter .label { margin: 0; }

/* ---------- audit trail, and the sales report's span picker ---------- */
/* Both pages ask "which slice of history" with the same fold, the same grid and
   the same pair of date boxes, so everything from here to the end of the block
   is shared and only the two card classes below are page-specific. */
.audit-filters .select, .audit-filters .input,
.report-filters .select, .report-filters .input { min-height: 42px; }

/*
 * The filter, laid out rather than left to wrap.
 *
 * Two columns on a phone and one row on a laptop. Wrapping had been deciding
 * the pairs on its own and getting them wrong — a lone dropdown stranded on its
 * own line, the button orphaned under it. These pairs are chosen: who and what
 * are the same question asked twice, and Show belongs beside the span it is
 * about to apply.
 */
.filter-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: .625rem;
    align-items: end;
}

/* The search takes the width it needs to be typed into. */
.filter-grid .filter-wide { grid-column: 1 / -1; }
.filter-grid .field { margin: 0; min-width: 0; }

.filter-dates {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: .625rem;
}

/* Both of these set `display`, which would otherwise beat the browser's own
   rule for the hidden attribute and leave the date boxes on screen for good. */
.filter-dates[hidden] { display: none; }
.filter-grid[hidden] { display: none; }

/*
 * The fold. On a phone the six controls and seven chips were most of the first
 * screen, and the entries — the thing anybody came for — started below it.
 */
.filter-summary {
    display: flex;
    align-items: center;
    gap: .625rem;
    padding: .875rem 1rem;
    cursor: pointer;
    user-select: none;
    -webkit-user-select: none;
    /* The default triangle is replaced by the caret at the other end, where a
       thumb reaching for it is not covering the words. */
    list-style: none;
}

.filter-summary::-webkit-details-marker { display: none; }
.filter-summary:hover { background: var(--surface-2); }
.filter-summary-mark { font-size: 1.125rem; color: var(--ink-3); flex: none; }

.filter-caret {
    flex: none;
    color: var(--ink-3);
    font-size: 1.125rem;
    line-height: 1;
    transition: transform .15s ease;
}

.filter-fold[open] .filter-caret { transform: rotate(180deg); }

/* Open, the summary is the card's own heading and needs the line under it that
   every other card head has. */
.filter-fold[open] .filter-summary { border-bottom: 1px solid var(--line); }

@media (prefers-reduced-motion: reduce) {
    .filter-caret { transition: none; }
}

@media (min-width: 900px) {
    /* Nothing to fold away up here — the card was never the thing in the way —
       so the summary goes and the contents stay put whatever the open attribute
       says. An author rule beats the browser's own for a closed <details>. */
    .filter-summary { display: none; }
    .filter-fold > .card-pad { display: block; }

    /* And the closed <details> has to actually give up its contents.
       `display: block` on the child used to be enough, because that is how the
       browser hid them; it now hides them with content-visibility on
       ::details-content instead, which no author rule on a child can reach —
       so up here the filter had quietly become a card 0px tall with the
       controls laid out inside it and clipped away. The block-size goes with
       it: the same pseudo-element is what .card-fold animates from zero. */
    .filter-fold::details-content {
        content-visibility: visible;
        block-size: auto;
    }

    .filter-grid { grid-template-columns: minmax(11rem, 1.7fr) 1fr 1fr 1fr auto; }
    .filter-grid .filter-wide { grid-column: auto; }

    /* The report asks two questions rather than four, and the row above would
       leave it with two empty tracks and a stretched button in the middle of
       them. Sized to the controls instead, and stopped short of the card's edge
       so the span picker does not read as a page-wide toolbar. */
    .report-filters .filter-grid {
        grid-template-columns: minmax(11rem, 1fr) minmax(9rem, 1fr) auto;
        max-width: 42rem;
    }

    /* Two boxes' worth, not half the card each. */
    .filter-dates { grid-template-columns: repeat(2, minmax(0, 11rem)); justify-content: start; }
}

/* The little key/value run under an entry: "reason: staff meal · free lines: 1".
   Inline so short ones sit on the row they belong to, and the separator is
   drawn between them rather than typed into the template. */
.audit-detail { display: inline; }
.audit-detail > span + span::before { content: " · "; color: var(--ink-3); }
.audit-detail b { font-weight: 600; color: var(--ink-3); }

/* Who was really at the keyboard. Deliberately not a badge — it is a sentence,
   and a badge would read as a category of entry rather than a caveat on one. */
.audit-behalf { color: var(--warn); }

@media (max-width: 720px) {
    /* Stacked, the amount is the one cell worth reading first after the row's
       own title, and a right-aligned column of one is just a ragged edge. */
    .audit-detail { display: block; text-align: left; }
    .audit-detail > span + span::before { content: none; }
    .audit-detail > span { display: block; }
}

/* ---------- the app's own confirm box ---------- */
/*
 * Sits above everything the app draws — the panel, the POS sheet, the toasts,
 * the impersonation strip — and below the screen lock at 200, which has to be
 * able to cover a question as readily as it covers the takings.
 */
.dialog {
    position: fixed;
    inset: 0;
    z-index: 150;
    display: grid;
    place-items: center;
    padding: 1.25rem;
}

.dialog[hidden] { display: none; }

.dialog-backdrop {
    position: absolute;
    inset: 0;
    background: rgb(0 0 0 / .55);
    animation: dialog-fade .12s ease-out;
}

.dialog-card {
    position: relative;
    width: min(26rem, 100%);
    padding: 1.5rem;
    background: var(--surface);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-lg);
    animation: dialog-rise .16s cubic-bezier(.32, .72, 0, 1);
}

.dialog-title { margin: 0 0 .5rem; font-size: 1.125rem; font-weight: 650; }
.dialog-message { margin: 0; color: var(--ink-2); line-height: 1.5; }

/*
 * Full-width buttons stacked, not a row of two.
 *
 * This is answered with a thumb on a counter, usually in a hurry, and often
 * about something that cannot be undone. Two 48px targets a finger's width
 * apart are how you delete the wrong product; one above the other, with the
 * safe one nearest the thumb, is not.
 */
.dialog-actions {
    display: flex;
    flex-direction: column-reverse;
    gap: .625rem;
    margin-top: 1.5rem;
}

.dialog-actions .btn { width: 100%; }
.dialog-actions .btn[hidden] { display: none; }

@keyframes dialog-fade {
    from { opacity: 0; }
    to { opacity: 1; }
}

@keyframes dialog-rise {
    from { opacity: 0; transform: translateY(8px) scale(.98); }
    to { opacity: 1; transform: none; }
}

/* The page underneath must not scroll away behind an open question. */
body.has-dialog { overflow: hidden; }

@media (prefers-reduced-motion: reduce) {
    .dialog-backdrop, .dialog-card { animation: none; }
}

/* ---------- the user guide ---------- */
/*
 * /help. Two columns like the profile page: the contents stay put while the
 * page scrolls past them, and on a phone they simply lead.
 *
 * The guide is written in Twig rather than in markdown, so everything below is
 * the typography for prose somebody hand-wrote — not a renderer's output. The
 * classes it uses are the app's own (.card, .badge, .table, .alert) wherever
 * there is one, and the handful here are for the things a page of prose needs
 * that a till never did.
 */
.help-grid {
    display: grid;
    grid-template-columns: minmax(230px, 280px) minmax(0, 1fr);
    gap: 1.5rem;
    align-items: start;
    max-width: 78rem;
    margin-inline: auto;
}

.help-aside { position: sticky; top: 5.25rem; min-width: 0; }
.help-doc { min-width: 0; }
.help-doc > * + * { margin-top: 1rem; }

@media (max-width: 900px) {
    .help-grid { grid-template-columns: 1fr; gap: 1rem; }
    .help-aside { position: static; }
}

/* ---------- the contents column ---------- */
.help-contents-summary {
    display: flex;
    align-items: center;
    gap: .625rem;
    padding: .875rem 1rem;
    cursor: pointer;
    user-select: none;
    -webkit-user-select: none;
    /* The default triangle goes; the caret at the far end is not under the
       thumb reaching for it. */
    list-style: none;
}

.help-contents-summary::-webkit-details-marker { display: none; }
.help-contents-summary:hover { background: var(--surface-2); }
.help-contents[open] .help-contents-summary { border-bottom: 1px solid var(--line); }
.help-contents[open] .filter-caret { transform: rotate(180deg); }

.help-contents-body { display: flex; flex-direction: column; gap: .75rem; padding: .75rem; }

.help-toc { display: flex; flex-direction: column; gap: .125rem; }
.help-toc-section { display: flex; flex-direction: column; gap: .125rem; margin-top: .625rem; }

.help-toc-head {
    margin: 0 0 .125rem;
    padding: 0 .5rem;
    font-size: .6875rem;
    font-weight: 700;
    letter-spacing: .06em;
    text-transform: uppercase;
    color: var(--ink-3);
}

.help-toc-link {
    display: flex;
    align-items: center;
    gap: .5rem;
    min-height: 36px;
    padding: .3125rem .5rem;
    border-radius: var(--radius-sm);
    color: var(--ink-2);
    font-size: .875rem;
    font-weight: 550;
    line-height: 1.3;
    text-decoration: none;
    transition: background .15s ease, color .15s ease;
}

.help-toc-link:hover { background: var(--surface-2); color: var(--ink); text-decoration: none; }
.help-toc-link.is-here { background: var(--brand-soft); color: var(--brand-strong); font-weight: 650; }
.help-toc-mark { flex: none; }
.help-toc-rule { flex: none; width: .5rem; height: 1px; background: var(--line-strong); }
.help-toc-link.is-here .help-toc-rule { background: var(--brand); }

/* A section whose every page has been filtered away has nothing left to head.
   The filter controller only knows about the links — this is the heading above
   them going quietly with the last of them. */
.help-toc-section:not(:has(.help-toc-link:not(.hide))) { display: none; }

.help-pdf { justify-content: center; }

@media (prefers-reduced-motion: reduce) {
    .help-toc-link { transition: none; }
}

@media (min-width: 901px) {
    /* Nothing to fold away up here — the column is the column. The summary goes
       and the contents stay put whatever the open attribute says, and the
       closed <details> has to actually give them up: the browser now hides them
       with content-visibility on ::details-content, which no author rule on a
       child can reach. Same fix as .filter-fold, same reason. */
    .help-contents-summary { display: none; }

    .help-contents::details-content {
        content-visibility: visible;
        block-size: auto;
    }

    /* A guide with forty pages in it is longer than the screen. The column
       scrolls inside itself rather than pushing the page down, and stops short
       of the bottom so the last link is never under the edge. */
    .help-contents-body {
        max-height: calc(100dvh - var(--chrome) - 5.5rem);
        overflow-y: auto;
        overscroll-behavior: contain;
    }
}

/* ---------- the page itself ---------- */
.help-standfirst {
    margin: 0 0 .25rem;
    font-size: 1.0625rem;
    line-height: 1.55;
    color: var(--ink-2);
}

.help-hero .help-standfirst { font-size: 1.125rem; color: var(--ink); }

.help-h2 { margin: 1.5rem 0 .75rem; font-size: 1.0625rem; font-weight: 700; }
.help-cards { display: flex; flex-direction: column; gap: .75rem; }
.help-section .card-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }

.help-index-list { list-style: none; margin: 0; padding: .5rem; display: flex; flex-direction: column; gap: .125rem; }
.help-index-list li { display: flex; flex-direction: column; gap: .0625rem; padding: .5rem; border-radius: var(--radius-sm); }
.help-index-list li:hover { background: var(--surface-2); }
.help-index-list a { font-weight: 600; text-decoration: none; }
.help-index-list a:hover { text-decoration: underline; }

/* ---------- prose ---------- */
.help-prose { color: var(--ink-2); font-size: .9375rem; line-height: 1.7; }
.help-prose > :first-child { margin-top: 0; }
.help-prose > :last-child { margin-bottom: 0; }

.help-prose h2 {
    margin: 1.75rem 0 .625rem;
    font-size: 1.125rem;
    font-weight: 700;
    line-height: 1.3;
    color: var(--ink);
    /* Anchored headings are jumped to from the page's own contents, and the
       topbar is sticky. */
    scroll-margin-top: 5.5rem;
}

.help-prose h3 { margin: 1.25rem 0 .375rem; font-size: 1rem; font-weight: 650; color: var(--ink); }
.help-prose p { margin: 0 0 .875rem; }
.help-prose ul, .help-prose ol { margin: 0 0 .875rem; padding-left: 1.375rem; }
.help-prose li { margin: .375rem 0; }
.help-prose li > ul, .help-prose li > ol { margin-top: .375rem; margin-bottom: 0; }
.help-prose strong { color: var(--ink); font-weight: 650; }
.help-prose a { color: var(--brand-strong); font-weight: 550; }
.help-prose hr { margin: 1.5rem 0; border: 0; border-top: 1px solid var(--line); }

.help-prose code {
    padding: .0625rem .3125rem;
    border: 1px solid var(--line);
    border-radius: 6px;
    background: var(--surface-2);
    color: var(--ink);
    font-family: var(--mono);
    font-size: .875em;
}

/* A table in prose is a table like any other, with a rail round it so a wide
   one scrolls sideways instead of pushing the page out. */
.help-table { margin: 0 0 1rem; overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-sm); }
.help-table .table th, .help-table .table td { padding: .625rem .75rem; font-size: .875rem; }
.help-table .table th { background: var(--surface-2); }

/* ---------- callouts ---------- */
/*
 * Deliberately not .alert: an alert in this app is something that just happened
 * and will go away again. These are part of the page, and read as an aside
 * rather than as news.
 */
.help-note {
    display: flex;
    gap: .625rem;
    margin: 1rem 0;
    padding: .75rem .875rem;
    border: 1px solid var(--line);
    border-left: 3px solid var(--ink-3);
    border-radius: var(--radius-sm);
    background: var(--surface-2);
}

.help-note > :first-child { margin-top: 0; }
.help-note > :last-child { margin-bottom: 0; }
.help-note-mark { flex: none; font-size: 1.0625rem; line-height: 1.5; }
.help-note-body { min-width: 0; }
.help-note-body > :last-child { margin-bottom: 0; }

.help-note.is-warn { border-left-color: var(--warn); background: var(--warn-soft); border-color: transparent; }
.help-note.is-danger { border-left-color: var(--danger); background: var(--danger-soft); border-color: transparent; }
.help-note.is-ok { border-left-color: var(--ok); background: var(--ok-soft); border-color: transparent; }
.help-note.is-info { border-left-color: var(--info); background: var(--info-soft); border-color: transparent; }

/* Who may open the screen this page is about. Sits under the standfirst, so
   nobody reads three pages before finding out it was never theirs to reach. */
.help-who { display: flex; flex-wrap: wrap; align-items: center; gap: .375rem; margin: 0 0 1rem; }
.help-who-label { font-size: .75rem; color: var(--ink-3); }

/* ---------- previous / next ---------- */
.help-steps { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; margin-top: 1.5rem; }
.help-step { display: flex; flex-direction: column; gap: .125rem; padding: .875rem 1rem; font-size: .9375rem; }
.help-step-way { font-size: .75rem; color: var(--ink-3); }
.help-step-next { text-align: right; }

@media (max-width: 560px) {
    .help-steps { grid-template-columns: 1fr; }
    .help-step-next { text-align: left; }
}

.help-foot { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--line); }

/* A key to press. Only ever appears in the guide's prose. */
.help-prose kbd {
    display: inline-block;
    min-width: 1.5rem;
    padding: .0625rem .375rem;
    border: 1px solid var(--line-strong);
    border-bottom-width: 2px;
    border-radius: 6px;
    background: var(--surface);
    color: var(--ink);
    font-family: var(--font);
    font-size: .8125em;
    font-weight: 600;
    text-align: center;
}
